feat: sales opportunity detail, procurement filters, permission resilience
- Add sales opportunity detail page with tabs (overview, notes, contacts, products, forecasts, activity) - Add sales note CRUD endpoints (create, update, delete) with server routes - Add opportunity types, contacts, product sequencing, and refresh API methods - Add AddProductModal component for catalog browsing - Update procurement.fetchMany to accept CatalogItemFilters object - Add procurement.fetchCategories and procurement.fetchFilters endpoints - Add resilient permission check (no-token returns all-true with __checkFailed) - Parallelize company detail data fetches for performance - Remove stale console.log statements across modules - Add comprehensive unit tests for all new API methods and permission edge cases
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -174,7 +174,12 @@ describe("optima api modules", () => {
|
|||||||
.mockResolvedValueOnce({ data: { data: [] } })
|
.mockResolvedValueOnce({ data: { data: [] } })
|
||||||
.mockResolvedValueOnce({ data: { data: { count: 4 } } });
|
.mockResolvedValueOnce({ data: { data: { count: 4 } } });
|
||||||
|
|
||||||
await procurement.fetchMany("token", 3, "switch", 10, true);
|
await procurement.fetchMany(
|
||||||
|
"token",
|
||||||
|
3,
|
||||||
|
{ search: "switch", includeInactive: true },
|
||||||
|
10,
|
||||||
|
);
|
||||||
const count = await procurement.count("token", true);
|
const count = await procurement.count("token", true);
|
||||||
|
|
||||||
expect(mockApi.get).toHaveBeenNthCalledWith(1, "/v1/procurement/items", {
|
expect(mockApi.get).toHaveBeenNthCalledWith(1, "/v1/procurement/items", {
|
||||||
@@ -208,6 +213,106 @@ describe("optima api modules", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("procurement.fetchMany passes all CatalogItemFilters as params", async () => {
|
||||||
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||||
|
|
||||||
|
await procurement.fetchMany("token", 1, {
|
||||||
|
search: "cable",
|
||||||
|
category: "Networking",
|
||||||
|
subcategory: "Ethernet",
|
||||||
|
group: "Cat6",
|
||||||
|
manufacturer: "Ubiquiti",
|
||||||
|
ecosystem: "unifi",
|
||||||
|
inStock: true,
|
||||||
|
minPrice: 10,
|
||||||
|
maxPrice: 500,
|
||||||
|
includeInactive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/items", {
|
||||||
|
params: {
|
||||||
|
page: 1,
|
||||||
|
rpp: 30,
|
||||||
|
search: "cable",
|
||||||
|
category: "Networking",
|
||||||
|
subcategory: "Ethernet",
|
||||||
|
group: "Cat6",
|
||||||
|
manufacturer: "Ubiquiti",
|
||||||
|
ecosystem: "unifi",
|
||||||
|
inStock: true,
|
||||||
|
minPrice: 10,
|
||||||
|
maxPrice: 500,
|
||||||
|
includeInactive: true,
|
||||||
|
},
|
||||||
|
headers: { Authorization: "Bearer token" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("procurement.fetchMany omits filters that are not set", async () => {
|
||||||
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||||
|
|
||||||
|
await procurement.fetchMany("token", 2, { category: "Audio" }, 15);
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/items", {
|
||||||
|
params: { page: 2, rpp: 15, category: "Audio" },
|
||||||
|
headers: { Authorization: "Bearer token" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("procurement.fetchCategories returns category tree", async () => {
|
||||||
|
const tree = { categories: [{ name: "Net" }], ecosystems: [] };
|
||||||
|
mockApi.get.mockResolvedValueOnce({ data: { data: tree } });
|
||||||
|
|
||||||
|
const result = await procurement.fetchCategories("token");
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/categories", {
|
||||||
|
headers: { Authorization: "Bearer token" },
|
||||||
|
});
|
||||||
|
expect(result).toEqual(tree);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("procurement.fetchFilters passes optional params", async () => {
|
||||||
|
const filterValues = {
|
||||||
|
categories: ["A"],
|
||||||
|
subcategories: ["B"],
|
||||||
|
manufacturers: ["C"],
|
||||||
|
};
|
||||||
|
mockApi.get.mockResolvedValueOnce({ data: { data: filterValues } });
|
||||||
|
|
||||||
|
const result = await procurement.fetchFilters("token", {
|
||||||
|
category: "Networking",
|
||||||
|
subcategory: "Switches",
|
||||||
|
includeInactive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/filters", {
|
||||||
|
params: {
|
||||||
|
category: "Networking",
|
||||||
|
subcategory: "Switches",
|
||||||
|
includeInactive: "true",
|
||||||
|
},
|
||||||
|
headers: { Authorization: "Bearer token" },
|
||||||
|
});
|
||||||
|
expect(result).toEqual(filterValues);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("procurement.fetchFilters works with no options", async () => {
|
||||||
|
const filterValues = {
|
||||||
|
categories: [],
|
||||||
|
subcategories: [],
|
||||||
|
manufacturers: [],
|
||||||
|
};
|
||||||
|
mockApi.get.mockResolvedValueOnce({ data: { data: filterValues } });
|
||||||
|
|
||||||
|
const result = await procurement.fetchFilters("token");
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/filters", {
|
||||||
|
params: {},
|
||||||
|
headers: { Authorization: "Bearer token" },
|
||||||
|
});
|
||||||
|
expect(result).toEqual(filterValues);
|
||||||
|
});
|
||||||
|
|
||||||
it("role add and remove permissions include payload", async () => {
|
it("role add and remove permissions include payload", async () => {
|
||||||
mockApi.post.mockResolvedValueOnce({ data: { ok: true } });
|
mockApi.post.mockResolvedValueOnce({ data: { ok: true } });
|
||||||
mockApi.delete.mockResolvedValueOnce({ data: { ok: true } });
|
mockApi.delete.mockResolvedValueOnce({ data: { ok: true } });
|
||||||
@@ -237,6 +342,144 @@ describe("optima api modules", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sales.fetchOpportunityTypes calls expected endpoint", async () => {
|
||||||
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||||
|
|
||||||
|
await sales.fetchOpportunityTypes("token");
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/sales/opportunity-types", {
|
||||||
|
headers: { Authorization: "Bearer token" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sales.fetchOne encodes identifier in URL", async () => {
|
||||||
|
mockApi.get.mockResolvedValueOnce({ data: { data: { id: "opp-1" } } });
|
||||||
|
|
||||||
|
await sales.fetchOne("token", "opp-1");
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
|
"/v1/sales/opportunities/opp-1",
|
||||||
|
{ headers: { Authorization: "Bearer token" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sales.fetchForecasts calls forecasts endpoint", async () => {
|
||||||
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||||
|
|
||||||
|
await sales.fetchForecasts("token", "opp-1");
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
|
"/v1/sales/opportunities/opp-1/forecasts",
|
||||||
|
{ headers: { Authorization: "Bearer token" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sales.fetchProducts calls products endpoint", async () => {
|
||||||
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||||
|
|
||||||
|
await sales.fetchProducts("token", "opp-1");
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
|
"/v1/sales/opportunities/opp-1/products",
|
||||||
|
{ headers: { Authorization: "Bearer token" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sales.fetchNotes calls notes endpoint", async () => {
|
||||||
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||||
|
|
||||||
|
await sales.fetchNotes("token", "opp-1");
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
|
"/v1/sales/opportunities/opp-1/notes",
|
||||||
|
{ headers: { Authorization: "Bearer token" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sales.createNote posts note payload", async () => {
|
||||||
|
mockApi.post.mockResolvedValueOnce({ data: { id: 1, text: "Hello" } });
|
||||||
|
|
||||||
|
await sales.createNote("token", "opp-1", {
|
||||||
|
text: "Hello",
|
||||||
|
flagged: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockApi.post).toHaveBeenCalledWith(
|
||||||
|
"/v1/sales/opportunities/opp-1/notes",
|
||||||
|
{ text: "Hello", flagged: true },
|
||||||
|
{ headers: { Authorization: "Bearer token" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sales.updateNote patches with noteId in URL", async () => {
|
||||||
|
mockApi.patch.mockResolvedValueOnce({ data: { ok: true } });
|
||||||
|
|
||||||
|
await sales.updateNote("token", "opp-1", 42, { text: "Updated" });
|
||||||
|
|
||||||
|
expect(mockApi.patch).toHaveBeenCalledWith(
|
||||||
|
"/v1/sales/opportunities/opp-1/notes/42",
|
||||||
|
{ text: "Updated" },
|
||||||
|
{ headers: { Authorization: "Bearer token" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sales.deleteNote calls delete with noteId in URL", async () => {
|
||||||
|
mockApi.delete.mockResolvedValueOnce({ data: { ok: true } });
|
||||||
|
|
||||||
|
await sales.deleteNote("token", "opp-1", 42);
|
||||||
|
|
||||||
|
expect(mockApi.delete).toHaveBeenCalledWith(
|
||||||
|
"/v1/sales/opportunities/opp-1/notes/42",
|
||||||
|
{ headers: { Authorization: "Bearer token" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sales.fetchContacts calls contacts endpoint", async () => {
|
||||||
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
||||||
|
|
||||||
|
await sales.fetchContacts("token", "opp-1");
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
|
"/v1/sales/opportunities/opp-1/contacts",
|
||||||
|
{ headers: { Authorization: "Bearer token" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sales.sequenceProducts patches with ordered IDs", async () => {
|
||||||
|
mockApi.patch.mockResolvedValueOnce({ data: { ok: true } });
|
||||||
|
|
||||||
|
await sales.sequenceProducts("token", "opp-1", [3, 1, 2]);
|
||||||
|
|
||||||
|
expect(mockApi.patch).toHaveBeenCalledWith(
|
||||||
|
"/v1/sales/opportunities/opp-1/products/sequence",
|
||||||
|
{ orderedIds: [3, 1, 2] },
|
||||||
|
{ headers: { Authorization: "Bearer token" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sales.refreshOpportunity posts to refresh endpoint", async () => {
|
||||||
|
mockApi.post.mockResolvedValueOnce({ data: { ok: true } });
|
||||||
|
|
||||||
|
await sales.refreshOpportunity("token", "opp-1");
|
||||||
|
|
||||||
|
expect(mockApi.post).toHaveBeenCalledWith(
|
||||||
|
"/v1/sales/opportunities/opp-1/refresh",
|
||||||
|
{},
|
||||||
|
{ headers: { Authorization: "Bearer token" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sales.fetchOne encodes special characters in identifier", async () => {
|
||||||
|
mockApi.get.mockResolvedValueOnce({ data: { data: {} } });
|
||||||
|
|
||||||
|
await sales.fetchOne("token", "opp/special#1");
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
|
`/v1/sales/opportunities/${encodeURIComponent("opp/special#1")}`,
|
||||||
|
{ headers: { Authorization: "Bearer token" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("users module uses expected endpoints", async () => {
|
it("users module uses expected endpoints", async () => {
|
||||||
mockApi.get.mockResolvedValue({ data: { data: [] } });
|
mockApi.get.mockResolvedValue({ data: { data: [] } });
|
||||||
mockApi.post.mockResolvedValueOnce({ data: { data: { results: [] } } });
|
mockApi.post.mockResolvedValueOnce({ data: { data: { results: [] } } });
|
||||||
|
|||||||
@@ -58,8 +58,6 @@ export const credential = {
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
data: Omit<Credential, "id" | "createdAt" | "updatedAt">,
|
data: Omit<Credential, "id" | "createdAt" | "updatedAt">,
|
||||||
) {
|
) {
|
||||||
console.log(data);
|
|
||||||
|
|
||||||
const response = await api.post("/v1/credential/credentials", data, {
|
const response = await api.post("/v1/credential/credentials", data, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
|||||||
@@ -1,16 +1,86 @@
|
|||||||
import api from "../axios";
|
import api from "../axios";
|
||||||
|
|
||||||
|
export interface CatalogItemFilters {
|
||||||
|
search?: string;
|
||||||
|
category?: string;
|
||||||
|
subcategory?: string;
|
||||||
|
group?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
ecosystem?: string;
|
||||||
|
inStock?: boolean;
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
|
includeInactive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryTreeEntry {
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
cwId?: number;
|
||||||
|
subcategories?: CategoryTreeEntry[];
|
||||||
|
entries?: CategoryTreeEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EcosystemManufacturer {
|
||||||
|
name: string;
|
||||||
|
cwId?: number;
|
||||||
|
category?: string;
|
||||||
|
subcategoryPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EcosystemEntry {
|
||||||
|
name: string;
|
||||||
|
manufacturers: EcosystemManufacturer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryTreeResponse {
|
||||||
|
categories: CategoryTreeEntry[];
|
||||||
|
ecosystems: EcosystemEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterValues {
|
||||||
|
categories: string[];
|
||||||
|
subcategories: string[];
|
||||||
|
manufacturers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogItem {
|
||||||
|
id: string;
|
||||||
|
identifier?: string;
|
||||||
|
cwCatalogId?: number;
|
||||||
|
description?: string;
|
||||||
|
partNumber?: string;
|
||||||
|
vendorSku?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
price?: number;
|
||||||
|
cost?: number;
|
||||||
|
unitOfMeasure?: string;
|
||||||
|
onHand?: number;
|
||||||
|
inactive?: boolean;
|
||||||
|
category?: string;
|
||||||
|
subcategory?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export const procurement = {
|
export const procurement = {
|
||||||
async fetchMany(
|
async fetchMany(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
search?: string,
|
filters: CatalogItemFilters = {},
|
||||||
rpp: number = 30,
|
rpp: number = 30,
|
||||||
includeInactive: boolean = false,
|
|
||||||
) {
|
) {
|
||||||
const params: Record<string, unknown> = { page, rpp };
|
const params: Record<string, unknown> = { page, rpp };
|
||||||
if (search && search.length > 0) params.search = search;
|
if (filters.search && filters.search.length > 0)
|
||||||
if (includeInactive) params.includeInactive = true;
|
params.search = filters.search;
|
||||||
|
if (filters.includeInactive) params.includeInactive = true;
|
||||||
|
if (filters.category) params.category = filters.category;
|
||||||
|
if (filters.subcategory) params.subcategory = filters.subcategory;
|
||||||
|
if (filters.group) params.group = filters.group;
|
||||||
|
if (filters.manufacturer) params.manufacturer = filters.manufacturer;
|
||||||
|
if (filters.ecosystem) params.ecosystem = filters.ecosystem;
|
||||||
|
if (filters.inStock) params.inStock = true;
|
||||||
|
if (filters.minPrice != null) params.minPrice = filters.minPrice;
|
||||||
|
if (filters.maxPrice != null) params.maxPrice = filters.maxPrice;
|
||||||
|
|
||||||
const response = await api.get("/v1/procurement/items", {
|
const response = await api.get("/v1/procurement/items", {
|
||||||
params,
|
params,
|
||||||
@@ -101,4 +171,35 @@ export const procurement = {
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async fetchCategories(accessToken: string): Promise<CategoryTreeResponse> {
|
||||||
|
const response = await api.get("/v1/procurement/categories", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchFilters(
|
||||||
|
accessToken: string,
|
||||||
|
options?: {
|
||||||
|
category?: string;
|
||||||
|
subcategory?: string;
|
||||||
|
includeInactive?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<FilterValues> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (options?.category) params.category = options.category;
|
||||||
|
if (options?.subcategory) params.subcategory = options.subcategory;
|
||||||
|
if (options?.includeInactive) params.includeInactive = "true";
|
||||||
|
|
||||||
|
const response = await api.get("/v1/procurement/filters", {
|
||||||
|
params,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,11 +22,54 @@ export interface SalesOpportunity {
|
|||||||
identifier?: string;
|
identifier?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
} | null;
|
} | null;
|
||||||
company?: { id?: number | string; name?: string } | null;
|
company?: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
cw_Identifier?: string;
|
||||||
|
cw_CompanyId?: number;
|
||||||
|
cw_Data?: {
|
||||||
|
address?: {
|
||||||
|
line1?: string;
|
||||||
|
line2?: string | null;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
};
|
||||||
|
allContacts?: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
cwId?: number;
|
||||||
|
inactive?: boolean;
|
||||||
|
title?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
contact?: { id?: number | string; name?: string } | null;
|
contact?: { id?: number | string; name?: string } | null;
|
||||||
site?: { id?: number | string; name?: string } | null;
|
site?: {
|
||||||
|
id?: number | string;
|
||||||
|
name?: string;
|
||||||
|
address?: {
|
||||||
|
line1?: string;
|
||||||
|
line2?: string | null;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
};
|
||||||
|
phoneNumber?: string | null;
|
||||||
|
faxNumber?: string | null;
|
||||||
|
primaryAddressFlag?: boolean;
|
||||||
|
defaultShippingFlag?: boolean;
|
||||||
|
defaultBillingFlag?: boolean;
|
||||||
|
defaultMailingFlag?: boolean;
|
||||||
|
} | null;
|
||||||
customerPO?: string | null;
|
customerPO?: string | null;
|
||||||
totalSalesTax?: number | null;
|
totalSalesTax?: number | null;
|
||||||
|
location?: { id?: number; name?: string } | null;
|
||||||
|
department?: { id?: number; name?: string } | null;
|
||||||
expectedCloseDate?: string | null;
|
expectedCloseDate?: string | null;
|
||||||
pipelineChangeDate?: string | null;
|
pipelineChangeDate?: string | null;
|
||||||
dateBecameLead?: string | null;
|
dateBecameLead?: string | null;
|
||||||
@@ -39,6 +82,21 @@ export interface SalesOpportunity {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpportunityType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
wonFlag?: boolean;
|
||||||
|
lostFlag?: boolean;
|
||||||
|
closedFlag?: boolean;
|
||||||
|
inactiveFlag?: boolean;
|
||||||
|
defaultFlag?: boolean;
|
||||||
|
enteredBy?: string;
|
||||||
|
dateEntered?: string;
|
||||||
|
_info?: { lastUpdated?: string; updatedBy?: string };
|
||||||
|
connectWiseId?: string;
|
||||||
|
optimaEquivalency?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export const sales = {
|
export const sales = {
|
||||||
async fetchMany(
|
async fetchMany(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -59,4 +117,150 @@ export const sales = {
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async fetchOpportunityTypes(accessToken: string) {
|
||||||
|
const response = await api.get("/v1/sales/opportunity-types", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchOne(accessToken: string, identifier: string) {
|
||||||
|
const response = await api.get(
|
||||||
|
`/v1/sales/opportunities/${encodeURIComponent(identifier)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchForecasts(accessToken: string, identifier: string) {
|
||||||
|
const response = await api.get(
|
||||||
|
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/forecasts`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchProducts(accessToken: string, identifier: string) {
|
||||||
|
const response = await api.get(
|
||||||
|
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/products`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchNotes(accessToken: string, identifier: string) {
|
||||||
|
const response = await api.get(
|
||||||
|
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/notes`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createNote(
|
||||||
|
accessToken: string,
|
||||||
|
identifier: string,
|
||||||
|
data: { text: string; flagged?: boolean },
|
||||||
|
) {
|
||||||
|
const response = await api.post(
|
||||||
|
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/notes`,
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateNote(
|
||||||
|
accessToken: string,
|
||||||
|
identifier: string,
|
||||||
|
noteId: number,
|
||||||
|
data: { text?: string; flagged?: boolean },
|
||||||
|
) {
|
||||||
|
const response = await api.patch(
|
||||||
|
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/notes/${noteId}`,
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteNote(accessToken: string, identifier: string, noteId: number) {
|
||||||
|
const response = await api.delete(
|
||||||
|
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/notes/${noteId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchContacts(accessToken: string, identifier: string) {
|
||||||
|
const response = await api.get(
|
||||||
|
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/contacts`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async sequenceProducts(
|
||||||
|
accessToken: string,
|
||||||
|
identifier: string,
|
||||||
|
orderedIds: number[],
|
||||||
|
) {
|
||||||
|
const response = await api.patch(
|
||||||
|
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/products/sequence`,
|
||||||
|
{ orderedIds },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshOpportunity(accessToken: string, identifier: string) {
|
||||||
|
const response = await api.post(
|
||||||
|
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/refresh`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const user = {
|
|||||||
)
|
)
|
||||||
).data.data;
|
).data.data;
|
||||||
|
|
||||||
console.log("Refreshed tokens:", refreshedTokens);
|
|
||||||
return refreshedTokens;
|
return refreshedTokens;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,26 @@ describe("permissions helpers", () => {
|
|||||||
expect(result.__checkFailed).toBe(true);
|
expect(result.__checkFailed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns all-true with __checkFailed when accessToken is empty", async () => {
|
||||||
|
const result = await checkPermissions("", ["x", "y"]);
|
||||||
|
|
||||||
|
expect(result.x).toBe(true);
|
||||||
|
expect(result.y).toBe(true);
|
||||||
|
expect(result.__checkFailed).toBe(true);
|
||||||
|
expect(mockCheckPermissions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all-true with __checkFailed when accessToken is falsy", async () => {
|
||||||
|
const result = await checkPermissions(
|
||||||
|
undefined as unknown as string,
|
||||||
|
["perm.a"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result["perm.a"]).toBe(true);
|
||||||
|
expect(result.__checkFailed).toBe(true);
|
||||||
|
expect(mockCheckPermissions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("hasPermission returns true only for explicit true values", () => {
|
it("hasPermission returns true only for explicit true values", () => {
|
||||||
expect(hasPermission({ "company.read": true }, "company.read")).toBe(true);
|
expect(hasPermission({ "company.read": true }, "company.read")).toBe(true);
|
||||||
expect(hasPermission({ "company.read": false }, "company.read")).toBe(
|
expect(hasPermission({ "company.read": false }, "company.read")).toBe(
|
||||||
|
|||||||
+20
-5
@@ -25,18 +25,33 @@ export async function checkPermissions(
|
|||||||
): Promise<PermissionMap> {
|
): Promise<PermissionMap> {
|
||||||
if (!permissions.length) return {};
|
if (!permissions.length) return {};
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
// Return all-true so UI doesn't hide features
|
||||||
|
const map = permissions.reduce<PermissionMap>((m, p) => {
|
||||||
|
m[p] = true;
|
||||||
|
return m;
|
||||||
|
}, {} as PermissionMap);
|
||||||
|
map.__checkFailed = true;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await optima.user.checkPermissions(accessToken, permissions);
|
const result = await optima.user.checkPermissions(accessToken, permissions);
|
||||||
|
|
||||||
const results: Array<{ permission: string; hasPermission: boolean }> =
|
const results: Array<{ permission: string; hasPermission: boolean }> =
|
||||||
result?.data?.results ?? [];
|
result?.data?.results ?? [];
|
||||||
|
|
||||||
return results.reduce<PermissionMap>((map, entry) => {
|
const map = results.reduce<PermissionMap>((m, entry) => {
|
||||||
map[entry.permission] = entry.hasPermission === true;
|
m[entry.permission] = entry.hasPermission === true;
|
||||||
return map;
|
return m;
|
||||||
}, {});
|
}, {});
|
||||||
} catch (err) {
|
|
||||||
console.error("Permission check failed:", err);
|
return map;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(
|
||||||
|
"Permission check failed:",
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
// Default every requested permission to true on failure so the UI
|
// Default every requested permission to true on failure so the UI
|
||||||
// doesn't hide features that the user may actually be allowed to use.
|
// doesn't hide features that the user may actually be allowed to use.
|
||||||
// The API will still enforce access if the user truly lacks permission.
|
// The API will still enforce access if the user truly lacks permission.
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
let canViewAdmin = false;
|
let canViewAdmin = false;
|
||||||
try {
|
try {
|
||||||
const userInfo = await optima.user.fetchInfo(accessToken);
|
const userInfo = await optima.user.fetchInfo(accessToken);
|
||||||
console.log("@me response:", JSON.stringify(userInfo, null, 2));
|
|
||||||
|
|
||||||
const permResult = await optima.user.checkPermissions(accessToken, [
|
const permResult = await optima.user.checkPermissions(accessToken, [
|
||||||
"ui.navigation.admin.view",
|
"ui.navigation.admin.view",
|
||||||
|
|||||||
@@ -79,11 +79,6 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
return {};
|
return {};
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.log(
|
|
||||||
"Error creating credential type:",
|
|
||||||
(err as AxiosError<{ error?: string }>)?.response?.data?.error,
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = (err as AxiosError)?.response?.data as
|
const data = (err as AxiosError)?.response?.data as
|
||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { optima } from "$lib";
|
import { optima } from "$lib";
|
||||||
import { handleApiError } from "$lib/optima-api/errorHandler";
|
import { handleApiError } from "$lib/optima-api/errorHandler";
|
||||||
import { resolvePermissions, type PermissionMap } from "$lib/permissions";
|
import { checkPermissions, type PermissionMap } from "$lib/permissions";
|
||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||||
@@ -18,8 +18,9 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Permissions are resolved locally from the Set populated in hooks — no API call
|
// Start the permission check separately so company.fetch can begin
|
||||||
const permissions = resolvePermissions(locals.userPermissions, [
|
// as soon as permissions resolve, without waiting for the other fetches.
|
||||||
|
const permissionsPromise = checkPermissions(accessToken, [
|
||||||
"company.fetch.address",
|
"company.fetch.address",
|
||||||
"company.fetch.contacts",
|
"company.fetch.contacts",
|
||||||
"credential.secure_values.read",
|
"credential.secure_values.read",
|
||||||
@@ -28,29 +29,46 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
"unifi.site.wifi.update",
|
"unifi.site.wifi.update",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// All data fetches can now run in parallel — no permissions waterfall
|
// Kick off all independent data fetches in parallel
|
||||||
const [
|
const configsPromise = optima.company.fetchConfigurations(
|
||||||
companyResult,
|
accessToken,
|
||||||
configsResult,
|
params.id,
|
||||||
credentialsResult,
|
);
|
||||||
credentialTypesResult,
|
const credentialsPromise = optima.credential
|
||||||
unifiSitesResult,
|
.fetchByCompany(accessToken, params.id)
|
||||||
] = await Promise.all([
|
.catch(() => ({ data: [] }));
|
||||||
|
const credentialTypesPromise = optima.credentialType
|
||||||
|
.fetchMany(accessToken)
|
||||||
|
.catch(() => ({ data: [] }));
|
||||||
|
const unifiSitesPromise = optima.unifi
|
||||||
|
.fetchCompanySites(accessToken, params.id)
|
||||||
|
.catch(() => ({ data: [] }));
|
||||||
|
|
||||||
|
// company.fetch only depends on permissions — start it as soon as
|
||||||
|
// permissions resolve, don't wait for the other fetches
|
||||||
|
const companyPromise = permissionsPromise.then((permissions) =>
|
||||||
optima.company.fetch(accessToken, params.id, {
|
optima.company.fetch(accessToken, params.id, {
|
||||||
includeAddress: permissions["company.fetch.address"] === true,
|
includeAddress: permissions["company.fetch.address"] === true,
|
||||||
includePrimaryContact: true,
|
includePrimaryContact: true,
|
||||||
includeAllContacts: permissions["company.fetch.contacts"] === true,
|
includeAllContacts: permissions["company.fetch.contacts"] === true,
|
||||||
}),
|
}),
|
||||||
optima.company.fetchConfigurations(accessToken, params.id),
|
);
|
||||||
optima.credential
|
|
||||||
.fetchByCompany(accessToken, params.id)
|
// Now await everything together
|
||||||
.catch(() => ({ data: [] })),
|
const [
|
||||||
optima.credentialType
|
permissions,
|
||||||
.fetchMany(accessToken)
|
configsResult,
|
||||||
.catch(() => ({ data: [] })),
|
credentialsResult,
|
||||||
optima.unifi
|
credentialTypesResult,
|
||||||
.fetchCompanySites(accessToken, params.id)
|
unifiSitesResult,
|
||||||
.catch(() => ({ data: [] })),
|
companyResult,
|
||||||
|
] = await Promise.all([
|
||||||
|
permissionsPromise,
|
||||||
|
configsPromise,
|
||||||
|
credentialsPromise,
|
||||||
|
credentialTypesPromise,
|
||||||
|
unifiSitesPromise,
|
||||||
|
companyPromise,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
const result = await optima.procurement.fetchMany(
|
const result = await optima.procurement.fetchMany(
|
||||||
accessToken,
|
accessToken,
|
||||||
1,
|
1,
|
||||||
query,
|
{ search: query, includeInactive: true },
|
||||||
20,
|
20,
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
return json({ data: result?.data ?? [] });
|
return json({ data: result?.data ?? [] });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
|||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return {
|
return {
|
||||||
opportunities: [],
|
opportunities: [],
|
||||||
|
opportunityTypes: [],
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
totalRecords: 0,
|
totalRecords: 0,
|
||||||
@@ -22,7 +23,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
|||||||
const includeClosed = url.searchParams.get("includeClosed") !== "false";
|
const includeClosed = url.searchParams.get("includeClosed") !== "false";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [result, permissions] = await Promise.all([
|
const [result, permissions, opportunityTypesResult] = await Promise.all([
|
||||||
optima.sales
|
optima.sales
|
||||||
.fetchMany(accessToken, page, search, 30, includeClosed)
|
.fetchMany(accessToken, page, search, 30, includeClosed)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -38,34 +39,19 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
checkPermissions(accessToken, ["sales.opportunity.fetch.many"]),
|
checkPermissions(accessToken, ["sales.opportunity.fetch.many"]),
|
||||||
|
optima.sales
|
||||||
|
.fetchOpportunityTypes(accessToken)
|
||||||
|
.catch(() => ({ data: [] })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log("Sales opportunities raw result:", {
|
|
||||||
page,
|
|
||||||
search,
|
|
||||||
includeClosed,
|
|
||||||
resultSummary: {
|
|
||||||
hasData: Boolean(result?.data),
|
|
||||||
keys: result?.data ? Object.keys(result.data) : [],
|
|
||||||
meta: result?.meta ?? result?.data?.meta ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const opportunities =
|
const opportunities =
|
||||||
result?.data?.data ??
|
result?.data?.data ?? result?.data?.opportunities ?? result?.data ?? [];
|
||||||
result?.data?.opportunities ??
|
|
||||||
result?.data ??
|
|
||||||
[];
|
|
||||||
const pagination =
|
const pagination =
|
||||||
result?.meta?.pagination ?? result?.data?.meta?.pagination ?? null;
|
result?.meta?.pagination ?? result?.data?.meta?.pagination ?? null;
|
||||||
|
|
||||||
console.log("Sales opportunities normalized:", {
|
|
||||||
count: opportunities?.length ?? 0,
|
|
||||||
pagination,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
opportunities,
|
opportunities,
|
||||||
|
opportunityTypes: opportunityTypesResult?.data ?? [],
|
||||||
totalPages: pagination?.totalPages ?? 1,
|
totalPages: pagination?.totalPages ?? 1,
|
||||||
currentPage: pagination?.currentPage ?? page,
|
currentPage: pagination?.currentPage ?? page,
|
||||||
totalRecords: pagination?.totalRecords ?? opportunities.length ?? 0,
|
totalRecords: pagination?.totalRecords ?? opportunities.length ?? 0,
|
||||||
|
|||||||
+116
-16
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto, afterNavigate } from "$app/navigation";
|
import { goto, afterNavigate } from "$app/navigation";
|
||||||
import type { PermissionMap } from "$lib/permissions";
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
|
import type { OpportunityType } from "$lib/optima-api/modules/sales";
|
||||||
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
||||||
import "../../styles/sales/sales.css";
|
import "../../styles/sales/sales.css";
|
||||||
|
|
||||||
@@ -13,8 +14,16 @@
|
|||||||
status?: { id?: number; name?: string } | null;
|
status?: { id?: number; name?: string } | null;
|
||||||
priority?: { id?: number; name?: string } | null;
|
priority?: { id?: number; name?: string } | null;
|
||||||
rating?: { id?: number; name?: string } | null;
|
rating?: { id?: number; name?: string } | null;
|
||||||
primarySalesRep?: { id?: number; identifier?: string; name?: string } | null;
|
primarySalesRep?: {
|
||||||
secondarySalesRep?: { id?: number; identifier?: string; name?: string } | null;
|
id?: number;
|
||||||
|
identifier?: string;
|
||||||
|
name?: string;
|
||||||
|
} | null;
|
||||||
|
secondarySalesRep?: {
|
||||||
|
id?: number;
|
||||||
|
identifier?: string;
|
||||||
|
name?: string;
|
||||||
|
} | null;
|
||||||
company?: { id?: number | string; name?: string } | null;
|
company?: { id?: number | string; name?: string } | null;
|
||||||
expectedCloseDate?: string | null;
|
expectedCloseDate?: string | null;
|
||||||
closedDate?: string | null;
|
closedDate?: string | null;
|
||||||
@@ -27,6 +36,7 @@
|
|||||||
export let data: {
|
export let data: {
|
||||||
permissions: PermissionMap;
|
permissions: PermissionMap;
|
||||||
opportunities: SalesOpportunity[];
|
opportunities: SalesOpportunity[];
|
||||||
|
opportunityTypes: OpportunityType[];
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalRecords: number;
|
totalRecords: number;
|
||||||
@@ -36,12 +46,32 @@
|
|||||||
|
|
||||||
$: hasAccess = data.permissions["sales.opportunity.fetch.many"] === true;
|
$: hasAccess = data.permissions["sales.opportunity.fetch.many"] === true;
|
||||||
|
|
||||||
|
// Build lookup maps for opportunity type resolution
|
||||||
|
// directMap: type id → OpportunityType (exact match)
|
||||||
|
// equivMap: type id → OpportunityType (matched via optimaEquivalency)
|
||||||
|
$: directMap = new Map<number, OpportunityType>(
|
||||||
|
data.opportunityTypes.map((t) => [t.id, t]),
|
||||||
|
);
|
||||||
|
$: equivMap = (() => {
|
||||||
|
const m = new Map<number, OpportunityType>();
|
||||||
|
for (const t of data.opportunityTypes) {
|
||||||
|
if (t.optimaEquivalency) {
|
||||||
|
for (const eqId of t.optimaEquivalency) {
|
||||||
|
m.set(eqId, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
})();
|
||||||
|
|
||||||
let searchInput = data.search;
|
let searchInput = data.search;
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
let isSearching = false;
|
let isSearching = false;
|
||||||
let searchInputEl: HTMLInputElement;
|
let searchInputEl: HTMLInputElement;
|
||||||
let searchStartedAt = 0;
|
let searchStartedAt = 0;
|
||||||
let isUserTyping = false;
|
let isUserTyping = false;
|
||||||
|
let lastAutoOpenSearch = "";
|
||||||
|
let autoOpenTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
let showClosed = data.includeClosed;
|
let showClosed = data.includeClosed;
|
||||||
let filterOpen = false;
|
let filterOpen = false;
|
||||||
@@ -80,6 +110,29 @@
|
|||||||
}, remaining);
|
}, remaining);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-open: when the server-confirmed search returns exactly 1 result
|
||||||
|
// whose CW opportunity ID matches the search term, navigate to it.
|
||||||
|
$: {
|
||||||
|
const serverSearch = data.search?.trim() || "";
|
||||||
|
if (
|
||||||
|
serverSearch &&
|
||||||
|
serverSearch !== lastAutoOpenSearch &&
|
||||||
|
data.opportunities.length === 1
|
||||||
|
) {
|
||||||
|
const opp = data.opportunities[0];
|
||||||
|
const cwId =
|
||||||
|
opp.cwOpportunityId != null ? String(opp.cwOpportunityId) : null;
|
||||||
|
const normalized = serverSearch.replace(/^(?:cw\s*#?|#)\s*/i, "");
|
||||||
|
if (cwId && normalized === cwId) {
|
||||||
|
lastAutoOpenSearch = serverSearch;
|
||||||
|
clearTimeout(autoOpenTimer);
|
||||||
|
autoOpenTimer = setTimeout(() => {
|
||||||
|
goto(`/sales/opportunity/${opp.id}`);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$: currentPage = data.currentPage;
|
$: currentPage = data.currentPage;
|
||||||
$: totalPages = data.totalPages;
|
$: totalPages = data.totalPages;
|
||||||
$: totalRecords = data.totalRecords;
|
$: totalRecords = data.totalRecords;
|
||||||
@@ -107,6 +160,7 @@
|
|||||||
isUserTyping = true;
|
isUserTyping = true;
|
||||||
searchStartedAt = Date.now();
|
searchStartedAt = Date.now();
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
|
clearTimeout(autoOpenTimer);
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
isSearching = true;
|
isSearching = true;
|
||||||
isUserTyping = false;
|
isUserTyping = false;
|
||||||
@@ -120,6 +174,7 @@
|
|||||||
isUserTyping = false;
|
isUserTyping = false;
|
||||||
searchStartedAt = Date.now();
|
searchStartedAt = Date.now();
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
|
clearTimeout(autoOpenTimer);
|
||||||
navigateWithFilters({ page: 1, keepFocus: true });
|
navigateWithFilters({ page: 1, keepFocus: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,17 +194,61 @@
|
|||||||
|
|
||||||
function statusLabel(op: SalesOpportunity): string {
|
function statusLabel(op: SalesOpportunity): string {
|
||||||
if (op.closedFlag) return "Closed";
|
if (op.closedFlag) return "Closed";
|
||||||
|
const statusId = op.status?.id;
|
||||||
|
if (statusId != null) {
|
||||||
|
if (directMap.has(statusId)) {
|
||||||
|
return directMap.get(statusId)!.name;
|
||||||
|
}
|
||||||
|
if (equivMap.has(statusId)) {
|
||||||
|
return equivMap.get(statusId)!.name + " *";
|
||||||
|
}
|
||||||
|
// Debug: log unmatched status IDs so we can see what's not resolving
|
||||||
|
console.log("[Status Debug] Unmatched status", {
|
||||||
|
oppName: op.name,
|
||||||
|
statusId,
|
||||||
|
statusName: op.status?.name,
|
||||||
|
directMapKeys: [...directMap.keys()],
|
||||||
|
equivMapKeys: [...equivMap.keys()],
|
||||||
|
});
|
||||||
|
}
|
||||||
return op.status?.name || "Open";
|
return op.status?.name || "Open";
|
||||||
}
|
}
|
||||||
|
|
||||||
function ownerLabel(op: SalesOpportunity): string {
|
function isEquivalencyStatus(op: SalesOpportunity): boolean {
|
||||||
|
if (op.closedFlag) return false;
|
||||||
|
const statusId = op.status?.id;
|
||||||
return (
|
return (
|
||||||
op.primarySalesRep?.name ||
|
statusId != null && !directMap.has(statusId) && equivMap.has(statusId)
|
||||||
op.secondarySalesRep?.name ||
|
|
||||||
"—"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function originalStatusName(op: SalesOpportunity): string {
|
||||||
|
return op.status?.name || "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the canonical OpportunityType for an opportunity (direct or equiv). */
|
||||||
|
function resolvedType(op: SalesOpportunity): OpportunityType | null {
|
||||||
|
const statusId = op.status?.id;
|
||||||
|
if (statusId == null) return null;
|
||||||
|
return directMap.get(statusId) ?? equivMap.get(statusId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Determine a color class based on the resolved type flags. */
|
||||||
|
function statusColorClass(op: SalesOpportunity): string {
|
||||||
|
if (op.closedFlag) return "status-closed";
|
||||||
|
const t = resolvedType(op);
|
||||||
|
if (!t) return "status-open";
|
||||||
|
if (t.wonFlag) return "status-won";
|
||||||
|
if (t.lostFlag) return "status-lost";
|
||||||
|
if (t.closedFlag) return "status-closed";
|
||||||
|
if (t.inactiveFlag) return "status-inactive";
|
||||||
|
return "status-open";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerLabel(op: SalesOpportunity): string {
|
||||||
|
return op.primarySalesRep?.name || op.secondarySalesRep?.name || "—";
|
||||||
|
}
|
||||||
|
|
||||||
function companyLabel(op: SalesOpportunity): string {
|
function companyLabel(op: SalesOpportunity): string {
|
||||||
return op.company?.name || "—";
|
return op.company?.name || "—";
|
||||||
}
|
}
|
||||||
@@ -289,12 +388,6 @@
|
|||||||
|
|
||||||
<div class="sales-body">
|
<div class="sales-body">
|
||||||
<div class="sales-table-wrap">
|
<div class="sales-table-wrap">
|
||||||
{#if isSearching && !isUserTyping}
|
|
||||||
<div class="sales-loading-overlay">
|
|
||||||
<div class="sales-spinner"></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if opportunities.length === 0}
|
{#if opportunities.length === 0}
|
||||||
<div class="sales-empty">
|
<div class="sales-empty">
|
||||||
<NoResultsMonkey
|
<NoResultsMonkey
|
||||||
@@ -319,7 +412,12 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each opportunities as opp (opp.id)}
|
{#each opportunities as opp (opp.id)}
|
||||||
<tr class="sales-row" class:closed-row={opp.closedFlag}>
|
<tr
|
||||||
|
class="sales-row"
|
||||||
|
class:closed-row={opp.closedFlag}
|
||||||
|
on:click={() => goto(`/sales/opportunity/${opp.id}`)}
|
||||||
|
style="cursor: pointer;"
|
||||||
|
>
|
||||||
<td class="col-opportunity">
|
<td class="col-opportunity">
|
||||||
<div class="sales-opportunity">
|
<div class="sales-opportunity">
|
||||||
<span class="opp-name">{opp.name}</span>
|
<span class="opp-name">{opp.name}</span>
|
||||||
@@ -334,9 +432,11 @@
|
|||||||
<td class="col-stage">{opp.stage?.name || "—"}</td>
|
<td class="col-stage">{opp.stage?.name || "—"}</td>
|
||||||
<td class="col-status">
|
<td class="col-status">
|
||||||
<span
|
<span
|
||||||
class="sales-status-badge"
|
class="sales-status-badge {statusColorClass(opp)}"
|
||||||
class:status-closed={opp.closedFlag}
|
class:status-equiv={isEquivalencyStatus(opp)}
|
||||||
class:status-open={!opp.closedFlag}
|
data-tooltip={isEquivalencyStatus(opp)
|
||||||
|
? `Original: ${originalStatusName(opp)}`
|
||||||
|
: undefined}
|
||||||
>
|
>
|
||||||
{statusLabel(opp)}
|
{statusLabel(opp)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto, afterNavigate } from "$app/navigation";
|
||||||
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
|
import type { OpportunityType } from "$lib/optima-api/modules/sales";
|
||||||
|
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
|
||||||
|
import "../../../styles/sales/sales.css";
|
||||||
|
|
||||||
|
type SalesOpportunity = {
|
||||||
|
id: string;
|
||||||
|
cwOpportunityId?: number;
|
||||||
|
name: string;
|
||||||
|
type?: { id?: number; name?: string } | null;
|
||||||
|
stage?: { id?: number; name?: string } | null;
|
||||||
|
status?: { id?: number; name?: string } | null;
|
||||||
|
priority?: { id?: number; name?: string } | null;
|
||||||
|
rating?: { id?: number; name?: string } | null;
|
||||||
|
primarySalesRep?: {
|
||||||
|
id?: number;
|
||||||
|
identifier?: string;
|
||||||
|
name?: string;
|
||||||
|
} | null;
|
||||||
|
secondarySalesRep?: {
|
||||||
|
id?: number;
|
||||||
|
identifier?: string;
|
||||||
|
name?: string;
|
||||||
|
} | null;
|
||||||
|
company?: { id?: number | string; name?: string } | null;
|
||||||
|
expectedCloseDate?: string | null;
|
||||||
|
closedDate?: string | null;
|
||||||
|
closedFlag?: boolean;
|
||||||
|
cwLastUpdated?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export let data: {
|
||||||
|
permissions: PermissionMap;
|
||||||
|
opportunities: SalesOpportunity[];
|
||||||
|
opportunityTypes: OpportunityType[];
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
totalRecords: number;
|
||||||
|
search: string;
|
||||||
|
includeClosed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
$: hasAccess = data.permissions["sales.opportunity.fetch.many"] === true;
|
||||||
|
|
||||||
|
// Build lookup maps for opportunity type resolution
|
||||||
|
// directMap: type id → OpportunityType (exact match)
|
||||||
|
// equivMap: type id → OpportunityType (matched via optimaEquivalency)
|
||||||
|
$: directMap = new Map<number, OpportunityType>(
|
||||||
|
data.opportunityTypes.map((t) => [t.id, t]),
|
||||||
|
);
|
||||||
|
$: equivMap = (() => {
|
||||||
|
const m = new Map<number, OpportunityType>();
|
||||||
|
for (const t of data.opportunityTypes) {
|
||||||
|
if (t.optimaEquivalency) {
|
||||||
|
for (const eqId of t.optimaEquivalency) {
|
||||||
|
m.set(eqId, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
})();
|
||||||
|
|
||||||
|
let searchInput = data.search;
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
let isSearching = false;
|
||||||
|
let searchInputEl: HTMLInputElement;
|
||||||
|
let searchStartedAt = 0;
|
||||||
|
let isUserTyping = false;
|
||||||
|
let lastAutoOpenSearch = "";
|
||||||
|
let autoOpenTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
let showClosed = data.includeClosed;
|
||||||
|
let filterOpen = false;
|
||||||
|
let filterBtnEl: HTMLButtonElement;
|
||||||
|
let filterPopoverEl: HTMLDivElement;
|
||||||
|
|
||||||
|
function toggleFilterPopover() {
|
||||||
|
filterOpen = !filterOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilterClickOutside(e: MouseEvent) {
|
||||||
|
if (!filterOpen) return;
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (filterBtnEl?.contains(target) || filterPopoverEl?.contains(target))
|
||||||
|
return;
|
||||||
|
filterOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleClosed() {
|
||||||
|
showClosed = !showClosed;
|
||||||
|
filterOpen = false;
|
||||||
|
navigateWithFilters({ page: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
$: activeFilterCount = showClosed ? 0 : 1;
|
||||||
|
|
||||||
|
afterNavigate(() => {
|
||||||
|
const elapsed = Date.now() - searchStartedAt;
|
||||||
|
const remaining = Math.max(0, 500 - elapsed);
|
||||||
|
setTimeout(() => {
|
||||||
|
isSearching = false;
|
||||||
|
isUserTyping = false;
|
||||||
|
if (searchInputEl && document.activeElement !== searchInputEl) {
|
||||||
|
requestAnimationFrame(() => searchInputEl?.focus());
|
||||||
|
}
|
||||||
|
}, remaining);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-open: when the server-confirmed search returns exactly 1 result
|
||||||
|
// whose CW opportunity ID matches the search term, navigate to it.
|
||||||
|
$: {
|
||||||
|
const serverSearch = data.search?.trim() || "";
|
||||||
|
if (
|
||||||
|
serverSearch &&
|
||||||
|
serverSearch !== lastAutoOpenSearch &&
|
||||||
|
data.opportunities.length === 1
|
||||||
|
) {
|
||||||
|
const opp = data.opportunities[0];
|
||||||
|
const cwId =
|
||||||
|
opp.cwOpportunityId != null ? String(opp.cwOpportunityId) : null;
|
||||||
|
const normalized = serverSearch.replace(/^(?:cw\s*#?|#)\s*/i, "");
|
||||||
|
if (cwId && normalized === cwId) {
|
||||||
|
lastAutoOpenSearch = serverSearch;
|
||||||
|
clearTimeout(autoOpenTimer);
|
||||||
|
autoOpenTimer = setTimeout(() => {
|
||||||
|
goto(`/sales/opportunity/${opp.id}`);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: currentPage = data.currentPage;
|
||||||
|
$: totalPages = data.totalPages;
|
||||||
|
$: totalRecords = data.totalRecords;
|
||||||
|
$: opportunities = data.opportunities;
|
||||||
|
|
||||||
|
function navigateWithFilters(
|
||||||
|
opts: { page?: number; keepFocus?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("page", String(opts.page ?? currentPage));
|
||||||
|
if (searchInput) params.set("search", searchInput);
|
||||||
|
if (!showClosed) params.set("includeClosed", "false");
|
||||||
|
goto(`/sales/opportunities?${params.toString()}`, {
|
||||||
|
replaceState: true,
|
||||||
|
keepFocus: opts.keepFocus ?? false,
|
||||||
|
noScroll: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToPage(p: number) {
|
||||||
|
navigateWithFilters({ page: p });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
isUserTyping = true;
|
||||||
|
searchStartedAt = Date.now();
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
clearTimeout(autoOpenTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
isSearching = true;
|
||||||
|
isUserTyping = false;
|
||||||
|
navigateWithFilters({ page: 1, keepFocus: true });
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
isSearching = true;
|
||||||
|
isUserTyping = false;
|
||||||
|
searchStartedAt = Date.now();
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
clearTimeout(autoOpenTimer);
|
||||||
|
navigateWithFilters({ page: 1, keepFocus: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr?: string | null): string {
|
||||||
|
if (!dateStr) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(op: SalesOpportunity): string {
|
||||||
|
if (op.closedFlag) return "Closed";
|
||||||
|
const statusId = op.status?.id;
|
||||||
|
if (statusId != null) {
|
||||||
|
if (directMap.has(statusId)) {
|
||||||
|
return directMap.get(statusId)!.name;
|
||||||
|
}
|
||||||
|
if (equivMap.has(statusId)) {
|
||||||
|
return equivMap.get(statusId)!.name + " *";
|
||||||
|
}
|
||||||
|
// Debug: log unmatched status IDs so we can see what's not resolving
|
||||||
|
console.log("[Status Debug] Unmatched status", {
|
||||||
|
oppName: op.name,
|
||||||
|
statusId,
|
||||||
|
statusName: op.status?.name,
|
||||||
|
directMapKeys: [...directMap.keys()],
|
||||||
|
equivMapKeys: [...equivMap.keys()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return op.status?.name || "Open";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEquivalencyStatus(op: SalesOpportunity): boolean {
|
||||||
|
if (op.closedFlag) return false;
|
||||||
|
const statusId = op.status?.id;
|
||||||
|
return (
|
||||||
|
statusId != null && !directMap.has(statusId) && equivMap.has(statusId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function originalStatusName(op: SalesOpportunity): string {
|
||||||
|
return op.status?.name || "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the canonical OpportunityType for an opportunity (direct or equiv). */
|
||||||
|
function resolvedType(op: SalesOpportunity): OpportunityType | null {
|
||||||
|
const statusId = op.status?.id;
|
||||||
|
if (statusId == null) return null;
|
||||||
|
return directMap.get(statusId) ?? equivMap.get(statusId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Determine a color class based on the resolved type flags. */
|
||||||
|
function statusColorClass(op: SalesOpportunity): string {
|
||||||
|
if (op.closedFlag) return "status-closed";
|
||||||
|
const t = resolvedType(op);
|
||||||
|
if (!t) return "status-open";
|
||||||
|
if (t.wonFlag) return "status-won";
|
||||||
|
if (t.lostFlag) return "status-lost";
|
||||||
|
if (t.closedFlag) return "status-closed";
|
||||||
|
if (t.inactiveFlag) return "status-inactive";
|
||||||
|
return "status-open";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerLabel(op: SalesOpportunity): string {
|
||||||
|
return op.primarySalesRep?.name || op.secondarySalesRep?.name || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function companyLabel(op: SalesOpportunity): string {
|
||||||
|
return op.company?.name || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityLabel(op: SalesOpportunity): string {
|
||||||
|
return op.priority?.name || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageNumbers(current: number, total: number): (number | "...")[] {
|
||||||
|
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
|
const pages: (number | "...")[] = [1];
|
||||||
|
if (current > 3) pages.push("...");
|
||||||
|
const start = Math.max(2, current - 1);
|
||||||
|
const end = Math.min(total - 1, current + 1);
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
if (current < total - 2) pages.push("...");
|
||||||
|
pages.push(total);
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: pageNumbers = getPageNumbers(currentPage, totalPages);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:click={handleFilterClickOutside} />
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Sales — Project Optima</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if !hasAccess}
|
||||||
|
<div class="sales-access-denied">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||||
|
</svg>
|
||||||
|
<h3>Access Denied</h3>
|
||||||
|
<p>
|
||||||
|
You don't have permission to view Sales opportunities. Contact your
|
||||||
|
administrator to request access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="sales-page">
|
||||||
|
<div class="sales-pane">
|
||||||
|
<div class="sales-header">
|
||||||
|
<div class="sales-header-left">
|
||||||
|
<h2 class="sales-title">Sales Opportunities</h2>
|
||||||
|
{#if totalRecords > 0}
|
||||||
|
<span class="sales-result-count">
|
||||||
|
{totalRecords} record{totalRecords === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="sales-header-actions">
|
||||||
|
<div class="sales-search-bar">
|
||||||
|
<svg
|
||||||
|
class="sales-search-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="M21 21l-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search opportunities…"
|
||||||
|
bind:this={searchInputEl}
|
||||||
|
bind:value={searchInput}
|
||||||
|
on:input={handleSearch}
|
||||||
|
on:keydown={handleKeydown}
|
||||||
|
/>
|
||||||
|
{#if searchInput}
|
||||||
|
<button
|
||||||
|
class="sales-search-clear"
|
||||||
|
on:click={() => {
|
||||||
|
searchInput = "";
|
||||||
|
handleSearch();
|
||||||
|
}}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sales-filter-wrap">
|
||||||
|
<button
|
||||||
|
class="sales-filter-btn"
|
||||||
|
class:has-filters={activeFilterCount > 0}
|
||||||
|
bind:this={filterBtnEl}
|
||||||
|
on:click={toggleFilterPopover}
|
||||||
|
aria-label="Filters"
|
||||||
|
aria-expanded={filterOpen}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
>
|
||||||
|
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
||||||
|
</svg>
|
||||||
|
Filters
|
||||||
|
{#if activeFilterCount > 0}
|
||||||
|
<span class="sales-filter-badge">{activeFilterCount}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if filterOpen}
|
||||||
|
<div class="sales-filter-popover" bind:this={filterPopoverEl}>
|
||||||
|
<label class="sales-filter-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showClosed}
|
||||||
|
on:change={toggleClosed}
|
||||||
|
/>
|
||||||
|
<span>Include closed opportunities</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sales-body">
|
||||||
|
<div class="sales-table-wrap">
|
||||||
|
{#if opportunities.length === 0}
|
||||||
|
<div class="sales-empty">
|
||||||
|
<NoResultsMonkey
|
||||||
|
message={searchInput
|
||||||
|
? "No opportunities match your search"
|
||||||
|
: "No opportunities found"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<table class="sales-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-opportunity">Opportunity</th>
|
||||||
|
<th class="col-company">Company</th>
|
||||||
|
<th class="col-stage">Stage</th>
|
||||||
|
<th class="col-status">Status</th>
|
||||||
|
<th class="col-priority">Priority</th>
|
||||||
|
<th class="col-owner">Owner</th>
|
||||||
|
<th class="col-close">Expected Close</th>
|
||||||
|
<th class="col-updated">Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each opportunities as opp (opp.id)}
|
||||||
|
<tr
|
||||||
|
class="sales-row"
|
||||||
|
class:closed-row={opp.closedFlag}
|
||||||
|
on:click={() => goto(`/sales/opportunity/${opp.id}`)}
|
||||||
|
style="cursor: pointer;"
|
||||||
|
>
|
||||||
|
<td class="col-opportunity">
|
||||||
|
<div class="sales-opportunity">
|
||||||
|
<span class="opp-name">{opp.name}</span>
|
||||||
|
{#if opp.cwOpportunityId}
|
||||||
|
<span class="opp-meta mono"
|
||||||
|
>CW #{opp.cwOpportunityId}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="col-company">{companyLabel(opp)}</td>
|
||||||
|
<td class="col-stage">{opp.stage?.name || "—"}</td>
|
||||||
|
<td class="col-status">
|
||||||
|
<span
|
||||||
|
class="sales-status-badge {statusColorClass(opp)}"
|
||||||
|
class:status-equiv={isEquivalencyStatus(opp)}
|
||||||
|
data-tooltip={isEquivalencyStatus(opp)
|
||||||
|
? `Original: ${originalStatusName(opp)}`
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
{statusLabel(opp)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-priority">
|
||||||
|
<span class="sales-priority">
|
||||||
|
{priorityLabel(opp)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-owner">{ownerLabel(opp)}</td>
|
||||||
|
<td class="col-close">
|
||||||
|
{formatDate(opp.expectedCloseDate)}
|
||||||
|
</td>
|
||||||
|
<td class="col-updated">
|
||||||
|
{formatDate(opp.cwLastUpdated || opp.updatedAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="sales-footer">
|
||||||
|
<span class="sales-page-info">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<nav class="sales-pagination" aria-label="Sales pagination">
|
||||||
|
<button
|
||||||
|
class="sales-page-btn"
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
on:click={() => navigateToPage(currentPage - 1)}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#each pageNumbers as p}
|
||||||
|
{#if p === "..."}
|
||||||
|
<span class="sales-page-ellipsis">…</span>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="sales-page-btn"
|
||||||
|
class:active={p === currentPage}
|
||||||
|
on:click={() => navigateToPage(p)}
|
||||||
|
aria-current={p === currentPage ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="sales-page-btn"
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
on:click={() => navigateToPage(currentPage + 1)}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sales-access-denied {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sales-access-denied svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--status-inactive-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sales-access-denied h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sales-access-denied p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { optima } from "$lib";
|
||||||
|
import { handleApiError } from "$lib/optima-api/errorHandler";
|
||||||
|
import { checkPermissions, type PermissionMap } from "$lib/permissions";
|
||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
return {
|
||||||
|
opportunity: null,
|
||||||
|
notes: [],
|
||||||
|
contacts: [],
|
||||||
|
products: [],
|
||||||
|
accessToken: null,
|
||||||
|
permissions: {} as PermissionMap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
opportunityResult,
|
||||||
|
notesResult,
|
||||||
|
contactsResult,
|
||||||
|
productsResult,
|
||||||
|
permissions,
|
||||||
|
] = await Promise.all([
|
||||||
|
optima.sales.fetchOne(accessToken, params.id),
|
||||||
|
optima.sales
|
||||||
|
.fetchNotes(accessToken, params.id)
|
||||||
|
.catch(() => ({ data: [] })),
|
||||||
|
optima.sales
|
||||||
|
.fetchContacts(accessToken, params.id)
|
||||||
|
.catch(() => ({ data: [] })),
|
||||||
|
optima.sales
|
||||||
|
.fetchProducts(accessToken, params.id)
|
||||||
|
.catch(() => ({ data: [] })),
|
||||||
|
checkPermissions(accessToken, [
|
||||||
|
"sales.opportunity.fetch",
|
||||||
|
"sales.opportunity.refresh",
|
||||||
|
"sales.opportunity.note.create",
|
||||||
|
"sales.opportunity.note.update",
|
||||||
|
"sales.opportunity.note.delete",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const opportunity = opportunityResult?.data ?? null;
|
||||||
|
const products = productsResult?.data ?? [];
|
||||||
|
console.log("[Products]", JSON.stringify(products, null, 2));
|
||||||
|
|
||||||
|
return {
|
||||||
|
opportunity,
|
||||||
|
opportunityId: params.id,
|
||||||
|
notes: notesResult?.data ?? [],
|
||||||
|
contacts: contactsResult?.data ?? [],
|
||||||
|
products,
|
||||||
|
accessToken,
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
handleApiError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import "../../../../styles/sales/opportunitydetail.css";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { invalidateAll } from "$app/navigation";
|
||||||
|
import type { PageData } from "./types";
|
||||||
|
|
||||||
|
// Tab components
|
||||||
|
import OpportunitySidebar from "./components/OpportunitySidebar.svelte";
|
||||||
|
import OverviewTab from "./components/OverviewTab.svelte";
|
||||||
|
import NotesTab from "./components/NotesTab.svelte";
|
||||||
|
import ContactsTab from "./components/ContactsTab.svelte";
|
||||||
|
import ActivityTab from "./components/ActivityTab.svelte";
|
||||||
|
import ProductsTab from "./components/ProductsTab.svelte";
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
$: opportunity = data.opportunity;
|
||||||
|
$: opportunityId = data.opportunityId;
|
||||||
|
$: notes = data.notes;
|
||||||
|
$: contacts = data.contacts;
|
||||||
|
$: products = data.products;
|
||||||
|
$: permissions = data.permissions;
|
||||||
|
|
||||||
|
// Mobile detection
|
||||||
|
let isMobile = false;
|
||||||
|
function checkMobile() {
|
||||||
|
isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
|
||||||
|
}
|
||||||
|
onMount(() => {
|
||||||
|
checkMobile();
|
||||||
|
window.addEventListener("resize", checkMobile);
|
||||||
|
return () => window.removeEventListener("resize", checkMobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab navigation
|
||||||
|
const tabs = [
|
||||||
|
"Overview",
|
||||||
|
"Products",
|
||||||
|
"Notes",
|
||||||
|
"Contacts",
|
||||||
|
"Activity",
|
||||||
|
] as const;
|
||||||
|
type Tab = (typeof tabs)[number];
|
||||||
|
let activeTab: Tab = "Overview";
|
||||||
|
|
||||||
|
// Mobile nav state
|
||||||
|
let mobileActiveTab: Tab | null = null;
|
||||||
|
|
||||||
|
function selectMobileTab(tab: Tab) {
|
||||||
|
activeTab = tab;
|
||||||
|
mobileActiveTab = tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mobileBack() {
|
||||||
|
mobileActiveTab = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{opportunity?.name ?? "Opportunity"} — Project Optima</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="opportunity-detail-page">
|
||||||
|
<!-- Left pane — Opportunity overview -->
|
||||||
|
<OpportunitySidebar {opportunity} {isMobile} {mobileActiveTab} />
|
||||||
|
|
||||||
|
<!-- Mobile vertical nav menu -->
|
||||||
|
{#if isMobile && mobileActiveTab === null}
|
||||||
|
<div class="mobile-nav-menu">
|
||||||
|
{#each tabs as tab}
|
||||||
|
<button
|
||||||
|
class="mobile-nav-item"
|
||||||
|
on:click={() => selectMobileTab(tab)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="mobile-nav-icon">
|
||||||
|
{#if tab === "Products"}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
|
||||||
|
/>
|
||||||
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||||
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
|
</svg>
|
||||||
|
{:else if tab === "Notes"}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
|
||||||
|
/><polyline points="14 2 14 8 20 8" /><line
|
||||||
|
x1="16"
|
||||||
|
y1="13"
|
||||||
|
x2="8"
|
||||||
|
y2="13"
|
||||||
|
/><line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
{:else if tab === "Contacts"}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle
|
||||||
|
cx="9"
|
||||||
|
cy="7"
|
||||||
|
r="4"
|
||||||
|
/><path d="M23 21v-2a4 4 0 00-3-3.87" /><path
|
||||||
|
d="M16 3.13a4 4 0 010 7.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="mobile-nav-label">{tab}</span>
|
||||||
|
{#if tab === "Products" && products.length > 0}
|
||||||
|
<span class="mobile-nav-badge">{products.length}</span>
|
||||||
|
{/if}
|
||||||
|
{#if tab === "Notes" && notes.length > 0}
|
||||||
|
<span class="mobile-nav-badge">{notes.length}</span>
|
||||||
|
{/if}
|
||||||
|
{#if tab === "Contacts" && contacts.length > 0}
|
||||||
|
<span class="mobile-nav-badge">{contacts.length}</span>
|
||||||
|
{/if}
|
||||||
|
<svg
|
||||||
|
class="mobile-nav-chevron"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Right pane -->
|
||||||
|
<div
|
||||||
|
class="opportunity-detail-right"
|
||||||
|
class:mobile-hidden={isMobile && mobileActiveTab === null}
|
||||||
|
>
|
||||||
|
<!-- Mobile content header with back button -->
|
||||||
|
{#if isMobile && mobileActiveTab !== null}
|
||||||
|
<div class="mobile-content-header">
|
||||||
|
<button
|
||||||
|
class="mobile-back-btn"
|
||||||
|
on:click={mobileBack}
|
||||||
|
type="button"
|
||||||
|
aria-label="Back to menu"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h3 class="mobile-content-title">{mobileActiveTab}</h3>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="tab-bar" role="tablist">
|
||||||
|
{#each tabs as tab}
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
class:active={activeTab === tab}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab}
|
||||||
|
on:click={() => (activeTab = tab)}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
{#if tab === "Products" && products.length > 0}
|
||||||
|
<span class="tab-count-badge">{products.length}</span>
|
||||||
|
{/if}
|
||||||
|
{#if tab === "Notes" && notes.length > 0}
|
||||||
|
<span class="tab-count-badge">{notes.length}</span>
|
||||||
|
{/if}
|
||||||
|
{#if tab === "Contacts" && contacts.length > 0}
|
||||||
|
<span class="tab-count-badge">{contacts.length}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="detail-pane-body">
|
||||||
|
{#if activeTab === "Overview"}
|
||||||
|
<OverviewTab {opportunity} {notes} {contacts} />
|
||||||
|
{:else if activeTab === "Products"}
|
||||||
|
<ProductsTab
|
||||||
|
{products}
|
||||||
|
accessToken={data.accessToken}
|
||||||
|
{opportunityId}
|
||||||
|
/>
|
||||||
|
{:else if activeTab === "Notes"}
|
||||||
|
<NotesTab
|
||||||
|
{notes}
|
||||||
|
{permissions}
|
||||||
|
{opportunityId}
|
||||||
|
on:notesChanged={() => {
|
||||||
|
invalidateAll();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else if activeTab === "Contacts"}
|
||||||
|
<ContactsTab {contacts} />
|
||||||
|
{:else if activeTab === "Activity"}
|
||||||
|
<ActivityTab />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="activity-tab">
|
||||||
|
<div class="overview-section">
|
||||||
|
<h3 class="overview-section-title">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" />
|
||||||
|
</svg>
|
||||||
|
Recent Activity
|
||||||
|
</h3>
|
||||||
|
<p class="overview-placeholder">Activity feed coming soon.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { OpportunityContact } from "../types";
|
||||||
|
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
|
||||||
|
|
||||||
|
export let contacts: OpportunityContact[];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="contacts-tab">
|
||||||
|
{#if contacts.length === 0}
|
||||||
|
<div class="tab-empty">
|
||||||
|
<NoResultsMonkey message="No contacts associated with this opportunity" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="contacts-grid">
|
||||||
|
{#each contacts as c (c.id)}
|
||||||
|
<div class="contact-card">
|
||||||
|
<div class="contact-avatar">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle
|
||||||
|
cx="12"
|
||||||
|
cy="7"
|
||||||
|
r="4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="contact-info">
|
||||||
|
<span class="contact-name">{c.contact?.name ?? "Unknown"}</span>
|
||||||
|
{#if c.role?.name}
|
||||||
|
<span class="contact-role">{c.role.name}</span>
|
||||||
|
{/if}
|
||||||
|
{#if c.company?.name}
|
||||||
|
<span class="contact-company">{c.company.name}</span>
|
||||||
|
{/if}
|
||||||
|
{#if c.notes}
|
||||||
|
<span class="contact-notes">{c.notes}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if c.referralFlag}
|
||||||
|
<span class="contact-badge referral">Referral</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { OpportunityForecast } from "../types";
|
||||||
|
import { formatCurrency, formatDate } from "../types";
|
||||||
|
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
|
||||||
|
|
||||||
|
export let forecasts: OpportunityForecast[];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="forecasts-tab">
|
||||||
|
{#if forecasts.length === 0}
|
||||||
|
<div class="tab-empty">
|
||||||
|
<NoResultsMonkey message="No forecast data available" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="forecasts-table-wrap">
|
||||||
|
<table class="forecasts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Month</th>
|
||||||
|
<th class="num">Revenue</th>
|
||||||
|
<th class="num">Cost</th>
|
||||||
|
<th class="num">Margin</th>
|
||||||
|
<th class="num">Probability</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each forecasts as f (f.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{f.forecastType ?? "—"}</td>
|
||||||
|
<td>{formatDate(f.forecastMonth)}</td>
|
||||||
|
<td class="num">{formatCurrency(f.revenue)}</td>
|
||||||
|
<td class="num">{formatCurrency(f.cost)}</td>
|
||||||
|
<td class="num">
|
||||||
|
{f.revenue != null && f.cost != null
|
||||||
|
? formatCurrency(f.revenue - f.cost)
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
{f.forecastPercentage != null
|
||||||
|
? `${f.forecastPercentage}%`
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="forecast-status-badge"
|
||||||
|
class:included={f.includedFlag}
|
||||||
|
>
|
||||||
|
{f.status?.name ?? "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Revenue summary -->
|
||||||
|
<div class="forecasts-summary">
|
||||||
|
<div class="forecast-summary-item">
|
||||||
|
<span class="forecast-summary-label">Total Revenue</span>
|
||||||
|
<span class="forecast-summary-value">
|
||||||
|
{formatCurrency(
|
||||||
|
forecasts.reduce((sum, f) => sum + (f.revenue ?? 0), 0),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="forecast-summary-item">
|
||||||
|
<span class="forecast-summary-label">Total Cost</span>
|
||||||
|
<span class="forecast-summary-value">
|
||||||
|
{formatCurrency(forecasts.reduce((sum, f) => sum + (f.cost ?? 0), 0))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="forecast-summary-item">
|
||||||
|
<span class="forecast-summary-label">Total Margin</span>
|
||||||
|
<span class="forecast-summary-value">
|
||||||
|
{formatCurrency(
|
||||||
|
forecasts.reduce(
|
||||||
|
(sum, f) => sum + ((f.revenue ?? 0) - (f.cost ?? 0)),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
import type { OpportunityNote } from "../types";
|
||||||
|
import { noteAuthorInitials, formatDateTime } from "../types";
|
||||||
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
|
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
|
||||||
|
|
||||||
|
export let notes: OpportunityNote[];
|
||||||
|
export let permissions: PermissionMap;
|
||||||
|
export let opportunityId: string;
|
||||||
|
|
||||||
|
$: console.log("Notes data:", JSON.stringify(notes, null, 2));
|
||||||
|
$: if (notes.length > 0)
|
||||||
|
console.log(
|
||||||
|
"First note keys:",
|
||||||
|
Object.keys(notes[0]),
|
||||||
|
"dateEntered:",
|
||||||
|
notes[0].dateEntered,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
$: canCreate = permissions["sales.opportunity.note.create"] === true;
|
||||||
|
$: canUpdate = permissions["sales.opportunity.note.update"] === true;
|
||||||
|
$: canDelete = permissions["sales.opportunity.note.delete"] === true;
|
||||||
|
|
||||||
|
// ── Compose state ──
|
||||||
|
let composing = false;
|
||||||
|
let composeText = "";
|
||||||
|
let composeFlagged = false;
|
||||||
|
let composeSaving = false;
|
||||||
|
let composeError = "";
|
||||||
|
|
||||||
|
// ── Edit state ──
|
||||||
|
let editingNoteId: number | null = null;
|
||||||
|
let editText = "";
|
||||||
|
let editFlagged = false;
|
||||||
|
let editSaving = false;
|
||||||
|
let editError = "";
|
||||||
|
|
||||||
|
// ── Delete state ──
|
||||||
|
let deletingNoteId: number | null = null;
|
||||||
|
let deleteLoading = false;
|
||||||
|
let deleteError = "";
|
||||||
|
|
||||||
|
// ── Menu state ──
|
||||||
|
let openMenuId: number | null = null;
|
||||||
|
|
||||||
|
function toggleMenu(id: number) {
|
||||||
|
openMenuId = openMenuId === id ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMenuClickOutside(e: MouseEvent) {
|
||||||
|
if (openMenuId === null) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest(".note-menu-wrap")) return;
|
||||||
|
openMenuId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compose ──
|
||||||
|
function startCompose() {
|
||||||
|
composing = true;
|
||||||
|
composeText = "";
|
||||||
|
composeFlagged = false;
|
||||||
|
composeError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelCompose() {
|
||||||
|
composing = false;
|
||||||
|
composeText = "";
|
||||||
|
composeFlagged = false;
|
||||||
|
composeError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitNote() {
|
||||||
|
if (!composeText.trim()) return;
|
||||||
|
composeSaving = true;
|
||||||
|
composeError = "";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/sales/opportunity/${opportunityId}/notes`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: composeText.trim(),
|
||||||
|
flagged: composeFlagged,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || "Failed to create note");
|
||||||
|
}
|
||||||
|
composing = false;
|
||||||
|
composeText = "";
|
||||||
|
composeFlagged = false;
|
||||||
|
dispatch("notesChanged");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
composeError =
|
||||||
|
err instanceof Error ? err.message : "Failed to create note";
|
||||||
|
} finally {
|
||||||
|
composeSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit ──
|
||||||
|
function startEdit(note: OpportunityNote) {
|
||||||
|
editingNoteId = note.id;
|
||||||
|
editText = note.text ?? "";
|
||||||
|
editFlagged = note.flagged ?? false;
|
||||||
|
editError = "";
|
||||||
|
openMenuId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingNoteId = null;
|
||||||
|
editText = "";
|
||||||
|
editFlagged = false;
|
||||||
|
editError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit() {
|
||||||
|
if (editingNoteId === null || !editText.trim()) return;
|
||||||
|
editSaving = true;
|
||||||
|
editError = "";
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/sales/opportunity/${opportunityId}/notes/${editingNoteId}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: editText.trim(),
|
||||||
|
flagged: editFlagged,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || "Failed to update note");
|
||||||
|
}
|
||||||
|
editingNoteId = null;
|
||||||
|
editText = "";
|
||||||
|
editFlagged = false;
|
||||||
|
dispatch("notesChanged");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
editError = err instanceof Error ? err.message : "Failed to update note";
|
||||||
|
} finally {
|
||||||
|
editSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ──
|
||||||
|
function confirmDelete(noteId: number) {
|
||||||
|
deletingNoteId = noteId;
|
||||||
|
deleteError = "";
|
||||||
|
openMenuId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
deletingNoteId = null;
|
||||||
|
deleteError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDelete() {
|
||||||
|
if (deletingNoteId === null) return;
|
||||||
|
deleteLoading = true;
|
||||||
|
deleteError = "";
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/sales/opportunity/${opportunityId}/notes/${deletingNoteId}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || "Failed to delete note");
|
||||||
|
}
|
||||||
|
deletingNoteId = null;
|
||||||
|
dispatch("notesChanged");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
deleteError =
|
||||||
|
err instanceof Error ? err.message : "Failed to delete note";
|
||||||
|
} finally {
|
||||||
|
deleteLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleComposeKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitNote();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
cancelCompose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitEdit();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:click={handleMenuClickOutside} />
|
||||||
|
|
||||||
|
<div class="notes-tab">
|
||||||
|
<!-- Header bar -->
|
||||||
|
<div class="notes-header">
|
||||||
|
<div class="notes-header-left">
|
||||||
|
<span class="notes-count">
|
||||||
|
{notes.length} note{notes.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if canCreate && !composing}
|
||||||
|
<button class="notes-add-btn" on:click={startCompose} type="button">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Add Note
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compose area -->
|
||||||
|
{#if composing}
|
||||||
|
<div class="note-compose">
|
||||||
|
<textarea
|
||||||
|
class="note-compose-textarea"
|
||||||
|
placeholder="Write a note…"
|
||||||
|
bind:value={composeText}
|
||||||
|
on:keydown={handleComposeKeydown}
|
||||||
|
rows="3"
|
||||||
|
disabled={composeSaving}
|
||||||
|
></textarea>
|
||||||
|
<div class="note-compose-footer">
|
||||||
|
<label class="note-flag-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={composeFlagged}
|
||||||
|
disabled={composeSaving}
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill={composeFlagged ? "currentColor" : "none"}
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
class="flag-icon"
|
||||||
|
class:flagged={composeFlagged}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"
|
||||||
|
/><line x1="4" y1="22" x2="4" y2="15" />
|
||||||
|
</svg>
|
||||||
|
Flag
|
||||||
|
</label>
|
||||||
|
<div class="note-compose-actions">
|
||||||
|
{#if composeError}
|
||||||
|
<span class="note-error">{composeError}</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="note-btn-cancel"
|
||||||
|
on:click={cancelCompose}
|
||||||
|
disabled={composeSaving}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="note-btn-save"
|
||||||
|
on:click={submitNote}
|
||||||
|
disabled={composeSaving || !composeText.trim()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if composeSaving}
|
||||||
|
Saving…
|
||||||
|
{:else}
|
||||||
|
Save
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Notes list -->
|
||||||
|
{#if notes.length === 0 && !composing}
|
||||||
|
<div class="tab-empty">
|
||||||
|
<NoResultsMonkey message="No notes yet" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="notes-list">
|
||||||
|
{#each notes as note (note.id)}
|
||||||
|
{#if editingNoteId === note.id}
|
||||||
|
<!-- Inline edit -->
|
||||||
|
<div class="note-card editing">
|
||||||
|
<textarea
|
||||||
|
class="note-edit-textarea"
|
||||||
|
bind:value={editText}
|
||||||
|
on:keydown={handleEditKeydown}
|
||||||
|
rows="3"
|
||||||
|
disabled={editSaving}
|
||||||
|
></textarea>
|
||||||
|
<div class="note-compose-footer">
|
||||||
|
<label class="note-flag-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={editFlagged}
|
||||||
|
disabled={editSaving}
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill={editFlagged ? "currentColor" : "none"}
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
class="flag-icon"
|
||||||
|
class:flagged={editFlagged}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"
|
||||||
|
/><line x1="4" y1="22" x2="4" y2="15" />
|
||||||
|
</svg>
|
||||||
|
Flag
|
||||||
|
</label>
|
||||||
|
<div class="note-compose-actions">
|
||||||
|
{#if editError}
|
||||||
|
<span class="note-error">{editError}</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="note-btn-cancel"
|
||||||
|
on:click={cancelEdit}
|
||||||
|
disabled={editSaving}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="note-btn-save"
|
||||||
|
on:click={submitEdit}
|
||||||
|
disabled={editSaving || !editText.trim()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if editSaving}
|
||||||
|
Saving…
|
||||||
|
{:else}
|
||||||
|
Update
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Read-only note card -->
|
||||||
|
<div class="note-card" class:flagged={note.flagged}>
|
||||||
|
<div class="note-card-header">
|
||||||
|
<div class="note-header-left">
|
||||||
|
{#if canUpdate || canDelete}
|
||||||
|
<div class="note-menu-wrap">
|
||||||
|
<button
|
||||||
|
class="note-menu-btn"
|
||||||
|
on:click|stopPropagation={() => toggleMenu(note.id)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Note actions"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="3" r="1.5" />
|
||||||
|
<circle cx="8" cy="8" r="1.5" />
|
||||||
|
<circle cx="8" cy="13" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if openMenuId === note.id}
|
||||||
|
<div class="note-menu-dropdown">
|
||||||
|
{#if canUpdate}
|
||||||
|
<button
|
||||||
|
class="note-menu-item"
|
||||||
|
on:click={() => startEdit(note)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if canDelete}
|
||||||
|
<button
|
||||||
|
class="note-menu-item danger"
|
||||||
|
on:click={() => confirmDelete(note.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path
|
||||||
|
d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if note.type?.name}
|
||||||
|
<span class="note-type-badge">{note.type.name}</span>
|
||||||
|
{/if}
|
||||||
|
{#if note.flagged}
|
||||||
|
<svg
|
||||||
|
class="note-flag-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"
|
||||||
|
/><line x1="4" y1="22" x2="4" y2="15" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="note-header-right">
|
||||||
|
<div class="note-author-info">
|
||||||
|
<span class="note-author-name"
|
||||||
|
>{note.enteredBy?.name ?? "Unknown"}</span
|
||||||
|
>
|
||||||
|
<span class="note-timestamp"
|
||||||
|
>{formatDateTime(note.dateEntered)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="note-avatar"
|
||||||
|
title={note.enteredBy?.name ?? "Unknown"}
|
||||||
|
>
|
||||||
|
{noteAuthorInitials(note.enteredBy?.name)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="note-card-body">
|
||||||
|
<p class="note-text">{note.text ?? ""}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
{#if deletingNoteId !== null}
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<div class="note-delete-overlay" on:click={cancelDelete} role="presentation">
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<div
|
||||||
|
class="note-delete-modal"
|
||||||
|
on:click|stopPropagation
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<h4 class="note-delete-title">Delete Note</h4>
|
||||||
|
<p class="note-delete-msg">
|
||||||
|
Are you sure you want to delete this note? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
{#if deleteError}
|
||||||
|
<p class="note-error">{deleteError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="note-delete-actions">
|
||||||
|
<button
|
||||||
|
class="note-btn-cancel"
|
||||||
|
on:click={cancelDelete}
|
||||||
|
disabled={deleteLoading}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="note-btn-delete"
|
||||||
|
on:click={executeDelete}
|
||||||
|
disabled={deleteLoading}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if deleteLoading}
|
||||||
|
Deleting…
|
||||||
|
{:else}
|
||||||
|
Delete
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
|
||||||
|
import { statusColorClass } from "../types";
|
||||||
|
|
||||||
|
export let opportunity: SalesOpportunity | null;
|
||||||
|
export let isMobile: boolean;
|
||||||
|
export let mobileActiveTab: string | null;
|
||||||
|
|
||||||
|
// Use site address first (more specific), fall back to company address
|
||||||
|
$: address =
|
||||||
|
opportunity?.site?.address ?? opportunity?.company?.cw_Data?.address;
|
||||||
|
// Find the matching contact in allContacts for phone/email
|
||||||
|
$: allContacts = opportunity?.company?.cw_Data?.allContacts ?? [];
|
||||||
|
$: matchedContact = opportunity?.contact?.id
|
||||||
|
? (allContacts.find((c) => c.cwId === opportunity?.contact?.id) ??
|
||||||
|
allContacts[0])
|
||||||
|
: (allContacts[0] ?? null);
|
||||||
|
$: contactPhone =
|
||||||
|
matchedContact?.phone ?? opportunity?.site?.phoneNumber ?? null;
|
||||||
|
$: contactEmail = matchedContact?.email ?? null;
|
||||||
|
|
||||||
|
function formatPhone(phone: string): string {
|
||||||
|
const digits = phone.replace(/\D/g, "");
|
||||||
|
if (digits.length === 10) {
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
}
|
||||||
|
if (digits.length === 11 && digits[0] === "1") {
|
||||||
|
return `(${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
|
||||||
|
}
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="opportunity-detail-left"
|
||||||
|
class:mobile-collapsed={isMobile && mobileActiveTab !== null}
|
||||||
|
>
|
||||||
|
<div class="opp-sidebar">
|
||||||
|
<button
|
||||||
|
class="back-btn"
|
||||||
|
on:click={() => goto("/sales")}
|
||||||
|
aria-label="Back to sales"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if opportunity}
|
||||||
|
<!-- ── Headline ── -->
|
||||||
|
<div class="opp-headline">
|
||||||
|
<h3 class="opp-name">{opportunity.name}</h3>
|
||||||
|
<div class="opp-meta-row">
|
||||||
|
{#if opportunity.cwOpportunityId}
|
||||||
|
<span class="opp-number">#{opportunity.cwOpportunityId}</span>
|
||||||
|
{/if}
|
||||||
|
{#if opportunity.status}
|
||||||
|
<span class="opp-status-badge {statusColorClass(opportunity)}">
|
||||||
|
{opportunity.closedFlag ? "Closed" : opportunity.status.name}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if opportunity.type?.name}
|
||||||
|
<span class="opp-type-badge">{opportunity.type.name}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Byline (Sales Rep) ── -->
|
||||||
|
{#if opportunity.primarySalesRep?.name || opportunity.secondarySalesRep?.name}
|
||||||
|
<div class="opp-byline">
|
||||||
|
{#if opportunity.primarySalesRep?.name}
|
||||||
|
<div class="opp-byline-rep">
|
||||||
|
<div class="opp-byline-avatar">
|
||||||
|
{opportunity.primarySalesRep.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div class="opp-byline-info">
|
||||||
|
<span class="opp-byline-name"
|
||||||
|
>{opportunity.primarySalesRep.name}</span
|
||||||
|
>
|
||||||
|
<span class="opp-byline-role">Primary Rep</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if opportunity.secondarySalesRep?.name}
|
||||||
|
<div class="opp-byline-rep">
|
||||||
|
<div class="opp-byline-avatar secondary">
|
||||||
|
{opportunity.secondarySalesRep.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div class="opp-byline-info">
|
||||||
|
<span class="opp-byline-name"
|
||||||
|
>{opportunity.secondarySalesRep.name}</span
|
||||||
|
>
|
||||||
|
<span class="opp-byline-role">Secondary Rep</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="opp-sidebar-divider"></div>
|
||||||
|
|
||||||
|
<div class="opp-sidebar-info">
|
||||||
|
<!-- ── Company ── -->
|
||||||
|
{#if opportunity.company?.name}
|
||||||
|
<a
|
||||||
|
href="/companies/{opportunity.company.id}"
|
||||||
|
class="opp-info-row opp-info-row-link"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="opp-info-icon"
|
||||||
|
>
|
||||||
|
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" /><path
|
||||||
|
d="M16 3h-8l-2 4h12z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="opp-info-content">
|
||||||
|
<span class="opp-info-label">Company</span>
|
||||||
|
<span class="opp-info-value">{opportunity.company.name}</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="opp-info-arrow"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Address / Site ── -->
|
||||||
|
{#if address || opportunity.site?.name}
|
||||||
|
<div class="opp-info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="opp-info-icon"
|
||||||
|
>
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" /><circle
|
||||||
|
cx="12"
|
||||||
|
cy="10"
|
||||||
|
r="3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="opp-info-content">
|
||||||
|
{#if opportunity.site?.name}
|
||||||
|
<span class="opp-info-label">{opportunity.site.name}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="opp-info-label">Address</span>
|
||||||
|
{/if}
|
||||||
|
{#if address}
|
||||||
|
<span class="opp-info-value">
|
||||||
|
{#if address.line1}{address.line1}<br />{/if}
|
||||||
|
{#if address.line2}{address.line2}<br />{/if}
|
||||||
|
{#if address.city || address.state || address.zip}
|
||||||
|
{[address.city, address.state]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}{address.zip ? ` ${address.zip}` : ""}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="opp-info-muted">No address on file</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Contact ── -->
|
||||||
|
{#if opportunity.contact?.name || matchedContact}
|
||||||
|
<div class="opp-info-row">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
class="opp-info-icon"
|
||||||
|
>
|
||||||
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle
|
||||||
|
cx="12"
|
||||||
|
cy="7"
|
||||||
|
r="4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="opp-info-content">
|
||||||
|
<span class="opp-info-label">Contact</span>
|
||||||
|
<span class="opp-info-value">
|
||||||
|
{opportunity.contact?.name ??
|
||||||
|
[matchedContact?.firstName, matchedContact?.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ") ??
|
||||||
|
"\u2014"}
|
||||||
|
</span>
|
||||||
|
{#if matchedContact?.title}
|
||||||
|
<span class="opp-info-sub">{matchedContact.title}</span>
|
||||||
|
{/if}
|
||||||
|
{#if contactPhone}
|
||||||
|
<a href="tel:{contactPhone}" class="opp-info-phone">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{formatPhone(contactPhone)}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if contactEmail}
|
||||||
|
<a href="mailto:{contactEmail}" class="opp-info-email">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
|
||||||
|
/><polyline points="22,6 12,13 2,6" />
|
||||||
|
</svg>
|
||||||
|
{contactEmail}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Description ── -->
|
||||||
|
{#if opportunity.notes}
|
||||||
|
<div class="opp-desc-section">
|
||||||
|
<div class="opp-desc-header">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
|
||||||
|
/><polyline points="14 2 14 8 20 8" /><line
|
||||||
|
x1="16"
|
||||||
|
y1="13"
|
||||||
|
x2="8"
|
||||||
|
y2="13"
|
||||||
|
/><line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
<span class="opp-desc-label">Description</span>
|
||||||
|
</div>
|
||||||
|
<div class="opp-desc-card">
|
||||||
|
<p class="opp-desc-text">{opportunity.notes}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="opp-sidebar-empty">
|
||||||
|
<p>Opportunity not found.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
|
||||||
|
import type { OpportunityNote, OpportunityContact } from "../types";
|
||||||
|
import { formatDate, formatCurrency, statusColorClass } from "../types";
|
||||||
|
|
||||||
|
export let opportunity: SalesOpportunity | null;
|
||||||
|
export let notes: OpportunityNote[];
|
||||||
|
export let contacts: OpportunityContact[];
|
||||||
|
|
||||||
|
// Timeline entries — built dynamically from available dates
|
||||||
|
$: timeline = [
|
||||||
|
opportunity?.dateBecameLead
|
||||||
|
? { label: "Became Lead", date: opportunity.dateBecameLead, icon: "lead" }
|
||||||
|
: null,
|
||||||
|
opportunity?.pipelineChangeDate
|
||||||
|
? {
|
||||||
|
label: "Pipeline Changed",
|
||||||
|
date: opportunity.pipelineChangeDate,
|
||||||
|
icon: "pipeline",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
opportunity?.expectedCloseDate
|
||||||
|
? {
|
||||||
|
label: "Expected Close",
|
||||||
|
date: opportunity.expectedCloseDate,
|
||||||
|
icon: "target",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
opportunity?.closedDate
|
||||||
|
? { label: "Closed", date: opportunity.closedDate, icon: "closed" }
|
||||||
|
: null,
|
||||||
|
].filter(Boolean) as { label: string; date: string; icon: string }[];
|
||||||
|
|
||||||
|
// Days until expected close
|
||||||
|
$: daysUntilClose = (() => {
|
||||||
|
if (!opportunity?.expectedCloseDate || opportunity?.closedFlag) return null;
|
||||||
|
const diff = Math.ceil(
|
||||||
|
(new Date(opportunity.expectedCloseDate).getTime() - Date.now()) /
|
||||||
|
(1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
return diff;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overview-tab">
|
||||||
|
<!-- ═══ Pipeline Banner ═══ -->
|
||||||
|
<div class="ov-pipeline-banner">
|
||||||
|
<div class="ov-pipeline-stages">
|
||||||
|
{#if opportunity?.stage?.name}
|
||||||
|
<div class="ov-pipeline-chip stage">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
{opportunity.stage.name}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if opportunity?.priority?.name}
|
||||||
|
<div class="ov-pipeline-chip priority">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||||
|
</svg>
|
||||||
|
{opportunity.priority.name}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if opportunity?.rating?.name}
|
||||||
|
<div class="ov-pipeline-chip rating">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{opportunity.rating.name}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if daysUntilClose !== null}
|
||||||
|
<div
|
||||||
|
class="ov-close-countdown"
|
||||||
|
class:overdue={daysUntilClose < 0}
|
||||||
|
class:soon={daysUntilClose >= 0 && daysUntilClose <= 14}
|
||||||
|
>
|
||||||
|
<span class="ov-close-number">{Math.abs(daysUntilClose)}</span>
|
||||||
|
<span class="ov-close-unit">
|
||||||
|
{daysUntilClose < 0
|
||||||
|
? `day${Math.abs(daysUntilClose) !== 1 ? "s" : ""} overdue`
|
||||||
|
: `day${daysUntilClose !== 1 ? "s" : ""} to close`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Deal Metrics ═══ -->
|
||||||
|
<div class="ov-metrics-row">
|
||||||
|
{#if opportunity?.expectedCloseDate}
|
||||||
|
<div class="ov-metric-card">
|
||||||
|
<div class="ov-metric-icon close">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" /><polyline
|
||||||
|
points="12 6 12 12 16 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ov-metric-body">
|
||||||
|
<span class="ov-metric-label">Expected Close</span>
|
||||||
|
<span class="ov-metric-value"
|
||||||
|
>{formatDate(opportunity.expectedCloseDate)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if opportunity?.totalSalesTax != null}
|
||||||
|
<div class="ov-metric-card">
|
||||||
|
<div class="ov-metric-icon money">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="1" x2="12" y2="23" /><path
|
||||||
|
d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ov-metric-body">
|
||||||
|
<span class="ov-metric-label">Sales Tax</span>
|
||||||
|
<span class="ov-metric-value"
|
||||||
|
>{formatCurrency(opportunity.totalSalesTax)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if opportunity?.source}
|
||||||
|
<div class="ov-metric-card">
|
||||||
|
<div class="ov-metric-icon source">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
>
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ov-metric-body">
|
||||||
|
<span class="ov-metric-label">Source</span>
|
||||||
|
<span class="ov-metric-value">{opportunity.source}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="ov-metric-card">
|
||||||
|
<div class="ov-metric-icon activity">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
>
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle
|
||||||
|
cx="9"
|
||||||
|
cy="7"
|
||||||
|
r="4"
|
||||||
|
/><path d="M23 21v-2a4 4 0 00-3-3.87" /><path
|
||||||
|
d="M16 3.13a4 4 0 010 7.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ov-metric-body">
|
||||||
|
<span class="ov-metric-label">Activity</span>
|
||||||
|
<span class="ov-metric-value"
|
||||||
|
>{notes.length} notes · {contacts.length} contacts</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Timeline ═══ -->
|
||||||
|
{#if timeline.length > 0}
|
||||||
|
<div class="ov-section">
|
||||||
|
<h3 class="ov-section-title">Timeline</h3>
|
||||||
|
<div class="ov-timeline">
|
||||||
|
{#each timeline as entry, i}
|
||||||
|
<div class="ov-timeline-item" class:last={i === timeline.length - 1}>
|
||||||
|
<div class="ov-timeline-dot"></div>
|
||||||
|
<div class="ov-timeline-content">
|
||||||
|
<span class="ov-timeline-label">{entry.label}</span>
|
||||||
|
<span class="ov-timeline-date">{formatDate(entry.date)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ═══ Details ═══ -->
|
||||||
|
<div class="ov-section">
|
||||||
|
<h3 class="ov-section-title">Details</h3>
|
||||||
|
<div class="ov-details-grid">
|
||||||
|
{#if opportunity?.cwOpportunityId}
|
||||||
|
<div class="ov-detail">
|
||||||
|
<span class="ov-detail-label">CW Opportunity ID</span>
|
||||||
|
<span class="ov-detail-value mono">{opportunity.cwOpportunityId}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if opportunity?.customerPO}
|
||||||
|
<div class="ov-detail">
|
||||||
|
<span class="ov-detail-label">Customer PO</span>
|
||||||
|
<span class="ov-detail-value">{opportunity.customerPO}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if opportunity?.campaign}
|
||||||
|
<div class="ov-detail">
|
||||||
|
<span class="ov-detail-label">Campaign</span>
|
||||||
|
<span class="ov-detail-value">{opportunity.campaign}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if opportunity?.location?.name}
|
||||||
|
<div class="ov-detail">
|
||||||
|
<span class="ov-detail-label">Location</span>
|
||||||
|
<span class="ov-detail-value">{opportunity.location.name}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if opportunity?.department?.name}
|
||||||
|
<div class="ov-detail">
|
||||||
|
<span class="ov-detail-label">Department</span>
|
||||||
|
<span class="ov-detail-value">{opportunity.department.name}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if opportunity?.closedBy}
|
||||||
|
<div class="ov-detail">
|
||||||
|
<span class="ov-detail-label">Closed By</span>
|
||||||
|
<span class="ov-detail-value">{opportunity.closedBy}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="ov-detail">
|
||||||
|
<span class="ov-detail-label">Last Synced</span>
|
||||||
|
<span class="ov-detail-value"
|
||||||
|
>{formatDate(opportunity?.cwLastUpdated)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
import { optima } from "$lib";
|
||||||
|
import { json, error } from "@sveltejs/kit";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
|
/** POST /sales/opportunity/[id]/notes — create a note */
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) throw error(401, "Unauthorized");
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.text?.trim()) throw error(400, "Note text is required");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await optima.sales.createNote(accessToken, params.id, {
|
||||||
|
text: body.text.trim(),
|
||||||
|
flagged: body.flagged ?? false,
|
||||||
|
});
|
||||||
|
return json(result, { status: 201 });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("Failed to create note:", err);
|
||||||
|
const status =
|
||||||
|
err && typeof err === "object" && "status" in err
|
||||||
|
? (err as { status: number }).status
|
||||||
|
: 500;
|
||||||
|
throw error(status, "Failed to create note");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { optima } from "$lib";
|
||||||
|
import { json, error } from "@sveltejs/kit";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
|
/** PATCH /sales/opportunity/[id]/notes/[noteId] — update a note */
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) throw error(401, "Unauthorized");
|
||||||
|
|
||||||
|
const noteId = Number(params.noteId);
|
||||||
|
if (isNaN(noteId)) throw error(400, "Invalid note ID");
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.text?.trim() && body.flagged === undefined) {
|
||||||
|
throw error(400, "At least text or flagged must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: { text?: string; flagged?: boolean } = {};
|
||||||
|
if (body.text?.trim()) payload.text = body.text.trim();
|
||||||
|
if (body.flagged !== undefined) payload.flagged = body.flagged;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await optima.sales.updateNote(
|
||||||
|
accessToken,
|
||||||
|
params.id,
|
||||||
|
noteId,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("Failed to update note:", err);
|
||||||
|
const status =
|
||||||
|
err && typeof err === "object" && "status" in err
|
||||||
|
? (err as { status: number }).status
|
||||||
|
: 500;
|
||||||
|
throw error(status, "Failed to update note");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** DELETE /sales/opportunity/[id]/notes/[noteId] — delete a note */
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const accessToken = locals.session?.accessToken;
|
||||||
|
if (!accessToken) throw error(401, "Unauthorized");
|
||||||
|
|
||||||
|
const noteId = Number(params.noteId);
|
||||||
|
if (isNaN(noteId)) throw error(400, "Invalid note ID");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await optima.sales.deleteNote(
|
||||||
|
accessToken,
|
||||||
|
params.id,
|
||||||
|
noteId,
|
||||||
|
);
|
||||||
|
return json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("Failed to delete note:", err);
|
||||||
|
const status =
|
||||||
|
err && typeof err === "object" && "status" in err
|
||||||
|
? (err as { status: number }).status
|
||||||
|
: 500;
|
||||||
|
throw error(status, "Failed to delete note");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import type { SalesOpportunity } from "$lib/optima-api/modules/sales";
|
||||||
|
import type { PermissionMap } from "$lib/permissions";
|
||||||
|
|
||||||
|
export interface OpportunityForecast {
|
||||||
|
id: number;
|
||||||
|
forecastType?: string;
|
||||||
|
forecastMonth?: string;
|
||||||
|
revenue?: number;
|
||||||
|
cost?: number;
|
||||||
|
forecastPercentage?: number;
|
||||||
|
status?: { id: number; name: string };
|
||||||
|
includedFlag?: boolean;
|
||||||
|
linkedFlag?: boolean;
|
||||||
|
recurringFlag?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteAuthor {
|
||||||
|
id: string;
|
||||||
|
identifier: string;
|
||||||
|
name: string;
|
||||||
|
cwMemberId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpportunityNote {
|
||||||
|
id: number;
|
||||||
|
text?: string;
|
||||||
|
type?: { id: number; name: string };
|
||||||
|
flagged?: boolean;
|
||||||
|
enteredBy?: NoteAuthor | null;
|
||||||
|
dateEntered?: string;
|
||||||
|
_info?: { lastUpdated?: string; updatedBy?: string };
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noteAuthorInitials(name?: string): string {
|
||||||
|
if (!name) return "?";
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0]?.toUpperCase() ?? "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(dateStr?: string | null): string {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpportunityProduct {
|
||||||
|
id: number;
|
||||||
|
forecastDescription?: string;
|
||||||
|
productDescription?: string;
|
||||||
|
productClass?: string;
|
||||||
|
forecastType?: string;
|
||||||
|
quantity?: number;
|
||||||
|
revenue?: number;
|
||||||
|
cost?: number;
|
||||||
|
margin?: number;
|
||||||
|
profit?: number;
|
||||||
|
percentage?: number;
|
||||||
|
status?: { id: number; name: string };
|
||||||
|
catalogItem?: { id: number; identifier: string };
|
||||||
|
opportunity?: { id: number; name: string };
|
||||||
|
includeFlag?: boolean;
|
||||||
|
linkFlag?: boolean;
|
||||||
|
recurringFlag?: boolean;
|
||||||
|
taxableFlag?: boolean;
|
||||||
|
cancelled?: boolean;
|
||||||
|
cancellationType?: "full" | "partial" | null;
|
||||||
|
quantityCancelled?: number;
|
||||||
|
cancelledReason?: string | null;
|
||||||
|
cancelledDate?: string | null;
|
||||||
|
recurringRevenue?: number;
|
||||||
|
recurringCost?: number;
|
||||||
|
cycles?: number;
|
||||||
|
sequenceNumber?: number;
|
||||||
|
subNumber?: number;
|
||||||
|
cwLastUpdated?: string;
|
||||||
|
cwUpdatedBy?: string;
|
||||||
|
onHand?: number | null;
|
||||||
|
inStock?: boolean | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpportunityContact {
|
||||||
|
id: number;
|
||||||
|
contact?: { id: number; name: string };
|
||||||
|
company?: { id: number; identifier?: string; name: string };
|
||||||
|
role?: { id: number; name: string };
|
||||||
|
notes?: string;
|
||||||
|
referralFlag?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageData {
|
||||||
|
opportunity: SalesOpportunity | null;
|
||||||
|
opportunityId: string;
|
||||||
|
notes: OpportunityNote[];
|
||||||
|
contacts: OpportunityContact[];
|
||||||
|
products: OpportunityProduct[];
|
||||||
|
accessToken: string | null;
|
||||||
|
permissions: PermissionMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function opportunityInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateStr?: string | null): string {
|
||||||
|
if (!dateStr) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount?: number | null): string {
|
||||||
|
if (amount == null) return "—";
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusColorClass(opportunity: SalesOpportunity): string {
|
||||||
|
if (opportunity.closedFlag) return "status-closed";
|
||||||
|
const name = opportunity.status?.name?.toLowerCase();
|
||||||
|
if (!name) return "status-open";
|
||||||
|
if (name === "won") return "status-won";
|
||||||
|
if (name === "lost") return "status-lost";
|
||||||
|
if (name === "inactive") return "status-inactive";
|
||||||
|
return "status-open";
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -373,11 +373,57 @@
|
|||||||
color: var(--status-active-color, #16a34a);
|
color: var(--status-active-color, #16a34a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sales-status-badge.status-won {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sales-status-badge.status-lost {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sales-status-badge.status-inactive {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
.sales-status-badge.status-closed {
|
.sales-status-badge.status-closed {
|
||||||
background: var(--status-inactive-bg, #fee2e2);
|
background: var(--status-inactive-bg, #fee2e2);
|
||||||
color: var(--status-inactive-color, #dc2626);
|
color: var(--status-inactive-color, #dc2626);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sales-status-badge.status-equiv {
|
||||||
|
border: 1px dashed currentColor;
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sales-status-badge.status-equiv[data-tooltip]::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--tooltip-bg, #1e293b);
|
||||||
|
color: var(--tooltip-color, #f8fafc);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sales-status-badge.status-equiv[data-tooltip]:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.sales-priority {
|
.sales-priority {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
if (accessToken && refreshToken) {
|
if (accessToken && refreshToken) {
|
||||||
const newSession = await optima.user.refreshSession(refreshToken);
|
const newSession = await optima.user.refreshSession(refreshToken);
|
||||||
|
|
||||||
console.log(newSession);
|
|
||||||
|
|
||||||
event.cookies.set("access_token", newSession.accessToken, {
|
event.cookies.set("access_token", newSession.accessToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
|
|||||||
Reference in New Issue
Block a user