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:
2026-03-01 13:08:58 -06:00
parent 27755d4a00
commit 4bec198db6
30 changed files with 10810 additions and 83 deletions
+244 -1
View File
@@ -174,7 +174,12 @@ describe("optima api modules", () => {
.mockResolvedValueOnce({ data: { data: [] } })
.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);
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 () => {
mockApi.post.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 () => {
mockApi.get.mockResolvedValue({ data: { data: [] } });
mockApi.post.mockResolvedValueOnce({ data: { data: { results: [] } } });
@@ -58,8 +58,6 @@ export const credential = {
accessToken: string,
data: Omit<Credential, "id" | "createdAt" | "updatedAt">,
) {
console.log(data);
const response = await api.post("/v1/credential/credentials", data, {
headers: {
Authorization: `Bearer ${accessToken}`,
+105 -4
View File
@@ -1,16 +1,86 @@
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 = {
async fetchMany(
accessToken: string,
page: number = 1,
search?: string,
filters: CatalogItemFilters = {},
rpp: number = 30,
includeInactive: boolean = false,
) {
const params: Record<string, unknown> = { page, rpp };
if (search && search.length > 0) params.search = search;
if (includeInactive) params.includeInactive = true;
if (filters.search && filters.search.length > 0)
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", {
params,
@@ -101,4 +171,35 @@ export const procurement = {
);
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;
},
};
+206 -2
View File
@@ -22,11 +22,54 @@ export interface SalesOpportunity {
identifier?: string;
name?: string;
} | 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;
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;
totalSalesTax?: number | null;
location?: { id?: number; name?: string } | null;
department?: { id?: number; name?: string } | null;
expectedCloseDate?: string | null;
pipelineChangeDate?: string | null;
dateBecameLead?: string | null;
@@ -39,6 +82,21 @@ export interface SalesOpportunity {
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 = {
async fetchMany(
accessToken: string,
@@ -59,4 +117,150 @@ export const sales = {
});
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;
},
};
-1
View File
@@ -25,7 +25,6 @@ export const user = {
)
).data.data;
console.log("Refreshed tokens:", refreshedTokens);
return refreshedTokens;
},
+20
View File
@@ -61,6 +61,26 @@ describe("permissions helpers", () => {
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", () => {
expect(hasPermission({ "company.read": true }, "company.read")).toBe(true);
expect(hasPermission({ "company.read": false }, "company.read")).toBe(
+20 -5
View File
@@ -25,18 +25,33 @@ export async function checkPermissions(
): Promise<PermissionMap> {
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 {
const result = await optima.user.checkPermissions(accessToken, permissions);
const results: Array<{ permission: string; hasPermission: boolean }> =
result?.data?.results ?? [];
return results.reduce<PermissionMap>((map, entry) => {
map[entry.permission] = entry.hasPermission === true;
return map;
const map = results.reduce<PermissionMap>((m, entry) => {
m[entry.permission] = entry.hasPermission === true;
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
// 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.