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.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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 } }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user