task-d.5.6

This commit is contained in:
phaichayon
2026-06-25 13:09:21 +07:00
parent c2a74b6764
commit 65dd0e2e56
27 changed files with 1583 additions and 179 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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({
}
}
});

View File

@@ -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)
});

View File

@@ -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)
});
}

View File

@@ -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;
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View 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>;

View File

@@ -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>;

View 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;
}

View File

@@ -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));
}

View File

@@ -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()
}));
}

View File

@@ -3,6 +3,12 @@ export const CRM_MASTER_OPTION_CATEGORIES = [
'crm_customer_status',
'crm_customer_type',
'crm_opportunity_status',
'crm_opportunity_stage',
'crm_opportunity_outcome_status',
'crm_lost_reason',
'crm_opportunity_lost_reason',
'crm_opportunity_cancel_reason',
'crm_opportunity_no_quotation_reason',
'crm_lead_awareness',
'crm_lead_status',
'crm_lead_followup_status',
@@ -59,4 +65,3 @@ export interface MasterOptionListResult {
items: MasterOptionRecord[];
totalItems: number;
}