feat: expand sales opportunity workflow and metrics APIs

This commit is contained in:
2026-03-15 23:38:56 -05:00
parent 33b34d08a7
commit e764932c39
55 changed files with 3425 additions and 157 deletions
+73 -8
View File
@@ -30,7 +30,8 @@
* | QuoteSent | 43 | 03. Quote Sent |
* | ConfirmedQuote | 57 | 04. Confirmed Quote |
* | Active | 58 | 05. Active |
* | PendingSent | 60 | Pending Sent |
* | ReadyToSend | 63 | Ready to Send |
* | PendingSent | 60 | Pending Sent (Deprecated) |
* | PendingRevision | 61 | Pending Revision |
* | PendingWon | 49 | 91. Pending Won |
* | Won | 29 | 95. Won |
@@ -65,6 +66,7 @@ export const OpportunityStatus = {
QuoteSent: 43,
ConfirmedQuote: 57,
Active: 58,
ReadyToSend: 63,
PendingSent: 60,
PendingRevision: 61,
PendingWon: 49,
@@ -180,19 +182,25 @@ const ALLOWED_TRANSITIONS: Record<number, Set<number>> = {
[OpportunityStatus.PendingNew]: new Set([OpportunityStatus.New]),
[OpportunityStatus.New]: new Set([
OpportunityStatus.ReadyToSend,
OpportunityStatus.InternalReview,
OpportunityStatus.QuoteSent,
OpportunityStatus.Canceled,
]),
[OpportunityStatus.InternalReview]: new Set([
OpportunityStatus.PendingSent,
OpportunityStatus.ReadyToSend,
OpportunityStatus.PendingRevision,
OpportunityStatus.QuoteSent, // reviewer manually sends
OpportunityStatus.Canceled,
]),
[OpportunityStatus.PendingSent]: new Set([OpportunityStatus.QuoteSent]),
[OpportunityStatus.ReadyToSend]: new Set([OpportunityStatus.QuoteSent]),
[OpportunityStatus.PendingSent]: new Set([
OpportunityStatus.ReadyToSend,
OpportunityStatus.QuoteSent,
]),
[OpportunityStatus.PendingRevision]: new Set([OpportunityStatus.Active]),
@@ -214,6 +222,7 @@ const ALLOWED_TRANSITIONS: Record<number, Set<number>> = {
]),
[OpportunityStatus.Active]: new Set([
OpportunityStatus.ReadyToSend,
OpportunityStatus.QuoteSent,
OpportunityStatus.InternalReview,
OpportunityStatus.Canceled,
@@ -275,7 +284,7 @@ export interface ReviewDecisionPayload extends BaseActionPayload {
decision: "approve" | "reject" | "send" | "cancel";
}
/** Transition from PendingSent → QuoteSent. */
/** Transition from ReadyToSend/PendingSent(deprecated) → QuoteSent. */
export interface SendQuotePayload extends BaseActionPayload {
/**
* If true, marks sent AND confirmed simultaneously.
@@ -313,6 +322,9 @@ export interface SendQuotePayload extends BaseActionPayload {
needsRevision?: boolean;
}
/** Mark opportunity as ready to send quote from send-capable statuses. */
export interface MarkReadyToSendPayload extends BaseActionPayload {}
/** Confirm receipt of a quote. */
export interface ConfirmQuotePayload extends BaseActionPayload {}
@@ -351,6 +363,7 @@ export type WorkflowAction =
| { action: "acceptNew"; payload: AcceptNewPayload }
| { action: "requestReview"; payload: RequestReviewPayload }
| { action: "reviewDecision"; payload: ReviewDecisionPayload }
| { action: "markReadyToSend"; payload: MarkReadyToSendPayload }
| { action: "sendQuote"; payload: SendQuotePayload }
| { action: "confirmQuote"; payload: ConfirmQuotePayload }
| { action: "finalize"; payload: FinalizePayload }
@@ -728,7 +741,7 @@ export async function transitionToInternalReview(
}
/**
* InternalReview → PendingSent | PendingRevision | QuoteSent | Canceled
* InternalReview → ReadyToSend | PendingRevision | QuoteSent | Canceled
*
* Reviewer makes a decision on an opportunity in InternalReview.
*/
@@ -753,9 +766,9 @@ export async function handleReviewDecision(
const activities: ActivityController[] = [];
switch (payload.decision) {
// ── Approve → PendingSent ──────────────────────────────────────────
// ── Approve → ReadyToSend ──────────────────────────────────────────
case "approve": {
const targetStatus = OpportunityStatus.PendingSent;
const targetStatus = OpportunityStatus.ReadyToSend;
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
if (transErr) return fail(transErr, currentStatus);
@@ -901,7 +914,7 @@ export async function handleReviewDecision(
}
/**
* PendingSent → QuoteSent (and its compound transitions)
* ReadyToSend/PendingSent(deprecated) → QuoteSent (and its compound transitions)
*
* Also handles New → QuoteSent (direct send, skipping review) and
* Active → QuoteSent (re-send after revision).
@@ -1172,6 +1185,54 @@ export async function transitionToQuoteSent(
return ok(currentStatus, targetStatus, activities);
}
/**
* New/Active/PendingSent(deprecated) → ReadyToSend
*
* Allows staging an opportunity as ready without immediately sending.
*/
export async function transitionToReadyToSend(
opportunity: OpportunityController,
user: WorkflowUser,
payload: MarkReadyToSendPayload,
): Promise<WorkflowResult> {
const currentStatus = opportunity.statusCwId;
if (currentStatus == null) return fail("Opportunity has no current status.");
if (!hasPermission(user, WorkflowPermissions.SEND)) {
return fail(
`User lacks the "${WorkflowPermissions.SEND}" permission required to mark an opportunity ready to send.`,
currentStatus,
);
}
const targetStatus = OpportunityStatus.ReadyToSend;
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
if (transErr) return fail(transErr, currentStatus);
const activity = await createWorkflowActivity({
name: `[Workflow] Marked ready to send — ${opportunity.name}`,
opportunityCwId: opportunity.cwOpportunityId,
companyCwId: opportunity.companyCwId,
assignToCwMemberId: user.cwMemberId,
notes: payload.note ?? "Marked ready to send.",
optimaType: OptimaType.OpportunityReview,
});
await syncOpportunityStatus({
opportunityId: opportunity.cwOpportunityId,
statusCwId: targetStatus,
});
await handleTimeEntry(
activity.cwActivityId,
user.cwMemberId,
payload,
payload.note ?? "Marked ready to send.",
);
return ok(currentStatus, targetStatus, [activity]);
}
/**
* QuoteSent → ConfirmedQuote
*
@@ -1754,6 +1815,10 @@ export async function processOpportunityAction(
result = await handleReviewDecision(opportunity, user, payload);
break;
case "markReadyToSend":
result = await transitionToReadyToSend(opportunity, user, payload);
break;
case "sendQuote":
result = await transitionToQuoteSent(opportunity, user, payload);
break;