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
File diff suppressed because it is too large Load Diff
+244 -1
View File
@@ -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}`,
+105 -4
View File
@@ -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;
},
}; };
+206 -2
View File
@@ -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;
},
}; };
-1
View File
@@ -25,7 +25,6 @@ export const user = {
) )
).data.data; ).data.data;
console.log("Refreshed tokens:", refreshedTokens);
return refreshedTokens; return refreshedTokens;
}, },
+20
View File
@@ -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
View File
@@ -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.
-1
View File
@@ -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;
+39 -21
View File
@@ -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) {
+7 -21
View File
@@ -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
View File
@@ -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>
+557
View File
@@ -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");
}
};
+156
View File
@@ -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
+46
View File
@@ -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;
-2
View File
@@ -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: "/",