feat: fix and add several things
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } }),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user