task-d.4
This commit is contained in:
@@ -37,6 +37,7 @@ export interface CrmDashboardSummary {
|
||||
activeEnquiries: number;
|
||||
wonCount: number;
|
||||
lostCount: number;
|
||||
winRate: number;
|
||||
hotEnquiries: number;
|
||||
enquiryValue: number;
|
||||
};
|
||||
@@ -150,6 +151,11 @@ export interface CrmDashboardResponse {
|
||||
myPendingApprovals: CrmDashboardApprovalRow[];
|
||||
};
|
||||
hotProjects: CrmDashboardHotProjectRow[];
|
||||
outcomeAnalytics: {
|
||||
winRate: number;
|
||||
lostByReason: Array<{ key: string; label: string; count: number; revenue: number }>;
|
||||
lostByCompetitor: Array<{ key: string; label: string; count: number; revenue: number }>;
|
||||
};
|
||||
visibility: {
|
||||
canViewCommercialData: boolean;
|
||||
canViewApprovalData: boolean;
|
||||
|
||||
@@ -70,7 +70,7 @@ export function DashboardSummaryCards({
|
||||
{ label: 'จำนวนโอกาสขาย', value: summary.enquiry.enquiryCount },
|
||||
{ label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.enquiry.activeEnquiries },
|
||||
{ label: 'จำนวนงานที่ชนะ', value: summary.enquiry.wonCount },
|
||||
{ label: 'จำนวนงานที่แพ้', value: summary.enquiry.lostCount }
|
||||
{ label: 'Win Rate', value: summary.enquiry.winRate, suffix: '%' }
|
||||
]}
|
||||
/>
|
||||
{canViewCommercialData ? (
|
||||
|
||||
@@ -30,7 +30,12 @@ import {
|
||||
getRevenueByBillingCustomer,
|
||||
getRevenueByConsultant,
|
||||
getRevenueByContractor,
|
||||
getRevenueByEndCustomer
|
||||
getRevenueByEndCustomer,
|
||||
getLostByCompetitor,
|
||||
getLostByReason,
|
||||
getLostRevenue,
|
||||
getWinRate,
|
||||
getWonRevenue
|
||||
} from '@/features/crm/reporting/server/service';
|
||||
import { getPipelineStageThaiLabel, getProjectPartyRoleThaiLabel } from '@/features/crm/shared/terminology';
|
||||
import type {
|
||||
@@ -281,7 +286,8 @@ async function loadScopedRows(
|
||||
function buildSummary(
|
||||
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
|
||||
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
|
||||
statusMaps: StatusMaps
|
||||
statusMaps: StatusMaps,
|
||||
outcomeMetrics: { wonValue: number; lostValue: number; winRate: number }
|
||||
): CrmDashboardSummary {
|
||||
const now = new Date();
|
||||
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
|
||||
@@ -304,6 +310,7 @@ function buildSummary(
|
||||
activeEnquiries: enquiryRows.length,
|
||||
wonCount: wonRows.length,
|
||||
lostCount: lostRows.length,
|
||||
winRate: outcomeMetrics.winRate,
|
||||
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
|
||||
enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
|
||||
},
|
||||
@@ -319,18 +326,8 @@ function buildSummary(
|
||||
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled')
|
||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
||||
),
|
||||
wonValue: round2(
|
||||
scopedQuotations
|
||||
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
|
||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
||||
),
|
||||
lostValue: round2(
|
||||
scopedQuotations
|
||||
.filter((row) =>
|
||||
['lost', 'rejected'].includes(statusMaps.quotationStatusById.get(row.status) ?? '')
|
||||
)
|
||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
||||
)
|
||||
wonValue: round2(outcomeMetrics.wonValue),
|
||||
lostValue: round2(outcomeMetrics.lostValue)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -387,9 +384,7 @@ function buildFunnel(
|
||||
key: 'closed_won',
|
||||
label: getPipelineStageThaiLabel('closed_won'),
|
||||
count: summary.enquiry.wonCount,
|
||||
value: scopedQuotations
|
||||
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
|
||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
||||
value: summary.revenue.wonValue
|
||||
}
|
||||
];
|
||||
|
||||
@@ -463,7 +458,8 @@ async function buildRevenueAnalytics(
|
||||
async function buildSalesRanking(
|
||||
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
|
||||
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
|
||||
statusMaps: StatusMaps
|
||||
statusMaps: StatusMaps,
|
||||
wonRevenueRows: Array<{ assignedToUserId: string | null; revenue: number }>
|
||||
): Promise<CrmDashboardSalesRankingRow[]> {
|
||||
const userIds = [
|
||||
...new Set(
|
||||
@@ -529,11 +525,29 @@ async function buildSalesRanking(
|
||||
current.approvedQuotations += 1;
|
||||
}
|
||||
|
||||
if (statusCode === 'accepted') {
|
||||
current.wonRevenue += row.totalAmount;
|
||||
rankingMap.set(row.salesmanId, current);
|
||||
}
|
||||
|
||||
for (const row of wonRevenueRows) {
|
||||
if (!row.assignedToUserId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rankingMap.set(row.salesmanId, current);
|
||||
const current =
|
||||
rankingMap.get(row.assignedToUserId) ??
|
||||
{
|
||||
salesPersonId: row.assignedToUserId,
|
||||
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
|
||||
leadCount: 0,
|
||||
enquiryCount: 0,
|
||||
quotationCount: 0,
|
||||
approvedQuotations: 0,
|
||||
wonRevenue: 0,
|
||||
conversionRate: 0
|
||||
};
|
||||
|
||||
current.wonRevenue += row.revenue;
|
||||
rankingMap.set(row.assignedToUserId, current);
|
||||
}
|
||||
|
||||
return [...rankingMap.values()]
|
||||
@@ -776,12 +790,33 @@ export async function getCrmDashboardData(
|
||||
const filters = normalizeDashboardFilters(rawFilters);
|
||||
const allowCommercialData = canViewCommercialData(context);
|
||||
const allowApprovalData = canViewApprovalData(context);
|
||||
const outcomeFilters = {
|
||||
dateFrom: filters.dateFrom,
|
||||
dateTo: filters.dateTo,
|
||||
branch: filters.branch,
|
||||
salesman: filters.salesman,
|
||||
productType: filters.productType
|
||||
};
|
||||
const [referenceData, statusMaps, scoped] = await Promise.all([
|
||||
getReferenceData(context.organizationId),
|
||||
getStatusMaps(context.organizationId),
|
||||
loadScopedRows(context, filters)
|
||||
]);
|
||||
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps);
|
||||
const [wonRevenueRows, lostRevenueRows, winRate, lostByReasonRows, lostByCompetitorRows] =
|
||||
allowCommercialData
|
||||
? await Promise.all([
|
||||
getWonRevenue(context.organizationId, outcomeFilters),
|
||||
getLostRevenue(context.organizationId, outcomeFilters),
|
||||
getWinRate(context.organizationId, outcomeFilters),
|
||||
getLostByReason(context.organizationId, outcomeFilters),
|
||||
getLostByCompetitor(context.organizationId, outcomeFilters)
|
||||
])
|
||||
: [[], [], 0, [], []];
|
||||
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps, {
|
||||
wonValue: wonRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
|
||||
lostValue: lostRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
|
||||
winRate
|
||||
});
|
||||
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
|
||||
allowCommercialData
|
||||
? buildRevenueAnalytics(context.organizationId, filters)
|
||||
@@ -792,7 +827,7 @@ export async function getCrmDashboardData(
|
||||
topConsultants: []
|
||||
}),
|
||||
allowCommercialData
|
||||
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps)
|
||||
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps, wonRevenueRows)
|
||||
: Promise.resolve([]),
|
||||
buildFollowups(context, filters),
|
||||
allowApprovalData
|
||||
@@ -825,6 +860,11 @@ export async function getCrmDashboardData(
|
||||
followups,
|
||||
approvals,
|
||||
hotProjects,
|
||||
outcomeAnalytics: {
|
||||
winRate,
|
||||
lostByReason: lostByReasonRows,
|
||||
lostByCompetitor: lostByCompetitorRows
|
||||
},
|
||||
visibility: {
|
||||
canViewCommercialData: allowCommercialData,
|
||||
canViewApprovalData: allowApprovalData
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
createEnquiryFollowup,
|
||||
deleteEnquiry,
|
||||
deleteEnquiryFollowup,
|
||||
markEnquiryLost,
|
||||
markEnquiryWon,
|
||||
reassignEnquiry,
|
||||
reopenEnquiry,
|
||||
updateEnquiry,
|
||||
updateEnquiryFollowup
|
||||
} from './service';
|
||||
@@ -14,6 +17,8 @@ import { enquiryKeys } from './queries';
|
||||
import type {
|
||||
EnquiryAssignmentMutationPayload,
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryMarkLostPayload,
|
||||
EnquiryMarkWonPayload,
|
||||
EnquiryMutationPayload
|
||||
} from './types';
|
||||
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
|
||||
@@ -38,11 +43,16 @@ async function invalidateEnquiryFollowups(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) });
|
||||
}
|
||||
|
||||
async function invalidateEnquiryAttachments(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.attachments(id) });
|
||||
}
|
||||
|
||||
export async function invalidateEnquiryMutationQueries(id: string) {
|
||||
await Promise.all([
|
||||
invalidateEnquiryLists(),
|
||||
invalidateEnquiryDetail(id),
|
||||
invalidateEnquiryProjectParties(id),
|
||||
invalidateEnquiryAttachments(id),
|
||||
invalidateDashboardQueries()
|
||||
]);
|
||||
}
|
||||
@@ -148,3 +158,33 @@ export const deleteEnquiryFollowupMutation = mutationOptions({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const markEnquiryWonMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: EnquiryMarkWonPayload }) =>
|
||||
markEnquiryWon(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateEnquiryMutationQueries(variables.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const markEnquiryLostMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: EnquiryMarkLostPayload }) =>
|
||||
markEnquiryLost(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateEnquiryMutationQueries(variables.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const reopenEnquiryMutation = mutationOptions({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
reopenEnquiry(id, { reopenReason: reason }),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateEnquiryMutationQueries(variables.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import {
|
||||
getEnquiryAttachments,
|
||||
getEnquiries,
|
||||
getEnquiryById,
|
||||
getEnquiryFollowups,
|
||||
@@ -16,7 +17,9 @@ export const enquiryKeys = {
|
||||
projectPartiesRoot: () => [...enquiryKeys.all, 'project-parties'] as const,
|
||||
projectParties: (id: string) => [...enquiryKeys.projectPartiesRoot(), id] as const,
|
||||
followupsRoot: () => [...enquiryKeys.all, 'followups'] as const,
|
||||
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const
|
||||
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const,
|
||||
attachmentsRoot: () => [...enquiryKeys.all, 'attachments'] as const,
|
||||
attachments: (id: string) => [...enquiryKeys.attachmentsRoot(), id] as const
|
||||
};
|
||||
|
||||
export const enquiriesQueryOptions = (filters: EnquiryFilters) =>
|
||||
@@ -42,3 +45,9 @@ export const enquiryFollowupsOptions = (id: string) =>
|
||||
queryKey: enquiryKeys.followups(id),
|
||||
queryFn: () => getEnquiryFollowups(id)
|
||||
});
|
||||
|
||||
export const enquiryAttachmentsOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: enquiryKeys.attachments(id),
|
||||
queryFn: () => getEnquiryAttachments(id)
|
||||
});
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
EnquiryAttachmentsResponse,
|
||||
EnquiryAssignmentMutationPayload,
|
||||
EnquiryDetailResponse,
|
||||
EnquiryFilters,
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryFollowupsResponse,
|
||||
EnquiryListResponse,
|
||||
EnquiryMarkLostPayload,
|
||||
EnquiryMarkWonPayload,
|
||||
EnquiryMutationPayload,
|
||||
EnquiryProjectPartiesResponse,
|
||||
EnquiryReopenPayload,
|
||||
MutationSuccessResponse
|
||||
} from './types';
|
||||
|
||||
@@ -76,6 +80,10 @@ export async function getEnquiryFollowups(id: string): Promise<EnquiryFollowupsR
|
||||
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
|
||||
}
|
||||
|
||||
export async function getEnquiryAttachments(id: string): Promise<EnquiryAttachmentsResponse> {
|
||||
return apiClient<EnquiryAttachmentsResponse>(`/crm/enquiries/${id}/po-attachments`);
|
||||
}
|
||||
|
||||
export async function createEnquiryFollowup(
|
||||
enquiryId: string,
|
||||
data: EnquiryFollowupMutationPayload
|
||||
@@ -102,3 +110,24 @@ export async function deleteEnquiryFollowup(enquiryId: string, followupId: strin
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function markEnquiryWon(id: string, data: EnquiryMarkWonPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/mark-won`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function markEnquiryLost(id: string, data: EnquiryMarkLostPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/mark-lost`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function reopenEnquiry(id: string, data: EnquiryReopenPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/reopen`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,6 +56,25 @@ export interface EnquiryAssignableUserLookup {
|
||||
businessRole: string;
|
||||
}
|
||||
|
||||
export interface EnquiryAttachmentRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
enquiryId: string;
|
||||
category: string;
|
||||
fileName: string;
|
||||
originalFileName: string;
|
||||
storageProvider: string;
|
||||
storageKey: string;
|
||||
fileSize: number | null;
|
||||
fileType: string | null;
|
||||
description: string | null;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost';
|
||||
|
||||
export interface EnquiryRecord {
|
||||
@@ -83,6 +102,19 @@ export interface EnquiryRecord {
|
||||
isHotProject: boolean;
|
||||
isActive: boolean;
|
||||
pipelineStage: EnquiryPipelineStage;
|
||||
closedWonAt: string | null;
|
||||
closedLostAt: string | null;
|
||||
closedByUserId: string | null;
|
||||
closedByName: string | null;
|
||||
poNumber: string | null;
|
||||
poDate: string | null;
|
||||
poAmount: number | null;
|
||||
poCurrency: string | null;
|
||||
lostReason: string | null;
|
||||
lostReasonCode: string | null;
|
||||
lostReasonLabel: string | null;
|
||||
lostCompetitor: string | null;
|
||||
lostRemark: string | null;
|
||||
assignedToUserId: string | null;
|
||||
assignedToName: string | null;
|
||||
assignedAt: string | null;
|
||||
@@ -140,6 +172,7 @@ export interface EnquiryReferenceData {
|
||||
priorities: EnquiryOption[];
|
||||
leadChannels: EnquiryOption[];
|
||||
followupTypes: EnquiryOption[];
|
||||
lostReasons: EnquiryOption[];
|
||||
projectPartyRoles: EnquiryOption[];
|
||||
customers: EnquiryCustomerLookup[];
|
||||
contacts: EnquiryContactLookup[];
|
||||
@@ -191,6 +224,13 @@ export interface EnquiryFollowupsResponse {
|
||||
items: EnquiryFollowupRecord[];
|
||||
}
|
||||
|
||||
export interface EnquiryAttachmentsResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
items: EnquiryAttachmentRecord[];
|
||||
}
|
||||
|
||||
export interface EnquiryProjectPartyMutationPayload {
|
||||
customerId: string;
|
||||
role: string;
|
||||
@@ -237,6 +277,24 @@ export interface EnquiryAssignmentMutationPayload {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface EnquiryMarkWonPayload {
|
||||
poNumber: string;
|
||||
poDate: string;
|
||||
poAmount?: number | null;
|
||||
poCurrency?: string | null;
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
export interface EnquiryMarkLostPayload {
|
||||
lostReason: string;
|
||||
lostCompetitor?: string | null;
|
||||
lostRemark?: string | null;
|
||||
}
|
||||
|
||||
export interface EnquiryReopenPayload {
|
||||
reopenReason: string;
|
||||
}
|
||||
|
||||
export interface EnquiryCustomerRelationItem {
|
||||
id: string;
|
||||
code: string;
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
||||
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryOutcomeCard } from './enquiry-outcome-card';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
|
||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
@@ -40,6 +41,10 @@ interface EnquiryDetailProps {
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
canViewRelatedQuotations: boolean;
|
||||
canViewOutcomeCommercialData: boolean;
|
||||
canMarkWon: boolean;
|
||||
canMarkLost: boolean;
|
||||
canReopen: boolean;
|
||||
canManageFollowups: {
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
@@ -56,6 +61,10 @@ export function EnquiryDetail({
|
||||
canAssign,
|
||||
canReassign,
|
||||
canViewRelatedQuotations,
|
||||
canViewOutcomeCommercialData,
|
||||
canMarkWon,
|
||||
canMarkLost,
|
||||
canReopen,
|
||||
canManageFollowups
|
||||
}: EnquiryDetailProps) {
|
||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||
@@ -374,6 +383,14 @@ export function EnquiryDetail({
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<EnquiryOutcomeCard
|
||||
enquiryId={enquiryId}
|
||||
referenceData={referenceData}
|
||||
canViewCommercialData={canViewOutcomeCommercialData}
|
||||
canMarkWon={canMarkWon}
|
||||
canMarkLost={canMarkLost}
|
||||
canReopen={canReopen}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ข้อมูลระบบ</CardTitle>
|
||||
|
||||
465
src/features/crm/enquiries/components/enquiry-outcome-card.tsx
Normal file
465
src/features/crm/enquiries/components/enquiry-outcome-card.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
invalidateEnquiryMutationQueries,
|
||||
markEnquiryLostMutation,
|
||||
markEnquiryWonMutation,
|
||||
reopenEnquiryMutation
|
||||
} from '../api/mutations';
|
||||
import { enquiryAttachmentsOptions, enquiryByIdOptions } from '../api/queries';
|
||||
import type { EnquiryReferenceData } from '../api/types';
|
||||
|
||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||
<div className='text-sm'>{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getOutcomeLabel(stage: string) {
|
||||
if (stage === 'closed_won') return 'Won';
|
||||
if (stage === 'closed_lost') return 'Lost';
|
||||
return 'Open';
|
||||
}
|
||||
|
||||
type EnquiryOutcomeCardProps = {
|
||||
enquiryId: string;
|
||||
referenceData: EnquiryReferenceData;
|
||||
canViewCommercialData: boolean;
|
||||
canMarkWon: boolean;
|
||||
canMarkLost: boolean;
|
||||
canReopen: boolean;
|
||||
};
|
||||
|
||||
export function EnquiryOutcomeCard({
|
||||
enquiryId,
|
||||
referenceData,
|
||||
canViewCommercialData,
|
||||
canMarkWon,
|
||||
canMarkLost,
|
||||
canReopen
|
||||
}: EnquiryOutcomeCardProps) {
|
||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||
const attachmentsQuery = useQuery({
|
||||
...enquiryAttachmentsOptions(enquiryId),
|
||||
enabled: canViewCommercialData
|
||||
});
|
||||
const enquiry = data.enquiry;
|
||||
const [wonOpen, setWonOpen] = useState(false);
|
||||
const [lostOpen, setLostOpen] = useState(false);
|
||||
const [reopenOpen, setReopenOpen] = useState(false);
|
||||
const [poNumber, setPoNumber] = useState('');
|
||||
const [poDate, setPoDate] = useState('');
|
||||
const [poAmount, setPoAmount] = useState('');
|
||||
const [poCurrency, setPoCurrency] = useState('THB');
|
||||
const [wonRemark, setWonRemark] = useState('');
|
||||
const [lostReason, setLostReason] = useState('');
|
||||
const [lostCompetitor, setLostCompetitor] = useState('');
|
||||
const [lostRemark, setLostRemark] = useState('');
|
||||
const [reopenReason, setReopenReason] = useState('');
|
||||
const [poFile, setPoFile] = useState<File | null>(null);
|
||||
const [poFileDescription, setPoFileDescription] = useState('');
|
||||
|
||||
const wonMutation = useMutation({
|
||||
...markEnquiryWonMutation,
|
||||
onSuccess: async () => {
|
||||
toast.success('Marked enquiry as won');
|
||||
setWonOpen(false);
|
||||
await invalidateEnquiryMutationQueries(enquiryId);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark won')
|
||||
});
|
||||
const lostMutation = useMutation({
|
||||
...markEnquiryLostMutation,
|
||||
onSuccess: async () => {
|
||||
toast.success('Marked enquiry as lost');
|
||||
setLostOpen(false);
|
||||
await invalidateEnquiryMutationQueries(enquiryId);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark lost')
|
||||
});
|
||||
const reopenMutation = useMutation({
|
||||
...reopenEnquiryMutation,
|
||||
onSuccess: async () => {
|
||||
toast.success('Reopened enquiry');
|
||||
setReopenOpen(false);
|
||||
await invalidateEnquiryMutationQueries(enquiryId);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to reopen enquiry')
|
||||
});
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!poFile) {
|
||||
throw new Error('Please choose a PO attachment file');
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', poFile);
|
||||
formData.append('description', poFileDescription);
|
||||
const response = await fetch(`/api/crm/enquiries/${enquiryId}/po-attachments`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
throw new Error(payload?.message ?? 'Failed to upload PO attachment');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('PO attachment uploaded');
|
||||
setPoFile(null);
|
||||
setPoFileDescription('');
|
||||
await invalidateEnquiryMutationQueries(enquiryId);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to upload PO attachment')
|
||||
});
|
||||
|
||||
const closedDate = enquiry.closedWonAt ?? enquiry.closedLostAt;
|
||||
const lostReasonLabel = useMemo(() => {
|
||||
if (!enquiry.lostReason) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
enquiry.lostReasonLabel ??
|
||||
referenceData.lostReasons.find(
|
||||
(item) => item.id === enquiry.lostReason || item.code === enquiry.lostReason
|
||||
)?.label ??
|
||||
enquiry.lostReason
|
||||
);
|
||||
}, [enquiry.lostReason, enquiry.lostReasonLabel, referenceData.lostReasons]);
|
||||
const attachmentItems = attachmentsQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Outcome</CardTitle>
|
||||
<CardDescription>Official won/lost lifecycle and purchase order tracking.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Badge variant={enquiry.pipelineStage === 'closed_won' ? 'default' : enquiry.pipelineStage === 'closed_lost' ? 'destructive' : 'secondary'}>
|
||||
{getOutcomeLabel(enquiry.pipelineStage)}
|
||||
</Badge>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{enquiry.pipelineStage === 'enquiry' && canMarkWon ? (
|
||||
<Button size='sm' onClick={() => {
|
||||
setPoNumber(enquiry.poNumber ?? '');
|
||||
setPoDate(enquiry.poDate ? enquiry.poDate.slice(0, 10) : '');
|
||||
setPoAmount(enquiry.poAmount !== null && enquiry.poAmount !== undefined ? String(enquiry.poAmount) : '');
|
||||
setPoCurrency(enquiry.poCurrency ?? 'THB');
|
||||
setWonRemark('');
|
||||
setWonOpen(true);
|
||||
}}>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
Mark Won
|
||||
</Button>
|
||||
) : null}
|
||||
{enquiry.pipelineStage === 'enquiry' && canMarkLost ? (
|
||||
<Button variant='destructive' size='sm' onClick={() => {
|
||||
setLostReason(enquiry.lostReason ?? '');
|
||||
setLostCompetitor(enquiry.lostCompetitor ?? '');
|
||||
setLostRemark(enquiry.lostRemark ?? '');
|
||||
setLostOpen(true);
|
||||
}}>
|
||||
<Icons.xCircle className='mr-2 h-4 w-4' />
|
||||
Mark Lost
|
||||
</Button>
|
||||
) : null}
|
||||
{enquiry.pipelineStage === 'closed_lost' && canReopen ? (
|
||||
<Button variant='outline' size='sm' onClick={() => setReopenOpen(true)}>
|
||||
Reopen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldItem label='Closed By' value={enquiry.closedByName} />
|
||||
<FieldItem
|
||||
label='Closed Date'
|
||||
value={closedDate ? new Date(closedDate).toLocaleString() : null}
|
||||
/>
|
||||
<FieldItem label='PO Number' value={enquiry.poNumber} />
|
||||
<FieldItem
|
||||
label='PO Date'
|
||||
value={enquiry.poDate ? new Date(enquiry.poDate).toLocaleDateString() : null}
|
||||
/>
|
||||
<FieldItem
|
||||
label='PO Amount'
|
||||
value={
|
||||
canViewCommercialData && enquiry.poAmount !== null
|
||||
? `${enquiry.poAmount.toLocaleString()} ${enquiry.poCurrency ?? ''}`.trim()
|
||||
: canViewCommercialData
|
||||
? null
|
||||
: 'Restricted'
|
||||
}
|
||||
/>
|
||||
<FieldItem label='Lost Reason' value={lostReasonLabel} />
|
||||
<FieldItem label='Lost Competitor' value={enquiry.lostCompetitor} />
|
||||
<FieldItem label='Remark' value={enquiry.lostRemark} />
|
||||
</div>
|
||||
|
||||
{canViewCommercialData && enquiry.pipelineStage === 'closed_won' ? (
|
||||
<div className='space-y-4 rounded-lg border p-4'>
|
||||
<div className='space-y-1'>
|
||||
<div className='font-medium'>Purchase Order Attachment</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Upload PDF, image, or Excel files for the received PO.
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='po-file'>PO file</Label>
|
||||
<Input
|
||||
id='po-file'
|
||||
type='file'
|
||||
accept='.pdf,.png,.jpg,.jpeg,.webp,.xls,.xlsx'
|
||||
onChange={(event) => setPoFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='po-file-description'>Description</Label>
|
||||
<Input
|
||||
id='po-file-description'
|
||||
value={poFileDescription}
|
||||
onChange={(event) => setPoFileDescription(event.target.value)}
|
||||
placeholder='Optional note for this file'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
isLoading={uploadMutation.isPending}
|
||||
disabled={!poFile}
|
||||
>
|
||||
<Icons.upload className='mr-2 h-4 w-4' />
|
||||
Upload PO
|
||||
</Button>
|
||||
{!attachmentItems.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
||||
No PO attachments uploaded yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
{attachmentItems.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className='flex flex-col gap-2 rounded-lg border p-3 md:flex-row md:items-center md:justify-between'
|
||||
>
|
||||
<div className='space-y-1'>
|
||||
<div className='font-medium'>{attachment.originalFileName}</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{attachment.fileType ?? 'Unknown type'}
|
||||
{attachment.fileSize ? ` • ${attachment.fileSize.toLocaleString()} bytes` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button asChild variant='outline' size='sm'>
|
||||
<Link href={`/api/crm/enquiries/${enquiryId}/po-attachments/${attachment.id}`}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant='outline' size='sm'>
|
||||
<Link href={`/api/crm/enquiries/${enquiryId}/po-attachments/${attachment.id}?download=1`}>
|
||||
Download
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={wonOpen} onOpenChange={setWonOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mark Won</DialogTitle>
|
||||
<DialogDescription>PO received is the official trigger for closed won.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='grid gap-4 py-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='po-number'>PO Number</Label>
|
||||
<Input id='po-number' value={poNumber} onChange={(e) => setPoNumber(e.target.value)} />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='po-date'>PO Date</Label>
|
||||
<Input id='po-date' type='date' value={poDate} onChange={(e) => setPoDate(e.target.value)} />
|
||||
</div>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='po-amount'>PO Amount</Label>
|
||||
<Input id='po-amount' value={poAmount} onChange={(e) => setPoAmount(e.target.value)} />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>PO Currency</Label>
|
||||
<Select value={poCurrency} onValueChange={setPoCurrency}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select currency' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='THB'>THB</SelectItem>
|
||||
<SelectItem value='USD'>USD</SelectItem>
|
||||
<SelectItem value='EUR'>EUR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='won-remark'>Remark</Label>
|
||||
<Textarea id='won-remark' value={wonRemark} onChange={(e) => setWonRemark(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => setWonOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={wonMutation.isPending}
|
||||
onClick={() =>
|
||||
wonMutation.mutate({
|
||||
id: enquiryId,
|
||||
values: {
|
||||
poNumber,
|
||||
poDate,
|
||||
poAmount: poAmount.trim() ? Number(poAmount) : null,
|
||||
poCurrency,
|
||||
remark: wonRemark
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
Confirm Won
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={lostOpen} onOpenChange={setLostOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mark Lost</DialogTitle>
|
||||
<DialogDescription>Lost reason is required for every closed lost outcome.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='grid gap-4 py-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Lost Reason</Label>
|
||||
<Select value={lostReason} onValueChange={setLostReason}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select lost reason' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.lostReasons.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='lost-competitor'>Lost Competitor</Label>
|
||||
<Input
|
||||
id='lost-competitor'
|
||||
value={lostCompetitor}
|
||||
onChange={(e) => setLostCompetitor(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='lost-remark'>Lost Remark</Label>
|
||||
<Textarea id='lost-remark' value={lostRemark} onChange={(e) => setLostRemark(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => setLostOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
isLoading={lostMutation.isPending}
|
||||
onClick={() =>
|
||||
lostMutation.mutate({
|
||||
id: enquiryId,
|
||||
values: {
|
||||
lostReason,
|
||||
lostCompetitor,
|
||||
lostRemark
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
Confirm Lost
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={reopenOpen} onOpenChange={setReopenOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reopen Opportunity</DialogTitle>
|
||||
<DialogDescription>Only lost opportunities can be reopened.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-2 py-2'>
|
||||
<Label htmlFor='reopen-reason'>Reopen Reason</Label>
|
||||
<Textarea
|
||||
id='reopen-reason'
|
||||
value={reopenReason}
|
||||
onChange={(e) => setReopenReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => setReopenOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={reopenMutation.isPending}
|
||||
onClick={() => reopenMutation.mutate({ id: enquiryId, reason: reopenReason })}
|
||||
>
|
||||
Reopen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -73,5 +73,29 @@ export const enquiryFollowupSchema = z.object({
|
||||
nextAction: z.string().optional()
|
||||
});
|
||||
|
||||
export const enquiryMarkWonSchema = 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 enquiryMarkLostSchema = z.object({
|
||||
lostReason: z.string().min(1, 'Lost reason is required'),
|
||||
lostCompetitor: z.string().optional().nullable(),
|
||||
lostRemark: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export const enquiryReopenSchema = z.object({
|
||||
reopenReason: z.string().min(3, 'Reopen reason is required')
|
||||
});
|
||||
|
||||
export type EnquiryFormValues = z.input<typeof enquirySchema>;
|
||||
export type EnquiryFollowupFormValues = z.input<typeof enquiryFollowupSchema>;
|
||||
export type EnquiryMarkWonFormValues = z.input<typeof enquiryMarkWonSchema>;
|
||||
export type EnquiryMarkLostFormValues = z.input<typeof enquiryMarkLostSchema>;
|
||||
export type EnquiryReopenFormValues = z.input<typeof enquiryReopenSchema>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
crmCustomerContacts,
|
||||
crmCustomers,
|
||||
crmEnquiries,
|
||||
crmEnquiryAttachments,
|
||||
crmEnquiryCustomers,
|
||||
crmEnquiryFollowups,
|
||||
crmQuotations,
|
||||
@@ -12,13 +13,15 @@ import {
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { auditAction, listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import { buildOrganizationStorageKey, getStorageProvider } from '@/features/foundation/storage/service';
|
||||
import type {
|
||||
EnquiryActivityRecord,
|
||||
EnquiryAssignableUserLookup,
|
||||
EnquiryAttachmentRecord,
|
||||
EnquiryBranchOption,
|
||||
EnquiryAssignmentMutationPayload,
|
||||
EnquiryCustomerRelationItem,
|
||||
@@ -26,12 +29,15 @@ import type {
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryFollowupRecord,
|
||||
EnquiryListItem,
|
||||
EnquiryMarkLostPayload,
|
||||
EnquiryMarkWonPayload,
|
||||
EnquiryMutationPayload,
|
||||
EnquiryOption,
|
||||
EnquiryProjectPartyListItem,
|
||||
EnquiryProjectPartyMutationPayload,
|
||||
EnquiryProjectPartyRecord,
|
||||
EnquiryPipelineStage,
|
||||
EnquiryReopenPayload,
|
||||
EnquiryRecord,
|
||||
EnquiryReferenceData
|
||||
} from '../api/types';
|
||||
@@ -40,6 +46,7 @@ import {
|
||||
PROJECT_PARTY_OPTION_CATEGORY,
|
||||
resolveProjectPartyRole
|
||||
} from '@/features/crm/shared/project-party';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
|
||||
const ENQUIRY_OPTION_CATEGORIES = {
|
||||
status: 'crm_enquiry_status',
|
||||
@@ -47,6 +54,7 @@ const ENQUIRY_OPTION_CATEGORIES = {
|
||||
priority: 'crm_priority',
|
||||
leadChannel: 'crm_lead_channel',
|
||||
followupType: 'crm_followup_type',
|
||||
lostReason: 'crm_lost_reason',
|
||||
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
|
||||
} as const;
|
||||
|
||||
@@ -58,15 +66,22 @@ const SALES_CREATED_ENQUIRY_ROLES = new Set([
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
]);
|
||||
const CLOSED_LOST_STATUS_CODES = new Set(['closed_lost', 'cancelled']);
|
||||
const CLOSED_WON_QUOTATION_STATUS_CODES = new Set(['accepted']);
|
||||
const CLOSED_LOST_QUOTATION_STATUS_CODES = new Set(['lost', 'rejected']);
|
||||
const PURCHASE_ORDER_ATTACHMENT_CATEGORY = 'purchase_order';
|
||||
const ALLOWED_PO_CONTENT_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
]);
|
||||
|
||||
export interface EnquiryAccessContext {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
membershipRole: string;
|
||||
businessRole: string;
|
||||
permissions?: string[];
|
||||
branchScopeIds: string[];
|
||||
productTypeScopeIds: string[];
|
||||
ownershipScope: string;
|
||||
@@ -98,7 +113,13 @@ function mapBranch(
|
||||
|
||||
function mapEnquiryRecord(
|
||||
row: typeof crmEnquiries.$inferSelect,
|
||||
options?: { assignedToName?: string | null; assignedByName?: string | null }
|
||||
options?: {
|
||||
assignedToName?: string | null;
|
||||
assignedByName?: string | null;
|
||||
closedByName?: string | null;
|
||||
lostReason?: { code: string; label: string } | null;
|
||||
canViewCommercialData?: boolean;
|
||||
}
|
||||
): EnquiryRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -125,6 +146,19 @@ function mapEnquiryRecord(
|
||||
isHotProject: row.isHotProject,
|
||||
isActive: row.isActive,
|
||||
pipelineStage: row.pipelineStage as EnquiryPipelineStage,
|
||||
closedWonAt: row.closedWonAt?.toISOString() ?? null,
|
||||
closedLostAt: row.closedLostAt?.toISOString() ?? null,
|
||||
closedByUserId: row.closedByUserId,
|
||||
closedByName: options?.closedByName ?? null,
|
||||
poNumber: row.poNumber,
|
||||
poDate: row.poDate?.toISOString() ?? null,
|
||||
poAmount: options?.canViewCommercialData === false ? null : row.poAmount,
|
||||
poCurrency: row.poCurrency,
|
||||
lostReason: row.lostReason,
|
||||
lostReasonCode: options?.lostReason?.code ?? null,
|
||||
lostReasonLabel: options?.lostReason?.label ?? null,
|
||||
lostCompetitor: row.lostCompetitor,
|
||||
lostRemark: row.lostRemark,
|
||||
assignedToUserId: row.assignedToUserId,
|
||||
assignedToName: options?.assignedToName ?? null,
|
||||
assignedAt: row.assignedAt?.toISOString() ?? null,
|
||||
@@ -139,6 +173,38 @@ function mapEnquiryRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function mapAttachmentRecord(row: typeof crmEnquiryAttachments.$inferSelect): EnquiryAttachmentRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
enquiryId: row.enquiryId,
|
||||
category: row.category,
|
||||
fileName: row.fileName,
|
||||
originalFileName: row.originalFileName,
|
||||
storageProvider: row.storageProvider,
|
||||
storageKey: row.storageKey,
|
||||
fileSize: row.fileSize,
|
||||
fileType: row.fileType,
|
||||
description: row.description,
|
||||
uploadedAt: row.uploadedAt.toISOString(),
|
||||
uploadedBy: row.uploadedBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function canViewOutcomeCommercialData(context?: EnquiryAccessContext) {
|
||||
if (!context) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
context.membershipRole === 'admin' ||
|
||||
(context.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead)
|
||||
);
|
||||
}
|
||||
|
||||
function hasEnquiryFullAccess(context: EnquiryAccessContext) {
|
||||
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
|
||||
}
|
||||
@@ -561,40 +627,16 @@ function derivePipelineStageFromRole(businessRole: string): EnquiryPipelineStage
|
||||
}
|
||||
|
||||
async function resolvePipelineStageForCreate(
|
||||
organizationId: string,
|
||||
businessRole: string,
|
||||
statusId: string
|
||||
): Promise<EnquiryPipelineStage> {
|
||||
const statusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
ENQUIRY_OPTION_CATEGORIES.status,
|
||||
statusId
|
||||
);
|
||||
|
||||
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
|
||||
return 'closed_lost';
|
||||
}
|
||||
|
||||
return derivePipelineStageFromRole(businessRole);
|
||||
}
|
||||
|
||||
async function resolvePipelineStageForUpdate(
|
||||
organizationId: string,
|
||||
current: typeof crmEnquiries.$inferSelect,
|
||||
payload: EnquiryMutationPayload
|
||||
): Promise<EnquiryPipelineStage> {
|
||||
const statusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
ENQUIRY_OPTION_CATEGORIES.status,
|
||||
payload.status
|
||||
);
|
||||
|
||||
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
|
||||
return 'closed_lost';
|
||||
}
|
||||
|
||||
if (current.pipelineStage === 'closed_won') {
|
||||
return 'closed_won';
|
||||
if (current.pipelineStage === 'closed_won' || current.pipelineStage === 'closed_lost') {
|
||||
return current.pipelineStage as EnquiryPipelineStage;
|
||||
}
|
||||
|
||||
if (current.pipelineStage === 'enquiry' || current.assignedToUserId) {
|
||||
@@ -614,6 +656,7 @@ export async function getEnquiryReferenceData(
|
||||
priorities,
|
||||
leadChannels,
|
||||
followupTypes,
|
||||
lostReasons,
|
||||
projectPartyRoles,
|
||||
customers,
|
||||
contacts,
|
||||
@@ -625,6 +668,7 @@ export async function getEnquiryReferenceData(
|
||||
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.projectPartyRole, { organizationId }),
|
||||
db
|
||||
.select({
|
||||
@@ -665,6 +709,7 @@ export async function getEnquiryReferenceData(
|
||||
priorities: priorities.map(mapOption),
|
||||
leadChannels: leadChannels.map(mapOption),
|
||||
followupTypes: followupTypes.map(mapOption),
|
||||
lostReasons: lostReasons.map(mapOption),
|
||||
projectPartyRoles: projectPartyRoles.map(mapOption),
|
||||
customers: customers.map((customer) => ({
|
||||
id: customer.id,
|
||||
@@ -844,20 +889,32 @@ export async function getEnquiryDetail(
|
||||
accessContext?: EnquiryAccessContext
|
||||
): Promise<EnquiryRecord> {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const relatedUserIds = [enquiry.assignedToUserId, enquiry.assignedBy].filter(Boolean) as string[];
|
||||
const userRows = relatedUserIds.length
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, relatedUserIds))
|
||||
: [];
|
||||
const relatedUserIds = [
|
||||
enquiry.assignedToUserId,
|
||||
enquiry.assignedBy,
|
||||
enquiry.closedByUserId
|
||||
].filter(Boolean) as string[];
|
||||
const [userRows, lostReasonOptions] = await Promise.all([
|
||||
relatedUserIds.length
|
||||
? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, relatedUserIds))
|
||||
: [],
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.lostReason, { organizationId })
|
||||
]);
|
||||
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
||||
const lostReasonOption =
|
||||
lostReasonOptions.find((option) => option.id === enquiry.lostReason || option.code === enquiry.lostReason) ??
|
||||
null;
|
||||
|
||||
return mapEnquiryRecord(enquiry, {
|
||||
assignedToName: enquiry.assignedToUserId
|
||||
? (userMap.get(enquiry.assignedToUserId) ?? null)
|
||||
: null,
|
||||
assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null
|
||||
assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null,
|
||||
closedByName: enquiry.closedByUserId ? (userMap.get(enquiry.closedByUserId) ?? null) : null,
|
||||
lostReason: lostReasonOption
|
||||
? { code: lostReasonOption.code, label: lostReasonOption.label }
|
||||
: null,
|
||||
canViewCommercialData: canViewOutcomeCommercialData(accessContext)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1043,11 +1100,7 @@ export async function createEnquiry(
|
||||
payload: EnquiryMutationPayload
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload, accessContext);
|
||||
const pipelineStage = await resolvePipelineStageForCreate(
|
||||
organizationId,
|
||||
accessContext.businessRole,
|
||||
payload.status
|
||||
);
|
||||
const pipelineStage = await resolvePipelineStageForCreate(accessContext.businessRole);
|
||||
|
||||
const documentCode = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
@@ -1113,7 +1166,7 @@ export async function updateEnquiry(
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload, accessContext);
|
||||
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload);
|
||||
const pipelineStage = await resolvePipelineStageForUpdate(current);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
@@ -1370,6 +1423,296 @@ export async function softDeleteEnquiryFollowup(
|
||||
return updated;
|
||||
}
|
||||
|
||||
function ensureEnquiryStageForOutcome(
|
||||
enquiry: typeof crmEnquiries.$inferSelect,
|
||||
expectedStage: EnquiryPipelineStage
|
||||
) {
|
||||
if (enquiry.pipelineStage !== expectedStage) {
|
||||
throw new AuthError(`Enquiry must be in ${expectedStage} stage`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markEnquiryAsWon(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryMarkWonPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
ensureEnquiryStageForOutcome(enquiry, 'enquiry');
|
||||
|
||||
if (!payload.poNumber.trim()) {
|
||||
throw new AuthError('PO number is required', 400);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
pipelineStage: 'closed_won',
|
||||
closedWonAt: now,
|
||||
closedLostAt: null,
|
||||
closedByUserId: userId,
|
||||
poNumber: payload.poNumber.trim(),
|
||||
poDate: new Date(payload.poDate),
|
||||
poAmount: payload.poAmount ?? null,
|
||||
poCurrency: payload.poCurrency?.trim() || null,
|
||||
lostReason: null,
|
||||
lostCompetitor: null,
|
||||
lostRemark: null,
|
||||
updatedAt: now,
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmEnquiries.id, id))
|
||||
.returning();
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
branchId: updated.branchId,
|
||||
userId,
|
||||
entityType: 'crm_enquiry',
|
||||
entityId: id,
|
||||
action: 'mark_won',
|
||||
afterData: {
|
||||
poNumber: updated.poNumber,
|
||||
poAmount: updated.poAmount,
|
||||
poCurrency: updated.poCurrency,
|
||||
remark: payload.remark?.trim() || null,
|
||||
closedBy: userId,
|
||||
closedAt: updated.closedWonAt?.toISOString() ?? null
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function markEnquiryAsLost(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryMarkLostPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
ensureEnquiryStageForOutcome(enquiry, 'enquiry');
|
||||
await assertMasterOptionValue(organizationId, ENQUIRY_OPTION_CATEGORIES.lostReason, payload.lostReason);
|
||||
|
||||
const lostReasonCode =
|
||||
(await resolveOptionCodeById(organizationId, ENQUIRY_OPTION_CATEGORIES.lostReason, payload.lostReason)) ??
|
||||
payload.lostReason;
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
pipelineStage: 'closed_lost',
|
||||
closedWonAt: null,
|
||||
closedLostAt: now,
|
||||
closedByUserId: userId,
|
||||
poNumber: null,
|
||||
poDate: null,
|
||||
poAmount: null,
|
||||
poCurrency: null,
|
||||
lostReason: payload.lostReason,
|
||||
lostCompetitor: payload.lostCompetitor?.trim() || null,
|
||||
lostRemark: payload.lostRemark?.trim() || null,
|
||||
updatedAt: now,
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmEnquiries.id, id))
|
||||
.returning();
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
branchId: updated.branchId,
|
||||
userId,
|
||||
entityType: 'crm_enquiry',
|
||||
entityId: id,
|
||||
action: 'mark_lost',
|
||||
afterData: {
|
||||
lostReason: lostReasonCode,
|
||||
lostCompetitor: updated.lostCompetitor,
|
||||
closedBy: userId,
|
||||
closedAt: updated.closedLostAt?.toISOString() ?? null
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function reopenLostEnquiry(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryReopenPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
|
||||
if (enquiry.pipelineStage === 'closed_won') {
|
||||
throw new AuthError('Won enquiries cannot be reopened', 400);
|
||||
}
|
||||
|
||||
ensureEnquiryStageForOutcome(enquiry, 'closed_lost');
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
pipelineStage: 'enquiry',
|
||||
closedLostAt: null,
|
||||
closedByUserId: null,
|
||||
lostReason: null,
|
||||
lostCompetitor: null,
|
||||
lostRemark: null,
|
||||
updatedAt: now,
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmEnquiries.id, id))
|
||||
.returning();
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
branchId: updated.branchId,
|
||||
userId,
|
||||
entityType: 'crm_enquiry',
|
||||
entityId: id,
|
||||
action: 'reopen',
|
||||
afterData: {
|
||||
reopenReason: payload.reopenReason.trim(),
|
||||
previousStage: enquiry.pipelineStage,
|
||||
nextStage: updated.pipelineStage
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function listEnquiryAttachments(
|
||||
enquiryId: string,
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmEnquiryAttachments)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryAttachments.organizationId, organizationId),
|
||||
eq(crmEnquiryAttachments.enquiryId, enquiryId),
|
||||
eq(crmEnquiryAttachments.category, PURCHASE_ORDER_ATTACHMENT_CATEGORY),
|
||||
isNull(crmEnquiryAttachments.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(crmEnquiryAttachments.uploadedAt));
|
||||
|
||||
return rows.map(mapAttachmentRecord);
|
||||
}
|
||||
|
||||
export async function getEnquiryAttachment(
|
||||
enquiryId: string,
|
||||
attachmentId: string,
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
|
||||
const [attachment] = await db
|
||||
.select()
|
||||
.from(crmEnquiryAttachments)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryAttachments.id, attachmentId),
|
||||
eq(crmEnquiryAttachments.organizationId, organizationId),
|
||||
eq(crmEnquiryAttachments.enquiryId, enquiryId),
|
||||
isNull(crmEnquiryAttachments.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!attachment) {
|
||||
throw new AuthError('Attachment not found', 404);
|
||||
}
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
export async function uploadPurchaseOrderAttachment(input: {
|
||||
enquiryId: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
file: {
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
};
|
||||
description?: string | null;
|
||||
accessContext?: EnquiryAccessContext;
|
||||
}) {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(
|
||||
input.enquiryId,
|
||||
input.organizationId,
|
||||
input.accessContext
|
||||
);
|
||||
|
||||
if (enquiry.pipelineStage !== 'closed_won') {
|
||||
throw new AuthError('PO attachment is available only for won enquiries', 400);
|
||||
}
|
||||
|
||||
if (!ALLOWED_PO_CONTENT_TYPES.has(input.file.type)) {
|
||||
throw new AuthError('Unsupported PO attachment file type', 400);
|
||||
}
|
||||
|
||||
const storageProvider = getStorageProvider();
|
||||
const safeFileName = input.file.name.replace(/[^a-zA-Z0-9._-]/g, '-');
|
||||
const storageKey = buildOrganizationStorageKey(input.organizationId, [
|
||||
'enquiries',
|
||||
input.enquiryId,
|
||||
'purchase-orders',
|
||||
`${Date.now()}-${safeFileName}`
|
||||
]);
|
||||
const stored = await storageProvider.putObject({
|
||||
key: storageKey,
|
||||
body: input.file.buffer,
|
||||
contentType: input.file.type,
|
||||
fileName: safeFileName
|
||||
});
|
||||
const [created] = await db
|
||||
.insert(crmEnquiryAttachments)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: input.organizationId,
|
||||
enquiryId: input.enquiryId,
|
||||
category: PURCHASE_ORDER_ATTACHMENT_CATEGORY,
|
||||
fileName: safeFileName,
|
||||
originalFileName: input.file.name,
|
||||
storageProvider: stored.provider,
|
||||
storageKey: stored.key,
|
||||
fileSize: stored.size,
|
||||
fileType: input.file.type,
|
||||
description: input.description?.trim() || null,
|
||||
uploadedBy: input.userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
await auditAction({
|
||||
organizationId: input.organizationId,
|
||||
branchId: enquiry.branchId,
|
||||
userId: input.userId,
|
||||
entityType: 'crm_enquiry',
|
||||
entityId: input.enquiryId,
|
||||
action: 'upload_po',
|
||||
afterData: {
|
||||
attachmentId: created.id,
|
||||
poNumber: enquiry.poNumber,
|
||||
fileName: created.fileName,
|
||||
fileType: created.fileType
|
||||
}
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function listCustomerEnquiryRelations(
|
||||
customerId: string,
|
||||
organizationId: string,
|
||||
@@ -1405,33 +1748,7 @@ export async function syncEnquiryPipelineStageFromQuotationStatus(
|
||||
organizationId: string,
|
||||
quotationStatusCode: string | null
|
||||
) {
|
||||
if (!quotationStatusCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStage: EnquiryPipelineStage | null = CLOSED_WON_QUOTATION_STATUS_CODES.has(
|
||||
quotationStatusCode
|
||||
)
|
||||
? 'closed_won'
|
||||
: CLOSED_LOST_QUOTATION_STATUS_CODES.has(quotationStatusCode)
|
||||
? 'closed_lost'
|
||||
: null;
|
||||
|
||||
if (!nextStage) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
pipelineStage: nextStage,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.id, enquiryId),
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
)
|
||||
);
|
||||
void enquiryId;
|
||||
void organizationId;
|
||||
void quotationStatusCode;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'drizzle-orm';
|
||||
import {
|
||||
crmCustomers,
|
||||
crmEnquiries,
|
||||
crmEnquiryCustomers,
|
||||
crmQuotationCustomers,
|
||||
crmQuotations
|
||||
@@ -24,7 +25,13 @@ import {
|
||||
PROJECT_PARTY_OPTION_CATEGORY,
|
||||
resolveProjectPartyRole
|
||||
} from '@/features/crm/shared/project-party';
|
||||
import type { RevenueAttributionFilters, RevenueAttributionSummary } from './types';
|
||||
import type {
|
||||
OutcomeBreakdownRow,
|
||||
OutcomeRevenueFilters,
|
||||
OutcomeRevenueRecord,
|
||||
RevenueAttributionFilters,
|
||||
RevenueAttributionSummary
|
||||
} from './types';
|
||||
|
||||
type SupportedRevenueRole = 'end_customer' | 'billing_customer' | 'contractor' | 'consultant';
|
||||
|
||||
@@ -378,3 +385,174 @@ export async function getRevenueByConsultant(
|
||||
) {
|
||||
return getRevenueByRole(organizationId, 'consultant', filters);
|
||||
}
|
||||
|
||||
function buildOutcomeRevenueFilters(
|
||||
organizationId: string,
|
||||
stage: 'closed_won' | 'closed_lost',
|
||||
filters: OutcomeRevenueFilters
|
||||
): SQL[] {
|
||||
const dateColumn = stage === 'closed_won' ? crmEnquiries.closedWonAt : crmEnquiries.closedLostAt;
|
||||
|
||||
return [
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt),
|
||||
eq(crmEnquiries.pipelineStage, stage),
|
||||
...(filters.branch ? [eq(crmEnquiries.branchId, filters.branch)] : []),
|
||||
...(filters.salesman ? [eq(crmEnquiries.assignedToUserId, filters.salesman)] : []),
|
||||
...(filters.productType ? [eq(crmEnquiries.productType, filters.productType)] : []),
|
||||
...(filters.dateFrom ? [gte(dateColumn, new Date(filters.dateFrom))] : []),
|
||||
...(filters.dateTo ? [lte(dateColumn, new Date(filters.dateTo))] : [])
|
||||
];
|
||||
}
|
||||
|
||||
async function getOutcomeRevenueRecords(
|
||||
organizationId: string,
|
||||
stage: 'closed_won' | 'closed_lost',
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
): Promise<OutcomeRevenueRecord[]> {
|
||||
const whereFilters = buildOutcomeRevenueFilters(organizationId, stage, filters);
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
const enquiries = await db.select().from(crmEnquiries).where(where);
|
||||
|
||||
if (!enquiries.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const enquiryIds = enquiries.map((row) => row.id);
|
||||
const quotationRows = await db
|
||||
.select({
|
||||
enquiryId: crmQuotations.enquiryId,
|
||||
totalAmount: crmQuotations.totalAmount
|
||||
})
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
inArray(crmQuotations.enquiryId, enquiryIds),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
);
|
||||
const quotationTotals = new Map<string, number>();
|
||||
|
||||
for (const row of quotationRows) {
|
||||
if (!row.enquiryId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
quotationTotals.set(row.enquiryId, (quotationTotals.get(row.enquiryId) ?? 0) + row.totalAmount);
|
||||
}
|
||||
|
||||
return enquiries.map((row) => ({
|
||||
enquiryId: row.id,
|
||||
customerId: row.customerId,
|
||||
branchId: row.branchId,
|
||||
productType: row.productType,
|
||||
assignedToUserId: row.assignedToUserId,
|
||||
revenue: stage === 'closed_won' ? row.poAmount ?? quotationTotals.get(row.id) ?? 0 : quotationTotals.get(row.id) ?? 0,
|
||||
closedAt:
|
||||
(stage === 'closed_won' ? row.closedWonAt : row.closedLostAt)?.toISOString() ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getWonRevenue(
|
||||
organizationId: string,
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
) {
|
||||
return getOutcomeRevenueRecords(organizationId, 'closed_won', filters);
|
||||
}
|
||||
|
||||
export async function getLostRevenue(
|
||||
organizationId: string,
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
) {
|
||||
return getOutcomeRevenueRecords(organizationId, 'closed_lost', filters);
|
||||
}
|
||||
|
||||
export async function getLostByReason(
|
||||
organizationId: string,
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
): Promise<OutcomeBreakdownRow[]> {
|
||||
const records = await getLostRevenue(organizationId, filters);
|
||||
const enquiryIds = records.map((row) => row.enquiryId);
|
||||
|
||||
if (!enquiryIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [enquiries, lostReasonOptions] = await Promise.all([
|
||||
db
|
||||
.select({ id: crmEnquiries.id, lostReason: crmEnquiries.lostReason })
|
||||
.from(crmEnquiries)
|
||||
.where(inArray(crmEnquiries.id, enquiryIds)),
|
||||
getActiveOptionsByCategory('crm_lost_reason', { organizationId })
|
||||
]);
|
||||
const lostReasonMap = new Map(
|
||||
lostReasonOptions.flatMap((item) => [
|
||||
[item.id, item.label] as const,
|
||||
[item.code, item.label] as const
|
||||
])
|
||||
);
|
||||
const grouped = new Map<string, OutcomeBreakdownRow>();
|
||||
|
||||
for (const row of records) {
|
||||
const enquiry = enquiries.find((item) => item.id === row.enquiryId);
|
||||
const key = enquiry?.lostReason ?? 'unknown';
|
||||
const current = grouped.get(key) ?? {
|
||||
key,
|
||||
label: lostReasonMap.get(key) ?? key,
|
||||
count: 0,
|
||||
revenue: 0
|
||||
};
|
||||
current.count += 1;
|
||||
current.revenue += row.revenue;
|
||||
grouped.set(key, current);
|
||||
}
|
||||
|
||||
return [...grouped.values()].sort((a, b) => b.count - a.count || b.revenue - a.revenue);
|
||||
}
|
||||
|
||||
export async function getLostByCompetitor(
|
||||
organizationId: string,
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
): Promise<OutcomeBreakdownRow[]> {
|
||||
const records = await getLostRevenue(organizationId, filters);
|
||||
const enquiryIds = records.map((row) => row.enquiryId);
|
||||
|
||||
if (!enquiryIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const enquiries = await db
|
||||
.select({ id: crmEnquiries.id, lostCompetitor: crmEnquiries.lostCompetitor })
|
||||
.from(crmEnquiries)
|
||||
.where(inArray(crmEnquiries.id, enquiryIds));
|
||||
const grouped = new Map<string, OutcomeBreakdownRow>();
|
||||
|
||||
for (const row of records) {
|
||||
const enquiry = enquiries.find((item) => item.id === row.enquiryId);
|
||||
const key = enquiry?.lostCompetitor?.trim() || 'unknown';
|
||||
const current = grouped.get(key) ?? { key, label: key, count: 0, revenue: 0 };
|
||||
current.count += 1;
|
||||
current.revenue += row.revenue;
|
||||
grouped.set(key, current);
|
||||
}
|
||||
|
||||
return [...grouped.values()].sort((a, b) => b.count - a.count || b.revenue - a.revenue);
|
||||
}
|
||||
|
||||
export async function getWinRate(
|
||||
organizationId: string,
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
) {
|
||||
const [wonRows, lostRows] = await Promise.all([
|
||||
getWonRevenue(organizationId, filters),
|
||||
getLostRevenue(organizationId, filters)
|
||||
]);
|
||||
const denominator = wonRows.length + lostRows.length;
|
||||
|
||||
if (denominator === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round((wonRows.length / denominator) * 10000) / 100;
|
||||
}
|
||||
|
||||
@@ -20,3 +20,28 @@ export interface RevenueAttributionSummary {
|
||||
quotationCount: number;
|
||||
quotationIds: string[];
|
||||
}
|
||||
|
||||
export interface OutcomeRevenueFilters {
|
||||
dateFrom?: string | null;
|
||||
dateTo?: string | null;
|
||||
branch?: string | null;
|
||||
salesman?: string | null;
|
||||
productType?: string | null;
|
||||
}
|
||||
|
||||
export interface OutcomeRevenueRecord {
|
||||
enquiryId: string;
|
||||
customerId: string;
|
||||
branchId: string | null;
|
||||
productType: string;
|
||||
assignedToUserId: string | null;
|
||||
revenue: number;
|
||||
closedAt: string | null;
|
||||
}
|
||||
|
||||
export interface OutcomeBreakdownRow {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user