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.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` |
@@ -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<number>([
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) {
@@ -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,
});
+1 -1
View File
@@ -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);
+15 -3
View File
@@ -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<void> {
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;
}
/**
+37 -1
View File
@@ -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<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.
*/
async fetchByCompany(companyId: string): Promise<UnifiSite[]> {
async fetchByCompany(companyId: number): Promise<UnifiSite[]> {
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<UnifiSite> {
async linkToCompany(siteId: string, companyId: number): Promise<UnifiSite> {
const site = await prisma.unifiSite.findFirst({ where: { id: siteId } });
if (!site)
throw new GenericError({
@@ -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<CWForecast> => {
fetchProducts: async (opportunityId: number): Promise<CWOpportunityProduct[]> => {
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<string, unknown>,
): Promise<CWForecastItem> => {
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<number, Record<string, unknown>>,
): Promise<CWForecastItem[]> => {
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<void> => {
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);
},
/**
@@ -313,3 +313,69 @@ export interface CWOpportunitySummary {
id: number;
_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;
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<Buffer> {
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,10 +239,9 @@ export async function generateQuote(
const colCount = showPricing ? 4 : showDiscount ? 3 : 2;
const tableRows: Record<string, unknown>[][] = [];
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.
function buildTableRows(items: QuoteLineItem[]): Record<string, unknown>[][] {
const rows: Record<string, unknown>[][] = [];
for (const item of items) {
const descriptionCell: Record<string, unknown> = item.narrative
? {
stack: [
@@ -235,7 +255,7 @@ export async function generateQuote(
}
: { text: item.description, style: "tdCell" };
tableRows.push([
rows.push([
{ text: String(item.qty), style: "tdCell", alignment: "center" },
descriptionCell,
...(showPricing
@@ -268,6 +288,11 @@ export async function generateQuote(
: []),
]);
}
return rows;
}
const tableRows = buildTableRows(regularItems);
const recurringTableRows = buildTableRows(recurringItems);
const headerImage = logoDataUrl
? { image: logoDataUrl, width: 200 }
@@ -731,7 +756,12 @@ export async function generateQuote(
],
},
{
],
},
],
} as any;
const signatureDateBlock = {
margin: [0, 40, 0, 0],
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],
stack: [
{
@@ -827,8 +945,7 @@ export async function generateQuote(
style: "disclaimer",
},
],
}),
};
});
const maybeDoc = printer.createPdfKitDocument(docDefinition as never) as any;
const pdfDoc =
+7
View File
@@ -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:
+47 -2
View File
@@ -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<ActivityController> {
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<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.
*/
+1
View File
@@ -954,6 +954,7 @@ export const sales = {
lineItemPricing?: boolean;
includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean;
separateRecurringServices?: boolean;
}
) {
const response = await api.post(
@@ -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",
@@ -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"}
@@ -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 =
@@ -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<string, unknown> | 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 @@
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</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}
</div>
@@ -1033,6 +1071,13 @@
>
Item narratives
</span>
<span
class="quotes-detail-badge"
class:active={selectedQuoteDetail.quoteRegenData.options
.separateRecurringServices}
>
Separate recurring
</span>
</div>
{/if}
</div>
@@ -1394,6 +1439,18 @@
/>
<span>Include item narratives</span>
</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>
<button
@@ -1403,6 +1460,7 @@
!accessToken ||
!opportunityId ||
!canCommitQuote}
title={backGenerateBlockedReason ?? undefined}
on:click={handleCommitQuote}
>
{#if isCommitting}