diff --git a/src/components/CreateOpportunityModal.svelte b/src/components/CreateOpportunityModal.svelte new file mode 100644 index 0000000..6583688 --- /dev/null +++ b/src/components/CreateOpportunityModal.svelte @@ -0,0 +1,2383 @@ + + +{#if isOpen} + +
+ + + +
+{/if} + + diff --git a/src/lib/index.ts b/src/lib/index.ts index 743d68c..0564590 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -11,6 +11,7 @@ import { users } from "./optima-api/modules/users"; import { unifi } from "./optima-api/modules/unifi"; import { procurement } from "./optima-api/modules/procurement"; import { sales } from "./optima-api/modules/sales"; +import { cw } from "./optima-api/modules/cw"; export const optima = { auth, @@ -24,6 +25,7 @@ export const optima = { unifi, procurement, sales, + cw, }; /** * @TODO diff --git a/src/lib/optima-api/modules/cw.ts b/src/lib/optima-api/modules/cw.ts new file mode 100644 index 0000000..5d28b3d --- /dev/null +++ b/src/lib/optima-api/modules/cw.ts @@ -0,0 +1,34 @@ +import api from "../axios"; + +export interface CWMember { + id: number; + identifier: string; + firstName: string; + lastName: string; + name: string; + officeEmail: string; + inactive: boolean; +} + +export const cw = { + /** + * Fetch all ConnectWise members from the server-side member cache. + * By default only active members are returned. + */ + async fetchMembers( + accessToken: string, + options?: { active?: boolean }, + ): Promise { + const params: Record = {}; + if (options?.active === false) params.active = "false"; + + const response = await api.get("/v1/cw/members", { + 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 598bf00..b5160b2 100644 --- a/src/lib/optima-api/modules/sales.ts +++ b/src/lib/optima-api/modules/sales.ts @@ -194,6 +194,27 @@ export interface CancelOpportunityProductBody { cancellationReason?: string | null; } +export interface CreateOpportunityBody { + name: string; + expectedCloseDate: string; + notes?: string; + rating?: { id: number }; + type?: { id: number }; + stage?: { id: number }; + status?: { id: number }; + priority?: { id: number }; + campaign?: { id: number }; + primarySalesRep?: { id: number }; + secondarySalesRep?: { id: number } | null; + company?: { id: number }; + contact?: { id: number } | null; + site?: { id: number } | null; + source?: string | null; + customerPO?: string | null; + locationId?: number; + businessUnitId?: number; +} + export interface QuoteRegenProduct { cwForecastId?: number; forecastDescription?: string; @@ -317,6 +338,15 @@ export const sales = { return response.data; }, + async createOpportunity(accessToken: string, body: CreateOpportunityBody) { + const response = await api.post("/v1/sales/opportunities", body, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return response.data; + }, + async fetchOne( accessToken: string, identifier: string, @@ -563,6 +593,23 @@ export const sales = { return response.data; }, + async updateOpportunity( + accessToken: string, + identifier: string, + body: Record, + ) { + const response = await api.patch( + `/v1/sales/opportunities/${encodeURIComponent(identifier)}`, + body, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return response.data; + }, + async fetchQuotes( accessToken: string, identifier: string, diff --git a/src/routes/api/companies/[id]/details/+server.ts b/src/routes/api/companies/[id]/details/+server.ts new file mode 100644 index 0000000..adb76bc --- /dev/null +++ b/src/routes/api/companies/[id]/details/+server.ts @@ -0,0 +1,22 @@ +import { optima } from "$lib"; +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +/** GET /api/companies/[id]/details — fetch company with contacts and address */ +export const GET: RequestHandler = async ({ params, locals }) => { + const accessToken = locals.session?.accessToken; + if (!accessToken) { + return json({ data: null }, { status: 401 }); + } + + try { + const result = await optima.company.fetch(accessToken, params.id, { + includeAllContacts: true, + includeAddress: true, + }); + return json({ data: result?.data ?? null }); + } catch (err) { + console.error("[api/companies/details] Failed:", err); + return json({ data: null }, { status: 500 }); + } +}; diff --git a/src/routes/api/companies/search/+server.ts b/src/routes/api/companies/search/+server.ts new file mode 100644 index 0000000..fa0bd4f --- /dev/null +++ b/src/routes/api/companies/search/+server.ts @@ -0,0 +1,27 @@ +import { optima } from "$lib"; +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ url, locals }) => { + const accessToken = locals.session?.accessToken; + if (!accessToken) { + return json({ data: [] }, { status: 401 }); + } + + const search = url.searchParams.get("search") || ""; + const page = Number(url.searchParams.get("page")) || 1; + const rpp = Number(url.searchParams.get("rpp")) || 15; + + try { + const result = await optima.company.fetchMany( + accessToken, + page, + search, + rpp, + ); + return json({ data: result?.data ?? [] }); + } catch (err) { + console.error("[api/companies/search] Failed:", err); + return json({ data: [] }, { status: 500 }); + } +}; diff --git a/src/routes/api/cw/members/+server.ts b/src/routes/api/cw/members/+server.ts new file mode 100644 index 0000000..9ecbadc --- /dev/null +++ b/src/routes/api/cw/members/+server.ts @@ -0,0 +1,18 @@ +import { optima } from "$lib"; +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ locals }) => { + const accessToken = locals.session?.accessToken; + if (!accessToken) { + return json({ data: [] }, { status: 401 }); + } + + try { + const members = await optima.cw.fetchMembers(accessToken); + return json({ data: members }); + } catch (err) { + console.error("[api/cw/members] Failed:", err); + return json({ data: [] }, { status: 500 }); + } +}; diff --git a/src/routes/api/sales/opportunities/+server.ts b/src/routes/api/sales/opportunities/+server.ts new file mode 100644 index 0000000..31089e1 --- /dev/null +++ b/src/routes/api/sales/opportunities/+server.ts @@ -0,0 +1,46 @@ +import { optima } from "$lib"; +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +/** POST /api/sales/opportunities — create an opportunity */ +export const POST: RequestHandler = async ({ request, locals }) => { + const accessToken = locals.session?.accessToken; + if (!accessToken) throw error(401, "Unauthorized"); + + const body = await request.json(); + if (!body.name?.trim()) throw error(400, "Name is required"); + if (!body.expectedCloseDate) + throw error(400, "Expected close date is required"); + + try { + const result = await optima.sales.createOpportunity(accessToken, { + name: body.name.trim(), + expectedCloseDate: body.expectedCloseDate, + notes: body.notes?.trim() || undefined, + type: body.type || undefined, + stage: body.stage || undefined, + status: body.status || undefined, + priority: body.priority || undefined, + rating: body.rating || undefined, + primarySalesRep: body.primarySalesRep || undefined, + secondarySalesRep: body.secondarySalesRep || undefined, + company: body.company || undefined, + contact: body.contact || undefined, + source: body.source || undefined, + customerPO: body.customerPO || undefined, + }); + return json(result, { status: 201 }); + } catch (err: unknown) { + console.error("Failed to create opportunity:", err); + const status = + err && typeof err === "object" && "status" in err + ? (err as { status: number }).status + : 500; + const message = + err && typeof err === "object" && "response" in err + ? (err as { response?: { data?: { message?: string } } }).response?.data + ?.message || "Failed to create opportunity" + : "Failed to create opportunity"; + throw error(status, message); + } +}; diff --git a/src/routes/sales/+page.server.ts b/src/routes/sales/+page.server.ts index 2fbb344..d9ac289 100644 --- a/src/routes/sales/+page.server.ts +++ b/src/routes/sales/+page.server.ts @@ -38,7 +38,10 @@ export const load: PageServerLoad = async ({ locals, url }) => { }, }; }), - checkPermissions(accessToken, ["sales.opportunity.fetch.many"]), + checkPermissions(accessToken, [ + "sales.opportunity.fetch.many", + "sales.opportunity.create", + ]), optima.sales .fetchOpportunityTypes(accessToken) .catch(() => ({ data: [] })), diff --git a/src/routes/sales/+page.svelte b/src/routes/sales/+page.svelte index b466dda..950fff4 100644 --- a/src/routes/sales/+page.svelte +++ b/src/routes/sales/+page.svelte @@ -1,8 +1,10 @@ + +
- - {#if opportunity} - -
-

{opportunity.name}

-
- {#if opportunity.cwOpportunityId} - #{opportunity.cwOpportunityId} - {/if} - {#if opportunity.status} - - {statusLabel(opportunity)} - - {/if} - {#if opportunity.type?.name} - {opportunity.type.name} - {/if} - {#if opportunity.type?.wonFlag || opportunity.type?.lostFlag} - - {opportunity.type.wonFlag ? "Won" : "Lost"} - - {/if} - {#if opportunity.rating?.name} - - - {#each [1, 2, 3] as level} - - {/each} - - {opportunity.rating.name} - - {/if} - {#if opportunity.probability?.percent != null} - - - - - - {opportunity.probability.percent}% - - {/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.primarySalesRep?.name || opportunity.secondarySalesRep?.name} - - {/if} - -
- -
- - {#if opportunity.company?.name} - +
+
+ +
+

Edit Opportunity

+ {#if opportunity.cwOpportunityId} + #{opportunity.cwOpportunityId} + {/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} - - +
+ +
+ + +
+ + +
+
+ Company +
+ + {#if companyDropdownOpen} +
+
+ + + + searchCompanies(companySearchQuery)} + on:click|stopPropagation + /> +
+
+ {#if companySearchLoading} +
Searching…
+ {:else if companySearchQuery.length > 0 && companySearchQuery.length < 2} +
+ Type 2+ characters +
+ {:else if companySearchQuery.length >= 2 && companyResults.length === 0} +
No results
+ {:else} + {#each companyResults as co (co.id)} + + {/each} + {/if} +
+
+ {/if} +
+
+ +
+ Primary Rep +
+ + {#if repDropdownOpen} +
+
+ + + + +
+
+ {#if membersLoading} +
Loading…
+ {:else if filteredMembers.length === 0} +
+ No members found +
+ {:else} + {#each filteredMembers as member (member.id)} + + {/each} + {/if} +
+
+ {/if} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + + {#if opportunity.secondarySalesRep?.name || opportunity.source} +
+ {#if opportunity.secondarySalesRep?.name} +
+ 2nd Rep + {opportunity.secondarySalesRep.name} +
+ {/if} + {#if opportunity.source} +
+ Source + {opportunity.source} +
{/if}
+ {/if} +
+ + + +
+ {:else} + +
+ + {#if canEdit} + + {/if} +
+ {#if opportunity} + +
+

{opportunity.name}

+
+ {#if opportunity.cwOpportunityId} + #{opportunity.cwOpportunityId} + {/if} + {#if opportunity.status} + + {statusLabel(opportunity)} + + {/if} + {#if opportunity.type?.name} + {opportunity.type.name} + {/if} + {#if opportunity.type?.wonFlag || opportunity.type?.lostFlag} + + {opportunity.type.wonFlag ? "Won" : "Lost"} + + {/if} + {#if opportunity.rating?.name} + + + {#each [1, 2, 3] as level} + + {/each} + + {opportunity.rating.name} + + {/if} + {#if opportunity.probability?.percent != null} + + + + + + {opportunity.probability.percent}% + + {/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.description} -
-
+ + {#if opportunity.primarySalesRep?.name || opportunity.secondarySalesRep?.name} + + {/if} + +
+ +
+ + {#if opportunity.company?.name} + - + - Description -
-
-

{opportunity.description}

-
-
- {/if} -
+
+ Company + {opportunity.company.name} +
+ + + +
+ {/if} - - - {:else} -
-

Opportunity not found.

-
+ + {#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.description} +
+
+ + + + Description +
+
+

{opportunity.description}

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

Opportunity not found.

+
+ {/if} {/if}
diff --git a/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte b/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte index 272ed4c..3fe4e5c 100644 --- a/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte +++ b/src/routes/sales/opportunity/[id]/components/OverviewTab.svelte @@ -123,9 +123,17 @@ // Days until expected close $: daysUntilClose = (() => { if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null; - const diff = Math.ceil( - (new Date(opportunity.expectedCloseDate).getTime() - Date.now()) / - (1000 * 60 * 60 * 24), + const raw = opportunity.expectedCloseDate; + const close = new Date(raw.includes("T") ? raw : raw + "T00:00:00"); + const now = new Date(); + const closeDay = new Date( + close.getFullYear(), + close.getMonth(), + close.getDate(), + ); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const diff = Math.round( + (closeDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24), ); return diff; })(); @@ -378,7 +386,6 @@ Product - Qty Revenue Margin @@ -396,6 +403,19 @@ > + + {#if p.cancellationType === "partial"} + {effectiveQty(p)}/{p.quantity} + {:else} + {p.quantity ?? "—"} + {/if} + {p.catalogItem?.identifier ?? "—"} @@ -411,14 +431,6 @@ {/if} - - {#if p.cancellationType === "partial"} - {effectiveQty(p)} - /{p.quantity} - {:else} - {p.quantity ?? "—"} - {/if} - {formatCurrency(p.revenue)} {#if p.cost && p.cost > 0} @@ -442,7 +454,6 @@ Subtotal - {formatCurrency(totalRevenue)} diff --git a/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte b/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte index 08a9659..02b05e5 100644 --- a/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte +++ b/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte @@ -17,12 +17,17 @@ LaborStyle, SpecialOrderBody, } from "$lib/optima-api/modules/sales"; + import type { PermissionMap } from "$lib/permissions"; export let products: OpportunityProduct[]; export let accessToken: string | null; export let opportunityId: string; export let productSequence: number[] | null = null; export let initialProductId: number | null = null; + export let permissions: PermissionMap = {} as PermissionMap; + + $: canViewMargin = permissions["sales.opportunity.view_margin"] !== false; + $: canViewCost = permissions["sales.opportunity.view_cost"] !== false; const dispatch = createEventDispatcher<{ sequenceSaved: number[]; @@ -531,9 +536,11 @@ $: totalCost = activeProducts.reduce((sum, p) => sum + (p.cost ?? 0), 0); $: totalMargin = activeProducts.reduce((sum, p) => sum + (p.margin ?? 0), 0); $: totalProfit = activeProducts.reduce((sum, p) => sum + (p.profit ?? 0), 0); - $: marginPct = totalCost > 0 ? (totalMargin / totalCost) * 100 : 0; + $: markupPct = totalCost > 0 ? (totalMargin / totalCost) * 100 : 0; + $: marginPct = totalRevenue > 0 ? (totalMargin / totalRevenue) * 100 : 0; - function marginHealthColor(revenue?: number, margin?: number): string { + /** Markup % = (price - cost) / cost × 100 */ + function markupHealthColor(revenue?: number, margin?: number): string { const cost = (revenue ?? 0) - (margin ?? 0); if (!cost || cost <= 0) return "neutral"; const pct = ((margin ?? 0) / cost) * 100; @@ -543,19 +550,43 @@ return "negative"; } - function marginBarWidthPct(revenue?: number, margin?: number): number { + function markupBarWidthPct(revenue?: number, margin?: number): number { const cost = (revenue ?? 0) - (margin ?? 0); if (!cost || cost <= 0) return 0; const pct = ((margin ?? 0) / cost) * 100; return Math.min(Math.abs(pct), 100); } - function isNegativeMargin(revenue?: number, margin?: number): boolean { + function isNegativeMarkup(revenue?: number, margin?: number): boolean { const cost = (revenue ?? 0) - (margin ?? 0); if (!cost || cost <= 0) return false; return ((margin ?? 0) / cost) * 100 < 0; } + /** Margin % = (price - cost) / price × 100 */ + function marginHealthColor(revenue?: number, margin?: number): string { + const rev = revenue ?? 0; + if (!rev || rev <= 0) return "neutral"; + const pct = ((margin ?? 0) / rev) * 100; + if (pct >= 25) return "healthy"; + if (pct >= 12) return "moderate"; + if (pct >= 0) return "low"; + return "negative"; + } + + function marginBarWidthPct(revenue?: number, margin?: number): number { + const rev = revenue ?? 0; + if (!rev || rev <= 0) return 0; + const pct = ((margin ?? 0) / rev) * 100; + return Math.min(Math.abs(pct), 100); + } + + function isNegativeMargin(revenue?: number, margin?: number): boolean { + const rev = revenue ?? 0; + if (!rev || rev <= 0) return false; + return ((margin ?? 0) / rev) * 100 < 0; + } + function checkForChanges() { hasChanges = activeProducts.some((p, i) => p.id !== originalOrderIds[i]); } @@ -786,57 +817,62 @@ {formatCurrency(totalRevenue)}
-
-
- +
+ + + +
+
+ Cost + {formatCurrency(totalCost)} +
+
+ {/if} + {#if canViewMargin} +
+
+ + + +
+
+ Margin + {formatCurrency(totalMargin)} +
+ - - + {marginPct.toFixed(1)}% +
-
- Cost - {formatCurrency(totalCost)} -
-
-
-
- - - -
-
- Margin - {formatCurrency(totalMargin)} -
- - {marginPct.toFixed(1)}% - -
+ {/if}
{formatCurrency(unitCost(p))}
-
- Margin - {formatCurrency(unitMargin(p))} -
+ {#if canViewMargin} +
+ Margin + {formatCurrency(unitMargin(p))} +
+ {/if}
@@ -1195,28 +1233,54 @@ >{formatCurrency(unitCost(p))}
-
- Margin - {formatCurrency(unitMargin(p))} -
-
-
-
+ {#if canViewMargin} +
+ Margin + {formatCurrency(unitMargin(p))} +
+
+
+ Markup +
+
+
+
+
+ Margin +
+
+
+
+
+ {/if} @@ -1295,18 +1359,22 @@ >{formatCurrency(unitPrice(p))} -
- Unit Cost - {formatCurrency(unitCost(p))} -
-
- Margin - {formatCurrency(unitMargin(p))} -
+ {#if canViewCost} +
+ Unit Cost + {formatCurrency(unitCost(p))} +
+ {/if} + {#if canViewMargin} +
+ Margin + {formatCurrency(unitMargin(p))} +
+ {/if}
@@ -1375,18 +1443,22 @@ >{formatCurrency(unitPrice(p))}
-
- Unit Cost - {formatCurrency(unitCost(p))} -
-
- Margin - {formatCurrency(unitMargin(p))} -
+ {#if canViewCost} +
+ Unit Cost + {formatCurrency(unitCost(p))} +
+ {/if} + {#if canViewMargin} +
+ Margin + {formatCurrency(unitMargin(p))} +
+ {/if}
@@ -1726,36 +1798,40 @@ > {/if}
-
- Unit Cost - {#if isEditing} - - {:else} - {formatCurrency(unitCost(selectedProduct))} - {/if} -
-
- Unit Margin - {#if isEditing} - {formatCurrency( - (parseFloat(editForm.unitPrice) || 0) - - (parseFloat(editForm.unitCost) || 0), - )} + {#if canViewCost} +
+ Unit Cost + {#if isEditing} + {:else} - {formatCurrency(unitMargin(selectedProduct))} - {/if} -
+ {formatCurrency(unitCost(selectedProduct))} + {/if} +
+ {/if} + {#if canViewMargin} +
+ Unit Margin + {#if isEditing} + {formatCurrency( + (parseFloat(editForm.unitPrice) || 0) - + (parseFloat(editForm.unitCost) || 0), + )} + {:else} + {formatCurrency(unitMargin(selectedProduct))} + {/if} +
+ {/if} @@ -1766,18 +1842,22 @@ >{formatCurrency(selectedProduct.revenue)} -
- Cost - {formatCurrency(selectedProduct.cost)} -
-
- Margin - {formatCurrency(selectedProduct.margin)} -
+ {#if canViewCost} +
+ Cost + {formatCurrency(selectedProduct.cost)} +
+ {/if} + {#if canViewMargin} +
+ Margin + {formatCurrency(selectedProduct.margin)} +
+ {/if}
Profit
-
- Margin % - - {selectedProduct.cost - ? ( - ((selectedProduct.margin ?? 0) / - selectedProduct.cost) * - 100 - ).toFixed(1) + "%" - : "—"} - -
- -
-
-
+ {#if canViewMargin} +
+
+ Markup % + + {selectedProduct.cost + ? ( + ((selectedProduct.margin ?? 0) / + selectedProduct.cost) * + 100 + ).toFixed(1) + "%" + : "—"} + +
+
+ Margin % + + {selectedProduct.revenue + ? ( + ((selectedProduct.margin ?? 0) / + selectedProduct.revenue) * + 100 + ).toFixed(1) + "%" + : "—"} + +
- - {selectedProduct.cost - ? ( - ((selectedProduct.margin ?? 0) / - selectedProduct.cost) * - 100 - ).toFixed(1) + "% margin" - : "—"} - -
+ +
+ Markup +
+
+
+ + {selectedProduct.cost + ? ( + ((selectedProduct.margin ?? 0) / + selectedProduct.cost) * + 100 + ).toFixed(1) + "%" + : "—"} + +
+ +
+ Margin +
+
+
+ + {selectedProduct.revenue + ? ( + ((selectedProduct.margin ?? 0) / + selectedProduct.revenue) * + 100 + ).toFixed(1) + "%" + : "—"} + +
+ {/if}
@@ -2000,12 +2126,16 @@ )} -
- Recurring Cost - {formatCurrency(selectedProduct.recurringCost)} -
+ {#if canViewCost} +
+ Recurring Cost + {formatCurrency( + selectedProduct.recurringCost, + )} +
+ {/if}
Cycles