feat: add opportunity workflows, delete routes, company sites, algorithms, and expanded test coverage
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { companies } from "../../../managers/companies";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* /v1/company/companies/[id]/sites */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/companies/:identifier/sites"],
|
||||
|
||||
async (c) => {
|
||||
const company = await companies.fetch(c.req.param("identifier"));
|
||||
const sites = await company.fetchSites();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company Sites Fetched Successfully!",
|
||||
sites,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["company.fetch", "company.fetch.sites"],
|
||||
}),
|
||||
);
|
||||
@@ -1,7 +1,8 @@
|
||||
import { default as fetchAll } from "./fetchAll";
|
||||
import { default as fetch } from "./[id]/fetch";
|
||||
import { default as configurations } from "./[id]/configurations";
|
||||
import { default as sites } from "./[id]/sites";
|
||||
import { default as unifiSites } from "./[id]/unifiSites";
|
||||
import { default as count } from "./count";
|
||||
|
||||
export { configurations, count, fetch, fetchAll, unifiSites };
|
||||
export { configurations, count, fetch, fetchAll, sites, unifiSites };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { default as count } from "./opportunities/count";
|
||||
import { default as fetch } from "./opportunities/[id]/fetch";
|
||||
import { default as refresh } from "./opportunities/[id]/refresh";
|
||||
import { default as updateOpportunity } from "./opportunities/[id]/update";
|
||||
import { default as deleteOpportunity } from "./opportunities/[id]/delete";
|
||||
import { default as products } from "./opportunities/[id]/products/fetchAll";
|
||||
import { default as addProduct } from "./opportunities/[id]/products/add";
|
||||
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
|
||||
@@ -13,6 +14,7 @@ import { default as laborOptions } from "./opportunities/[id]/products/laborOpti
|
||||
import { default as resequenceProducts } from "./opportunities/[id]/products/resequence";
|
||||
import { default as updateProduct } from "./opportunities/[id]/products/update";
|
||||
import { default as cancelProduct } from "./opportunities/[id]/products/cancel";
|
||||
import { default as deleteProduct } from "./opportunities/[id]/products/delete";
|
||||
import { default as notes } from "./opportunities/[id]/notes/fetchAll";
|
||||
import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
|
||||
import { default as createNote } from "./opportunities/[id]/notes/create";
|
||||
@@ -24,6 +26,9 @@ import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
|
||||
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
|
||||
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
|
||||
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
|
||||
import { default as workflowDispatch } from "./opportunities/[id]/workflow/dispatch";
|
||||
import { default as workflowStatus } from "./opportunities/[id]/workflow/status";
|
||||
import { default as workflowHistory } from "./opportunities/[id]/workflow/history";
|
||||
|
||||
export {
|
||||
addProduct,
|
||||
@@ -32,6 +37,7 @@ export {
|
||||
addSpecialOrderProduct,
|
||||
count,
|
||||
createOpportunity,
|
||||
deleteOpportunity,
|
||||
fetch,
|
||||
fetchAll,
|
||||
fetchOpportunityTypes,
|
||||
@@ -39,6 +45,7 @@ export {
|
||||
resequenceProducts,
|
||||
updateProduct,
|
||||
cancelProduct,
|
||||
deleteProduct,
|
||||
notes,
|
||||
fetchNote,
|
||||
createNote,
|
||||
@@ -52,4 +59,7 @@ export {
|
||||
fetchDownloads,
|
||||
refresh,
|
||||
updateOpportunity,
|
||||
workflowDispatch,
|
||||
workflowStatus,
|
||||
workflowHistory,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createRoute } from "../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../middleware/authorization";
|
||||
import GenericError from "../../../../Errors/GenericError";
|
||||
|
||||
/* DELETE /v1/sales/opportunities/:identifier */
|
||||
export default createRoute(
|
||||
"delete",
|
||||
["/opportunities/:identifier"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
|
||||
try {
|
||||
await opportunities.deleteItem(identifier);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity deleted successfully!",
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
const isAxios =
|
||||
err != null && typeof err === "object" && "isAxiosError" in err;
|
||||
|
||||
if (isAxios) {
|
||||
const axiosErr = err as any;
|
||||
const cwStatus: number = axiosErr.response?.status ?? 502;
|
||||
const cwMessage: string =
|
||||
axiosErr.response?.data?.message ??
|
||||
"Failed to delete the opportunity in ConnectWise";
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: cwStatus,
|
||||
message: cwMessage,
|
||||
error: "ConnectWiseDeleteError",
|
||||
successful: false,
|
||||
meta: { timestamp: Date.now() },
|
||||
},
|
||||
cwStatus as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.delete"] }),
|
||||
);
|
||||
@@ -11,6 +11,7 @@ const productItemSchema = z
|
||||
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
|
||||
forecastDescription: z.string().optional(),
|
||||
productDescription: z.string().optional(),
|
||||
customerDescription: z.string().nullable().optional(),
|
||||
quantity: z.number().positive().optional(),
|
||||
status: z.object({ id: z.number().int().positive() }).optional(),
|
||||
productClass: z.string().optional(),
|
||||
@@ -54,7 +55,40 @@ export default createRoute(
|
||||
);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const created = await item.addProducts(gatedItems);
|
||||
|
||||
// Strip customerDescription from forecast payloads — CW only accepts
|
||||
// it on procurement products, not forecast items.
|
||||
const customerDescriptions = gatedItems.map(
|
||||
(g: any) => g.customerDescription,
|
||||
);
|
||||
const forecastPayloads = gatedItems.map(
|
||||
({ customerDescription, ...rest }: any) => rest,
|
||||
);
|
||||
|
||||
const created = await item.addProducts(forecastPayloads);
|
||||
|
||||
// If any items included customerDescription, patch the linked
|
||||
// procurement products after creation. This is best-effort since
|
||||
// newly created forecast items may not have a linked procurement
|
||||
// product yet.
|
||||
const procurementUpdates = created
|
||||
.map((product, idx) => ({
|
||||
product,
|
||||
customerDescription: customerDescriptions[idx],
|
||||
}))
|
||||
.filter((entry) => entry.customerDescription != null);
|
||||
|
||||
if (procurementUpdates.length > 0) {
|
||||
await Promise.all(
|
||||
procurementUpdates.map(({ product, customerDescription }) =>
|
||||
item
|
||||
.updateProcurementProductByForecastItem(product.cwForecastId, {
|
||||
customerDescription,
|
||||
})
|
||||
.catch(() => null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const isBatch = Array.isArray(body);
|
||||
const response = apiResponse.created(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
|
||||
/* DELETE /v1/sales/opportunities/:identifier/products/:productId */
|
||||
export default createRoute(
|
||||
"delete",
|
||||
["/opportunities/:identifier/products/:productId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const productId = Number(c.req.param("productId"));
|
||||
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidProductId",
|
||||
message: "productId must be a positive integer",
|
||||
});
|
||||
}
|
||||
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
|
||||
// Verify the forecast item exists before attempting deletion
|
||||
const products = await opportunity.fetchProducts();
|
||||
const product = products.find((item) => item.cwForecastId === productId);
|
||||
|
||||
if (!product) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${productId} not found on opportunity`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await opportunity.deleteProduct(productId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Product deleted from opportunity successfully!",
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
const isAxios =
|
||||
err != null && typeof err === "object" && "isAxiosError" in err;
|
||||
|
||||
if (isAxios) {
|
||||
const axiosErr = err as any;
|
||||
const cwStatus: number = axiosErr.response?.status ?? 502;
|
||||
const cwMessage: string =
|
||||
axiosErr.response?.data?.message ??
|
||||
"Failed to delete the product in ConnectWise";
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: cwStatus,
|
||||
message: cwMessage,
|
||||
error: "ConnectWiseDeleteError",
|
||||
successful: false,
|
||||
meta: { timestamp: Date.now() },
|
||||
},
|
||||
cwStatus as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.delete"] }),
|
||||
);
|
||||
@@ -98,12 +98,6 @@ export default createRoute(
|
||||
if (input.quantity !== undefined) {
|
||||
forecastPatch.quantity = input.quantity;
|
||||
}
|
||||
if (
|
||||
input.customerDescription !== undefined &&
|
||||
input.customerDescription !== null
|
||||
) {
|
||||
forecastPatch.customerDescription = input.customerDescription;
|
||||
}
|
||||
if (input.unitPrice !== undefined) {
|
||||
forecastPatch.revenue = Number(
|
||||
(input.unitPrice * effectiveQuantity).toFixed(2),
|
||||
|
||||
@@ -4,6 +4,11 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
import { cwMembers } from "../../../../../managers/cwMembers";
|
||||
import {
|
||||
createWorkflowActivity,
|
||||
OptimaType,
|
||||
} from "../../../../../workflows/wf.opportunity";
|
||||
|
||||
const commitQuoteSchema = z
|
||||
.object({
|
||||
@@ -29,6 +34,34 @@ export default createRoute(
|
||||
|
||||
const quote = await item.commitQuote(opts ?? {}, user);
|
||||
|
||||
// Create a workflow activity for the generated quote
|
||||
try {
|
||||
let cwMemberId: number | null = null;
|
||||
|
||||
if (user.cwIdentifier) {
|
||||
const cwMember = await cwMembers.fetch(user.cwIdentifier);
|
||||
cwMemberId = cwMember.cwMemberId;
|
||||
}
|
||||
|
||||
if (cwMemberId) {
|
||||
await createWorkflowActivity({
|
||||
name: `[Workflow] Quote generated — ${item.name}`,
|
||||
opportunityCwId: item.cwOpportunityId,
|
||||
companyCwId: item.companyCwId,
|
||||
assignToCwMemberId: cwMemberId,
|
||||
notes: `Quote "${quote.quoteFileName}" generated.`,
|
||||
optimaType: OptimaType.QuoteGenerated,
|
||||
quoteId: quote.id,
|
||||
});
|
||||
}
|
||||
} catch (activityErr) {
|
||||
console.error(
|
||||
"[Quote Commit] Failed to create workflow activity:",
|
||||
activityErr,
|
||||
);
|
||||
// Don't fail the quote commit if the activity fails
|
||||
}
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Quote committed successfully!",
|
||||
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { z } from "zod";
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { cwMembers } from "../../../../../managers/cwMembers";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
import {
|
||||
processOpportunityAction,
|
||||
type WorkflowAction,
|
||||
type WorkflowUser,
|
||||
} from "../../../../../workflows/wf.opportunity";
|
||||
|
||||
// ── Zod schemas ───────────────────────────────────────────────────────────
|
||||
|
||||
const basePayload = z.object({
|
||||
note: z.string().optional(),
|
||||
timeStarted: z.string().datetime().optional(),
|
||||
timeEnded: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const noteRequiredPayload = z.object({
|
||||
note: z.string().min(1, "A non-empty note is required."),
|
||||
timeStarted: z.string().datetime().optional(),
|
||||
timeEnded: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const dispatchSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("acceptNew"),
|
||||
payload: basePayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("requestReview"),
|
||||
payload: noteRequiredPayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("reviewDecision"),
|
||||
payload: noteRequiredPayload.extend({
|
||||
decision: z.enum(["approve", "reject", "send", "cancel"]),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("sendQuote"),
|
||||
payload: basePayload.extend({
|
||||
quoteConfirmed: z.boolean().optional(),
|
||||
won: z.boolean().optional(),
|
||||
lost: z.boolean().optional(),
|
||||
finalize: z.boolean().optional(),
|
||||
needsRevision: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("confirmQuote"),
|
||||
payload: basePayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("finalize"),
|
||||
payload: noteRequiredPayload.extend({
|
||||
outcome: z.enum(["won", "lost"]),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("resurrect"),
|
||||
payload: noteRequiredPayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("beginRevision"),
|
||||
payload: basePayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("resendQuote"),
|
||||
payload: basePayload.extend({
|
||||
quoteConfirmed: z.boolean().optional(),
|
||||
won: z.boolean().optional(),
|
||||
lost: z.boolean().optional(),
|
||||
finalize: z.boolean().optional(),
|
||||
needsRevision: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("cancel"),
|
||||
payload: noteRequiredPayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("reopen"),
|
||||
payload: noteRequiredPayload,
|
||||
}),
|
||||
]);
|
||||
|
||||
// ── Route ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/workflow */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/workflow"],
|
||||
async (c) => {
|
||||
try {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
console.log(
|
||||
"[Workflow Dispatch] Raw request body:",
|
||||
JSON.stringify(body, null, 2),
|
||||
);
|
||||
const parsed = dispatchSchema.parse(body);
|
||||
console.log(
|
||||
"[Workflow Dispatch] Parsed payload:",
|
||||
JSON.stringify(parsed.payload, null, 2),
|
||||
);
|
||||
const user = c.get("user");
|
||||
|
||||
// ── Resolve opportunity ────────────────────────────────────────────
|
||||
const opportunity = await opportunities.fetchItem(identifier);
|
||||
|
||||
// ── Build WorkflowUser ─────────────────────────────────────────────
|
||||
if (!user.cwIdentifier) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "MissingCwIdentifier",
|
||||
message:
|
||||
"Your account is not linked to a ConnectWise member. A CW member association is required to execute workflow actions.",
|
||||
});
|
||||
}
|
||||
|
||||
const cwMember = await cwMembers.fetch(user.cwIdentifier);
|
||||
const permissions = await user.readAllPermissions();
|
||||
|
||||
const workflowUser: WorkflowUser = {
|
||||
id: user.id,
|
||||
cwMemberId: cwMember.cwMemberId,
|
||||
permissions,
|
||||
};
|
||||
|
||||
// ── Dispatch ───────────────────────────────────────────────────────
|
||||
const result = await processOpportunityAction(
|
||||
opportunity,
|
||||
parsed as WorkflowAction,
|
||||
workflowUser,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`[Workflow Dispatch] Transition failed for opportunity "${identifier}":`,
|
||||
result.error,
|
||||
);
|
||||
const response = apiResponse.error(
|
||||
new GenericError({
|
||||
status: 422,
|
||||
name: "WorkflowTransitionFailed",
|
||||
message: result.error ?? "Workflow action failed.",
|
||||
}),
|
||||
);
|
||||
return c.json(
|
||||
{
|
||||
...response,
|
||||
data: {
|
||||
previousStatusId: result.previousStatusId,
|
||||
previousStatus: result.previousStatus,
|
||||
newStatusId: result.newStatusId,
|
||||
newStatus: result.newStatus,
|
||||
},
|
||||
},
|
||||
response.status as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Workflow action completed successfully.",
|
||||
{
|
||||
previousStatusId: result.previousStatusId,
|
||||
previousStatus: result.previousStatus,
|
||||
newStatusId: result.newStatusId,
|
||||
newStatus: result.newStatus,
|
||||
activitiesCreated: result.activitiesCreated.map((a) => a.toJson()),
|
||||
coldCheck: result.coldCheck,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err: any) {
|
||||
console.error("[Workflow Dispatch] Unhandled error:", err);
|
||||
if (err?.response?.data) {
|
||||
console.error(
|
||||
"[Workflow Dispatch] CW response body:",
|
||||
JSON.stringify(err.response.data, null, 2),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.workflow"] }),
|
||||
);
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { activityCw } from "../../../../../modules/cw-utils/activities/activities";
|
||||
import { ActivityController } from "../../../../../controllers/ActivityController";
|
||||
import { OptimaType } from "../../../../../workflows/wf.opportunity";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const OPTIMA_TYPE_VALUES = new Set<string>([
|
||||
OptimaType.OpportunityCreated,
|
||||
OptimaType.OpportunitySetup,
|
||||
OptimaType.OpportunityReview,
|
||||
OptimaType.QuoteSent,
|
||||
OptimaType.QuoteConfirmed,
|
||||
OptimaType.QuoteSentConfirmed,
|
||||
OptimaType.QuoteGenerated,
|
||||
OptimaType.Revision,
|
||||
OptimaType.Finalized,
|
||||
OptimaType.Converted,
|
||||
]);
|
||||
|
||||
/** QuoteID custom field ID (matches wf.opportunity.ts QUOTE_ID_FIELD_ID). */
|
||||
const QUOTE_ID_FIELD_ID = 48;
|
||||
|
||||
/** Close Date custom field ID (matches wf.opportunity.ts CLOSE_DATE_FIELD_ID). */
|
||||
const CLOSE_DATE_FIELD_ID = 49;
|
||||
|
||||
/**
|
||||
* Extract the Optima_Type value from a CW activity's custom fields.
|
||||
* Returns the string value if present, or null.
|
||||
*/
|
||||
function extractOptimaType(
|
||||
customFields: { id: number; value: unknown }[] | undefined,
|
||||
): string | null {
|
||||
if (!customFields) return null;
|
||||
const field = customFields.find((f) => f.id === OptimaType.FIELD_ID);
|
||||
if (!field?.value || typeof field.value !== "string") return null;
|
||||
return OPTIMA_TYPE_VALUES.has(field.value) ? field.value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the QuoteID custom field value from a CW activity.
|
||||
* Returns the string value or null.
|
||||
*/
|
||||
function extractQuoteId(
|
||||
customFields: { id: number; value: unknown }[] | undefined,
|
||||
): string | null {
|
||||
if (!customFields) return null;
|
||||
const field = customFields.find((f) => f.id === QUOTE_ID_FIELD_ID);
|
||||
if (!field?.value || typeof field.value !== "string") return null;
|
||||
return field.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the Close Date custom field value from a CW activity.
|
||||
* Returns the ISO-8601 string or null.
|
||||
*/
|
||||
function extractCloseDate(
|
||||
customFields: { id: number; value: unknown }[] | undefined,
|
||||
): string | null {
|
||||
if (!customFields) return null;
|
||||
const field = customFields.find((f) => f.id === CLOSE_DATE_FIELD_ID);
|
||||
if (!field?.value || typeof field.value !== "string") return null;
|
||||
return field.value;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ROUTE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/workflow/history */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/workflow/history"],
|
||||
async (c) => {
|
||||
try {
|
||||
const identifier = c.req.param("identifier");
|
||||
const filterType = c.req.query("type") ?? null; // optional filter by Optima_Type value
|
||||
|
||||
// Resolve the opportunity to get the CW opportunity ID
|
||||
const opportunity = await opportunities.fetchItem(identifier);
|
||||
|
||||
// Fetch all activities for this opportunity from CW
|
||||
const activitiesCollection = await activityCw.fetchByOpportunity(
|
||||
opportunity.cwOpportunityId,
|
||||
);
|
||||
|
||||
// Filter to workflow activities (those with a valid Optima_Type)
|
||||
const workflowActivities: {
|
||||
activity: ReturnType<ActivityController["toJson"]>;
|
||||
optimaType: string;
|
||||
quoteId: string | null;
|
||||
closed: boolean;
|
||||
closedAt: string | null;
|
||||
}[] = [];
|
||||
|
||||
for (const [, raw] of activitiesCollection) {
|
||||
const controller = new ActivityController(raw);
|
||||
const json = controller.toJson();
|
||||
const optimaType = extractOptimaType(raw.customFields);
|
||||
|
||||
if (!optimaType) continue;
|
||||
if (filterType && optimaType !== filterType) continue;
|
||||
|
||||
const quoteId = extractQuoteId(raw.customFields);
|
||||
const closed = raw.status?.id === 2;
|
||||
const closedAt = extractCloseDate(raw.customFields);
|
||||
|
||||
workflowActivities.push({
|
||||
activity: json,
|
||||
optimaType,
|
||||
quoteId,
|
||||
closed,
|
||||
closedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
workflowActivities.sort((a, b) => {
|
||||
const dateA = new Date(
|
||||
a.activity.dateEnd ?? a.activity.dateStart ?? 0,
|
||||
).getTime();
|
||||
const dateB = new Date(
|
||||
b.activity.dateEnd ?? b.activity.dateStart ?? 0,
|
||||
).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Workflow history fetched successfully.",
|
||||
{
|
||||
opportunityId: opportunity.id,
|
||||
cwOpportunityId: opportunity.cwOpportunityId,
|
||||
totalActivities: workflowActivities.length,
|
||||
activities: workflowActivities,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
console.error("[Workflow History] Unhandled error:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,377 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import {
|
||||
OpportunityStatus,
|
||||
StatusIdToKey,
|
||||
WorkflowPermissions,
|
||||
type OpportunityStatusKey,
|
||||
} from "../../../../../workflows/wf.opportunity";
|
||||
import {
|
||||
checkColdStatus,
|
||||
type ColdCheckResult,
|
||||
} from "../../../../../modules/algorithms/algo.coldThreshold";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ACTION AVAILABILITY MAP
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Per-status list of actions the user can invoke.
|
||||
*
|
||||
* Each entry describes the action key, a human-readable label, the
|
||||
* expected target status(es), whether a note is required, and any
|
||||
* permission gate beyond the base workflow permission.
|
||||
*/
|
||||
interface AvailableAction {
|
||||
action: string;
|
||||
label: string;
|
||||
targetStatuses: { key: OpportunityStatusKey; id: number }[];
|
||||
requiresNote: boolean;
|
||||
requiresPermission: string | null;
|
||||
/** Extra payload fields that can/must be provided. */
|
||||
payloadHints?: Record<string, string>;
|
||||
}
|
||||
|
||||
const ACTION_MAP: Record<number, AvailableAction[]> = {
|
||||
[OpportunityStatus.PendingNew]: [
|
||||
{
|
||||
action: "acceptNew",
|
||||
label: "Accept / Set Up Opportunity",
|
||||
targetStatuses: [{ key: "New", id: OpportunityStatus.New }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.New]: [
|
||||
{
|
||||
action: "sendQuote",
|
||||
label: "Send Quote (skip review)",
|
||||
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: {
|
||||
quoteConfirmed: "boolean — mark confirmed simultaneously",
|
||||
won: "boolean — immediate win",
|
||||
lost: "boolean — immediate rejection",
|
||||
needsRevision: "boolean — needs revision",
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "requestReview",
|
||||
label: "Send to Internal Review",
|
||||
targetStatuses: [
|
||||
{ key: "InternalReview", id: OpportunityStatus.InternalReview },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
},
|
||||
{
|
||||
action: "cancel",
|
||||
label: "Cancel Opportunity",
|
||||
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.CANCEL,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.InternalReview]: [
|
||||
{
|
||||
action: "reviewDecision",
|
||||
label: "Approve (move to Pending Sent)",
|
||||
targetStatuses: [
|
||||
{ key: "PendingSent", id: OpportunityStatus.PendingSent },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { decision: '"approve"' },
|
||||
},
|
||||
{
|
||||
action: "reviewDecision",
|
||||
label: "Reject (move to Pending Revision)",
|
||||
targetStatuses: [
|
||||
{ key: "PendingRevision", id: OpportunityStatus.PendingRevision },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { decision: '"reject"' },
|
||||
},
|
||||
{
|
||||
action: "reviewDecision",
|
||||
label: "Send Quote (reviewer sends directly)",
|
||||
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { decision: '"send"' },
|
||||
},
|
||||
{
|
||||
action: "reviewDecision",
|
||||
label: "Cancel from Review",
|
||||
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.CANCEL,
|
||||
payloadHints: { decision: '"cancel"' },
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.PendingSent]: [
|
||||
{
|
||||
action: "sendQuote",
|
||||
label: "Send Quote to Customer",
|
||||
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: {
|
||||
quoteConfirmed: "boolean — mark confirmed simultaneously",
|
||||
won: "boolean — immediate win",
|
||||
lost: "boolean — immediate rejection",
|
||||
needsRevision: "boolean — needs revision",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.PendingRevision]: [
|
||||
{
|
||||
action: "beginRevision",
|
||||
label: "Begin Revision",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.QuoteSent]: [
|
||||
{
|
||||
action: "confirmQuote",
|
||||
label: "Confirm Quote Receipt",
|
||||
targetStatuses: [
|
||||
{ key: "ConfirmedQuote", id: OpportunityStatus.ConfirmedQuote },
|
||||
],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
},
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Mark as Won",
|
||||
targetStatuses: [
|
||||
{ key: "Won", id: OpportunityStatus.Won },
|
||||
{ key: "PendingWon", id: OpportunityStatus.PendingWon },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { outcome: '"won"' },
|
||||
},
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Mark as Lost",
|
||||
targetStatuses: [
|
||||
{ key: "PendingLost", id: OpportunityStatus.PendingLost },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { outcome: '"lost"' },
|
||||
},
|
||||
{
|
||||
action: "resendQuote",
|
||||
label: "Revise & Re-send (back to Active)",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: { needsRevision: "true" },
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.ConfirmedQuote]: [
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Mark as Won",
|
||||
targetStatuses: [
|
||||
{ key: "Won", id: OpportunityStatus.Won },
|
||||
{ key: "PendingWon", id: OpportunityStatus.PendingWon },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { outcome: '"won"' },
|
||||
},
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Mark as Lost",
|
||||
targetStatuses: [
|
||||
{ key: "PendingLost", id: OpportunityStatus.PendingLost },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { outcome: '"lost"' },
|
||||
},
|
||||
{
|
||||
action: "resendQuote",
|
||||
label: "Revise & Re-send (back to Active)",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: { needsRevision: "true" },
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.Active]: [
|
||||
{
|
||||
action: "resendQuote",
|
||||
label: "Send Revised Quote",
|
||||
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: {
|
||||
quoteConfirmed: "boolean — mark confirmed simultaneously",
|
||||
won: "boolean — immediate win",
|
||||
lost: "boolean — immediate rejection",
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "requestReview",
|
||||
label: "Send to Internal Review",
|
||||
targetStatuses: [
|
||||
{ key: "InternalReview", id: OpportunityStatus.InternalReview },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
},
|
||||
{
|
||||
action: "cancel",
|
||||
label: "Cancel Opportunity",
|
||||
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.CANCEL,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.PendingWon]: [
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Approve Won",
|
||||
targetStatuses: [{ key: "Won", id: OpportunityStatus.Won }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.FINALIZE,
|
||||
payloadHints: { outcome: '"won"' },
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.PendingLost]: [
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Approve Lost",
|
||||
targetStatuses: [{ key: "Lost", id: OpportunityStatus.Lost }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.FINALIZE,
|
||||
payloadHints: { outcome: '"lost"' },
|
||||
},
|
||||
{
|
||||
action: "resurrect",
|
||||
label: "Resurrect (back to Active)",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.Won]: [],
|
||||
[OpportunityStatus.Lost]: [],
|
||||
|
||||
[OpportunityStatus.Canceled]: [
|
||||
{
|
||||
action: "reopen",
|
||||
label: "Re-open Opportunity",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ROUTE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/workflow */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/workflow"],
|
||||
async (c) => {
|
||||
try {
|
||||
const identifier = c.req.param("identifier");
|
||||
const user = c.get("user");
|
||||
|
||||
const opportunity = await opportunities.fetchItem(identifier);
|
||||
|
||||
const statusCwId = opportunity.statusCwId;
|
||||
const statusKey: OpportunityStatusKey | null =
|
||||
statusCwId != null ? (StatusIdToKey[statusCwId] ?? null) : null;
|
||||
|
||||
const isOptimaStage = opportunity.stageName === "Optima";
|
||||
const isTerminal =
|
||||
statusCwId === OpportunityStatus.Won ||
|
||||
statusCwId === OpportunityStatus.Lost;
|
||||
|
||||
// ── Resolve available actions (permission-aware) ──────────────────
|
||||
const rawActions =
|
||||
statusCwId != null ? (ACTION_MAP[statusCwId] ?? []) : [];
|
||||
|
||||
const resolvedActions = await Promise.all(
|
||||
rawActions.map(async (a) => {
|
||||
const hasGate =
|
||||
!a.requiresPermission ||
|
||||
(await user.hasPermission(a.requiresPermission));
|
||||
return { ...a, permitted: hasGate };
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Cold check (only for QuoteSent / ConfirmedQuote) ──────────────
|
||||
let coldCheck: ColdCheckResult | null = null;
|
||||
if (
|
||||
statusCwId === OpportunityStatus.QuoteSent ||
|
||||
statusCwId === OpportunityStatus.ConfirmedQuote
|
||||
) {
|
||||
// Fetch activities to determine latest activity date
|
||||
const activities = await opportunity.fetchActivities();
|
||||
const latestDate =
|
||||
activities.length > 0
|
||||
? new Date(
|
||||
Math.max(
|
||||
...activities.map((a) => {
|
||||
const json = a.toJson();
|
||||
return new Date(
|
||||
json.dateEnd ?? json.dateStart ?? 0,
|
||||
).getTime();
|
||||
}),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
coldCheck = checkColdStatus({
|
||||
statusCwId,
|
||||
lastActivityDate: latestDate,
|
||||
});
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Workflow status fetched successfully.",
|
||||
{
|
||||
currentStatusId: statusCwId,
|
||||
currentStatus: statusKey,
|
||||
stageName: opportunity.stageName ?? null,
|
||||
isOptimaStage,
|
||||
isTerminal,
|
||||
availableActions: isOptimaStage ? resolvedActions : [],
|
||||
coldCheck,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
console.error("[Workflow Status] Unhandled error:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -5,6 +5,11 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { z } from "zod";
|
||||
import { cwMembers } from "../../../managers/cwMembers";
|
||||
import {
|
||||
createWorkflowActivity,
|
||||
OptimaType,
|
||||
} from "../../../workflows/wf.opportunity";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -41,6 +46,34 @@ export default createRoute(
|
||||
try {
|
||||
const item = await opportunities.createItem(data);
|
||||
|
||||
// Create a workflow activity for the new opportunity
|
||||
try {
|
||||
const user = c.get("user");
|
||||
let cwMemberId: number | null = null;
|
||||
|
||||
if (user.cwIdentifier) {
|
||||
const cwMember = await cwMembers.fetch(user.cwIdentifier);
|
||||
cwMemberId = cwMember.cwMemberId;
|
||||
}
|
||||
|
||||
if (cwMemberId) {
|
||||
await createWorkflowActivity({
|
||||
name: `[Workflow] Opportunity created — ${item.name}`,
|
||||
opportunityCwId: item.cwOpportunityId,
|
||||
companyCwId: item.companyCwId,
|
||||
assignToCwMemberId: cwMemberId,
|
||||
notes: "Opportunity created.",
|
||||
optimaType: OptimaType.OpportunityCreated,
|
||||
});
|
||||
}
|
||||
} catch (activityErr) {
|
||||
console.error(
|
||||
"[Opportunity Create] Failed to create workflow activity:",
|
||||
activityErr,
|
||||
);
|
||||
// Don't fail the opportunity creation if the activity fails
|
||||
}
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Opportunity created successfully!",
|
||||
item.toJson(),
|
||||
|
||||
@@ -1351,6 +1351,35 @@ export class OpportunityController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Product
|
||||
*
|
||||
* Removes a forecast item from this opportunity in ConnectWise,
|
||||
* removes the item ID from the local productSequence, and
|
||||
* invalidates the products cache.
|
||||
*
|
||||
* @param forecastItemId - The CW forecast item ID to delete
|
||||
*/
|
||||
public async deleteProduct(forecastItemId: number): Promise<void> {
|
||||
await opportunityCw.deleteProduct(this.cwOpportunityId, forecastItemId);
|
||||
|
||||
// Remove the deleted item from the local product sequence
|
||||
if (this.productSequence.includes(forecastItemId)) {
|
||||
const updatedSequence = this.productSequence.filter(
|
||||
(id) => id !== forecastItemId,
|
||||
);
|
||||
|
||||
await prisma.opportunity.update({
|
||||
where: { id: this.id },
|
||||
data: { productSequence: updatedSequence },
|
||||
});
|
||||
|
||||
this.productSequence = updatedSequence;
|
||||
}
|
||||
|
||||
await invalidateProductsCache(this.cwOpportunityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Procurement Product By Forecast Item
|
||||
*
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
fetchAndCacheActivities,
|
||||
fetchAndCacheCompanyCwData,
|
||||
fetchAndCacheOppCwData,
|
||||
invalidateAllOpportunityCaches,
|
||||
} from "../modules/cache/opportunityCache";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -524,4 +525,42 @@ export const opportunities = {
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Opportunity
|
||||
*
|
||||
* Deletes an opportunity from ConnectWise, removes the local database
|
||||
* record, and invalidates all related Redis caches.
|
||||
*
|
||||
* @param identifier - Internal ID (string) or CW opportunity ID (number)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteItem(identifier: string | number): Promise<void> {
|
||||
const isNumeric =
|
||||
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
||||
|
||||
const record = await prisma.opportunity.findFirst({
|
||||
where: isNumeric
|
||||
? { cwOpportunityId: Number(identifier) }
|
||||
: { id: identifier as string },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
cause: `No opportunity exists with identifier '${identifier}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete from ConnectWise first
|
||||
await opportunityCw.delete(record.cwOpportunityId);
|
||||
|
||||
// Remove the local DB record
|
||||
await prisma.opportunity.delete({ where: { id: record.id } });
|
||||
|
||||
// Invalidate all related caches
|
||||
await invalidateAllOpportunityCaches(record.cwOpportunityId);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ export const users = {
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
userId: msData.id,
|
||||
email: msData.mail,
|
||||
email: msData.mail ?? msData.userPrincipalName,
|
||||
name: `${msData.givenName} ${msData.surname}`,
|
||||
login: msData.userPrincipalName,
|
||||
cwIdentifier,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @module algo.coldThreshold
|
||||
*
|
||||
* Cold-Detection Algorithm
|
||||
* ========================
|
||||
*
|
||||
* Determines whether an opportunity has stalled in a status long enough
|
||||
* to be considered "cold". When an opportunity goes cold it is
|
||||
* automatically moved to InternalReview, a system-generated activity is
|
||||
* logged, and it is flagged for the internal review report.
|
||||
*
|
||||
* ## Thresholds (defaults)
|
||||
*
|
||||
* | Status | Stall Threshold |
|
||||
* |-----------------|-----------------|
|
||||
* | QuoteSent | 14 days |
|
||||
* | ConfirmedQuote | 30 days |
|
||||
*
|
||||
* Only these two statuses are eligible for cold detection. All other
|
||||
* statuses return `cold: false`.
|
||||
*
|
||||
* ## How "last activity date" is determined
|
||||
*
|
||||
* The algorithm uses `lastActivityDate` — the most recent of:
|
||||
* - the latest activity's `dateStart`
|
||||
* - the opportunity's `cwLastUpdated`
|
||||
*
|
||||
* The caller is responsible for resolving this value before calling
|
||||
* `checkColdStatus`.
|
||||
*/
|
||||
|
||||
import type { OpportunityController } from "../../controllers/OpportunityController";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Stall thresholds in milliseconds, keyed by CW status ID. */
|
||||
export const COLD_THRESHOLDS: Record<number, { days: number; ms: number }> = {
|
||||
/** QuoteSent — CW status ID 43, "03. Quote Sent" */
|
||||
43: { days: 14, ms: 14 * 24 * 60 * 60 * 1000 },
|
||||
|
||||
/** ConfirmedQuote — CW status ID 57, "04. Confirmed Quote" */
|
||||
57: { days: 30, ms: 30 * 24 * 60 * 60 * 1000 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ColdCheckInput {
|
||||
/** Current CW status ID of the opportunity. */
|
||||
statusCwId: number | null;
|
||||
|
||||
/**
|
||||
* The most recent meaningful date to measure staleness from.
|
||||
* Typically the latest of the last activity dateStart or cwLastUpdated.
|
||||
*/
|
||||
lastActivityDate: Date | null;
|
||||
|
||||
/** Override for "now" — useful for testing. Defaults to `new Date()`. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface ColdCheckResult {
|
||||
/** Whether the opportunity is considered cold. */
|
||||
cold: boolean;
|
||||
|
||||
/**
|
||||
* Which threshold triggered the cold flag.
|
||||
* `null` when `cold` is `false`.
|
||||
*/
|
||||
triggeredBy: {
|
||||
statusCwId: number;
|
||||
statusName: string;
|
||||
thresholdDays: number;
|
||||
staleDays: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_NAMES: Record<number, string> = {
|
||||
43: "QuoteSent",
|
||||
57: "ConfirmedQuote",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Evaluate whether an opportunity has exceeded its cold-stall threshold.
|
||||
*
|
||||
* @returns A `ColdCheckResult` indicating cold status and trigger metadata.
|
||||
*/
|
||||
export function checkColdStatus(input: ColdCheckInput): ColdCheckResult {
|
||||
const NOT_COLD: ColdCheckResult = { cold: false, triggeredBy: null };
|
||||
|
||||
if (!input.statusCwId) return NOT_COLD;
|
||||
|
||||
const threshold = COLD_THRESHOLDS[input.statusCwId];
|
||||
if (!threshold) return NOT_COLD;
|
||||
|
||||
if (!input.lastActivityDate) return NOT_COLD;
|
||||
|
||||
const now = input.now ?? new Date();
|
||||
const elapsed = now.getTime() - input.lastActivityDate.getTime();
|
||||
|
||||
if (elapsed < threshold.ms) return NOT_COLD;
|
||||
|
||||
return {
|
||||
cold: true,
|
||||
triggeredBy: {
|
||||
statusCwId: input.statusCwId,
|
||||
statusName: STATUS_NAMES[input.statusCwId] ?? "Unknown",
|
||||
thresholdDays: threshold.days,
|
||||
staleDays: Math.floor(elapsed / (24 * 60 * 60 * 1000)),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @module algo.followUpScheduler
|
||||
*
|
||||
* Follow-Up Scheduling Algorithm
|
||||
* ===============================
|
||||
*
|
||||
* Determines the due date for follow-up activities created by the
|
||||
* opportunity workflow. The follow-up is always assigned to the user
|
||||
* who triggered its creation.
|
||||
*
|
||||
* ## TODO — Calendar-aware scheduling
|
||||
*
|
||||
* This module currently uses a **dummy algorithm** that schedules the
|
||||
* follow-up for the next business day at 10:00 AM local time.
|
||||
*
|
||||
* It needs to be replaced with an availability-aware algorithm that:
|
||||
* 1. Reads the assigned user's calendar (Microsoft Graph / CW schedule).
|
||||
* 2. Finds the earliest open slot of sufficient duration.
|
||||
* 3. Respects company-wide blackout dates (holidays, company events).
|
||||
* 4. Accounts for the user's working-hours preferences.
|
||||
*
|
||||
* Until that integration is complete, the simple "next business day"
|
||||
* heuristic is used as a placeholder.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FollowUpScheduleInput {
|
||||
/** The user who triggered the activity (follow-up is assigned to them). */
|
||||
triggeredByUserId: string;
|
||||
|
||||
/** Optional override for "now" — useful for testing. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface FollowUpScheduleResult {
|
||||
/** Suggested due date for the follow-up activity. */
|
||||
dueDate: Date;
|
||||
|
||||
/** ISO string version for CW API payloads. */
|
||||
dueDateIso: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schedule a follow-up activity.
|
||||
*
|
||||
* Returns a suggested `dueDate` for the follow-up activity.
|
||||
* Currently uses dummy logic: next business day at 10:00 AM.
|
||||
*
|
||||
* @param input - Scheduling parameters
|
||||
* @returns The scheduled follow-up date
|
||||
*/
|
||||
export function scheduleFollowUp(
|
||||
input: FollowUpScheduleInput,
|
||||
): FollowUpScheduleResult {
|
||||
const now = input.now ?? new Date();
|
||||
const dueDate = getNextBusinessDay(now);
|
||||
|
||||
// Set to 10:00 AM
|
||||
dueDate.setHours(10, 0, 0, 0);
|
||||
|
||||
return {
|
||||
dueDate,
|
||||
dueDateIso: dueDate.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the next business day (Mon–Fri) from the given date.
|
||||
* If the given date is already a weekday before 10 AM, returns
|
||||
* the NEXT business day (not the same day).
|
||||
*/
|
||||
function getNextBusinessDay(from: Date): Date {
|
||||
const result = new Date(from);
|
||||
|
||||
// Always advance at least one day
|
||||
result.setDate(result.getDate() + 1);
|
||||
|
||||
const day = result.getDay();
|
||||
|
||||
// Saturday → Monday (+2)
|
||||
if (day === 6) result.setDate(result.getDate() + 2);
|
||||
// Sunday → Monday (+1)
|
||||
if (day === 0) result.setDate(result.getDate() + 1);
|
||||
|
||||
return result;
|
||||
}
|
||||
+18
@@ -499,6 +499,24 @@ export async function invalidateProductsCache(
|
||||
await redis.del(productsCacheKey(cwOpportunityId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cached data for an opportunity.
|
||||
*
|
||||
* Removes activities, notes, contacts, products, and CW data cache keys.
|
||||
* Call this when an opportunity is deleted.
|
||||
*/
|
||||
export async function invalidateAllOpportunityCaches(
|
||||
cwOpportunityId: number,
|
||||
): Promise<void> {
|
||||
await redis.del(
|
||||
activityCacheKey(cwOpportunityId),
|
||||
notesCacheKey(cwOpportunityId),
|
||||
contactsCacheKey(cwOpportunityId),
|
||||
productsCacheKey(cwOpportunityId),
|
||||
oppCwDataCacheKey(cwOpportunityId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Site TTL — 20 minutes. Site/address data rarely changes so we cache
|
||||
* aggressively. The background refresh does NOT proactively warm site keys;
|
||||
|
||||
@@ -295,6 +295,31 @@ export const opportunityCw = {
|
||||
return touchedIndices.map((i) => (updated.forecastItems ?? [])[i]!);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Forecast Item
|
||||
*
|
||||
* Removes a forecast item from an opportunity by PUTting the forecast
|
||||
* without the target item. CW's forecast endpoint replaces the entire
|
||||
* forecast items list on PUT.
|
||||
*/
|
||||
deleteProduct: async (
|
||||
opportunityId: number,
|
||||
forecastItemId: 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) {
|
||||
throw new Error(
|
||||
`Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const url = `/sales/opportunities/${opportunityId}/forecast`;
|
||||
await connectWiseApi.put(url, { ...forecast, forecastItems: filtered });
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunity Notes
|
||||
*
|
||||
@@ -463,4 +488,13 @@ export const opportunityCw = {
|
||||
);
|
||||
return response.data as CWProcurementProduct;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Opportunity
|
||||
*
|
||||
* Deletes an opportunity from ConnectWise by its CW opportunity ID.
|
||||
*/
|
||||
delete: async (opportunityId: number): Promise<void> => {
|
||||
await connectWiseApi.delete(`/sales/opportunities/${opportunityId}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -213,7 +213,6 @@ export interface CWForecastItemCreate {
|
||||
catalogItem?: { id: number };
|
||||
forecastDescription?: string;
|
||||
productDescription?: string;
|
||||
customerDescription?: string;
|
||||
quantity?: number;
|
||||
status?: { id: number };
|
||||
productClass?: string;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @module cw.opportunityService
|
||||
*
|
||||
* ConnectWise Opportunity Service
|
||||
* ================================
|
||||
*
|
||||
* Methods for ConnectWise integrations that the opportunity workflow
|
||||
* calls. Some are still stubs (marked with console.warn); others are
|
||||
* fully implemented against the CW REST API.
|
||||
*/
|
||||
|
||||
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
||||
import { connectWiseApi } from "../constants";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TimeEntryInput {
|
||||
/** CW activity ID to charge the time entry to. */
|
||||
activityId: number;
|
||||
/** CW member ID of the user submitting time. */
|
||||
cwMemberId: number;
|
||||
/** ISO-8601 datetime when work started. */
|
||||
timeStart: string;
|
||||
/** ISO-8601 datetime when work ended. */
|
||||
timeEnd: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface TimeEntryResult {
|
||||
success: boolean;
|
||||
cwTimeEntryId: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface StatusSyncInput {
|
||||
opportunityId: number;
|
||||
statusCwId: number;
|
||||
}
|
||||
|
||||
export interface StatusSyncResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Submit a time entry to ConnectWise for an opportunity activity.
|
||||
*
|
||||
* Called automatically whenever `timeStart` and `timeEnd` are provided
|
||||
* on a workflow action.
|
||||
*/
|
||||
export async function submitTimeEntry(
|
||||
input: TimeEntryInput,
|
||||
): Promise<TimeEntryResult> {
|
||||
try {
|
||||
const stripMs = (d: string) => d.replace(/\.\d{3}Z$/, "Z");
|
||||
|
||||
const response = await connectWiseApi.post("/time/entries", {
|
||||
member: { id: input.cwMemberId },
|
||||
chargeToType: "Activity",
|
||||
chargeToId: input.activityId,
|
||||
timeStart: stripMs(input.timeStart),
|
||||
timeEnd: stripMs(input.timeEnd),
|
||||
notes: input.notes,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
cwTimeEntryId: response.data?.id ?? null,
|
||||
message: `Time entry ${response.data?.id} created for activity ${input.activityId}.`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[cw.opportunityService] submitTimeEntry FAILED — activityId=${input.activityId}, cwMemberId=${input.cwMemberId}`,
|
||||
error?.response?.data ?? error,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
cwTimeEntryId: null,
|
||||
message: `Failed to submit time entry: ${error?.message ?? "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync an opportunity's status to ConnectWise.
|
||||
*
|
||||
* Called whenever the workflow transitions an opportunity to a new
|
||||
* status, ensuring the CW record stays in sync.
|
||||
*/
|
||||
export async function syncOpportunityStatus(
|
||||
input: StatusSyncInput,
|
||||
): Promise<StatusSyncResult> {
|
||||
try {
|
||||
await opportunityCw.update(input.opportunityId, {
|
||||
status: { id: input.statusCwId },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Opportunity ${input.opportunityId} status synced to CW status ID ${input.statusCwId}.`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[cw.opportunityService] syncOpportunityStatus FAILED — opportunityId=${input.opportunityId}, statusCwId=${input.statusCwId}`,
|
||||
error?.response?.data ?? error,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to sync opportunity status: ${error?.message ?? "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,12 @@ export const PERMISSION_NODES = {
|
||||
usedIn: ["src/api/companies/[id]/configurations.ts"],
|
||||
dependencies: ["company.fetch"],
|
||||
},
|
||||
{
|
||||
node: "company.fetch.sites",
|
||||
description: "Fetch company sites from ConnectWise",
|
||||
usedIn: ["src/api/companies/[id]/sites.ts"],
|
||||
dependencies: ["company.fetch"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -435,6 +441,13 @@ export const PERMISSION_NODES = {
|
||||
description: "Create a new opportunity in ConnectWise",
|
||||
usedIn: ["src/api/sales/opportunities/create.ts"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.delete",
|
||||
description:
|
||||
"Delete an opportunity from ConnectWise and the local database",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/delete.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.note.create",
|
||||
description: "Create a new note on an opportunity",
|
||||
@@ -464,6 +477,12 @@ export const PERMISSION_NODES = {
|
||||
],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.product.delete",
|
||||
description: "Delete a product (forecast item) from an opportunity",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/products/delete.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.product.add",
|
||||
description:
|
||||
@@ -557,6 +576,75 @@ export const PERMISSION_NODES = {
|
||||
usedIn: [],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.view_profit",
|
||||
description:
|
||||
"View profit data on opportunity products. Controls visibility of profit values in the UI.",
|
||||
usedIn: [],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.finalize",
|
||||
description:
|
||||
"Finalize an opportunity as Won or Lost. Without this permission, win/lose actions route to PendingWon/PendingLost instead.",
|
||||
usedIn: [
|
||||
"src/workflows/wf.opportunity.ts",
|
||||
"src/api/sales/opportunities/[id]/workflow/dispatch.ts",
|
||||
],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.cancel",
|
||||
description:
|
||||
"Cancel an opportunity. Required to transition any eligible opportunity to the Canceled status.",
|
||||
usedIn: [
|
||||
"src/workflows/wf.opportunity.ts",
|
||||
"src/api/sales/opportunities/[id]/workflow/dispatch.ts",
|
||||
],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.review",
|
||||
description:
|
||||
"Submit an opportunity for internal review. Required to transition an opportunity into the InternalReview status.",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.send",
|
||||
description:
|
||||
"Send a quote to the customer. Required to transition an opportunity to QuoteSent (and its compound transitions like immediate won/lost/confirmed).",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.reopen",
|
||||
description:
|
||||
"Re-open a cancelled opportunity. Required to transition an opportunity from Canceled back to Active.",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.win",
|
||||
description:
|
||||
"Mark an opportunity as won (or pending won). Gates the win button in the UI. Required for finalize(won), sendQuote(won), and transitionToPending(won).",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.lose",
|
||||
description:
|
||||
"Mark an opportunity as lost (or pending lost). Gates the lose button in the UI. Required for finalize(lost), sendQuote(lost), and transitionToPending(lost).",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.workflow",
|
||||
description:
|
||||
"Execute opportunity workflow actions (status transitions, review decisions, quote sending, etc.). Base gate for the workflow dispatch endpoint.",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/workflow/dispatch.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
+174
-12
@@ -18,7 +18,7 @@ export interface QuoteStatus {
|
||||
|
||||
export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
//
|
||||
// FUTURE
|
||||
// FUTURE LEAD
|
||||
//
|
||||
{
|
||||
id: 51,
|
||||
@@ -41,6 +41,27 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// PENDING NEW
|
||||
//
|
||||
{
|
||||
id: 37,
|
||||
name: "Pending New",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2022-04-28T21:04:53Z",
|
||||
_info: {
|
||||
lastUpdated: "2022-04-28T21:06:24Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "f90e72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [],
|
||||
},
|
||||
|
||||
//
|
||||
// NEW
|
||||
//
|
||||
@@ -62,7 +83,6 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
optimaEquivalency: [
|
||||
1, // Pre2021-1) New
|
||||
13, // Pre2021-Initial Contact Made
|
||||
37, // 00. Pending New
|
||||
],
|
||||
},
|
||||
|
||||
@@ -90,6 +110,53 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
27, // Z4. Waiting-VendorInfo
|
||||
28, // Z5. Waiting-OtherTTStaff
|
||||
41, // PRE2405. Review Ready
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// QUOTE SENT
|
||||
//
|
||||
{
|
||||
id: 43,
|
||||
name: "Quote Sent",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2022-04-28T21:06:02Z",
|
||||
_info: {
|
||||
lastUpdated: "2024-04-28T15:06:55Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "ff0e72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [
|
||||
17, // Pre2021-5) Quote Sent
|
||||
25, // ZOLD---Quote Sent
|
||||
55, // PRE24_70. Quote Sent - Sell
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// CONFIRMED QUOTE
|
||||
//
|
||||
{
|
||||
id: 57,
|
||||
name: "Confirmed Quote",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2024-04-28T15:07:11Z",
|
||||
_info: {
|
||||
lastUpdated: "2024-04-28T15:07:11Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "0d0f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [
|
||||
54, // PRE24_90. Customer Approved
|
||||
],
|
||||
},
|
||||
@@ -116,14 +183,10 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
9, // Pre2021-Recommendation
|
||||
15, // Pre2021-3) Onsite Assess Sch'd
|
||||
16, // Pre2021-4) Quote Info Gathered
|
||||
17, // Pre2021-5) Quote Sent
|
||||
18, // Pre2021-6) Follow-up #1 Made
|
||||
19, // Pre2021-7) Follow-up #2 Made
|
||||
20, // Pre2021-8) Follow-up #3 Made
|
||||
|
||||
25, // ZOLD---Quote Sent
|
||||
43, // 03. Quote Sent
|
||||
|
||||
38, // PRE2402. On-Site Ready
|
||||
39, // PRE2403. On-Site Scheduled
|
||||
40, // PRE2404. On-Site Complete
|
||||
@@ -134,11 +197,72 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
47, // PRE2412. Follow-Up3
|
||||
48, // PRE2413. Follow-Up Extended
|
||||
52, // PRE2489. Overdue
|
||||
55, // PRE24_70. Quote Sent - Sell
|
||||
57, // 04. Confirmed Quote
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// PENDING SENT
|
||||
//
|
||||
{
|
||||
id: 60,
|
||||
name: "Pending Sent",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2026-03-08T23:06:01Z",
|
||||
_info: {
|
||||
lastUpdated: "2026-03-08T23:06:01Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "c900215f-431b-f111-b2ee-000c29c55070",
|
||||
optimaEquivalency: [],
|
||||
},
|
||||
|
||||
//
|
||||
// PENDING REVISION
|
||||
//
|
||||
{
|
||||
id: 61,
|
||||
name: "Pending Revision",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2026-03-08T23:06:06Z",
|
||||
_info: {
|
||||
lastUpdated: "2026-03-08T23:06:06Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "ca00215f-431b-f111-b2ee-000c29c55070",
|
||||
optimaEquivalency: [],
|
||||
},
|
||||
|
||||
//
|
||||
// PENDING WON
|
||||
//
|
||||
{
|
||||
id: 49,
|
||||
name: "Pending Won",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2023-02-08T21:27:35Z",
|
||||
_info: {
|
||||
lastUpdated: "2024-01-21T20:39:47Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "050f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [],
|
||||
},
|
||||
|
||||
//
|
||||
// WON
|
||||
//
|
||||
@@ -159,11 +283,30 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
connectWiseId: "f10e72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [
|
||||
2, // Pre2021-8) Won
|
||||
54, // PRE24_90. Customer Approved (if you treat as effectively Won)
|
||||
49, // 91. Pending Won
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// PENDING LOST
|
||||
//
|
||||
{
|
||||
id: 50,
|
||||
name: "Pending Lost",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2023-02-08T21:32:10Z",
|
||||
_info: {
|
||||
lastUpdated: "2023-02-08T21:32:41Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "060f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [],
|
||||
},
|
||||
|
||||
//
|
||||
// LOST
|
||||
//
|
||||
@@ -192,8 +335,27 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
32, // Pre2024_99. Lost-NoDecision
|
||||
33, // Pre2024_99. Lost-Pricing
|
||||
34, // Pre2024_99. Lost-OtherTTQuote
|
||||
|
||||
50, // 98. Pending Lost
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// CANCELED
|
||||
//
|
||||
{
|
||||
id: 59,
|
||||
name: "Canceled",
|
||||
wonFlag: false,
|
||||
lostFlag: true,
|
||||
closedFlag: true,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2026-03-08T22:45:07Z",
|
||||
_info: {
|
||||
lastUpdated: "2026-03-08T22:45:07Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "83ddc173-401b-f111-b2ee-000c29c55070",
|
||||
optimaEquivalency: [],
|
||||
},
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user