feat: expand sales opportunity workflow and metrics APIs
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user