diff --git a/src/components/AddProductModal.svelte b/src/components/AddProductModal.svelte new file mode 100644 index 0000000..7650129 --- /dev/null +++ b/src/components/AddProductModal.svelte @@ -0,0 +1,3440 @@ + + +{#if isOpen} + + +{/if} + + diff --git a/src/lib/optima-api/modules/api-modules.spec.ts b/src/lib/optima-api/modules/api-modules.spec.ts index 7d1d2f5..c98760f 100644 --- a/src/lib/optima-api/modules/api-modules.spec.ts +++ b/src/lib/optima-api/modules/api-modules.spec.ts @@ -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: [] } } }); diff --git a/src/lib/optima-api/modules/credentials.ts b/src/lib/optima-api/modules/credentials.ts index 1e8b396..e841f7e 100644 --- a/src/lib/optima-api/modules/credentials.ts +++ b/src/lib/optima-api/modules/credentials.ts @@ -58,8 +58,6 @@ export const credential = { accessToken: string, data: Omit, ) { - console.log(data); - const response = await api.post("/v1/credential/credentials", data, { headers: { Authorization: `Bearer ${accessToken}`, diff --git a/src/lib/optima-api/modules/procurement.ts b/src/lib/optima-api/modules/procurement.ts index d03e407..78ab614 100644 --- a/src/lib/optima-api/modules/procurement.ts +++ b/src/lib/optima-api/modules/procurement.ts @@ -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 = { 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 { + 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 { + const params: Record = {}; + 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; + }, }; diff --git a/src/lib/optima-api/modules/sales.ts b/src/lib/optima-api/modules/sales.ts index 0626806..3ab89cb 100644 --- a/src/lib/optima-api/modules/sales.ts +++ b/src/lib/optima-api/modules/sales.ts @@ -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; + }, }; diff --git a/src/lib/optima-api/modules/user.ts b/src/lib/optima-api/modules/user.ts index 067beba..7a6686d 100644 --- a/src/lib/optima-api/modules/user.ts +++ b/src/lib/optima-api/modules/user.ts @@ -25,7 +25,6 @@ export const user = { ) ).data.data; - console.log("Refreshed tokens:", refreshedTokens); return refreshedTokens; }, diff --git a/src/lib/permissions.spec.ts b/src/lib/permissions.spec.ts index 5934db0..897b446 100644 --- a/src/lib/permissions.spec.ts +++ b/src/lib/permissions.spec.ts @@ -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( diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index d49ab9e..c7982e3 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -25,18 +25,33 @@ export async function checkPermissions( ): Promise { if (!permissions.length) return {}; + if (!accessToken) { + // Return all-true so UI doesn't hide features + const map = permissions.reduce((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((map, entry) => { - map[entry.permission] = entry.hasPermission === true; - return map; + const map = results.reduce((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. diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 58a8288..b2f3a30 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -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", diff --git a/src/routes/admin/credential-types/+page.server.ts b/src/routes/admin/credential-types/+page.server.ts index dfea1a5..6b4822c 100644 --- a/src/routes/admin/credential-types/+page.server.ts +++ b/src/routes/admin/credential-types/+page.server.ts @@ -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 | undefined; diff --git a/src/routes/companies/[id]/+page.server.ts b/src/routes/companies/[id]/+page.server.ts index 3039380..edaa59e 100644 --- a/src/routes/companies/[id]/+page.server.ts +++ b/src/routes/companies/[id]/+page.server.ts @@ -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 { diff --git a/src/routes/procurement/catalog/search/+server.ts b/src/routes/procurement/catalog/search/+server.ts index 3cfe67e..cc00922 100644 --- a/src/routes/procurement/catalog/search/+server.ts +++ b/src/routes/procurement/catalog/search/+server.ts @@ -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) { diff --git a/src/routes/sales/+page.server.ts b/src/routes/sales/+page.server.ts index 9e76052..2fbb344 100644 --- a/src/routes/sales/+page.server.ts +++ b/src/routes/sales/+page.server.ts @@ -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, diff --git a/src/routes/sales/+page.svelte b/src/routes/sales/+page.svelte index 0832c68..fc2c5d1 100644 --- a/src/routes/sales/+page.svelte +++ b/src/routes/sales/+page.svelte @@ -1,6 +1,7 @@ + + + + + Sales — Project Optima + + +{#if !hasAccess} +
+ + + + +

Access Denied

+

+ You don't have permission to view Sales opportunities. Contact your + administrator to request access. +

+
+{:else} +
+
+
+
+

Sales Opportunities

+ {#if totalRecords > 0} + + {totalRecords} record{totalRecords === 1 ? "" : "s"} + + {/if} +
+
+ + +
+ + + {#if filterOpen} +
+ +
+ {/if} +
+
+
+ +
+
+ {#if opportunities.length === 0} +
+ +
+ {:else} + + + + + + + + + + + + + + + {#each opportunities as opp (opp.id)} + goto(`/sales/opportunity/${opp.id}`)} + style="cursor: pointer;" + > + + + + + + + + + + {/each} + +
OpportunityCompanyStageStatusPriorityOwnerExpected CloseUpdated
+
+ {opp.name} + {#if opp.cwOpportunityId} + CW #{opp.cwOpportunityId} + {/if} +
+
{companyLabel(opp)}{opp.stage?.name || "—"} + + {statusLabel(opp)} + + + + {priorityLabel(opp)} + + {ownerLabel(opp)} + {formatDate(opp.expectedCloseDate)} + + {formatDate(opp.cwLastUpdated || opp.updatedAt)} +
+ {/if} +
+
+ + {#if totalPages > 1} + + {/if} +
+
+{/if} + + diff --git a/src/routes/sales/opportunity/[id]/+page.server.ts b/src/routes/sales/opportunity/[id]/+page.server.ts new file mode 100644 index 0000000..7d4398a --- /dev/null +++ b/src/routes/sales/opportunity/[id]/+page.server.ts @@ -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); + } +}; diff --git a/src/routes/sales/opportunity/[id]/+page.svelte b/src/routes/sales/opportunity/[id]/+page.svelte new file mode 100644 index 0000000..36399ab --- /dev/null +++ b/src/routes/sales/opportunity/[id]/+page.svelte @@ -0,0 +1,242 @@ + + + + {opportunity?.name ?? "Opportunity"} — Project Optima + + +
+ + + + + {#if isMobile && mobileActiveTab === null} +
+ {#each tabs as tab} + + {/each} +
+ {/if} + + +
+ + {#if isMobile && mobileActiveTab !== null} +
+ +

{mobileActiveTab}

+
+ {/if} + +
+ {#each tabs as tab} + + {/each} +
+
+ {#if activeTab === "Overview"} + + {:else if activeTab === "Products"} + + {:else if activeTab === "Notes"} + { + invalidateAll(); + }} + /> + {:else if activeTab === "Contacts"} + + {:else if activeTab === "Activity"} + + {/if} +
+
+
diff --git a/src/routes/sales/opportunity/[id]/components/ActivityTab.svelte b/src/routes/sales/opportunity/[id]/components/ActivityTab.svelte new file mode 100644 index 0000000..3a2f233 --- /dev/null +++ b/src/routes/sales/opportunity/[id]/components/ActivityTab.svelte @@ -0,0 +1,21 @@ + + +
+
+

+ + + + Recent Activity +

+

Activity feed coming soon.

+
+
diff --git a/src/routes/sales/opportunity/[id]/components/ContactsTab.svelte b/src/routes/sales/opportunity/[id]/components/ContactsTab.svelte new file mode 100644 index 0000000..ea30ab6 --- /dev/null +++ b/src/routes/sales/opportunity/[id]/components/ContactsTab.svelte @@ -0,0 +1,52 @@ + + +
+ {#if contacts.length === 0} +
+ +
+ {:else} +
+ {#each contacts as c (c.id)} +
+
+ + + +
+
+ {c.contact?.name ?? "Unknown"} + {#if c.role?.name} + {c.role.name} + {/if} + {#if c.company?.name} + {c.company.name} + {/if} + {#if c.notes} + {c.notes} + {/if} +
+ {#if c.referralFlag} + Referral + {/if} +
+ {/each} +
+ {/if} +
diff --git a/src/routes/sales/opportunity/[id]/components/ForecastsTab.svelte b/src/routes/sales/opportunity/[id]/components/ForecastsTab.svelte new file mode 100644 index 0000000..d143d51 --- /dev/null +++ b/src/routes/sales/opportunity/[id]/components/ForecastsTab.svelte @@ -0,0 +1,88 @@ + + +
+ {#if forecasts.length === 0} +
+ +
+ {:else} +
+ + + + + + + + + + + + + + {#each forecasts as f (f.id)} + + + + + + + + + + {/each} + +
TypeMonthRevenueCostMarginProbabilityStatus
{f.forecastType ?? "—"}{formatDate(f.forecastMonth)}{formatCurrency(f.revenue)}{formatCurrency(f.cost)} + {f.revenue != null && f.cost != null + ? formatCurrency(f.revenue - f.cost) + : "—"} + + {f.forecastPercentage != null + ? `${f.forecastPercentage}%` + : "—"} + + + {f.status?.name ?? "—"} + +
+
+ + +
+
+ Total Revenue + + {formatCurrency( + forecasts.reduce((sum, f) => sum + (f.revenue ?? 0), 0), + )} + +
+
+ Total Cost + + {formatCurrency(forecasts.reduce((sum, f) => sum + (f.cost ?? 0), 0))} + +
+
+ Total Margin + + {formatCurrency( + forecasts.reduce( + (sum, f) => sum + ((f.revenue ?? 0) - (f.cost ?? 0)), + 0, + ), + )} + +
+
+ {/if} +
diff --git a/src/routes/sales/opportunity/[id]/components/NotesTab.svelte b/src/routes/sales/opportunity/[id]/components/NotesTab.svelte new file mode 100644 index 0000000..3fe5e5c --- /dev/null +++ b/src/routes/sales/opportunity/[id]/components/NotesTab.svelte @@ -0,0 +1,531 @@ + + + + +
+ +
+
+ + {notes.length} note{notes.length === 1 ? "" : "s"} + +
+ {#if canCreate && !composing} + + {/if} +
+ + + {#if composing} +
+ + +
+ {/if} + + + {#if notes.length === 0 && !composing} +
+ +
+ {:else} +
+ {#each notes as note (note.id)} + {#if editingNoteId === note.id} + +
+ + +
+ {:else} + +
+
+
+ {#if canUpdate || canDelete} +
+ + {#if openMenuId === note.id} +
+ {#if canUpdate} + + {/if} + {#if canDelete} + + {/if} +
+ {/if} +
+ {/if} + {#if note.type?.name} + {note.type.name} + {/if} + {#if note.flagged} + + + + {/if} +
+
+
+ {note.enteredBy?.name ?? "Unknown"} + {formatDateTime(note.dateEntered)} +
+
+ {noteAuthorInitials(note.enteredBy?.name)} +
+
+
+
+

{note.text ?? ""}

+
+
+ {/if} + {/each} +
+ {/if} +
+ + +{#if deletingNoteId !== null} + + +{/if} diff --git a/src/routes/sales/opportunity/[id]/components/OpportunitySidebar.svelte b/src/routes/sales/opportunity/[id]/components/OpportunitySidebar.svelte new file mode 100644 index 0000000..8fcd4f9 --- /dev/null +++ b/src/routes/sales/opportunity/[id]/components/OpportunitySidebar.svelte @@ -0,0 +1,285 @@ + + +
+
+ + {#if opportunity} + +
+

{opportunity.name}

+
+ {#if opportunity.cwOpportunityId} + #{opportunity.cwOpportunityId} + {/if} + {#if opportunity.status} + + {opportunity.closedFlag ? "Closed" : opportunity.status.name} + + {/if} + {#if opportunity.type?.name} + {opportunity.type.name} + {/if} +
+
+ + + {#if opportunity.primarySalesRep?.name || opportunity.secondarySalesRep?.name} + + {/if} + +
+ +
+ + {#if opportunity.company?.name} + + + + +
+ Company + {opportunity.company.name} +
+ + + +
+ {/if} + + + {#if address || opportunity.site?.name} +
+ + + +
+ {#if opportunity.site?.name} + {opportunity.site.name} + {:else} + Address + {/if} + {#if address} + + {#if address.line1}{address.line1}
{/if} + {#if address.line2}{address.line2}
{/if} + {#if address.city || address.state || address.zip} + {[address.city, address.state] + .filter(Boolean) + .join(", ")}{address.zip ? ` ${address.zip}` : ""} + {/if} +
+ {:else} + No address on file + {/if} +
+
+ {/if} + + + {#if opportunity.contact?.name || matchedContact} +
+ + + +
+ Contact + + {opportunity.contact?.name ?? + [matchedContact?.firstName, matchedContact?.lastName] + .filter(Boolean) + .join(" ") ?? + "\u2014"} + + {#if matchedContact?.title} + {matchedContact.title} + {/if} + {#if contactPhone} + + + + + {formatPhone(contactPhone)} + + {/if} + {#if contactEmail} + + + + + {contactEmail} + + {/if} +
+
+ {/if} + + + {#if opportunity.notes} +
+
+ + + + Description +
+
+

{opportunity.notes}

+
+
+ {/if} +
+ {:else} +
+

Opportunity not found.

+
+ {/if} +
+
diff --git a/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte b/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte new file mode 100644 index 0000000..bf3925a --- /dev/null +++ b/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte @@ -0,0 +1,282 @@ + + +
+ +
+
+ {#if opportunity?.stage?.name} +
+ + + + {opportunity.stage.name} +
+ {/if} + {#if opportunity?.priority?.name} +
+ + + + {opportunity.priority.name} +
+ {/if} + {#if opportunity?.rating?.name} +
+ + + + {opportunity.rating.name} +
+ {/if} +
+ {#if daysUntilClose !== null} +
= 0 && daysUntilClose <= 14} + > + {Math.abs(daysUntilClose)} + + {daysUntilClose < 0 + ? `day${Math.abs(daysUntilClose) !== 1 ? "s" : ""} overdue` + : `day${daysUntilClose !== 1 ? "s" : ""} to close`} + +
+ {/if} +
+ + +
+ {#if opportunity?.expectedCloseDate} +
+
+ + + +
+
+ Expected Close + {formatDate(opportunity.expectedCloseDate)} +
+
+ {/if} + {#if opportunity?.totalSalesTax != null} +
+
+ + + +
+
+ Sales Tax + {formatCurrency(opportunity.totalSalesTax)} +
+
+ {/if} + {#if opportunity?.source} +
+
+ + + +
+
+ Source + {opportunity.source} +
+
+ {/if} +
+
+ + + +
+
+ Activity + {notes.length} notes · {contacts.length} contacts +
+
+
+ + + {#if timeline.length > 0} +
+

Timeline

+
+ {#each timeline as entry, i} +
+
+
+ {entry.label} + {formatDate(entry.date)} +
+
+ {/each} +
+
+ {/if} + + +
+

Details

+
+ {#if opportunity?.cwOpportunityId} +
+ CW Opportunity ID + {opportunity.cwOpportunityId} +
+ {/if} + {#if opportunity?.customerPO} +
+ Customer PO + {opportunity.customerPO} +
+ {/if} + {#if opportunity?.campaign} +
+ Campaign + {opportunity.campaign} +
+ {/if} + {#if opportunity?.location?.name} +
+ Location + {opportunity.location.name} +
+ {/if} + {#if opportunity?.department?.name} +
+ Department + {opportunity.department.name} +
+ {/if} + {#if opportunity?.closedBy} +
+ Closed By + {opportunity.closedBy} +
+ {/if} +
+ Last Synced + {formatDate(opportunity?.cwLastUpdated)} +
+
+
+
diff --git a/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte b/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte new file mode 100644 index 0000000..a79fbef --- /dev/null +++ b/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte @@ -0,0 +1,2652 @@ + + + + +
+ {#if products.length === 0} +
+ +
+ {:else} + +
+
+
+ + + +
+
+ Revenue + {formatCurrency(totalRevenue)} +
+
+
+
+ + + +
+
+ Cost + {formatCurrency(totalCost)} +
+
+
+
+ + + +
+
+ Margin + {formatCurrency(totalMargin)} +
+ + {marginPct.toFixed(1)}% + +
+
+
+ + + +
+
+ Profit + {formatCurrency(totalProfit)} +
+
+
+ + +
+
+ + +
+ {#if hasChanges} +
+ {#if saveError} + {saveError} + {/if} + + +
+ {/if} +
+ + +
+ +
+
+ + {#if viewMode === "compact"} +
+ {#each activeProducts as p, i (p.id)} + + +
handleDragStart(e, i)} + on:dragover={(e) => handleDragOver(e, i)} + on:drop={handleDrop} + on:dragend={handleDragEnd} + on:click={() => selectProduct(p)} + > + +
+ + + + + +
+ + +
+ x{effectiveQty(p) ?? "—"} +
+ {p.productDescription ?? "—"} + {#if p.catalogItem?.identifier} + {p.catalogItem.identifier} + {/if} + {#if isPartiallyCancelled(p)} + {p.quantityCancelled} cancelled + {/if} +
+ +
+
+ Unit Price + {formatCurrency(unitPrice(p))} +
+
+ Unit Cost + {formatCurrency(unitCost(p))} +
+
+ Margin + {formatCurrency(unitMargin(p))} +
+
+ +
+ + {p.productClass ?? "—"} + + {#if hasInventory(p) && p.onHand != null} + = 1 && p.onHand <= 5} + class:stock-good={p.onHand > 5} + > + {p.onHand} on hand + + {/if} +
+
+ + +
+ + + +
+
+ {/each} +
+ {:else} +
+ {#each activeProducts as p, i (p.id)} + + +
handleDragStart(e, i)} + on:dragover={(e) => handleDragOver(e, i)} + on:drop={handleDrop} + on:dragend={handleDragEnd} + on:click={() => selectProduct(p)} + > + +
+ + + + + +
+ + +
+ +
+ x{effectiveQty(p) ?? "—"} +
+ {p.productDescription ?? "—"} + {#if p.catalogItem?.identifier} + {p.catalogItem.identifier} + {/if} + {#if isPartiallyCancelled(p)} + {p.quantityCancelled} cancelled + {/if} +
+
+ + {p.productClass ?? "—"} + + {#if hasInventory(p) && p.onHand != null} + = 1 && p.onHand <= 5} + class:stock-good={p.onHand > 5} + > + {p.onHand} on hand + + {/if} +
+
+ + +
+
+ Unit Price + {formatCurrency(unitPrice(p))} +
+
+ Unit Cost + {formatCurrency(unitCost(p))} +
+
+ Margin + {formatCurrency(unitMargin(p))} +
+
+
+
+
+
+ + +
+ + + +
+
+ {/each} +
+ {/if} + + + {#if cancelledProducts.length > 0} + + +
(showCancelled = !showCancelled)} + > + + + + Cancelled + {cancelledProducts.length} +
+
+ + {#if showCancelled} + {#if viewMode === "compact"} +
+ {#each cancelledProducts as p (p.id)} + + +
selectProduct(p)} + > +
+
+ x{p.quantity ?? "—"} +
+ {p.productDescription ?? "—"} + {#if p.catalogItem?.identifier} + {p.catalogItem.identifier} + {/if} +
+ +
+
+ Unit Price + {formatCurrency(unitPrice(p))} +
+
+ Unit Cost + {formatCurrency(unitCost(p))} +
+
+ Margin + {formatCurrency(unitMargin(p))} +
+
+ +
+ + {p.productClass ?? "—"} + +
+
+
+ + + +
+
+ {/each} +
+ {:else} +
+ {#each cancelledProducts as p (p.id)} + + +
selectProduct(p)} + > +
+
+
+
+ + {p.productDescription ?? "—"} + + {#if p.catalogItem?.identifier} + {p.catalogItem.identifier} + {/if} +
+
+ x{p.quantity ?? "—"} + + {p.productClass ?? "—"} + +
+
+
+
+ Unit Price + {formatCurrency(unitPrice(p))} +
+
+ Unit Cost + {formatCurrency(unitCost(p))} +
+
+ Margin + {formatCurrency(unitMargin(p))} +
+
+
+
+ + + +
+
+ {/each} +
+ {/if} + {/if} + {/if} +
+
+ + + {#if showPanel && selectedProduct} + + +
+ + +
+ {#key selectedProduct.id} +
+ +
+
+

+ {selectedProduct.productDescription ?? "Product"} +

+
+ {#if selectedProduct.productClass} + + {selectedProduct.productClass} + + {/if} + {#if selectedProduct.status?.name} + + {selectedProduct.status.name} + + {/if} +
+
+ +
+ + + {#if selectedProduct.cancelled || selectedProduct.cancellationType || (selectedProduct.quantityCancelled != null && selectedProduct.quantityCancelled > 0)} +
+
+ + + + + {selectedProduct.cancellationType === "partial" + ? "Partially Cancelled" + : "Cancelled"} + +
+ {#if selectedProduct.quantityCancelled || selectedProduct.cancelledReason || selectedProduct.cancelledDate} +
+ {#if selectedProduct.quantityCancelled} +
+ Qty Cancelled{selectedProduct.quantityCancelled}{#if selectedProduct.quantity} of {selectedProduct.quantity}{/if} +
+ {/if} + {#if selectedProduct.cancelledReason} +
+ Reason{selectedProduct.cancelledReason} +
+ {/if} + {#if selectedProduct.cancelledDate} +
+ Date{formatDate(selectedProduct.cancelledDate)} +
+ {/if} +
+ {/if} +
+ {/if} + + +
+
+ + + + Identity +
+ {#if selectedProduct.catalogItem?.identifier} +
+ Catalog Item + {selectedProduct.catalogItem.identifier} +
+ {/if} + {#if selectedProduct.forecastDescription} +
+ Forecast Description + {selectedProduct.forecastDescription} +
+ {/if} + {#if selectedProduct.forecastType} +
+ Forecast Type + {selectedProduct.forecastType} +
+ {/if} +
+ Quantity + {selectedProduct.quantity ?? "—"} +
+
+ + +
+
+ + + + Financials +
+
+
+ Revenue + {formatCurrency(selectedProduct.revenue)} +
+
+ Cost + {formatCurrency(selectedProduct.cost)} +
+
+ Margin + {formatCurrency(selectedProduct.margin)} +
+
+ Profit + {formatCurrency(selectedProduct.profit)} +
+
+
+ Percentage + + {selectedProduct.percentage != null + ? `${selectedProduct.percentage}%` + : "—"} + +
+ +
+
+
+
+ + {selectedProduct.revenue + ? ( + ((selectedProduct.margin ?? 0) / + selectedProduct.revenue) * + 100 + ).toFixed(1) + "% margin" + : "—"} + +
+
+ + +
+
+ + + + + Flags & Settings +
+
+
+ + {#if selectedProduct.includeFlag}{:else}{/if} + + Included +
+
+ + {#if selectedProduct.linkFlag}{:else}{/if} + + Linked +
+
+ + {#if selectedProduct.taxableFlag}{:else}{/if} + + Taxable +
+
+ + {#if selectedProduct.recurringFlag}{:else}{/if} + + Recurring +
+
+ + {#if selectedProduct.recurringFlag} +
+
+ Recurring Revenue + {formatCurrency( + selectedProduct.recurringRevenue, + )} +
+
+ Recurring Cost + {formatCurrency(selectedProduct.recurringCost)} +
+
+ Cycles + {selectedProduct.cycles ?? "—"} +
+
+ {/if} +
+ + + {#if hasInventory(selectedProduct)} +
+
+ + + + + Inventory +
+
+
+ On Hand + + {#if selectedProduct.onHand != null} + = 1 && + selectedProduct.onHand <= 5} + class:stock-good={selectedProduct.onHand > 5} + >{selectedProduct.onHand} + {:else} + — + {/if} + +
+
+ In Stock + + {selectedProduct.inStock == null + ? "—" + : selectedProduct.inStock + ? "Yes" + : "No"} + +
+
+
+ {/if} + + +
+
+ + + + History +
+
+ Last Updated + {formatDate(selectedProduct.cwLastUpdated)} +
+ {#if selectedProduct.cwUpdatedBy} +
+ Updated By + {selectedProduct.cwUpdatedBy} +
+ {/if} +
+
+ {/key} +
+
+ {/if} +
+ {/if} +
+ + + + diff --git a/src/routes/sales/opportunity/[id]/notes/+server.ts b/src/routes/sales/opportunity/[id]/notes/+server.ts new file mode 100644 index 0000000..675b48a --- /dev/null +++ b/src/routes/sales/opportunity/[id]/notes/+server.ts @@ -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"); + } +}; diff --git a/src/routes/sales/opportunity/[id]/notes/[noteId]/+server.ts b/src/routes/sales/opportunity/[id]/notes/[noteId]/+server.ts new file mode 100644 index 0000000..49ec4c6 --- /dev/null +++ b/src/routes/sales/opportunity/[id]/notes/[noteId]/+server.ts @@ -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"); + } +}; diff --git a/src/routes/sales/opportunity/[id]/types.ts b/src/routes/sales/opportunity/[id]/types.ts new file mode 100644 index 0000000..a2a96da --- /dev/null +++ b/src/routes/sales/opportunity/[id]/types.ts @@ -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"; +} diff --git a/src/styles/sales/opportunitydetail.css b/src/styles/sales/opportunitydetail.css new file mode 100644 index 0000000..842d000 --- /dev/null +++ b/src/styles/sales/opportunitydetail.css @@ -0,0 +1,1548 @@ +/* ═══════════════════════════════════════════════════ + Opportunity Detail — Two-Pane Layout + ═══════════════════════════════════════════════════ */ + +/* Page container */ +.opportunity-detail-page { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; + width: 100%; + gap: 16px; +} + +/* ── Left pane (1/4 width) ── */ +.opportunity-detail-left { + display: flex; + flex-direction: column; + flex: 0 0 25%; + min-height: 0; + background: var(--bg-surface); + border-radius: 12px; + box-shadow: var(--header-shadow); + overflow: hidden; +} + +/* ── Right pane (3/4 width) ── */ +.opportunity-detail-right { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + background: var(--bg-surface); + border-radius: 12px; + box-shadow: var(--header-shadow); + overflow: hidden; +} + +/* ── Back button ── */ +.back-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: var(--bg-surface); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + flex-shrink: 0; +} + +.back-btn:hover { + background: var(--nav-hover-bg); + color: var(--text-primary); + border-color: var(--input-focus-border); +} + +/* ═══════════════════════════════════════════════════ + Left Pane — Sidebar + ═══════════════════════════════════════════════════ */ + +.opp-sidebar { + padding: 20px; + flex: 1; + display: flex; + flex-direction: column; +} + +/* ── Headline ── */ +.opp-headline { + margin-top: 12px; + margin-bottom: 0; +} + +.opp-name { + margin: 0; + font-size: 17px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.3; + word-break: break-word; +} + +.opp-meta-row { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-top: 8px; +} + +.opp-number { + font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; + font-size: 12px; + color: var(--text-secondary); +} + +.opp-status-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 5px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.opp-status-badge.status-open { + background: var(--status-active-bg, rgba(34, 197, 94, 0.12)); + color: var(--status-active-color, #22c55e); +} + +.opp-status-badge.status-won { + background: rgba(34, 197, 94, 0.12); + color: #22c55e; +} + +.opp-status-badge.status-lost { + background: rgba(239, 68, 68, 0.12); + color: #ef4444; +} + +.opp-status-badge.status-closed { + background: var(--status-inactive-bg, rgba(107, 114, 128, 0.12)); + color: var(--status-inactive-color, #6b7280); +} + +.opp-status-badge.status-inactive { + background: rgba(107, 114, 128, 0.12); + color: #6b7280; +} + +.opp-stage-badge { + display: inline-flex; + padding: 2px 8px; + border-radius: 5px; + font-size: 10px; + font-weight: 600; + background: color-mix(in srgb, var(--accent-primary) 10%, transparent); + color: var(--accent-primary); +} + +.opp-type-badge { + display: inline-flex; + padding: 2px 8px; + border-radius: 5px; + font-size: 10px; + font-weight: 600; + background: var(--nav-hover-bg); + color: var(--text-secondary); +} + +/* ── Byline (Sales Rep) ── */ +.opp-byline { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 14px; +} + +.opp-byline-rep { + display: flex; + align-items: center; + gap: 8px; +} + +.opp-byline-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + color: white; + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} + +.opp-byline-avatar.secondary { + background: linear-gradient(135deg, #64748b, #94a3b8); +} + +.opp-byline-info { + display: flex; + flex-direction: column; + gap: 0; +} + +.opp-byline-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.2; +} + +.opp-byline-role { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.2; +} + +/* ── Divider ── */ +.opp-sidebar-divider { + height: 1px; + background: var(--border-subtle); + margin: 16px 0; +} + +/* ── Sidebar Info ── */ +.opp-sidebar-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Info rows (address, contact) */ +.opp-info-row { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 8px; + border-radius: 8px; + transition: background 0.15s; +} + +.opp-info-row:hover { + background: var(--nav-hover-bg); +} + +/* Clickable row (company link) */ +.opp-info-row-link { + text-decoration: none; + color: inherit; + cursor: pointer; +} + +.opp-info-arrow { + flex-shrink: 0; + align-self: center; + color: var(--text-muted); + stroke: var(--text-muted); + opacity: 0; + transform: translateX(-6px); + transition: + opacity 0.2s ease, + transform 0.2s ease; +} + +.opp-info-row-link:hover .opp-info-arrow { + opacity: 1; + transform: translateX(0); +} + +.opp-info-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--text-muted); + stroke: var(--text-muted); + margin-top: 2px; +} + +.opp-info-content { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.opp-info-label { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.opp-info-value { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.5; + word-break: break-word; +} + +.opp-info-sub { + font-size: 12px; + color: var(--text-secondary); + font-weight: 400; + margin-bottom: 2px; +} + +.opp-info-muted { + font-size: 12px; + color: var(--text-muted); + font-style: italic; +} + +/* Phone — prominent */ +.opp-info-phone { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 2px; + font-size: 13px; + font-weight: 600; + color: #16a34a; + text-decoration: none; + transition: opacity 0.15s; +} + +.opp-info-phone svg { + flex-shrink: 0; + stroke: #16a34a; +} + +.opp-info-phone:hover { + opacity: 0.75; +} + +/* Email — secondary to phone */ +.opp-info-email { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); + text-decoration: none; +} + +.opp-info-email svg { + flex-shrink: 0; + color: var(--text-muted); + stroke: var(--text-muted); +} + +.opp-info-email:hover { + color: var(--accent); +} + +/* Description section — boxed */ +.opp-desc-section { + margin-top: 4px; + padding-top: 4px; +} + +.opp-desc-header { + display: flex; + align-items: center; + gap: 6px; + padding: 0 8px 8px; + color: var(--text-muted); +} + +.opp-desc-label { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.opp-desc-card { + padding: 10px 12px; + border-radius: 8px; + background: var(--nav-hover-bg); +} + +.opp-desc-text { + margin: 0; + font-size: 13px; + font-weight: 400; + line-height: 1.55; + color: var(--text-secondary); + word-break: break-word; +} + +.opp-sidebar-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--text-secondary); + font-size: 13px; +} + +/* ═══════════════════════════════════════════════════ + Right Pane — Tab Bar + ═══════════════════════════════════════════════════ */ + +.tab-bar { + display: flex; + align-items: stretch; + gap: 0; + padding: 0 24px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; + background: var(--bg-surface); + position: relative; + z-index: 10; +} + +.tab-btn { + position: relative; + padding: 14px 16px 12px; + border: none; + background: none; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: color 0.15s; + white-space: nowrap; +} + +.tab-btn::after { + content: ""; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + border-radius: 2px 2px 0 0; + background: transparent; + transition: background 0.15s; +} + +.tab-btn:hover { + color: var(--text-primary); +} + +.tab-btn.active { + color: var(--accent-primary); + font-weight: 600; +} + +.tab-btn.active::after { + background: var(--accent-primary); +} + +.tab-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + margin-left: 6px; + border-radius: 9px; + font-size: 10px; + font-weight: 600; + background: var(--nav-hover-bg); + color: var(--text-secondary); +} + +.tab-btn.active .tab-count-badge { + background: var(--accent-primary, var(--accent, #3b82f6)); + color: #fff; + font-weight: 700; +} + +/* ── Detail pane body ── */ +.opportunity-detail-right .detail-pane-body { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +/* ═══════════════════════════════════════════════════ + Overview Tab + ═══════════════════════════════════════════════════ */ + +.overview-tab { + display: flex; + flex-direction: column; + gap: 28px; +} + +/* ── Pipeline Banner ── */ +.ov-pipeline-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.ov-pipeline-stages { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ov-pipeline-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.ov-pipeline-chip.stage { + background: rgba(99, 102, 241, 0.1); + color: #6366f1; +} + +.ov-pipeline-chip.priority { + background: rgba(245, 158, 11, 0.1); + color: #d97706; +} + +.ov-pipeline-chip.rating { + background: rgba(16, 185, 129, 0.1); + color: #059669; +} + +.ov-pipeline-chip svg { + stroke: currentColor; +} + +/* Close countdown */ +.ov-close-countdown { + display: flex; + align-items: baseline; + gap: 6px; + padding: 8px 14px; + border-radius: 10px; + background: var(--nav-hover-bg); +} + +.ov-close-countdown.soon { + background: rgba(245, 158, 11, 0.08); +} + +.ov-close-countdown.overdue { + background: rgba(239, 68, 68, 0.08); +} + +.ov-close-number { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); +} + +.ov-close-countdown.soon .ov-close-number { + color: #d97706; +} + +.ov-close-countdown.overdue .ov-close-number { + color: #dc2626; +} + +.ov-close-unit { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); +} + +.ov-close-countdown.soon .ov-close-unit { + color: #d97706; +} + +.ov-close-countdown.overdue .ov-close-unit { + color: #dc2626; +} + +/* ── Metric Cards ── */ +.ov-metrics-row { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} + +.ov-metric-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px; + border-radius: 10px; + background: var(--card-bg); + border: 1px solid var(--card-border); + transition: border-color 0.15s; +} + +.ov-metric-card:hover { + border-color: var(--card-hover-border); +} + +.ov-metric-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 9px; + flex-shrink: 0; + color: #fff; +} + +.ov-metric-icon.close { + background: linear-gradient(135deg, #6366f1, #818cf8); +} + +.ov-metric-icon.money { + background: linear-gradient(135deg, #10b981, #34d399); +} + +.ov-metric-icon.source { + background: linear-gradient(135deg, #f59e0b, #fbbf24); +} + +.ov-metric-icon.activity { + background: linear-gradient(135deg, #0ea5e9, #38bdf8); +} + +.ov-metric-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.ov-metric-label { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ov-metric-value { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +/* ── Sections (Timeline, Details) ── */ +.ov-section { + display: flex; + flex-direction: column; + gap: 14px; +} + +.ov-section-title { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ── Timeline ── */ +.ov-timeline { + display: flex; + flex-direction: column; + padding-left: 4px; +} + +.ov-timeline-item { + display: flex; + align-items: flex-start; + gap: 14px; + position: relative; + padding-bottom: 20px; + padding-left: 16px; +} + +.ov-timeline-item:not(.last)::before { + content: ""; + position: absolute; + left: 8px; + top: 12px; + bottom: 0; + width: 2px; + background: var(--border-subtle); +} + +.ov-timeline-dot { + position: absolute; + left: 3px; + top: 4px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--bg-surface); + border: 2px solid var(--accent); + flex-shrink: 0; +} + +.ov-timeline-item.last .ov-timeline-dot { + background: var(--accent); +} + +.ov-timeline-content { + display: flex; + flex-direction: column; + gap: 1px; +} + +.ov-timeline-label { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.ov-timeline-date { + font-size: 12px; + color: var(--text-secondary); +} + +/* ── Details Grid ── */ +.ov-details-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 10px; +} + +.ov-detail { + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 12px; + border-radius: 8px; + background: var(--nav-hover-bg); +} + +.ov-detail-label { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ov-detail-value { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + word-break: break-word; +} + +.ov-detail-value.mono { + font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; + font-size: 12px; +} + +.overview-placeholder { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + padding: 12px 0; +} + +/* ═══════════════════════════════════════════════════ + Forecasts Tab + ═══════════════════════════════════════════════════ */ + +.forecasts-tab { + display: flex; + flex-direction: column; + gap: 20px; +} + +.forecasts-table-wrap { + overflow-x: auto; + border-radius: 8px; + border: 1px solid var(--border-subtle); +} + +.forecasts-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.forecasts-table th { + padding: 10px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); + background: var(--nav-hover-bg); + border-bottom: 1px solid var(--border-subtle); +} + +.forecasts-table th.num, +.forecasts-table td.num { + text-align: right; +} + +.forecasts-table td { + padding: 10px 14px; + color: var(--text-primary); + border-bottom: 1px solid var(--border-subtle); +} + +.forecasts-table tbody tr:last-child td { + border-bottom: none; +} + +.forecasts-table tbody tr:hover { + background: var(--nav-hover-bg); +} + +.forecast-status-badge { + display: inline-flex; + padding: 2px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + background: var(--nav-hover-bg); + color: var(--text-secondary); +} + +.forecast-status-badge.included { + background: rgba(34, 197, 94, 0.12); + color: #22c55e; +} + +.forecasts-summary { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; +} + +.forecast-summary-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 16px; + border-radius: 10px; + background: var(--nav-hover-bg); +} + +.forecast-summary-label { + font-size: 11px; + font-weight: 500; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.forecast-summary-value { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); +} + +/* ═══════════════════════════════════════════════════ + Notes Tab + ═══════════════════════════════════════════════════ */ + +.notes-tab { + display: flex; + flex-direction: column; + gap: 16px; +} + +.notes-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.notes-header-left { + display: flex; + align-items: center; + gap: 8px; +} + +.notes-count { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.notes-add-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 14px; + border: none; + border-radius: 8px; + background: var(--accent-primary); + color: #fff; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s, + transform 0.1s; +} + +.notes-add-btn:hover { + filter: brightness(1.1); +} + +.notes-add-btn:active { + transform: scale(0.97); +} + +/* ── Compose / Edit area ── */ + +.note-compose { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 16px; + border-radius: 10px; + background: var(--nav-hover-bg); + border: 1px solid var(--border-subtle); +} + +.note-compose-textarea, +.note-edit-textarea { + width: 100%; + min-height: 72px; + padding: 10px 12px; + border: 1px solid var(--input-border); + border-radius: 8px; + background: var(--input-bg); + color: var(--input-text); + font-size: 13px; + line-height: 1.6; + font-family: inherit; + resize: vertical; + outline: none; + transition: + border-color 0.2s, + box-shadow 0.2s; +} + +.note-compose-textarea:focus, +.note-edit-textarea:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 2px + color-mix(in srgb, var(--accent-primary) 20%, transparent); +} + +.note-compose-textarea:disabled, +.note-edit-textarea:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.note-compose-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.note-flag-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; +} + +.note-flag-toggle input[type="checkbox"] { + display: none; +} + +.flag-icon { + color: var(--text-muted); + transition: color 0.15s; +} + +.flag-icon.flagged { + color: #f59e0b; +} + +.note-compose-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.note-btn-cancel { + padding: 5px 14px; + border: 1px solid var(--border-subtle); + border-radius: 7px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} + +.note-btn-cancel:hover { + background: var(--nav-hover-bg); + color: var(--text-primary); +} + +.note-btn-save { + padding: 5px 16px; + border: none; + border-radius: 7px; + background: var(--accent-primary); + color: #fff; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s, + transform 0.1s; +} + +.note-btn-save:hover:not(:disabled) { + filter: brightness(1.1); +} + +.note-btn-save:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.note-error { + font-size: 11px; + color: #ef4444; + font-weight: 500; +} + +/* ── Notes list ── */ + +.notes-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ── Note card ── */ + +.note-card { + display: flex; + flex-direction: column; + border-radius: 10px; + background: var(--nav-hover-bg); + border-left: 3px solid var(--border-subtle); + transition: border-color 0.15s; +} + +.note-card.flagged { + border-left-color: #f59e0b; +} + +.note-card.editing { + border-left-color: var(--accent-primary); + border: 1px solid var(--accent-primary); + border-left-width: 3px; + padding: 14px 16px; +} + +/* ── Card header ── */ + +.note-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.note-header-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.note-header-right { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.note-avatar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 50%; + background: #6366f1; + color: #fff; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.3px; + user-select: none; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.note-author-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; +} + +.note-author-info { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 1px; +} + +.note-author-info .note-timestamp { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} + +.note-type-badge { + display: inline-flex; + padding: 1px 7px; + border-radius: 5px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + background: color-mix(in srgb, var(--accent-primary) 12%, transparent); + color: var(--accent-primary); + white-space: nowrap; +} + +.note-flag-icon { + color: #f59e0b; + flex-shrink: 0; +} + +.note-timestamp { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} + +/* ── Card body ── */ + +.note-card-body { + padding: 12px 16px; +} + +.note-text { + margin: 0; + font-size: 13px; + line-height: 1.65; + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; +} + +/* ── Note action menu ── */ + +.note-menu-wrap { + position: relative; +} + +.note-menu-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} + +.note-menu-btn:hover { + background: color-mix(in srgb, var(--text-muted) 12%, transparent); + color: var(--text-primary); +} + +.note-menu-dropdown { + position: absolute; + top: 100%; + left: 0; + z-index: 50; + min-width: 120px; + padding: 4px; + border-radius: 8px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +.note-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-primary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s; + text-align: left; +} + +.note-menu-item:hover { + background: var(--nav-hover-bg); +} + +.note-menu-item.danger { + color: #ef4444; +} + +.note-menu-item.danger:hover { + background: color-mix(in srgb, #ef4444 10%, transparent); +} + +/* ── Delete confirmation modal ── */ + +.note-delete-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(4px); +} + +.note-delete-modal { + width: 380px; + max-width: 90vw; + padding: 24px; + border-radius: 14px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} + +.note-delete-title { + margin: 0 0 8px; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.note-delete-msg { + margin: 0 0 18px; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); +} + +.note-delete-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.note-btn-delete { + padding: 6px 18px; + border: none; + border-radius: 7px; + background: #ef4444; + color: #fff; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} + +.note-btn-delete:hover:not(:disabled) { + background: #dc2626; +} + +.note-btn-delete:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ═══════════════════════════════════════════════════ + Contacts Tab + ═══════════════════════════════════════════════════ */ + +.contacts-tab { + display: flex; + flex-direction: column; + gap: 12px; +} + +.contacts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +.contact-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border-radius: 10px; + background: var(--nav-hover-bg); +} + +.contact-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: color-mix(in srgb, var(--accent-primary) 12%, transparent); + color: var(--accent-primary); + flex-shrink: 0; +} + +.contact-info { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +.contact-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.contact-role { + font-size: 12px; + color: var(--accent-primary); + font-weight: 500; +} + +.contact-company { + font-size: 12px; + color: var(--text-secondary); +} + +.contact-notes { + font-size: 11px; + color: var(--text-secondary); + margin-top: 4px; +} + +.contact-badge { + display: inline-flex; + padding: 2px 8px; + border-radius: 6px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.contact-badge.referral { + background: rgba(99, 102, 241, 0.12); + color: #6366f1; +} + +/* ── Activity tab ── */ +.activity-tab { + display: flex; + flex-direction: column; + gap: 24px; +} + +/* ── Empty state ── */ +.tab-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; +} + +/* ═══════════════════════════════════════════════════ + Mobile — Responsive + ═══════════════════════════════════════════════════ */ + +@media (max-width: 768px) { + .opportunity-detail-page { + flex-direction: column; + gap: 0; + } + + .opportunity-detail-left { + flex: none; + border-radius: 12px 12px 0 0; + } + + .opportunity-detail-left.mobile-collapsed { + display: none; + } + + .opportunity-detail-right { + border-radius: 0 0 12px 12px; + } + + .opportunity-detail-right.mobile-hidden { + display: none; + } + + .tab-bar { + display: none; + } + + /* Mobile nav menu */ + .mobile-nav-menu { + display: flex; + flex-direction: column; + background: var(--bg-surface); + border-radius: 0 0 12px 12px; + overflow: hidden; + } + + .mobile-nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + border: none; + background: none; + color: var(--text-primary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + border-bottom: 1px solid var(--border-subtle); + text-align: left; + width: 100%; + } + + .mobile-nav-item:last-child { + border-bottom: none; + } + + .mobile-nav-item:hover { + background: var(--nav-hover-bg); + } + + .mobile-nav-icon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--nav-hover-bg); + color: var(--text-secondary); + } + + .mobile-nav-label { + flex: 1; + } + + .mobile-nav-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + background: color-mix(in srgb, var(--accent-primary) 12%, transparent); + color: var(--accent-primary); + } + + .mobile-nav-chevron { + color: var(--text-secondary); + } + + /* Mobile content header */ + .mobile-content-header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; + } + + .mobile-back-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: none; + color: var(--text-secondary); + cursor: pointer; + } + + .mobile-content-title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + + .ov-metrics-row { + grid-template-columns: 1fr; + } + + .ov-details-grid { + grid-template-columns: 1fr; + } + + .contacts-grid { + grid-template-columns: 1fr; + } + + .forecasts-summary { + grid-template-columns: 1fr; + } +} diff --git a/src/styles/sales/sales.css b/src/styles/sales/sales.css index ab4ed7c..adeff91 100644 --- a/src/styles/sales/sales.css +++ b/src/styles/sales/sales.css @@ -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; diff --git a/src/xhooks.server.ts b/src/xhooks.server.ts index dc6d114..a3bdf9e 100644 --- a/src/xhooks.server.ts +++ b/src/xhooks.server.ts @@ -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: "/",