From db727e0a9dee731297e67ff266a52044ed7bc4f1 Mon Sep 17 00:00:00 2001 From: Jackson Roberts Date: Tue, 28 Apr 2026 01:48:11 +0000 Subject: [PATCH] feat: fix and add several things --- api/PERMISSIONS.md | 1 + .../sales/opportunities/[id]/quotes/commit.ts | 36 ++ .../api/sockets/events/liveQuotePreview.ts | 1 + api/src/api/unifi/site/link.ts | 2 +- api/src/controllers/OpportunityController.ts | 18 +- api/src/managers/opportunities.ts | 38 ++- api/src/managers/unifiSites.ts | 4 +- .../cw-utils/opportunities/opportunities.ts | 45 ++- .../opportunities/opportunity.types.ts | 66 ++++ api/src/modules/pdf-utils/generateQuote.ts | 313 ++++++++++++------ api/src/types/PermissionNodes.ts | 7 + api/src/workflows/wf.opportunity.ts | 49 ++- ui/src/lib/optima-api/modules/sales.ts | 1 + .../sales/opportunity/[id]/+page.server.ts | 1 + .../sales/opportunity/[id]/+page.svelte | 28 +- .../[id]/components/ProductsTab.svelte | 6 +- .../[id]/components/QuotesTab.svelte | 60 +++- 17 files changed, 541 insertions(+), 135 deletions(-) diff --git a/api/PERMISSIONS.md b/api/PERMISSIONS.md index 691dbfe..94820a0 100644 --- a/api/PERMISSIONS.md +++ b/api/PERMISSIONS.md @@ -164,6 +164,7 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy | `sales.opportunity.product.add.labor` | Add labor products via the dedicated labor route with Field/Tech catalog selection and labor pricing inputs. | [src/api/sales/opportunities/[id]/products/addLabor.ts](src/api/sales/opportunities/[id]/products/addLabor.ts), [src/api/sales/opportunities/[id]/products/laborOptions.ts](src/api/sales/opportunities/[id]/products/laborOptions.ts) | `sales.opportunity.fetch` | | `sales.opportunity.quote.fetch` | Fetch all committed quotes for an opportunity. | [src/api/sales/opportunities/[id]/quotes/fetchAll.ts](src/api/sales/opportunities/[id]/quotes/fetchAll.ts) | `sales.opportunity.fetch` | | `sales.opportunity.quote.commit` | Generate and store a finalized quote PDF for an opportunity with regeneration metadata and creator attribution. | [src/api/sales/opportunities/[id]/quotes/commit.ts](src/api/sales/opportunities/[id]/quotes/commit.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.quote.commit.backgenerate` | Generate a quote on an opportunity that is in a workflow state other than New or Active (e.g. PendingWon, QuoteSent). Without this permission, quote generation is restricted to the New and Active states only. | [src/api/sales/opportunities/[id]/quotes/commit.ts](src/api/sales/opportunities/[id]/quotes/commit.ts) | `sales.opportunity.quote.commit` | | `sales.opportunity.quote.preview` | Generate a preview-stamped quote PDF for an opportunity without storing it. | [src/api/sales/opportunities/[id]/quotes/preview.ts](src/api/sales/opportunities/[id]/quotes/preview.ts) | `sales.opportunity.fetch` | | `sales.opportunity.quote.download` | Download a committed quote PDF. Each download is recorded with timestamp and user info. | [src/api/sales/opportunities/[id]/quotes/download.ts](src/api/sales/opportunities/[id]/quotes/download.ts) | `sales.opportunity.fetch` | | `sales.opportunity.quote.fetch_downloads` | Fetch download/print history for all quotes on an opportunity. Admin-level permission. | [src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts](src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts) | `sales.opportunity.fetch` | diff --git a/api/src/api/sales/opportunities/[id]/quotes/commit.ts b/api/src/api/sales/opportunities/[id]/quotes/commit.ts index 5b892c7..9d30139 100644 --- a/api/src/api/sales/opportunities/[id]/quotes/commit.ts +++ b/api/src/api/sales/opportunities/[id]/quotes/commit.ts @@ -7,14 +7,23 @@ import { z } from "zod"; import { cwMembers } from "../../../../../managers/cwMembers"; import { createWorkflowActivity, + resolveQuoteParentActivityCwId, OptimaType, + OpportunityStatus, } from "../../../../../workflows/wf.opportunity"; +/** Status IDs that do NOT require the backgenerate permission. */ +const STANDARD_GENERATE_STATUSES = new Set([ + OpportunityStatus.New, + OpportunityStatus.Active, +]); + const commitQuoteSchema = z .object({ lineItemPricing: z.boolean().optional(), includeQuoteNarrative: z.boolean().optional(), includeItemNarratives: z.boolean().optional(), + separateRecurringServices: z.boolean().optional(), }) .strict() .optional(); @@ -32,6 +41,28 @@ export default createRoute( const item = await opportunities.fetchRecord(identifier); const user = c.get("user"); + // If the opportunity is in the Optima workflow and NOT in a standard generate state + // (New or Active), require the backgenerate permission. + if ( + item.stageName === "Optima" && + item.statusCwId != null && + !STANDARD_GENERATE_STATUSES.has(item.statusCwId) + ) { + const canBackGenerate = await user.hasPermission( + "sales.opportunity.quote.commit.backgenerate", + ); + if (!canBackGenerate) { + return c.json( + { + successful: false, + message: + "Generating a quote in this workflow state requires the 'sales.opportunity.quote.commit.backgenerate' permission.", + }, + 403, + ); + } + } + const quote = await item.commitQuote(opts ?? {}, user); // Create a workflow activity for the generated quote @@ -44,6 +75,10 @@ export default createRoute( } if (cwMemberId) { + const parentActivityCwId = await resolveQuoteParentActivityCwId( + item.cwOpportunityId, + ); + await createWorkflowActivity({ name: `[Workflow] Quote generated — ${item.name}`, opportunityCwId: item.cwOpportunityId, @@ -52,6 +87,7 @@ export default createRoute( notes: `Quote "${quote.quoteFileName}" generated.`, optimaType: OptimaType.QuoteGenerated, quoteId: quote.id, + parentActivityCwId, }); } } catch (activityErr) { diff --git a/api/src/api/sockets/events/liveQuotePreview.ts b/api/src/api/sockets/events/liveQuotePreview.ts index 3080438..9cd1f8e 100644 --- a/api/src/api/sockets/events/liveQuotePreview.ts +++ b/api/src/api/sockets/events/liveQuotePreview.ts @@ -53,6 +53,7 @@ export const registerLiveQuotePreviewHandlers = (socket: Socket) => { lineItemPricing: opts?.lineItemPricing, includeQuoteNarrative: opts?.includeQuoteNarrative, includeItemNarratives: opts?.includeItemNarratives, + separateRecurringServices: opts?.separateRecurringServices, logoPath: opts?.logoPath, showPreview: true, }); diff --git a/api/src/api/unifi/site/link.ts b/api/src/api/unifi/site/link.ts index b2e9107..203628c 100644 --- a/api/src/api/unifi/site/link.ts +++ b/api/src/api/unifi/site/link.ts @@ -12,7 +12,7 @@ export default createRoute( async (c) => { const siteId = c.req.param("id"); const body = await c.req.json(); - const schema = z.object({ companyId: z.string() }).strict(); + const schema = z.object({ companyId: z.number().int() }).strict(); const { companyId } = schema.parse(body); const site = await unifiSites.linkToCompany(siteId, companyId); diff --git a/api/src/controllers/OpportunityController.ts b/api/src/controllers/OpportunityController.ts index 82c8d26..3e96d46 100644 --- a/api/src/controllers/OpportunityController.ts +++ b/api/src/controllers/OpportunityController.ts @@ -825,8 +825,6 @@ export class OpportunityController { }, }); - console.log("[ROWS_DEBUG]", rows) - let ordered = rows; if (this.productSequence.length > 0) { const byId = new Map(rows.map((row) => [row.id, row])); @@ -944,6 +942,7 @@ export class OpportunityController { lineItemPricing?: boolean; includeQuoteNarrative?: boolean; includeItemNarratives?: boolean; + separateRecurringServices?: boolean; showPreview?: boolean; // INTERNAL ONLY logoPath?: string; metadata?: QuoteMetadata; @@ -952,6 +951,7 @@ export class OpportunityController { lineItemPricing: opts?.lineItemPricing ?? true, includeQuoteNarrative: opts?.includeQuoteNarrative ?? true, includeItemNarratives: opts?.includeItemNarratives ?? true, + separateRecurringServices: opts?.separateRecurringServices ?? true, showPreview: opts?.showPreview ?? false, logoPath: opts?.logoPath, }; @@ -1016,6 +1016,9 @@ export class OpportunityController { item.description || item.customerDescription || item.productDescription || "Line Item", unitPrice, narrative: shouldIncludeNarrative ? itemNarrative : undefined, + isRecurring: options.separateRecurringServices + ? item.catalogItemIdentifier?.startsWith("RSV") ?? false + : false, }; }); @@ -1120,6 +1123,7 @@ export class OpportunityController { }, isPreview: options.showPreview, showLineItemPricing: options.lineItemPricing, + separateRecurringServices: options.separateRecurringServices, metadata: opts?.metadata, }; @@ -1151,6 +1155,7 @@ export class OpportunityController { lineItemPricing?: boolean; includeQuoteNarrative?: boolean; includeItemNarratives?: boolean; + separateRecurringServices?: boolean; logoPath?: string; } = {}, user: UserController @@ -1159,6 +1164,7 @@ export class OpportunityController { lineItemPricing: opts?.lineItemPricing ?? true, includeQuoteNarrative: opts?.includeQuoteNarrative ?? true, includeItemNarratives: opts?.includeItemNarratives ?? true, + separateRecurringServices: opts?.separateRecurringServices ?? true, logoPath: opts?.logoPath, }; @@ -1240,6 +1246,7 @@ export class OpportunityController { lineItemPricing: quoteOptions.lineItemPricing, includeQuoteNarrative: quoteOptions.includeQuoteNarrative, includeItemNarratives: quoteOptions.includeItemNarratives, + separateRecurringServices: quoteOptions.separateRecurringServices, }, // Opportunity metadata @@ -1651,6 +1658,11 @@ export class OpportunityController { public async deleteProduct(forecastItemId: number): Promise { await opportunityCw.deleteProduct(this.cwOpportunityId, forecastItemId); + // Remove the deleted item from the local ProductData table + await prisma.productData.deleteMany({ + where: { id: forecastItemId, opportunityId: this.cwOpportunityId }, + }); + // Remove the deleted item from the local product sequence if (this.productSequence.includes(forecastItemId)) { const updatedSequence = this.productSequence.filter( @@ -1665,7 +1677,7 @@ export class OpportunityController { this.productSequence = updatedSequence; } - // No cache invalidation needed + return; } /** diff --git a/api/src/managers/opportunities.ts b/api/src/managers/opportunities.ts index c82f0a5..8278152 100644 --- a/api/src/managers/opportunities.ts +++ b/api/src/managers/opportunities.ts @@ -175,7 +175,7 @@ export const opportunities = { const record = await prisma.opportunity.findFirst({ where: isNumeric - ? ({ cwOpportunityId: Number(identifier) } as any) + ? { id: Number(identifier) } : { uid: identifier as string }, include: { company: { include: { contacts: true, companyAddresses: true } }, @@ -548,4 +548,40 @@ export const opportunities = { }) ); }, + + /** + * Delete Opportunity + * + * Deletes an opportunity from ConnectWise and removes the corresponding + * record (along with its associated ProductData) from the local database. + * + * @param identifier - The internal uid (string) or CW opportunity ID (number) + */ + async deleteItem(identifier: string | number): Promise { + const isNumeric = + typeof identifier === "number" || /^\d+$/.test(String(identifier)); + + const record = await prisma.opportunity.findFirst({ + where: isNumeric + ? { id: Number(identifier) } + : { uid: identifier as string }, + select: { uid: true, id: true }, + }); + + if (!record) { + throw new GenericError({ + message: "Opportunity not found", + name: "OpportunityNotFound", + cause: `No opportunity exists with identifier '${identifier}'`, + status: 404, + }); + } + + await opportunityCw.delete(record.id); + + await prisma.$transaction([ + prisma.productData.deleteMany({ where: { opportunityId: record.id } }), + prisma.opportunity.delete({ where: { uid: record.uid } }), + ]); + }, }; diff --git a/api/src/managers/unifiSites.ts b/api/src/managers/unifiSites.ts index d7006d2..e5bb593 100644 --- a/api/src/managers/unifiSites.ts +++ b/api/src/managers/unifiSites.ts @@ -66,7 +66,7 @@ export const unifiSites = { /** * Fetch all UniFi site records linked to a specific company. */ - async fetchByCompany(companyId: string): Promise { + async fetchByCompany(companyId: number): Promise { return prisma.unifiSite.findMany({ where: { companyId }, }); @@ -75,7 +75,7 @@ export const unifiSites = { /** * Link a UniFi site to a company. */ - async linkToCompany(siteId: string, companyId: string): Promise { + async linkToCompany(siteId: string, companyId: number): Promise { const site = await prisma.unifiSite.findFirst({ where: { id: siteId } }); if (!site) throw new GenericError({ diff --git a/api/src/modules/cw-utils/opportunities/opportunities.ts b/api/src/modules/cw-utils/opportunities/opportunities.ts index f755b91..a2a840d 100644 --- a/api/src/modules/cw-utils/opportunities/opportunities.ts +++ b/api/src/modules/cw-utils/opportunities/opportunities.ts @@ -6,6 +6,7 @@ import { CWOpportunitySummary, CWForecast, CWForecastItem, + CWOpportunityProduct, CWForecastItemCreate, CWProcurementProduct, CWProcurementProductCreate, @@ -158,9 +159,9 @@ export const opportunityCw = { * Fetches the full forecast object (products, revenue summaries, totals) * for a given opportunity. */ - fetchProducts: async (opportunityId: number): Promise => { + fetchProducts: async (opportunityId: number): Promise => { const response = await connectWiseApi.get( - `/sales/opportunities/${opportunityId}/forecast`, + `/procurement/products?conditions=opportunity/id=${opportunityId}&pageSize=1000`, ); return response.data; }, @@ -180,18 +181,18 @@ export const opportunityCw = { const items_to_add = Array.isArray(data) ? data : [data]; const url = `/sales/opportunities/${opportunityId}/forecast`; - // 1. Fetch existing forecast to derive defaults & diff IDs later + // 1. Fetch existing products to derive defaults & diff IDs later const existing = await opportunityCw.fetchProducts(opportunityId); const existingIds = new Set( - (existing.forecastItems ?? []).map((fi) => fi.id), + existing.map((p) => p.forecastDetailId).filter((id): id is number => id != null), ); // Derive sensible defaults from an existing item when available - const templateItem = (existing.forecastItems ?? [])[0]; - const defaultStatus = templateItem?.status - ? { id: templateItem.status.id } + const templateItem = existing[0]; + const defaultStatus = templateItem?.forecastStatus + ? { id: templateItem.forecastStatus.id } : { id: 1 }; - const defaultForecastType = templateItem?.forecastType ?? "Product"; + const defaultForecastType = "Product"; // 2. Build forecast items with required CW fields filled in const forecastItems = items_to_add.map((newItem) => ({ @@ -234,9 +235,8 @@ export const opportunityCw = { forecastItemId: number, data: Record, ): Promise => { - const forecast = await opportunityCw.fetchProducts(opportunityId); - const items = forecast.forecastItems ?? []; - const idx = items.findIndex((fi) => fi.id === forecastItemId); + const items = await opportunityCw.fetchProducts(opportunityId); + const idx = items.findIndex((p) => p.forecastDetailId === forecastItemId); if (idx === -1) { throw new Error( `Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`, @@ -265,14 +265,13 @@ export const opportunityCw = { opportunityId: number, updates: Map>, ): Promise => { - const forecast = await opportunityCw.fetchProducts(opportunityId); - const items = forecast.forecastItems ?? []; + const items = await opportunityCw.fetchProducts(opportunityId); const operations: { op: "replace"; path: string; value: unknown }[] = []; const touchedIndices: number[] = []; for (const [itemId, changes] of updates) { - const idx = items.findIndex((fi) => fi.id === itemId); + const idx = items.findIndex((p) => p.forecastDetailId === itemId); if (idx === -1) { throw new Error( `Forecast item ${itemId} not found on opportunity ${opportunityId}`, @@ -304,20 +303,18 @@ export const opportunityCw = { */ deleteProduct: async ( opportunityId: number, - forecastItemId: number, + productId: number, ): Promise => { - const forecast = await opportunityCw.fetchProducts(opportunityId); - const items = forecast.forecastItems ?? []; - - const filtered = items.filter((fi) => fi.id !== forecastItemId); - if (filtered.length === items.length) { + const products = await opportunityCw.fetchProducts(opportunityId); + const found = products.find((p) => p.id === productId); + if (!found) { throw new Error( - `Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`, + `Product ${productId} not found on opportunity ${opportunityId}`, ); } - - const url = `/sales/opportunities/${opportunityId}/forecast`; - await connectWiseApi.put(url, { ...forecast, forecastItems: filtered }); + const filtered = products.filter((p) => p.id !== productId); + const url = `/procurement/products/${productId}`; + await connectWiseApi.delete(url); }, /** diff --git a/api/src/modules/cw-utils/opportunities/opportunity.types.ts b/api/src/modules/cw-utils/opportunities/opportunity.types.ts index 7cc23ee..41381dd 100644 --- a/api/src/modules/cw-utils/opportunities/opportunity.types.ts +++ b/api/src/modules/cw-utils/opportunities/opportunity.types.ts @@ -313,3 +313,69 @@ export interface CWOpportunitySummary { id: number; _info?: Record; } + +export interface CWOpportunityProduct { + id: number; + catalogItem?: { + id: number; + identifier: string; + _info?: Record; + }; + description: string; + sequenceNumber: number; + quantity: number; + unitOfMeasure?: { + id: number; + name: string; + _info?: Record; + }; + price: number; + cost: number; + extPrice: number; + extCost: number; + discount: number; + margin: number; + billableOption: string; + locationId: number; + location?: CWReference; + businessUnitId: number; + businessUnit?: CWReference; + vendor?: { + id: number; + identifier: string; + name: string; + _info?: Record; + }; + vendorSku?: string; + taxableFlag: boolean; + dropshipFlag: boolean; + specialOrderFlag: boolean; + phaseProductFlag: boolean; + cancelledFlag: boolean; + quantityCancelled: number; + customerDescription: string; + productSuppliedFlag: boolean; + subContractorAmountLimit: number; + opportunity?: { + id: number; + name: string; + _info?: Record; + }; + calculatedPriceFlag: boolean; + calculatedCostFlag: boolean; + forecastDetailId?: number; + taxCode?: CWReference; + listPrice?: number; + company?: CWCompanyReference; + forecastStatus?: CWReference; + productClass: string; + needToPurchaseFlag: boolean; + minimumStockFlag: boolean; + poApprovedFlag: boolean; + uom?: string; + customFields?: CWCustomField[]; + _info?: { + lastUpdated: string; + updatedBy: string; + }; +} diff --git a/api/src/modules/pdf-utils/generateQuote.ts b/api/src/modules/pdf-utils/generateQuote.ts index 7f4a856..f4c3a82 100644 --- a/api/src/modules/pdf-utils/generateQuote.ts +++ b/api/src/modules/pdf-utils/generateQuote.ts @@ -7,6 +7,7 @@ export interface QuoteLineItem { description: string; unitPrice: number; narrative?: string; + isRecurring?: boolean; } export interface CustomerInfo { @@ -60,6 +61,7 @@ export interface QuoteData { quoteNarrative?: string; isPreview?: boolean; showLineItemPricing?: boolean; + separateRecurringServices?: boolean; metadata?: QuoteMetadata; } @@ -185,7 +187,16 @@ export async function generateQuote( logoPath = DEFAULT_LOGO_PATH ): Promise { const t: QuoteTheme = { ...DEFAULT_THEME, ...theme }; - const subTotal = data.lineItems.reduce( + + const separateRecurring = data.separateRecurringServices ?? false; + const regularItems = separateRecurring + ? data.lineItems.filter((item) => !item.isRecurring) + : data.lineItems; + const recurringItems = separateRecurring + ? data.lineItems.filter((item) => item.isRecurring) + : []; + + const subTotal = regularItems.reduce( (sum, item) => sum + item.qty * item.unitPrice, 0 ); @@ -196,13 +207,23 @@ export async function generateQuote( const showPricing = data.showLineItemPricing ?? false; - const discountTotal = data.lineItems.reduce((sum, item) => { + // Check discounts across all items so both tables share the same column structure + const allDiscountTotal = data.lineItems.reduce((sum, item) => { const lineTotal = item.qty * item.unitPrice; return lineTotal < 0 ? sum + lineTotal : sum; }, 0); - const hasDiscounts = discountTotal < 0; + const discountTotal = regularItems.reduce((sum, item) => { + const lineTotal = item.qty * item.unitPrice; + return lineTotal < 0 ? sum + lineTotal : sum; + }, 0); + const hasDiscounts = allDiscountTotal < 0; const showDiscount = !showPricing && hasDiscounts; + const recurringTotal = recurringItems.reduce( + (sum, item) => sum + item.qty * item.unitPrice, + 0 + ); + const tableHeader = [ { text: "Qty", style: "thCell", alignment: "center" }, { text: "Description", style: "thCell" }, @@ -218,57 +239,61 @@ export async function generateQuote( const colCount = showPricing ? 4 : showDiscount ? 3 : 2; - const tableRows: Record[][] = []; - for (const item of data.lineItems) { - // Build the description cell — stack description + narrative so they - // are a single cell and pdfmake never splits them across pages. - const descriptionCell: Record = item.narrative - ? { - stack: [ - { text: item.description, style: "tdCell" }, - { - text: item.narrative, - style: "narrative", - margin: [0, 2, 8, 0], - }, - ], - } - : { text: item.description, style: "tdCell" }; + function buildTableRows(items: QuoteLineItem[]): Record[][] { + const rows: Record[][] = []; + for (const item of items) { + const descriptionCell: Record = item.narrative + ? { + stack: [ + { text: item.description, style: "tdCell" }, + { + text: item.narrative, + style: "narrative", + margin: [0, 2, 8, 0], + }, + ], + } + : { text: item.description, style: "tdCell" }; - tableRows.push([ - { text: String(item.qty), style: "tdCell", alignment: "center" }, - descriptionCell, - ...(showPricing - ? [ - { - text: fmtMoney(item.unitPrice), - style: "tdCell", - alignment: "right", - noWrap: true, - }, - { - text: fmtMoney(item.qty * item.unitPrice), - style: "tdCell", - alignment: "right", - noWrap: true, - }, - ] - : showDiscount - ? [ - { - text: - item.qty * item.unitPrice < 0 - ? fmtMoney(item.qty * item.unitPrice) - : "", - style: "tdCell", - alignment: "right", - noWrap: true, - }, - ] - : []), - ]); + rows.push([ + { text: String(item.qty), style: "tdCell", alignment: "center" }, + descriptionCell, + ...(showPricing + ? [ + { + text: fmtMoney(item.unitPrice), + style: "tdCell", + alignment: "right", + noWrap: true, + }, + { + text: fmtMoney(item.qty * item.unitPrice), + style: "tdCell", + alignment: "right", + noWrap: true, + }, + ] + : showDiscount + ? [ + { + text: + item.qty * item.unitPrice < 0 + ? fmtMoney(item.qty * item.unitPrice) + : "", + style: "tdCell", + alignment: "right", + noWrap: true, + }, + ] + : []), + ]); + } + return rows; } + const tableRows = buildTableRows(regularItems); + const recurringTableRows = buildTableRows(recurringItems); + const headerImage = logoDataUrl ? { image: logoDataUrl, width: 200 } : { @@ -731,64 +756,157 @@ export async function generateQuote( ], }, + ], + }, + ], + } as any; + + const signatureDateBlock = { + margin: [0, 40, 0, 0], + columns: [ + { + width: "50%", + stack: [ { - margin: [0, 40, 0, 0], - columns: [ + canvas: [ { - width: "50%", - stack: [ - { - canvas: [ - { - type: "line", - x1: 0, - y1: 0, - x2: 220, - y2: 0, - lineWidth: 0.75, - lineColor: "#999", - }, - ], - }, - { - text: "Authorized Signature", - fontSize: 7, - color: "#888", - margin: [0, 3, 0, 0], - }, - ], + type: "line", + x1: 0, + y1: 0, + x2: 220, + y2: 0, + lineWidth: 0.75, + lineColor: "#999", }, + ], + }, + { + text: "Authorized Signature", + fontSize: 7, + color: "#888", + margin: [0, 3, 0, 0], + }, + ], + }, + { + width: "50%", + stack: [ + { + canvas: [ { - width: "50%", - stack: [ - { - canvas: [ + type: "line", + x1: 0, + y1: 0, + x2: 160, + y2: 0, + lineWidth: 0.75, + lineColor: "#999", + }, + ], + }, + { + text: "Date", + fontSize: 7, + color: "#888", + margin: [0, 3, 0, 0], + }, + ], + }, + ], + }; + + // Inject recurring services section into content if needed + if (separateRecurring && recurringItems.length > 0) { + const tableWidths = showPricing + ? [40, "*", 75, 75] + : showDiscount + ? [40, "*", 75] + : [40, "*"]; + + (docDefinition as any).content.push( + { ...hr(t.accent, 1), margin: [0, 18, 0, 0] }, + { + text: "RECURRING SERVICES", + style: "sectionTitle", + margin: [0, 8, 0, 0], + }, + { + margin: [0, 6, 0, 0], + table: { + headerRows: 1, + dontBreakRows: true, + widths: tableWidths, + body: [tableHeader, ...recurringTableRows], + }, + layout: { + fillColor: (rowIndex: number) => { + if (rowIndex === 0) return t.headerBg; + return rowIndex % 2 === 0 ? ROW_ALT : null; + }, + hLineWidth: (i: number, node: { table: { body: unknown[] } }) => { + if (i === 0 || i === 1) return 0; + if (i === node.table.body.length) return 1; + return 0.5; + }, + vLineWidth: () => 0, + hLineColor: (i: number, node: { table: { body: unknown[] } }) => + i === node.table.body.length ? t.headerBg : "#E8E0D0", + paddingLeft: (col: number) => (col === 0 ? 6 : 8), + paddingRight: () => 8, + paddingTop: () => 4, + paddingBottom: () => 4, + }, + }, + { + unbreakable: true, + stack: [ + { + margin: [0, 6, 0, 0], + columns: [ + { width: "*", text: "" }, + { + width: 250, + table: { + widths: ["*", 110], + body: [ + [ { - type: "line", - x1: 0, - y1: 0, - x2: 160, - y2: 0, - lineWidth: 0.75, - lineColor: "#999", + text: "Monthly Total", + style: "totalFinalLabel", + fillColor: t.headerBg, + margin: [10, 8, 6, 8], + border: [false, false, false, false], + }, + { + text: fmt(recurringTotal), + style: "totalFinalValue", + alignment: "right", + noWrap: true, + fillColor: t.brandLight, + margin: [6, 7, 8, 7], + border: [false, false, false, false], }, ], - }, - { - text: "Date", - fontSize: 7, - color: "#888", - margin: [0, 3, 0, 0], - }, - ], + ], + }, + layout: { + hLineWidth: () => 0, + vLineWidth: () => 0, + }, }, ], }, ], }, - ], + signatureDateBlock + ); + } else { + (docDefinition as any).content[ + (docDefinition as any).content.length - 1 + ].stack.push(signatureDateBlock); + } - footer: (currentPage: number, pageCount: number) => ({ + (docDefinition as any).footer = (currentPage: number, pageCount: number) => ({ margin: [0, 0, 0, 0], stack: [ { @@ -827,8 +945,7 @@ export async function generateQuote( style: "disclaimer", }, ], - }), - }; + }); const maybeDoc = printer.createPdfKitDocument(docDefinition as never) as any; const pdfDoc = diff --git a/api/src/types/PermissionNodes.ts b/api/src/types/PermissionNodes.ts index b6c135a..c29d8d6 100644 --- a/api/src/types/PermissionNodes.ts +++ b/api/src/types/PermissionNodes.ts @@ -568,6 +568,13 @@ export const PERMISSION_NODES = { usedIn: ["src/api/sales/opportunities/[id]/quotes/commit.ts"], dependencies: ["sales.opportunity.fetch"], }, + { + node: "sales.opportunity.quote.commit.backgenerate", + description: + "Generate a quote on an opportunity that is in a workflow state other than New or Active (e.g. PendingWon, QuoteSent). Requires sales.opportunity.quote.commit as a base.", + usedIn: ["src/api/sales/opportunities/[id]/quotes/commit.ts"], + dependencies: ["sales.opportunity.quote.commit"], + }, { node: "sales.opportunity.quote.preview", description: diff --git a/api/src/workflows/wf.opportunity.ts b/api/src/workflows/wf.opportunity.ts index 43d7ab7..f8cf436 100644 --- a/api/src/workflows/wf.opportunity.ts +++ b/api/src/workflows/wf.opportunity.ts @@ -468,11 +468,11 @@ function ok( /** * Build the `customFields` array for a CW activity with Optima_Type set, - * and optionally a QuoteID. + * and optionally a QuoteID, CloseDate, or ParentActivity. */ function buildCustomFields( optimaType: OptimaTypeValue, - opts?: { quoteId?: string; closeDate?: string }, + opts?: { quoteId?: string; closeDate?: string; parentActivityCwId?: number }, ) { const fields: any[] = [ { @@ -507,6 +507,17 @@ function buildCustomFields( }); } + if (opts?.parentActivityCwId != null) { + fields.push({ + id: PARENT_ACTIVITY_FIELD_ID, + caption: "Parent_Activity", + type: "Text", + entryMethod: "EntryField", + numberOfDecimals: 0, + value: String(opts.parentActivityCwId), + }); + } + return fields; } @@ -523,6 +534,7 @@ export async function createWorkflowActivity(opts: { quoteId?: string; dateStart?: string; dateEnd?: string; + parentActivityCwId?: number | null; }): Promise { const shouldStayOpen = STAYS_OPEN_TYPES.has(opts.optimaType); @@ -546,6 +558,7 @@ export async function createWorkflowActivity(opts: { value: buildCustomFields(opts.optimaType, { quoteId: opts.quoteId, closeDate: now, + parentActivityCwId: opts.parentActivityCwId ?? undefined, }), }, ]; @@ -562,6 +575,38 @@ export async function createWorkflowActivity(opts: { return patched; } +/** + * Resolve the parent activity CW ID for a newly generated quote activity. + * + * Finds the most recently created workflow activity (by CW ID descending) for + * the opportunity, excluding QuoteGenerated and ScheduleEntry types. This + * ensures the quote activity is nested under the current workflow state's + * activity regardless of whether that activity is open or closed. + */ +export async function resolveQuoteParentActivityCwId( + opportunityCwId: number, +): Promise { + try { + const existingActivities = await activityCw.fetchByOpportunityDirect(opportunityCwId); + // Sort descending by CW id so the most recently created comes first + const sorted = [...existingActivities].sort((a, b) => (b.id ?? 0) - (a.id ?? 0)); + for (const raw of sorted) { + const optimaField = raw.customFields?.find( + (f: any) => f.id === OptimaType.FIELD_ID, + ); + if (!optimaField?.value) continue; + // Skip QuoteGenerated and ScheduleEntry — these should not be parents + if (optimaField.value === OptimaType.QuoteGenerated) continue; + if (optimaField.value === OptimaType.ScheduleEntry) continue; + return raw.id; + } + return null; + } catch (err) { + console.warn(`[Workflow:QuoteParent] Could not resolve parent activity: ${err}`); + return null; + } +} + /** * Handle optional time entry: submit to CW if timeStart and timeEnd are provided. */ diff --git a/ui/src/lib/optima-api/modules/sales.ts b/ui/src/lib/optima-api/modules/sales.ts index a7a3d9d..6b9d3cd 100644 --- a/ui/src/lib/optima-api/modules/sales.ts +++ b/ui/src/lib/optima-api/modules/sales.ts @@ -954,6 +954,7 @@ export const sales = { lineItemPricing?: boolean; includeQuoteNarrative?: boolean; includeItemNarratives?: boolean; + separateRecurringServices?: boolean; } ) { const response = await api.post( diff --git a/ui/src/routes/sales/opportunity/[id]/+page.server.ts b/ui/src/routes/sales/opportunity/[id]/+page.server.ts index b3e86fc..9128527 100644 --- a/ui/src/routes/sales/opportunity/[id]/+page.server.ts +++ b/ui/src/routes/sales/opportunity/[id]/+page.server.ts @@ -34,6 +34,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { "sales.opportunity.note.delete", "sales.opportunity.quote.fetch", "sales.opportunity.quote.commit", + "sales.opportunity.quote.commit.backgenerate", "sales.opportunity.quote.preview", "sales.opportunity.quote.download", "sales.opportunity.quote.fetch_downloads", diff --git a/ui/src/routes/sales/opportunity/[id]/+page.svelte b/ui/src/routes/sales/opportunity/[id]/+page.svelte index d558547..de95281 100644 --- a/ui/src/routes/sales/opportunity/[id]/+page.svelte +++ b/ui/src/routes/sales/opportunity/[id]/+page.svelte @@ -69,6 +69,31 @@ ); })(); + /** + * Quote generation is only blocked in truly terminal states (Won/Lost). + * All other workflow states — including PendingWon, PendingLost, etc. — + * allow generating a quote. Outside the Optima workflow, fall back to + * the standard isClosedOpportunity check. + */ + $: isQuoteGenerationBlocked = (() => { + const wfKey = workflowStatus + ? (STATUS_ID_TO_KEY[workflowStatus.currentStatusId] ?? null) + : null; + if (wfKey) return TERMINAL_STATUSES.has(wfKey); + return isClosedOpportunity; + })(); + + /** + * True when the opportunity is in the Optima workflow and in a state + * other than New or Active, meaning back-generate permission is required. + */ + $: isBackGenerateState = (() => { + if (!workflowStatus?.isOptimaStage) return false; + const wfKey = STATUS_ID_TO_KEY[workflowStatus.currentStatusId] ?? null; + if (!wfKey) return false; + return !EDITABLE_STATUSES.has(wfKey); + })(); + // Workflow read-only: only New and Active are editable (when in Optima workflow). $: isReadOnly = (() => { if (isClosedOpportunity) return true; @@ -451,7 +476,8 @@ initialQuotes={quotes} initialQuoteId={pendingQuoteId} {permissions} - isClosedOpportunity={isReadOnly} + isClosedOpportunity={isQuoteGenerationBlocked} + {isBackGenerateState} on:quotesChanged={handleQuotesChanged} /> {:else if activeTab === "Notes"} diff --git a/ui/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte b/ui/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte index c15d2f5..dabe242 100644 --- a/ui/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte +++ b/ui/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte @@ -430,17 +430,19 @@ isDeletingProduct = true; deleteProductError = ""; + const deletedId = selectedProduct.id; try { await optima.sales.deleteProduct( accessToken, opportunityId, - selectedProduct.id + deletedId ); + products = products.filter((p) => p.id !== deletedId); + dispatch("productsChanged", products); showDeleteModal = false; selectedProduct = null; showPanel = false; isClosing = false; - await refreshProducts(); } catch (err) { console.error("[DeleteProduct] Failed:", err); deleteProductError = diff --git a/ui/src/routes/sales/opportunity/[id]/components/QuotesTab.svelte b/ui/src/routes/sales/opportunity/[id]/components/QuotesTab.svelte index e999e42..b913f61 100644 --- a/ui/src/routes/sales/opportunity/[id]/components/QuotesTab.svelte +++ b/ui/src/routes/sales/opportunity/[id]/components/QuotesTab.svelte @@ -17,14 +17,26 @@ export let initialQuoteId: string | null = null; export let permissions: PermissionMap = {} as PermissionMap; export let isClosedOpportunity: boolean = false; + /** True when the opportunity is in an Optima workflow state other than New or Active. */ + export let isBackGenerateState: boolean = false; const dispatch = createEventDispatcher<{ quotesChanged: CommittedQuote[] }>(); // ── Permission helpers ── $: canFetchQuotes = permissions["sales.opportunity.quote.fetch"] !== false; + $: canBackGenerate = + permissions["sales.opportunity.quote.commit.backgenerate"] === true; $: canCommitQuote = !isClosedOpportunity && - permissions["sales.opportunity.quote.commit"] === true; + permissions["sales.opportunity.quote.commit"] === true && + (!isBackGenerateState || canBackGenerate); + $: backGenerateBlockedReason = + !isClosedOpportunity && + permissions["sales.opportunity.quote.commit"] === true && + isBackGenerateState && + !canBackGenerate + ? "Generating a quote in this workflow state requires additional permissions." + : null; $: canPreviewQuote = permissions["sales.opportunity.quote.preview"] === true; $: canDownloadQuote = permissions["sales.opportunity.quote.download"] === true; @@ -100,6 +112,7 @@ lineItemPricing: false, includeQuoteNarrative: true, includeItemNarratives: true, + separateRecurringServices: true, }; let isCommitting = false; @@ -510,6 +523,7 @@ lineItemPricing: quotePreviewOptions.lineItemPricing, includeQuoteNarrative: quotePreviewOptions.includeQuoteNarrative, includeItemNarratives: quotePreviewOptions.includeItemNarratives, + separateRecurringServices: quotePreviewOptions.separateRecurringServices, }; } @@ -546,6 +560,10 @@ quotePreviewOptions.includeItemNarratives = options.includeItemNarratives; } + if (typeof options.separateRecurringServices === "boolean") { + quotePreviewOptions.separateRecurringServices = + options.separateRecurringServices; + } } const nestedData = data.data as Record | undefined; @@ -739,6 +757,7 @@ lineItemPricing: quotePreviewOptions.lineItemPricing, includeQuoteNarrative: quotePreviewOptions.includeQuoteNarrative, includeItemNarratives: quotePreviewOptions.includeItemNarratives, + separateRecurringServices: quotePreviewOptions.separateRecurringServices, }); commitSuccess = result.message || "Quote created successfully!"; // Reload quotes (basic + detail) and switch to list view @@ -902,6 +921,25 @@ + {:else if backGenerateBlockedReason} + {/if} @@ -1033,6 +1071,13 @@ > Item narratives + + Separate recurring + {/if} @@ -1394,6 +1439,18 @@ /> Include item narratives +