task-d.5.6
This commit is contained in:
@@ -297,7 +297,7 @@ export function CustomerDetail({
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>เธเนเธญเธกเธนเธฅเธฃเธฐเธเธ</CardTitle>
|
||||
<CardTitle>ข้อมูลระบบ</CardTitle>
|
||||
<CardDescription>ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
@@ -319,4 +319,3 @@ export function CustomerDetail({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ export function DashboardFollowups({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dashboard งานติดตาม</CardTitle>
|
||||
<CardDescription>ภาพรวมงานติดตามที่กำลังจะถึงและที่ดำเนินการแล้วของโอกาสขายและใบเสนอราคา</CardDescription>
|
||||
<CardDescription>
|
||||
ภาพรวมงานติดตามที่กำลังจะถึงและที่ดำเนินการแล้วของโอกาสขายและใบเสนอราคา
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
@@ -38,7 +40,7 @@ export function DashboardFollowups({
|
||||
<TableHead>ลูกค้า</TableHead>
|
||||
<TableHead>โอกาสขาย</TableHead>
|
||||
<TableHead>ผู้ดูแลโอกาสขาย</TableHead>
|
||||
<TableHead>เธ—เธตเนเธกเธฒ</TableHead>
|
||||
<TableHead>ที่มา</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -65,4 +67,3 @@ export function DashboardFollowups({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>โอกาสขายเด่นที่กำลังดำเนินการ</CardTitle>
|
||||
<CardDescription>จัดอันดับโอกาสขายสำคัญตามมูลค่าที่ประเมินไว้</CardDescription>
|
||||
<CardDescription>
|
||||
จัดอันดับโอกาสขายสำคัญตามมูลค่าที่ประเมินไว้
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
@@ -21,7 +23,7 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
||||
<TableRow>
|
||||
<TableHead>โครงการ</TableHead>
|
||||
<TableHead>ลูกค้า</TableHead>
|
||||
<TableHead className='text-right'>เธกเธนเธฅเธเนเธฒ</TableHead>
|
||||
<TableHead className='text-right'>มูลค่า</TableHead>
|
||||
<TableHead>ผู้ดูแลโอกาสขาย</TableHead>
|
||||
<TableHead className='text-right'>ความน่าจะเป็น</TableHead>
|
||||
</TableRow>
|
||||
@@ -53,4 +55,3 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,15 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>อันดับผลงานฝ่ายขาย</CardTitle>
|
||||
<CardDescription>การนับลีดและโอกาสขายยึดตามผู้รับผิดชอบ ส่วนใบเสนอราคายึดตามผู้ขาย</CardDescription>
|
||||
<CardDescription>
|
||||
การนับลีดและโอกาสขายยึดตามผู้รับผิดชอบ ส่วนใบเสนอราคาและงานที่ชนะยึดตามผู้ขาย
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ผู้ขาย</TableHead>
|
||||
<TableHead>ผู้ขาย</TableHead>
|
||||
<TableHead className='text-right'>ลีด</TableHead>
|
||||
<TableHead className='text-right'>โอกาสขาย</TableHead>
|
||||
<TableHead className='text-right'>ใบเสนอราคา</TableHead>
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import type {
|
||||
OpportunityAssignmentMutationPayload,
|
||||
OpportunityFollowupMutationPayload,
|
||||
OpportunityMarkCancelledPayload,
|
||||
OpportunityMarkLostPayload,
|
||||
OpportunityMarkNoQuotationPayload,
|
||||
OpportunityMarkWonPayload,
|
||||
OpportunityMutationPayload
|
||||
} from './types';
|
||||
import { opportunityKeys } from './queries';
|
||||
import {
|
||||
assignOpportunity,
|
||||
createOpportunity,
|
||||
createOpportunityFollowup,
|
||||
deleteOpportunity,
|
||||
deleteOpportunityFollowup,
|
||||
markOpportunityCancelled,
|
||||
markOpportunityLost,
|
||||
markOpportunityNoQuotation,
|
||||
markOpportunityWon,
|
||||
reassignOpportunity,
|
||||
reopenOpportunity,
|
||||
updateOpportunity,
|
||||
updateOpportunityFollowup
|
||||
} from './service';
|
||||
import { opportunityKeys } from './queries';
|
||||
import type {
|
||||
OpportunityAssignmentMutationPayload,
|
||||
OpportunityFollowupMutationPayload,
|
||||
OpportunityMarkLostPayload,
|
||||
OpportunityMarkWonPayload,
|
||||
OpportunityMutationPayload
|
||||
} from './types';
|
||||
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
|
||||
|
||||
async function invalidateOpportunityLists() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: opportunityKeys.lists() });
|
||||
@@ -67,7 +71,7 @@ export async function invalidateOpportunityFollowupMutationQueries(opportunityId
|
||||
}
|
||||
|
||||
export const createOpportunityMutation = mutationOptions({
|
||||
mutationFn: (data: OpportunityMutationPayload) => createOpportunity(data),
|
||||
mutationFn: (values: OpportunityMutationPayload) => createOpportunity(values),
|
||||
onSettled: async (_data, error) => {
|
||||
if (!error) {
|
||||
await Promise.all([invalidateOpportunityLists(), invalidateDashboardQueries()]);
|
||||
@@ -89,10 +93,11 @@ export const deleteOpportunityMutation = mutationOptions({
|
||||
mutationFn: (id: string) => deleteOpportunity(id),
|
||||
onSettled: async (_data, error, id) => {
|
||||
if (!error) {
|
||||
getQueryClient().removeQueries({ queryKey: opportunityKeys.detail(id) });
|
||||
getQueryClient().removeQueries({ queryKey: opportunityKeys.projectParties(id) });
|
||||
getQueryClient().removeQueries({ queryKey: opportunityKeys.followups(id) });
|
||||
await Promise.all([invalidateOpportunityLists(), invalidateDashboardQueries()]);
|
||||
await Promise.all([
|
||||
invalidateOpportunityLists(),
|
||||
invalidateDashboardQueries(),
|
||||
getQueryClient().removeQueries({ queryKey: opportunityKeys.detail(id) })
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -179,6 +184,26 @@ export const markOpportunityLostMutation = mutationOptions({
|
||||
}
|
||||
});
|
||||
|
||||
export const markOpportunityCancelledMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: OpportunityMarkCancelledPayload }) =>
|
||||
markOpportunityCancelled(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateOpportunityMutationQueries(variables.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const markOpportunityNoQuotationMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: OpportunityMarkNoQuotationPayload }) =>
|
||||
markOpportunityNoQuotation(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateOpportunityMutationQueries(variables.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const reopenOpportunityMutation = mutationOptions({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
reopenOpportunity(id, { reopenReason: reason }),
|
||||
@@ -188,4 +213,3 @@ export const reopenOpportunityMutation = mutationOptions({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import type { OpportunityFilters } from './types';
|
||||
import {
|
||||
getOpportunityAttachments,
|
||||
getOpportunities,
|
||||
getOpportunityById,
|
||||
getOpportunityFollowups,
|
||||
getOpportunityProjectParties
|
||||
getOpportunityProjectParties,
|
||||
getOpportunities
|
||||
} from './service';
|
||||
import type { OpportunityFilters } from './types';
|
||||
|
||||
export const opportunityKeys = {
|
||||
all: ['crm-opportunities'] as const,
|
||||
@@ -51,4 +51,3 @@ export const opportunityAttachmentsOptions = (id: string) =>
|
||||
queryKey: opportunityKeys.attachments(id),
|
||||
queryFn: () => getOpportunityAttachments(id)
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
OpportunityAttachmentsResponse,
|
||||
MutationSuccessResponse,
|
||||
OpportunityAssignmentMutationPayload,
|
||||
OpportunityAttachmentsResponse,
|
||||
OpportunityDetailResponse,
|
||||
OpportunityFilters,
|
||||
OpportunityFollowupMutationPayload,
|
||||
OpportunityFollowupsResponse,
|
||||
OpportunityListResponse,
|
||||
OpportunityMarkCancelledPayload,
|
||||
OpportunityMarkLostPayload,
|
||||
OpportunityMarkNoQuotationPayload,
|
||||
OpportunityMarkWonPayload,
|
||||
OpportunityMutationPayload,
|
||||
OpportunityProjectPartiesResponse,
|
||||
OpportunityReopenPayload,
|
||||
MutationSuccessResponse
|
||||
OpportunityReopenPayload
|
||||
} from './types';
|
||||
|
||||
export async function getOpportunities(filters: OpportunityFilters): Promise<OpportunityListResponse> {
|
||||
@@ -23,14 +25,18 @@ export async function getOpportunities(filters: OpportunityFilters): Promise<Opp
|
||||
if (filters.search) searchParams.set('search', filters.search);
|
||||
if (filters.pipelineStage) searchParams.set('pipelineStage', filters.pipelineStage);
|
||||
if (filters.status) searchParams.set('status', filters.status);
|
||||
if (filters.stage) searchParams.set('stage', filters.stage);
|
||||
if (filters.outcomeStatus) searchParams.set('outcomeStatus', filters.outcomeStatus);
|
||||
if (filters.productType) searchParams.set('productType', filters.productType);
|
||||
if (filters.priority) searchParams.set('priority', filters.priority);
|
||||
if (filters.branch) searchParams.set('branch', filters.branch);
|
||||
if (filters.customer) searchParams.set('customer', filters.customer);
|
||||
if (filters.lostReason) searchParams.set('lostReason', filters.lostReason);
|
||||
if (filters.closedDateFrom) searchParams.set('closedDateFrom', filters.closedDateFrom);
|
||||
if (filters.closedDateTo) searchParams.set('closedDateTo', filters.closedDateTo);
|
||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||
|
||||
const query = searchParams.toString();
|
||||
|
||||
return apiClient<OpportunityListResponse>(`/crm/opportunities${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
@@ -125,10 +131,23 @@ export async function markOpportunityLost(id: string, data: OpportunityMarkLostP
|
||||
});
|
||||
}
|
||||
|
||||
export async function markOpportunityCancelled(id: string, data: OpportunityMarkCancelledPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/mark-cancelled`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function markOpportunityNoQuotation(id: string, data: OpportunityMarkNoQuotationPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/mark-no-quotation`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function reopenOpportunity(id: string, data: OpportunityReopenPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/reopen`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface OpportunityAttachmentRecord {
|
||||
}
|
||||
|
||||
export type OpportunityPipelineStage = 'lead' | 'opportunity' | 'closed_won' | 'closed_lost';
|
||||
export type OpportunityOutcomeStatus = 'open' | 'won' | 'lost' | 'cancelled' | 'no_quotation';
|
||||
|
||||
export interface OpportunityRecord {
|
||||
id: string;
|
||||
@@ -92,6 +93,8 @@ export interface OpportunityRecord {
|
||||
projectLocation: string | null;
|
||||
productType: string;
|
||||
status: string;
|
||||
stage: string;
|
||||
outcomeStatus: OpportunityOutcomeStatus;
|
||||
priority: string;
|
||||
leadChannel: string | null;
|
||||
estimatedValue: number | null;
|
||||
@@ -103,6 +106,7 @@ export interface OpportunityRecord {
|
||||
isHotProject: boolean;
|
||||
isActive: boolean;
|
||||
pipelineStage: OpportunityPipelineStage;
|
||||
closedAt: string | null;
|
||||
closedWonAt: string | null;
|
||||
closedLostAt: string | null;
|
||||
closedByUserId: string | null;
|
||||
@@ -114,8 +118,11 @@ export interface OpportunityRecord {
|
||||
lostReason: string | null;
|
||||
lostReasonCode: string | null;
|
||||
lostReasonLabel: string | null;
|
||||
lostDetail: string | null;
|
||||
lostCompetitor: string | null;
|
||||
lostRemark: string | null;
|
||||
cancelReason: string | null;
|
||||
noQuotationReason: string | null;
|
||||
assignedToUserId: string | null;
|
||||
assignedToName: string | null;
|
||||
assignedAt: string | null;
|
||||
@@ -172,11 +179,15 @@ export interface OpportunityActivityRecord {
|
||||
export interface OpportunityReferenceData {
|
||||
branches: OpportunityBranchOption[];
|
||||
statuses: OpportunityOption[];
|
||||
stages: OpportunityOption[];
|
||||
outcomeStatuses: OpportunityOption[];
|
||||
productTypes: OpportunityOption[];
|
||||
priorities: OpportunityOption[];
|
||||
leadChannels: OpportunityOption[];
|
||||
followupTypes: OpportunityOption[];
|
||||
lostReasons: OpportunityOption[];
|
||||
cancelReasons: OpportunityOption[];
|
||||
noQuotationReasons: OpportunityOption[];
|
||||
projectPartyRoles: OpportunityOption[];
|
||||
customers: OpportunityCustomerLookup[];
|
||||
contacts: OpportunityContactLookup[];
|
||||
@@ -189,10 +200,15 @@ export interface OpportunityFilters {
|
||||
search?: string;
|
||||
pipelineStage?: OpportunityPipelineStage;
|
||||
status?: string;
|
||||
stage?: string;
|
||||
outcomeStatus?: string;
|
||||
productType?: string;
|
||||
priority?: string;
|
||||
branch?: string;
|
||||
customer?: string;
|
||||
lostReason?: string;
|
||||
closedDateFrom?: string;
|
||||
closedDateTo?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
@@ -291,10 +307,19 @@ export interface OpportunityMarkWonPayload {
|
||||
|
||||
export interface OpportunityMarkLostPayload {
|
||||
lostReason: string;
|
||||
lostDetail?: string | null;
|
||||
lostCompetitor?: string | null;
|
||||
lostRemark?: string | null;
|
||||
}
|
||||
|
||||
export interface OpportunityMarkCancelledPayload {
|
||||
cancelReason: string;
|
||||
}
|
||||
|
||||
export interface OpportunityMarkNoQuotationPayload {
|
||||
noQuotationReason: string;
|
||||
}
|
||||
|
||||
export interface OpportunityReopenPayload {
|
||||
reopenReason: string;
|
||||
}
|
||||
@@ -312,4 +337,3 @@ export interface MutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,11 +92,10 @@ export function OpportunityCellAction({
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'แก้ไขลีด' : 'แก้ไขโอกาสขาย'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> เธฅเธ
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> ลบ
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ export function OpportunityFollowupsTab({
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> เธฅเธ
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> ลบ
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,4 +146,3 @@ export function OpportunityFollowupsTab({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
60
src/features/crm/opportunities/schemas/lifecycle.schema.ts
Normal file
60
src/features/crm/opportunities/schemas/lifecycle.schema.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
function coerceOptionalNumber(message: string) {
|
||||
return z.preprocess((value) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Number(trimmed);
|
||||
}
|
||||
|
||||
return value;
|
||||
}, z.number().refine((value) => !Number.isNaN(value), message).optional());
|
||||
}
|
||||
|
||||
export const opportunityMarkWonSchema = z.object({
|
||||
poNumber: z.string().min(1, 'PO number required'),
|
||||
poDate: z.string().min(1, 'PO date required'),
|
||||
poAmount: coerceOptionalNumber('PO amount must be number').refine(
|
||||
(value) => value === undefined || value >= 0,
|
||||
'PO amount must be 0 or greater'
|
||||
),
|
||||
poCurrency: z.string().optional().nullable(),
|
||||
remark: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export const opportunityMarkLostSchema = z.object({
|
||||
lostReason: z.string().min(1, 'Lost reason required'),
|
||||
lostDetail: z.string().optional().nullable(),
|
||||
lostCompetitor: z.string().optional().nullable(),
|
||||
lostRemark: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export const opportunityMarkCancelledSchema = z.object({
|
||||
cancelReason: z.string().min(1, 'Cancel reason required')
|
||||
});
|
||||
|
||||
export const opportunityMarkNoQuotationSchema = z.object({
|
||||
noQuotationReason: z.string().min(1, 'No quotation reason required')
|
||||
});
|
||||
|
||||
export const opportunityReopenSchema = z.object({
|
||||
reopenReason: z.string().min(3, 'Reopen reason required')
|
||||
});
|
||||
|
||||
export type OpportunityMarkWonFormValues = z.input<typeof opportunityMarkWonSchema>;
|
||||
export type OpportunityMarkLostFormValues = z.input<typeof opportunityMarkLostSchema>;
|
||||
export type OpportunityMarkCancelledFormValues = z.input<typeof opportunityMarkCancelledSchema>;
|
||||
export type OpportunityMarkNoQuotationFormValues = z.input<typeof opportunityMarkNoQuotationSchema>;
|
||||
export type OpportunityReopenFormValues = z.input<typeof opportunityReopenSchema>;
|
||||
@@ -1,5 +1,18 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export {
|
||||
opportunityMarkCancelledSchema,
|
||||
opportunityMarkLostSchema,
|
||||
opportunityMarkNoQuotationSchema,
|
||||
opportunityMarkWonSchema,
|
||||
opportunityReopenSchema,
|
||||
type OpportunityMarkCancelledFormValues,
|
||||
type OpportunityMarkLostFormValues,
|
||||
type OpportunityMarkNoQuotationFormValues,
|
||||
type OpportunityMarkWonFormValues,
|
||||
type OpportunityReopenFormValues
|
||||
} from './lifecycle.schema';
|
||||
|
||||
function coerceOptionalNumber(message: string) {
|
||||
return z.preprocess((value) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
@@ -12,7 +25,6 @@ function coerceOptionalNumber(message: string) {
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -25,7 +37,7 @@ function coerceOptionalNumber(message: string) {
|
||||
}
|
||||
|
||||
export const opportunitySchema = z.object({
|
||||
customerId: z.string().min(1, 'Please select a customer'),
|
||||
customerId: z.string().min(1, 'Please select customer'),
|
||||
contactId: z.string().optional().nullable(),
|
||||
assignedToUserId: z.string().optional().nullable(),
|
||||
title: z.string().min(2, 'Title must be at least 2 characters'),
|
||||
@@ -34,9 +46,9 @@ export const opportunitySchema = z.object({
|
||||
projectName: z.string().optional(),
|
||||
projectLocation: z.string().optional(),
|
||||
branchId: z.string().optional().nullable(),
|
||||
productType: z.string().min(1, 'Please select a product type'),
|
||||
status: z.string().min(1, 'Please select a status'),
|
||||
priority: z.string().min(1, 'Please select a priority'),
|
||||
productType: z.string().min(1, 'Please select product type'),
|
||||
status: z.string().min(1, 'Please select stage'),
|
||||
priority: z.string().min(1, 'Please select priority'),
|
||||
leadChannel: z.string().optional().nullable(),
|
||||
estimatedValue: coerceOptionalNumber('Estimated value must be a number').refine(
|
||||
(value) => value === undefined || value >= 0,
|
||||
@@ -55,8 +67,8 @@ export const opportunitySchema = z.object({
|
||||
projectParties: z
|
||||
.array(
|
||||
z.object({
|
||||
customerId: z.string().min(1, 'Please select a customer'),
|
||||
role: z.string().min(1, 'Please select a role'),
|
||||
customerId: z.string().min(1, 'Please select customer'),
|
||||
role: z.string().min(1, 'Please select role'),
|
||||
remark: z.string().nullable().optional()
|
||||
})
|
||||
)
|
||||
@@ -64,8 +76,8 @@ export const opportunitySchema = z.object({
|
||||
});
|
||||
|
||||
export const opportunityFollowupSchema = z.object({
|
||||
followupDate: z.string().min(1, 'Follow-up date is required'),
|
||||
followupType: z.string().min(1, 'Please select a follow-up type'),
|
||||
followupDate: z.string().min(1, 'Follow-up date required'),
|
||||
followupType: z.string().min(1, 'Please select follow-up type'),
|
||||
contactId: z.string().optional().nullable(),
|
||||
outcome: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
@@ -73,30 +85,5 @@ export const opportunityFollowupSchema = z.object({
|
||||
nextAction: z.string().optional()
|
||||
});
|
||||
|
||||
export const opportunityMarkWonSchema = z.object({
|
||||
poNumber: z.string().min(1, 'PO number is required'),
|
||||
poDate: z.string().min(1, 'PO date is required'),
|
||||
poAmount: coerceOptionalNumber('PO amount must be a number').refine(
|
||||
(value) => value === undefined || value >= 0,
|
||||
'PO amount must be 0 or greater'
|
||||
),
|
||||
poCurrency: z.string().optional().nullable(),
|
||||
remark: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export const opportunityMarkLostSchema = z.object({
|
||||
lostReason: z.string().min(1, 'Lost reason is required'),
|
||||
lostCompetitor: z.string().optional().nullable(),
|
||||
lostRemark: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export const opportunityReopenSchema = z.object({
|
||||
reopenReason: z.string().min(3, 'Reopen reason is required')
|
||||
});
|
||||
|
||||
export type OpportunityFormValues = z.input<typeof opportunitySchema>;
|
||||
export type OpportunityFollowupFormValues = z.input<typeof opportunityFollowupSchema>;
|
||||
export type OpportunityMarkWonFormValues = z.input<typeof opportunityMarkWonSchema>;
|
||||
export type OpportunityMarkLostFormValues = z.input<typeof opportunityMarkLostSchema>;
|
||||
export type OpportunityReopenFormValues = z.input<typeof opportunityReopenSchema>;
|
||||
|
||||
|
||||
243
src/features/crm/opportunities/server/lifecycle.service.ts
Normal file
243
src/features/crm/opportunities/server/lifecycle.service.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { crmOpportunities } from '@/db/schema';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import type {
|
||||
OpportunityMarkCancelledPayload,
|
||||
OpportunityMarkLostPayload,
|
||||
OpportunityMarkNoQuotationPayload,
|
||||
OpportunityMarkWonPayload,
|
||||
OpportunityReopenPayload
|
||||
} from '../api/types';
|
||||
import {
|
||||
assertOpportunityBelongsToOrganization,
|
||||
type OpportunityAccessContext
|
||||
} from './service';
|
||||
|
||||
type OpportunityRow = typeof crmOpportunities.$inferSelect;
|
||||
|
||||
function isClosed(opportunity: OpportunityRow) {
|
||||
return (opportunity.outcomeStatus ?? 'open') !== 'open';
|
||||
}
|
||||
|
||||
function assertCanClose(opportunity: OpportunityRow) {
|
||||
if (opportunity.pipelineStage !== 'opportunity') {
|
||||
throw new AuthError('Opportunity must be in opportunity stage', 400);
|
||||
}
|
||||
|
||||
if (isClosed(opportunity)) {
|
||||
throw new AuthError('Opportunity is already closed', 400);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLegacyPipelineStage(outcomeStatus: OpportunityRow['outcomeStatus']) {
|
||||
if (outcomeStatus === 'won') return 'closed_won' as const;
|
||||
if (outcomeStatus === 'lost') return 'closed_lost' as const;
|
||||
return 'opportunity' as const;
|
||||
}
|
||||
|
||||
async function updateClosedOpportunity(
|
||||
opportunity: OpportunityRow,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload:
|
||||
| { outcomeStatus: 'won'; reason: string | null; action: 'mark_opportunity_won'; values: OpportunityMarkWonPayload }
|
||||
| { outcomeStatus: 'lost'; reason: string; action: 'mark_opportunity_lost'; values: OpportunityMarkLostPayload }
|
||||
| {
|
||||
outcomeStatus: 'cancelled';
|
||||
reason: string;
|
||||
action: 'mark_opportunity_cancelled';
|
||||
values: OpportunityMarkCancelledPayload;
|
||||
}
|
||||
| {
|
||||
outcomeStatus: 'no_quotation';
|
||||
reason: string;
|
||||
action: 'mark_opportunity_no_quotation';
|
||||
values: OpportunityMarkNoQuotationPayload;
|
||||
}
|
||||
) {
|
||||
assertCanClose(opportunity);
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(crmOpportunities)
|
||||
.set({
|
||||
status: 'closed',
|
||||
outcomeStatus: payload.outcomeStatus,
|
||||
pipelineStage: buildLegacyPipelineStage(payload.outcomeStatus),
|
||||
closedAt: now,
|
||||
closedWonAt: payload.outcomeStatus === 'won' ? now : null,
|
||||
closedLostAt: payload.outcomeStatus === 'lost' ? now : null,
|
||||
closedByUserId: userId,
|
||||
poNumber: payload.outcomeStatus === 'won' ? payload.values.poNumber.trim() : null,
|
||||
poDate:
|
||||
payload.outcomeStatus === 'won' && payload.values.poDate
|
||||
? new Date(payload.values.poDate)
|
||||
: null,
|
||||
poAmount: payload.outcomeStatus === 'won' ? payload.values.poAmount ?? null : null,
|
||||
poCurrency: payload.outcomeStatus === 'won' ? payload.values.poCurrency?.trim() || null : null,
|
||||
lostReason: payload.outcomeStatus === 'lost' ? payload.values.lostReason : null,
|
||||
lostDetail: payload.outcomeStatus === 'lost' ? payload.values.lostDetail?.trim() || null : null,
|
||||
lostCompetitor:
|
||||
payload.outcomeStatus === 'lost' ? payload.values.lostCompetitor?.trim() || null : null,
|
||||
lostRemark: payload.outcomeStatus === 'lost' ? payload.values.lostRemark?.trim() || null : null,
|
||||
cancelReason: payload.outcomeStatus === 'cancelled' ? payload.values.cancelReason : null,
|
||||
noQuotationReason:
|
||||
payload.outcomeStatus === 'no_quotation' ? payload.values.noQuotationReason : null,
|
||||
updatedAt: now,
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmOpportunities.id, opportunity.id))
|
||||
.returning();
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
branchId: updated.branchId,
|
||||
userId,
|
||||
entityType: 'crm_opportunity',
|
||||
entityId: updated.id,
|
||||
action: payload.action,
|
||||
afterData: {
|
||||
previousStage: opportunity.status,
|
||||
previousOutcomeStatus: opportunity.outcomeStatus ?? 'open',
|
||||
newStage: updated.status,
|
||||
newOutcomeStatus: updated.outcomeStatus,
|
||||
reason: payload.reason,
|
||||
actedBy: userId,
|
||||
actedAt: now.toISOString(),
|
||||
closedAt: updated.closedAt?.toISOString() ?? null
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function markOpportunityWon(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: OpportunityMarkWonPayload,
|
||||
accessContext?: OpportunityAccessContext
|
||||
) {
|
||||
const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
if (!payload.poNumber.trim()) {
|
||||
throw new AuthError('PO number required', 400);
|
||||
}
|
||||
|
||||
return updateClosedOpportunity(opportunity, organizationId, userId, {
|
||||
outcomeStatus: 'won',
|
||||
action: 'mark_opportunity_won',
|
||||
reason: payload.remark?.trim() || null,
|
||||
values: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function markOpportunityLost(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: OpportunityMarkLostPayload,
|
||||
accessContext?: OpportunityAccessContext
|
||||
) {
|
||||
const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
return updateClosedOpportunity(opportunity, organizationId, userId, {
|
||||
outcomeStatus: 'lost',
|
||||
action: 'mark_opportunity_lost',
|
||||
reason: payload.lostReason,
|
||||
values: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function markOpportunityCancelled(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: OpportunityMarkCancelledPayload,
|
||||
accessContext?: OpportunityAccessContext
|
||||
) {
|
||||
const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
return updateClosedOpportunity(opportunity, organizationId, userId, {
|
||||
outcomeStatus: 'cancelled',
|
||||
action: 'mark_opportunity_cancelled',
|
||||
reason: payload.cancelReason,
|
||||
values: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function markOpportunityNoQuotation(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: OpportunityMarkNoQuotationPayload,
|
||||
accessContext?: OpportunityAccessContext
|
||||
) {
|
||||
const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
return updateClosedOpportunity(opportunity, organizationId, userId, {
|
||||
outcomeStatus: 'no_quotation',
|
||||
action: 'mark_opportunity_no_quotation',
|
||||
reason: payload.noQuotationReason,
|
||||
values: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function reopenOpportunity(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: OpportunityReopenPayload,
|
||||
accessContext?: OpportunityAccessContext
|
||||
) {
|
||||
const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
|
||||
if (!isClosed(opportunity)) {
|
||||
throw new AuthError('Only closed opportunities can be reopened', 400);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(crmOpportunities)
|
||||
.set({
|
||||
status: 'quotation_created',
|
||||
outcomeStatus: 'open',
|
||||
pipelineStage: 'opportunity',
|
||||
closedAt: null,
|
||||
closedWonAt: null,
|
||||
closedLostAt: null,
|
||||
closedByUserId: null,
|
||||
poNumber: null,
|
||||
poDate: null,
|
||||
poAmount: null,
|
||||
poCurrency: null,
|
||||
lostReason: null,
|
||||
lostDetail: null,
|
||||
lostCompetitor: null,
|
||||
lostRemark: null,
|
||||
cancelReason: null,
|
||||
noQuotationReason: null,
|
||||
updatedAt: now,
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmOpportunities.id, opportunity.id))
|
||||
.returning();
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
branchId: updated.branchId,
|
||||
userId,
|
||||
entityType: 'crm_opportunity',
|
||||
entityId: updated.id,
|
||||
action: 'reopen_opportunity',
|
||||
afterData: {
|
||||
previousStage: opportunity.status,
|
||||
previousOutcomeStatus: opportunity.outcomeStatus ?? 'open',
|
||||
newStage: updated.status,
|
||||
newOutcomeStatus: updated.outcomeStatus,
|
||||
reason: payload.reopenReason.trim(),
|
||||
actedBy: userId,
|
||||
actedAt: now.toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||
import {
|
||||
crmCustomerContacts,
|
||||
crmCustomers,
|
||||
@@ -29,7 +29,9 @@ import type {
|
||||
OpportunityFollowupMutationPayload,
|
||||
OpportunityFollowupRecord,
|
||||
OpportunityListItem,
|
||||
OpportunityMarkCancelledPayload,
|
||||
OpportunityMarkLostPayload,
|
||||
OpportunityMarkNoQuotationPayload,
|
||||
OpportunityMarkWonPayload,
|
||||
OpportunityMutationPayload,
|
||||
OpportunityOption,
|
||||
@@ -49,12 +51,15 @@ import {
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
|
||||
const ENQUIRY_OPTION_CATEGORIES = {
|
||||
status: 'crm_opportunity_status',
|
||||
status: 'crm_opportunity_stage',
|
||||
productType: 'crm_product_type',
|
||||
priority: 'crm_priority',
|
||||
leadChannel: 'crm_lead_channel',
|
||||
followupType: 'crm_followup_type',
|
||||
lostReason: 'crm_lost_reason',
|
||||
outcomeStatus: 'crm_opportunity_outcome_status',
|
||||
lostReason: 'crm_opportunity_lost_reason',
|
||||
cancelReason: 'crm_opportunity_cancel_reason',
|
||||
noQuotationReason: 'crm_opportunity_no_quotation_reason',
|
||||
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
|
||||
} as const;
|
||||
|
||||
@@ -136,6 +141,8 @@ function mapOpportunityRecord(
|
||||
projectLocation: row.projectLocation,
|
||||
productType: row.productType,
|
||||
status: row.status,
|
||||
stage: row.status,
|
||||
outcomeStatus: (row.outcomeStatus ?? 'open') as OpportunityRecord['outcomeStatus'],
|
||||
priority: row.priority,
|
||||
leadChannel: row.leadChannel,
|
||||
estimatedValue: row.estimatedValue,
|
||||
@@ -147,6 +154,7 @@ function mapOpportunityRecord(
|
||||
isHotProject: row.isHotProject,
|
||||
isActive: row.isActive,
|
||||
pipelineStage: row.pipelineStage as OpportunityPipelineStage,
|
||||
closedAt: row.closedAt?.toISOString() ?? null,
|
||||
closedWonAt: row.closedWonAt?.toISOString() ?? null,
|
||||
closedLostAt: row.closedLostAt?.toISOString() ?? null,
|
||||
closedByUserId: row.closedByUserId,
|
||||
@@ -158,8 +166,11 @@ function mapOpportunityRecord(
|
||||
lostReason: row.lostReason,
|
||||
lostReasonCode: options?.lostReason?.code ?? null,
|
||||
lostReasonLabel: options?.lostReason?.label ?? null,
|
||||
lostDetail: row.lostDetail,
|
||||
lostCompetitor: row.lostCompetitor,
|
||||
lostRemark: row.lostRemark,
|
||||
cancelReason: row.cancelReason,
|
||||
noQuotationReason: row.noQuotationReason,
|
||||
assignedToUserId: row.assignedToUserId,
|
||||
assignedToName: options?.assignedToName ?? null,
|
||||
assignedAt: row.assignedAt?.toISOString() ?? null,
|
||||
@@ -468,7 +479,7 @@ export async function assertAssignableUserBelongsToOrganization(
|
||||
return membership;
|
||||
}
|
||||
|
||||
async function assertOpportunityBelongsToOrganization(
|
||||
export async function assertOpportunityBelongsToOrganization(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: OpportunityAccessContext
|
||||
@@ -496,6 +507,16 @@ async function assertOpportunityBelongsToOrganization(
|
||||
return opportunity;
|
||||
}
|
||||
|
||||
function isClosedOpportunity(opportunity: typeof crmOpportunities.$inferSelect) {
|
||||
return (opportunity.outcomeStatus ?? 'open') !== 'open';
|
||||
}
|
||||
|
||||
function assertOpportunityOpenForEditing(opportunity: typeof crmOpportunities.$inferSelect) {
|
||||
if (isClosedOpportunity(opportunity)) {
|
||||
throw new AuthError('Closed opportunities must be reopened before editing', 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertFollowupBelongsToOpportunity(
|
||||
followupId: string,
|
||||
opportunityId: string,
|
||||
@@ -586,6 +607,7 @@ async function validateFollowupPayload(
|
||||
organizationId,
|
||||
accessContext
|
||||
);
|
||||
assertOpportunityOpenForEditing(opportunity);
|
||||
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
@@ -601,10 +623,13 @@ async function validateFollowupPayload(
|
||||
function buildOpportunityFilters(context: OpportunityAccessContext, filters: OpportunityFilters): SQL[] {
|
||||
const pipelineStages = splitFilterValue(filters.pipelineStage);
|
||||
const statuses = splitFilterValue(filters.status);
|
||||
const stages = splitFilterValue(filters.stage);
|
||||
const outcomeStatuses = splitFilterValue(filters.outcomeStatus);
|
||||
const productTypes = splitFilterValue(filters.productType);
|
||||
const priorities = splitFilterValue(filters.priority);
|
||||
const branches = splitFilterValue(filters.branch);
|
||||
const customers = splitFilterValue(filters.customer);
|
||||
const lostReasons = splitFilterValue(filters.lostReason);
|
||||
|
||||
return [
|
||||
eq(crmOpportunities.organizationId, context.organizationId),
|
||||
@@ -612,10 +637,15 @@ function buildOpportunityFilters(context: OpportunityAccessContext, filters: Opp
|
||||
...buildAccessScopedFilters(context),
|
||||
...(pipelineStages.length ? [inArray(crmOpportunities.pipelineStage, pipelineStages)] : []),
|
||||
...(statuses.length ? [inArray(crmOpportunities.status, statuses)] : []),
|
||||
...(stages.length ? [inArray(crmOpportunities.status, stages)] : []),
|
||||
...(outcomeStatuses.length ? [inArray(crmOpportunities.outcomeStatus, outcomeStatuses)] : []),
|
||||
...(productTypes.length ? [inArray(crmOpportunities.productType, productTypes)] : []),
|
||||
...(priorities.length ? [inArray(crmOpportunities.priority, priorities)] : []),
|
||||
...(branches.length ? [inArray(crmOpportunities.branchId, branches)] : []),
|
||||
...(customers.length ? [inArray(crmOpportunities.customerId, customers)] : []),
|
||||
...(lostReasons.length ? [inArray(crmOpportunities.lostReason, lostReasons)] : []),
|
||||
...(filters.closedDateFrom ? [sql`${crmOpportunities.closedAt} >= ${new Date(filters.closedDateFrom)}`] : []),
|
||||
...(filters.closedDateTo ? [sql`${crmOpportunities.closedAt} <= ${new Date(filters.closedDateTo)}`] : []),
|
||||
...(filters.search
|
||||
? [
|
||||
or(
|
||||
@@ -658,12 +688,15 @@ export async function getOpportunityReferenceData(
|
||||
): Promise<OpportunityReferenceData> {
|
||||
const [
|
||||
branches,
|
||||
statuses,
|
||||
stages,
|
||||
outcomeStatuses,
|
||||
productTypes,
|
||||
priorities,
|
||||
leadChannels,
|
||||
followupTypes,
|
||||
lostReasons,
|
||||
cancelReasons,
|
||||
noQuotationReasons,
|
||||
projectPartyRoles,
|
||||
customers,
|
||||
contacts,
|
||||
@@ -671,11 +704,14 @@ export async function getOpportunityReferenceData(
|
||||
] = await Promise.all([
|
||||
getUserBranches(),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.outcomeStatus, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.productType, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.lostReason, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.cancelReason, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.noQuotationReason, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
|
||||
db
|
||||
.select({
|
||||
@@ -711,12 +747,16 @@ export async function getOpportunityReferenceData(
|
||||
|
||||
return {
|
||||
branches: branches.map(mapBranch),
|
||||
statuses: statuses.map(mapOption),
|
||||
statuses: stages.map(mapOption),
|
||||
stages: stages.map(mapOption),
|
||||
outcomeStatuses: outcomeStatuses.map(mapOption),
|
||||
productTypes: productTypes.map(mapOption),
|
||||
priorities: priorities.map(mapOption),
|
||||
leadChannels: leadChannels.map(mapOption),
|
||||
followupTypes: followupTypes.map(mapOption),
|
||||
lostReasons: lostReasons.map(mapOption),
|
||||
cancelReasons: cancelReasons.map(mapOption),
|
||||
noQuotationReasons: noQuotationReasons.map(mapOption),
|
||||
projectPartyRoles: projectPartyRoles.map(mapOption),
|
||||
customers: customers.map((customer) => ({
|
||||
id: customer.id,
|
||||
@@ -1173,6 +1213,7 @@ export async function updateOpportunity(
|
||||
) {
|
||||
await validateOpportunityPayload(organizationId, payload, accessContext);
|
||||
const current = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
assertOpportunityOpenForEditing(current);
|
||||
const pipelineStage = await resolvePipelineStageForUpdate(current);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
@@ -1755,8 +1796,37 @@ export async function syncOpportunityPipelineStageFromQuotationStatus(
|
||||
organizationId: string,
|
||||
quotationStatusCode: string | null
|
||||
) {
|
||||
void opportunityId;
|
||||
void organizationId;
|
||||
void quotationStatusCode;
|
||||
}
|
||||
const [opportunity] = await db
|
||||
.select()
|
||||
.from(crmOpportunities)
|
||||
.where(
|
||||
and(
|
||||
eq(crmOpportunities.id, opportunityId),
|
||||
eq(crmOpportunities.organizationId, organizationId),
|
||||
isNull(crmOpportunities.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!opportunity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((opportunity.outcomeStatus ?? 'open') !== 'open') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStage = quotationStatusCode ? 'quotation_created' : opportunity.status;
|
||||
if (nextStage === opportunity.status && opportunity.pipelineStage === 'opportunity') {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(crmOpportunities)
|
||||
.set({
|
||||
status: nextStage,
|
||||
pipelineStage: 'opportunity',
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmOpportunities.id, opportunityId));
|
||||
}
|
||||
|
||||
@@ -457,6 +457,14 @@ async function assertOpportunityBelongsToOrganization(id: string, organizationId
|
||||
return opportunity;
|
||||
}
|
||||
|
||||
function assertOpportunityOpenForQuotation(
|
||||
opportunity: Awaited<ReturnType<typeof assertOpportunityBelongsToOrganization>>
|
||||
) {
|
||||
if ((opportunity.outcomeStatus ?? 'open') !== 'open') {
|
||||
throw new AuthError('Closed opportunities cannot create quotations', 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertQuotationBelongsToOrganization(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
@@ -1292,6 +1300,9 @@ export async function createQuotation(
|
||||
const opportunity = payload.opportunityId
|
||||
? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId)
|
||||
: null;
|
||||
if (opportunity) {
|
||||
assertOpportunityOpenForQuotation(opportunity);
|
||||
}
|
||||
const documentCode = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
documentType: 'quotation',
|
||||
@@ -2345,4 +2356,3 @@ export async function listCustomerQuotationRelations(
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user