feat: fix and add several things

This commit is contained in:
2026-04-28 01:48:11 +00:00
parent c3d55b898f
commit db727e0a9d
17 changed files with 541 additions and 135 deletions
+1
View File
@@ -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.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.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` | 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.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.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` | | `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` |
@@ -7,14 +7,23 @@ import { z } from "zod";
import { cwMembers } from "../../../../../managers/cwMembers"; import { cwMembers } from "../../../../../managers/cwMembers";
import { import {
createWorkflowActivity, createWorkflowActivity,
resolveQuoteParentActivityCwId,
OptimaType, OptimaType,
OpportunityStatus,
} from "../../../../../workflows/wf.opportunity"; } from "../../../../../workflows/wf.opportunity";
/** Status IDs that do NOT require the backgenerate permission. */
const STANDARD_GENERATE_STATUSES = new Set<number>([
OpportunityStatus.New,
OpportunityStatus.Active,
]);
const commitQuoteSchema = z const commitQuoteSchema = z
.object({ .object({
lineItemPricing: z.boolean().optional(), lineItemPricing: z.boolean().optional(),
includeQuoteNarrative: z.boolean().optional(), includeQuoteNarrative: z.boolean().optional(),
includeItemNarratives: z.boolean().optional(), includeItemNarratives: z.boolean().optional(),
separateRecurringServices: z.boolean().optional(),
}) })
.strict() .strict()
.optional(); .optional();
@@ -32,6 +41,28 @@ export default createRoute(
const item = await opportunities.fetchRecord(identifier); const item = await opportunities.fetchRecord(identifier);
const user = c.get("user"); 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); const quote = await item.commitQuote(opts ?? {}, user);
// Create a workflow activity for the generated quote // Create a workflow activity for the generated quote
@@ -44,6 +75,10 @@ export default createRoute(
} }
if (cwMemberId) { if (cwMemberId) {
const parentActivityCwId = await resolveQuoteParentActivityCwId(
item.cwOpportunityId,
);
await createWorkflowActivity({ await createWorkflowActivity({
name: `[Workflow] Quote generated — ${item.name}`, name: `[Workflow] Quote generated — ${item.name}`,
opportunityCwId: item.cwOpportunityId, opportunityCwId: item.cwOpportunityId,
@@ -52,6 +87,7 @@ export default createRoute(
notes: `Quote "${quote.quoteFileName}" generated.`, notes: `Quote "${quote.quoteFileName}" generated.`,
optimaType: OptimaType.QuoteGenerated, optimaType: OptimaType.QuoteGenerated,
quoteId: quote.id, quoteId: quote.id,
parentActivityCwId,
}); });
} }
} catch (activityErr) { } catch (activityErr) {
@@ -53,6 +53,7 @@ export const registerLiveQuotePreviewHandlers = (socket: Socket) => {
lineItemPricing: opts?.lineItemPricing, lineItemPricing: opts?.lineItemPricing,
includeQuoteNarrative: opts?.includeQuoteNarrative, includeQuoteNarrative: opts?.includeQuoteNarrative,
includeItemNarratives: opts?.includeItemNarratives, includeItemNarratives: opts?.includeItemNarratives,
separateRecurringServices: opts?.separateRecurringServices,
logoPath: opts?.logoPath, logoPath: opts?.logoPath,
showPreview: true, showPreview: true,
}); });
+1 -1
View File
@@ -12,7 +12,7 @@ export default createRoute(
async (c) => { async (c) => {
const siteId = c.req.param("id"); const siteId = c.req.param("id");
const body = await c.req.json(); 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 { companyId } = schema.parse(body);
const site = await unifiSites.linkToCompany(siteId, companyId); const site = await unifiSites.linkToCompany(siteId, companyId);
+15 -3
View File
@@ -825,8 +825,6 @@ export class OpportunityController {
}, },
}); });
console.log("[ROWS_DEBUG]", rows)
let ordered = rows; let ordered = rows;
if (this.productSequence.length > 0) { if (this.productSequence.length > 0) {
const byId = new Map(rows.map((row) => [row.id, row])); const byId = new Map(rows.map((row) => [row.id, row]));
@@ -944,6 +942,7 @@ export class OpportunityController {
lineItemPricing?: boolean; lineItemPricing?: boolean;
includeQuoteNarrative?: boolean; includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean; includeItemNarratives?: boolean;
separateRecurringServices?: boolean;
showPreview?: boolean; // INTERNAL ONLY showPreview?: boolean; // INTERNAL ONLY
logoPath?: string; logoPath?: string;
metadata?: QuoteMetadata; metadata?: QuoteMetadata;
@@ -952,6 +951,7 @@ export class OpportunityController {
lineItemPricing: opts?.lineItemPricing ?? true, lineItemPricing: opts?.lineItemPricing ?? true,
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true, includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
includeItemNarratives: opts?.includeItemNarratives ?? true, includeItemNarratives: opts?.includeItemNarratives ?? true,
separateRecurringServices: opts?.separateRecurringServices ?? true,
showPreview: opts?.showPreview ?? false, showPreview: opts?.showPreview ?? false,
logoPath: opts?.logoPath, logoPath: opts?.logoPath,
}; };
@@ -1016,6 +1016,9 @@ export class OpportunityController {
item.description || item.customerDescription || item.productDescription || "Line Item", item.description || item.customerDescription || item.productDescription || "Line Item",
unitPrice, unitPrice,
narrative: shouldIncludeNarrative ? itemNarrative : undefined, narrative: shouldIncludeNarrative ? itemNarrative : undefined,
isRecurring: options.separateRecurringServices
? item.catalogItemIdentifier?.startsWith("RSV") ?? false
: false,
}; };
}); });
@@ -1120,6 +1123,7 @@ export class OpportunityController {
}, },
isPreview: options.showPreview, isPreview: options.showPreview,
showLineItemPricing: options.lineItemPricing, showLineItemPricing: options.lineItemPricing,
separateRecurringServices: options.separateRecurringServices,
metadata: opts?.metadata, metadata: opts?.metadata,
}; };
@@ -1151,6 +1155,7 @@ export class OpportunityController {
lineItemPricing?: boolean; lineItemPricing?: boolean;
includeQuoteNarrative?: boolean; includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean; includeItemNarratives?: boolean;
separateRecurringServices?: boolean;
logoPath?: string; logoPath?: string;
} = {}, } = {},
user: UserController user: UserController
@@ -1159,6 +1164,7 @@ export class OpportunityController {
lineItemPricing: opts?.lineItemPricing ?? true, lineItemPricing: opts?.lineItemPricing ?? true,
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true, includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
includeItemNarratives: opts?.includeItemNarratives ?? true, includeItemNarratives: opts?.includeItemNarratives ?? true,
separateRecurringServices: opts?.separateRecurringServices ?? true,
logoPath: opts?.logoPath, logoPath: opts?.logoPath,
}; };
@@ -1240,6 +1246,7 @@ export class OpportunityController {
lineItemPricing: quoteOptions.lineItemPricing, lineItemPricing: quoteOptions.lineItemPricing,
includeQuoteNarrative: quoteOptions.includeQuoteNarrative, includeQuoteNarrative: quoteOptions.includeQuoteNarrative,
includeItemNarratives: quoteOptions.includeItemNarratives, includeItemNarratives: quoteOptions.includeItemNarratives,
separateRecurringServices: quoteOptions.separateRecurringServices,
}, },
// Opportunity metadata // Opportunity metadata
@@ -1651,6 +1658,11 @@ export class OpportunityController {
public async deleteProduct(forecastItemId: number): Promise<void> { public async deleteProduct(forecastItemId: number): Promise<void> {
await opportunityCw.deleteProduct(this.cwOpportunityId, forecastItemId); 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 // Remove the deleted item from the local product sequence
if (this.productSequence.includes(forecastItemId)) { if (this.productSequence.includes(forecastItemId)) {
const updatedSequence = this.productSequence.filter( const updatedSequence = this.productSequence.filter(
@@ -1665,7 +1677,7 @@ export class OpportunityController {
this.productSequence = updatedSequence; this.productSequence = updatedSequence;
} }
// No cache invalidation needed return;
} }
/** /**
+37 -1
View File
@@ -175,7 +175,7 @@ export const opportunities = {
const record = await prisma.opportunity.findFirst({ const record = await prisma.opportunity.findFirst({
where: isNumeric where: isNumeric
? ({ cwOpportunityId: Number(identifier) } as any) ? { id: Number(identifier) }
: { uid: identifier as string }, : { uid: identifier as string },
include: { include: {
company: { include: { contacts: true, companyAddresses: true } }, 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<void> {
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 } }),
]);
},
}; };
+2 -2
View File
@@ -66,7 +66,7 @@ export const unifiSites = {
/** /**
* Fetch all UniFi site records linked to a specific company. * Fetch all UniFi site records linked to a specific company.
*/ */
async fetchByCompany(companyId: string): Promise<UnifiSite[]> { async fetchByCompany(companyId: number): Promise<UnifiSite[]> {
return prisma.unifiSite.findMany({ return prisma.unifiSite.findMany({
where: { companyId }, where: { companyId },
}); });
@@ -75,7 +75,7 @@ export const unifiSites = {
/** /**
* Link a UniFi site to a company. * Link a UniFi site to a company.
*/ */
async linkToCompany(siteId: string, companyId: string): Promise<UnifiSite> { async linkToCompany(siteId: string, companyId: number): Promise<UnifiSite> {
const site = await prisma.unifiSite.findFirst({ where: { id: siteId } }); const site = await prisma.unifiSite.findFirst({ where: { id: siteId } });
if (!site) if (!site)
throw new GenericError({ throw new GenericError({
@@ -6,6 +6,7 @@ import {
CWOpportunitySummary, CWOpportunitySummary,
CWForecast, CWForecast,
CWForecastItem, CWForecastItem,
CWOpportunityProduct,
CWForecastItemCreate, CWForecastItemCreate,
CWProcurementProduct, CWProcurementProduct,
CWProcurementProductCreate, CWProcurementProductCreate,
@@ -158,9 +159,9 @@ export const opportunityCw = {
* Fetches the full forecast object (products, revenue summaries, totals) * Fetches the full forecast object (products, revenue summaries, totals)
* for a given opportunity. * for a given opportunity.
*/ */
fetchProducts: async (opportunityId: number): Promise<CWForecast> => { fetchProducts: async (opportunityId: number): Promise<CWOpportunityProduct[]> => {
const response = await connectWiseApi.get( const response = await connectWiseApi.get(
`/sales/opportunities/${opportunityId}/forecast`, `/procurement/products?conditions=opportunity/id=${opportunityId}&pageSize=1000`,
); );
return response.data; return response.data;
}, },
@@ -180,18 +181,18 @@ export const opportunityCw = {
const items_to_add = Array.isArray(data) ? data : [data]; const items_to_add = Array.isArray(data) ? data : [data];
const url = `/sales/opportunities/${opportunityId}/forecast`; 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 existing = await opportunityCw.fetchProducts(opportunityId);
const existingIds = new Set( 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 // Derive sensible defaults from an existing item when available
const templateItem = (existing.forecastItems ?? [])[0]; const templateItem = existing[0];
const defaultStatus = templateItem?.status const defaultStatus = templateItem?.forecastStatus
? { id: templateItem.status.id } ? { id: templateItem.forecastStatus.id }
: { id: 1 }; : { id: 1 };
const defaultForecastType = templateItem?.forecastType ?? "Product"; const defaultForecastType = "Product";
// 2. Build forecast items with required CW fields filled in // 2. Build forecast items with required CW fields filled in
const forecastItems = items_to_add.map((newItem) => ({ const forecastItems = items_to_add.map((newItem) => ({
@@ -234,9 +235,8 @@ export const opportunityCw = {
forecastItemId: number, forecastItemId: number,
data: Record<string, unknown>, data: Record<string, unknown>,
): Promise<CWForecastItem> => { ): Promise<CWForecastItem> => {
const forecast = await opportunityCw.fetchProducts(opportunityId); const items = await opportunityCw.fetchProducts(opportunityId);
const items = forecast.forecastItems ?? []; const idx = items.findIndex((p) => p.forecastDetailId === forecastItemId);
const idx = items.findIndex((fi) => fi.id === forecastItemId);
if (idx === -1) { if (idx === -1) {
throw new Error( throw new Error(
`Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`, `Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`,
@@ -265,14 +265,13 @@ export const opportunityCw = {
opportunityId: number, opportunityId: number,
updates: Map<number, Record<string, unknown>>, updates: Map<number, Record<string, unknown>>,
): Promise<CWForecastItem[]> => { ): Promise<CWForecastItem[]> => {
const forecast = await opportunityCw.fetchProducts(opportunityId); const items = await opportunityCw.fetchProducts(opportunityId);
const items = forecast.forecastItems ?? [];
const operations: { op: "replace"; path: string; value: unknown }[] = []; const operations: { op: "replace"; path: string; value: unknown }[] = [];
const touchedIndices: number[] = []; const touchedIndices: number[] = [];
for (const [itemId, changes] of updates) { 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) { if (idx === -1) {
throw new Error( throw new Error(
`Forecast item ${itemId} not found on opportunity ${opportunityId}`, `Forecast item ${itemId} not found on opportunity ${opportunityId}`,
@@ -304,20 +303,18 @@ export const opportunityCw = {
*/ */
deleteProduct: async ( deleteProduct: async (
opportunityId: number, opportunityId: number,
forecastItemId: number, productId: number,
): Promise<void> => { ): Promise<void> => {
const forecast = await opportunityCw.fetchProducts(opportunityId); const products = await opportunityCw.fetchProducts(opportunityId);
const items = forecast.forecastItems ?? []; const found = products.find((p) => p.id === productId);
if (!found) {
const filtered = items.filter((fi) => fi.id !== forecastItemId);
if (filtered.length === items.length) {
throw new Error( throw new Error(
`Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`, `Product ${productId} not found on opportunity ${opportunityId}`,
); );
} }
const filtered = products.filter((p) => p.id !== productId);
const url = `/sales/opportunities/${opportunityId}/forecast`; const url = `/procurement/products/${productId}`;
await connectWiseApi.put(url, { ...forecast, forecastItems: filtered }); await connectWiseApi.delete(url);
}, },
/** /**
@@ -313,3 +313,69 @@ export interface CWOpportunitySummary {
id: number; id: number;
_info?: Record<string, string>; _info?: Record<string, string>;
} }
export interface CWOpportunityProduct {
id: number;
catalogItem?: {
id: number;
identifier: string;
_info?: Record<string, string>;
};
description: string;
sequenceNumber: number;
quantity: number;
unitOfMeasure?: {
id: number;
name: string;
_info?: Record<string, string>;
};
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<string, string>;
};
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<string, string>;
};
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;
};
}
+133 -16
View File
@@ -7,6 +7,7 @@ export interface QuoteLineItem {
description: string; description: string;
unitPrice: number; unitPrice: number;
narrative?: string; narrative?: string;
isRecurring?: boolean;
} }
export interface CustomerInfo { export interface CustomerInfo {
@@ -60,6 +61,7 @@ export interface QuoteData {
quoteNarrative?: string; quoteNarrative?: string;
isPreview?: boolean; isPreview?: boolean;
showLineItemPricing?: boolean; showLineItemPricing?: boolean;
separateRecurringServices?: boolean;
metadata?: QuoteMetadata; metadata?: QuoteMetadata;
} }
@@ -185,7 +187,16 @@ export async function generateQuote(
logoPath = DEFAULT_LOGO_PATH logoPath = DEFAULT_LOGO_PATH
): Promise<Buffer> { ): Promise<Buffer> {
const t: QuoteTheme = { ...DEFAULT_THEME, ...theme }; 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, (sum, item) => sum + item.qty * item.unitPrice,
0 0
); );
@@ -196,13 +207,23 @@ export async function generateQuote(
const showPricing = data.showLineItemPricing ?? false; 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; const lineTotal = item.qty * item.unitPrice;
return lineTotal < 0 ? sum + lineTotal : sum; return lineTotal < 0 ? sum + lineTotal : sum;
}, 0); }, 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 showDiscount = !showPricing && hasDiscounts;
const recurringTotal = recurringItems.reduce(
(sum, item) => sum + item.qty * item.unitPrice,
0
);
const tableHeader = [ const tableHeader = [
{ text: "Qty", style: "thCell", alignment: "center" }, { text: "Qty", style: "thCell", alignment: "center" },
{ text: "Description", style: "thCell" }, { text: "Description", style: "thCell" },
@@ -218,10 +239,9 @@ export async function generateQuote(
const colCount = showPricing ? 4 : showDiscount ? 3 : 2; const colCount = showPricing ? 4 : showDiscount ? 3 : 2;
const tableRows: Record<string, unknown>[][] = []; function buildTableRows(items: QuoteLineItem[]): Record<string, unknown>[][] {
for (const item of data.lineItems) { const rows: Record<string, unknown>[][] = [];
// Build the description cell — stack description + narrative so they for (const item of items) {
// are a single cell and pdfmake never splits them across pages.
const descriptionCell: Record<string, unknown> = item.narrative const descriptionCell: Record<string, unknown> = item.narrative
? { ? {
stack: [ stack: [
@@ -235,7 +255,7 @@ export async function generateQuote(
} }
: { text: item.description, style: "tdCell" }; : { text: item.description, style: "tdCell" };
tableRows.push([ rows.push([
{ text: String(item.qty), style: "tdCell", alignment: "center" }, { text: String(item.qty), style: "tdCell", alignment: "center" },
descriptionCell, descriptionCell,
...(showPricing ...(showPricing
@@ -268,6 +288,11 @@ export async function generateQuote(
: []), : []),
]); ]);
} }
return rows;
}
const tableRows = buildTableRows(regularItems);
const recurringTableRows = buildTableRows(recurringItems);
const headerImage = logoDataUrl const headerImage = logoDataUrl
? { image: logoDataUrl, width: 200 } ? { image: logoDataUrl, width: 200 }
@@ -731,7 +756,12 @@ export async function generateQuote(
], ],
}, },
{ ],
},
],
} as any;
const signatureDateBlock = {
margin: [0, 40, 0, 0], margin: [0, 40, 0, 0],
columns: [ columns: [
{ {
@@ -783,12 +813,100 @@ export async function generateQuote(
], ],
}, },
], ],
}, };
],
},
],
footer: (currentPage: number, pageCount: number) => ({ // 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: [
[
{
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],
},
],
],
},
layout: {
hLineWidth: () => 0,
vLineWidth: () => 0,
},
},
],
},
],
},
signatureDateBlock
);
} else {
(docDefinition as any).content[
(docDefinition as any).content.length - 1
].stack.push(signatureDateBlock);
}
(docDefinition as any).footer = (currentPage: number, pageCount: number) => ({
margin: [0, 0, 0, 0], margin: [0, 0, 0, 0],
stack: [ stack: [
{ {
@@ -827,8 +945,7 @@ export async function generateQuote(
style: "disclaimer", style: "disclaimer",
}, },
], ],
}), });
};
const maybeDoc = printer.createPdfKitDocument(docDefinition as never) as any; const maybeDoc = printer.createPdfKitDocument(docDefinition as never) as any;
const pdfDoc = const pdfDoc =
+7
View File
@@ -568,6 +568,13 @@ export const PERMISSION_NODES = {
usedIn: ["src/api/sales/opportunities/[id]/quotes/commit.ts"], usedIn: ["src/api/sales/opportunities/[id]/quotes/commit.ts"],
dependencies: ["sales.opportunity.fetch"], 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", node: "sales.opportunity.quote.preview",
description: description:
+47 -2
View File
@@ -468,11 +468,11 @@ function ok(
/** /**
* Build the `customFields` array for a CW activity with Optima_Type set, * 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( function buildCustomFields(
optimaType: OptimaTypeValue, optimaType: OptimaTypeValue,
opts?: { quoteId?: string; closeDate?: string }, opts?: { quoteId?: string; closeDate?: string; parentActivityCwId?: number },
) { ) {
const fields: any[] = [ 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; return fields;
} }
@@ -523,6 +534,7 @@ export async function createWorkflowActivity(opts: {
quoteId?: string; quoteId?: string;
dateStart?: string; dateStart?: string;
dateEnd?: string; dateEnd?: string;
parentActivityCwId?: number | null;
}): Promise<ActivityController> { }): Promise<ActivityController> {
const shouldStayOpen = STAYS_OPEN_TYPES.has(opts.optimaType); const shouldStayOpen = STAYS_OPEN_TYPES.has(opts.optimaType);
@@ -546,6 +558,7 @@ export async function createWorkflowActivity(opts: {
value: buildCustomFields(opts.optimaType, { value: buildCustomFields(opts.optimaType, {
quoteId: opts.quoteId, quoteId: opts.quoteId,
closeDate: now, closeDate: now,
parentActivityCwId: opts.parentActivityCwId ?? undefined,
}), }),
}, },
]; ];
@@ -562,6 +575,38 @@ export async function createWorkflowActivity(opts: {
return patched; 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<number | null> {
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. * Handle optional time entry: submit to CW if timeStart and timeEnd are provided.
*/ */
+1
View File
@@ -954,6 +954,7 @@ export const sales = {
lineItemPricing?: boolean; lineItemPricing?: boolean;
includeQuoteNarrative?: boolean; includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean; includeItemNarratives?: boolean;
separateRecurringServices?: boolean;
} }
) { ) {
const response = await api.post( const response = await api.post(
@@ -34,6 +34,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
"sales.opportunity.note.delete", "sales.opportunity.note.delete",
"sales.opportunity.quote.fetch", "sales.opportunity.quote.fetch",
"sales.opportunity.quote.commit", "sales.opportunity.quote.commit",
"sales.opportunity.quote.commit.backgenerate",
"sales.opportunity.quote.preview", "sales.opportunity.quote.preview",
"sales.opportunity.quote.download", "sales.opportunity.quote.download",
"sales.opportunity.quote.fetch_downloads", "sales.opportunity.quote.fetch_downloads",
@@ -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). // Workflow read-only: only New and Active are editable (when in Optima workflow).
$: isReadOnly = (() => { $: isReadOnly = (() => {
if (isClosedOpportunity) return true; if (isClosedOpportunity) return true;
@@ -451,7 +476,8 @@
initialQuotes={quotes} initialQuotes={quotes}
initialQuoteId={pendingQuoteId} initialQuoteId={pendingQuoteId}
{permissions} {permissions}
isClosedOpportunity={isReadOnly} isClosedOpportunity={isQuoteGenerationBlocked}
{isBackGenerateState}
on:quotesChanged={handleQuotesChanged} on:quotesChanged={handleQuotesChanged}
/> />
{:else if activeTab === "Notes"} {:else if activeTab === "Notes"}
@@ -430,17 +430,19 @@
isDeletingProduct = true; isDeletingProduct = true;
deleteProductError = ""; deleteProductError = "";
const deletedId = selectedProduct.id;
try { try {
await optima.sales.deleteProduct( await optima.sales.deleteProduct(
accessToken, accessToken,
opportunityId, opportunityId,
selectedProduct.id deletedId
); );
products = products.filter((p) => p.id !== deletedId);
dispatch("productsChanged", products);
showDeleteModal = false; showDeleteModal = false;
selectedProduct = null; selectedProduct = null;
showPanel = false; showPanel = false;
isClosing = false; isClosing = false;
await refreshProducts();
} catch (err) { } catch (err) {
console.error("[DeleteProduct] Failed:", err); console.error("[DeleteProduct] Failed:", err);
deleteProductError = deleteProductError =
@@ -17,14 +17,26 @@
export let initialQuoteId: string | null = null; export let initialQuoteId: string | null = null;
export let permissions: PermissionMap = {} as PermissionMap; export let permissions: PermissionMap = {} as PermissionMap;
export let isClosedOpportunity: boolean = false; 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[] }>(); const dispatch = createEventDispatcher<{ quotesChanged: CommittedQuote[] }>();
// ── Permission helpers ── // ── Permission helpers ──
$: canFetchQuotes = permissions["sales.opportunity.quote.fetch"] !== false; $: canFetchQuotes = permissions["sales.opportunity.quote.fetch"] !== false;
$: canBackGenerate =
permissions["sales.opportunity.quote.commit.backgenerate"] === true;
$: canCommitQuote = $: canCommitQuote =
!isClosedOpportunity && !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; $: canPreviewQuote = permissions["sales.opportunity.quote.preview"] === true;
$: canDownloadQuote = $: canDownloadQuote =
permissions["sales.opportunity.quote.download"] === true; permissions["sales.opportunity.quote.download"] === true;
@@ -100,6 +112,7 @@
lineItemPricing: false, lineItemPricing: false,
includeQuoteNarrative: true, includeQuoteNarrative: true,
includeItemNarratives: true, includeItemNarratives: true,
separateRecurringServices: true,
}; };
let isCommitting = false; let isCommitting = false;
@@ -510,6 +523,7 @@
lineItemPricing: quotePreviewOptions.lineItemPricing, lineItemPricing: quotePreviewOptions.lineItemPricing,
includeQuoteNarrative: quotePreviewOptions.includeQuoteNarrative, includeQuoteNarrative: quotePreviewOptions.includeQuoteNarrative,
includeItemNarratives: quotePreviewOptions.includeItemNarratives, includeItemNarratives: quotePreviewOptions.includeItemNarratives,
separateRecurringServices: quotePreviewOptions.separateRecurringServices,
}; };
} }
@@ -546,6 +560,10 @@
quotePreviewOptions.includeItemNarratives = quotePreviewOptions.includeItemNarratives =
options.includeItemNarratives; options.includeItemNarratives;
} }
if (typeof options.separateRecurringServices === "boolean") {
quotePreviewOptions.separateRecurringServices =
options.separateRecurringServices;
}
} }
const nestedData = data.data as Record<string, unknown> | undefined; const nestedData = data.data as Record<string, unknown> | undefined;
@@ -739,6 +757,7 @@
lineItemPricing: quotePreviewOptions.lineItemPricing, lineItemPricing: quotePreviewOptions.lineItemPricing,
includeQuoteNarrative: quotePreviewOptions.includeQuoteNarrative, includeQuoteNarrative: quotePreviewOptions.includeQuoteNarrative,
includeItemNarratives: quotePreviewOptions.includeItemNarratives, includeItemNarratives: quotePreviewOptions.includeItemNarratives,
separateRecurringServices: quotePreviewOptions.separateRecurringServices,
}); });
commitSuccess = result.message || "Quote created successfully!"; commitSuccess = result.message || "Quote created successfully!";
// Reload quotes (basic + detail) and switch to list view // Reload quotes (basic + detail) and switch to list view
@@ -902,6 +921,25 @@
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
</button> </button>
{:else if backGenerateBlockedReason}
<button
class="quotes-new-btn"
type="button"
disabled
title={backGenerateBlockedReason}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
{/if} {/if}
</div> </div>
@@ -1033,6 +1071,13 @@
> >
Item narratives Item narratives
</span> </span>
<span
class="quotes-detail-badge"
class:active={selectedQuoteDetail.quoteRegenData.options
.separateRecurringServices}
>
Separate recurring
</span>
</div> </div>
{/if} {/if}
</div> </div>
@@ -1394,6 +1439,18 @@
/> />
<span>Include item narratives</span> <span>Include item narratives</span>
</label> </label>
<label class="quotes-flag-row">
<input
type="checkbox"
checked={quotePreviewOptions.separateRecurringServices}
on:change={() => {
quotePreviewOptions.separateRecurringServices =
!quotePreviewOptions.separateRecurringServices;
publishPreviewPayload();
}}
/>
<span>Separate recurring services</span>
</label>
</div> </div>
<button <button
@@ -1403,6 +1460,7 @@
!accessToken || !accessToken ||
!opportunityId || !opportunityId ||
!canCommitQuote} !canCommitQuote}
title={backGenerateBlockedReason ?? undefined}
on:click={handleCommitQuote} on:click={handleCommitQuote}
> >
{#if isCommitting} {#if isCommitting}