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