task-k.2
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import { and, asc, count, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import {
|
||||
crmCustomers,
|
||||
crmEnquiries,
|
||||
crmEnquiryFollowups,
|
||||
crmQuotations,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||
import { db } from '@/lib/db';
|
||||
import type { CrmSharedReportFilters } from '../../api/types';
|
||||
import type {
|
||||
ParsedCrmReportFilters,
|
||||
PipelineDatasetRecord,
|
||||
ResolvedReportContext
|
||||
} from '../types';
|
||||
|
||||
function buildRequiredFilters(filters: CrmSharedReportFilters): ParsedCrmReportFilters {
|
||||
return {
|
||||
dateFrom: filters.dateFrom ?? null,
|
||||
dateTo: filters.dateTo ?? null,
|
||||
branch: filters.branch ?? null,
|
||||
productType: filters.productType ?? null,
|
||||
sales: filters.sales ?? null,
|
||||
customer: filters.customer ?? null,
|
||||
customerOwner: filters.customerOwner ?? null,
|
||||
leadSource: filters.leadSource ?? null,
|
||||
pipelineStage: filters.pipelineStage ?? null,
|
||||
outcome: filters.outcome ?? null,
|
||||
lostReason: filters.lostReason ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function getDateRange(filters: ParsedCrmReportFilters) {
|
||||
const start = filters.dateFrom ? new Date(`${filters.dateFrom}T00:00:00.000Z`) : null;
|
||||
const end = filters.dateTo ? new Date(`${filters.dateTo}T23:59:59.999Z`) : null;
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function matchesDateRange(value: Date, start: Date | null, end: Date | null) {
|
||||
if (start && value < start) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (end && value > end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isOpenOutcome(stage: string) {
|
||||
return stage !== 'closed_won' && stage !== 'closed_lost';
|
||||
}
|
||||
|
||||
function canAccessRecord(
|
||||
context: ResolvedReportContext,
|
||||
row: {
|
||||
branchId: string | null;
|
||||
productType: string;
|
||||
createdBy: string;
|
||||
assignedToUserId: string | null;
|
||||
}
|
||||
) {
|
||||
if (!hasBranchScopeAccess(context, row.branchId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasProductScopeAccess(context, row.productType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.membershipRole === 'admin' || context.ownershipScope !== 'own') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
|
||||
}
|
||||
|
||||
export async function listPipelineReportDataset(
|
||||
context: ResolvedReportContext,
|
||||
filters: CrmSharedReportFilters
|
||||
): Promise<{
|
||||
filters: ParsedCrmReportFilters;
|
||||
rows: PipelineDatasetRecord[];
|
||||
}> {
|
||||
const requiredFilters = buildRequiredFilters(filters);
|
||||
const { start, end } = getDateRange(requiredFilters);
|
||||
const [branches, productTypes, leadSources, enquiryRows, quotationRows, followupRows] =
|
||||
await Promise.all([
|
||||
getUserBranches(),
|
||||
getActiveOptionsByCategory('crm_product_type', { organizationId: context.organizationId }),
|
||||
getActiveOptionsByCategory('crm_lead_channel', { organizationId: context.organizationId }),
|
||||
db
|
||||
.select({
|
||||
id: crmEnquiries.id,
|
||||
code: crmEnquiries.code,
|
||||
customerId: crmEnquiries.customerId,
|
||||
branchId: crmEnquiries.branchId,
|
||||
productType: crmEnquiries.productType,
|
||||
leadChannel: crmEnquiries.leadChannel,
|
||||
pipelineStage: crmEnquiries.pipelineStage,
|
||||
createdAt: crmEnquiries.createdAt,
|
||||
createdBy: crmEnquiries.createdBy,
|
||||
assignedToUserId: crmEnquiries.assignedToUserId,
|
||||
estimatedValue: crmEnquiries.estimatedValue,
|
||||
poAmount: crmEnquiries.poAmount,
|
||||
closedWonAt: crmEnquiries.closedWonAt,
|
||||
closedLostAt: crmEnquiries.closedLostAt,
|
||||
lostReason: crmEnquiries.lostReason,
|
||||
customerName: crmCustomers.name,
|
||||
customerOwnerUserId: crmCustomers.ownerUserId
|
||||
})
|
||||
.from(crmEnquiries)
|
||||
.innerJoin(crmCustomers, eq(crmCustomers.id, crmEnquiries.customerId))
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.organizationId, context.organizationId),
|
||||
isNull(crmEnquiries.deletedAt),
|
||||
eq(crmEnquiries.isActive, true),
|
||||
isNull(crmCustomers.deletedAt),
|
||||
eq(crmCustomers.isActive, true)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmEnquiries.createdAt)),
|
||||
db
|
||||
.select({
|
||||
enquiryId: crmQuotations.enquiryId,
|
||||
quotationCount: count(crmQuotations.id),
|
||||
quotationValue: sql<number>`coalesce(sum(${crmQuotations.totalAmount}), 0)`
|
||||
})
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.organizationId, context.organizationId),
|
||||
isNull(crmQuotations.deletedAt),
|
||||
eq(crmQuotations.isActive, true),
|
||||
sql`${crmQuotations.enquiryId} is not null`
|
||||
)
|
||||
)
|
||||
.groupBy(crmQuotations.enquiryId),
|
||||
db
|
||||
.select({
|
||||
enquiryId: crmEnquiryFollowups.enquiryId,
|
||||
lastFollowupDate: sql<Date | null>`max(${crmEnquiryFollowups.followupDate})`
|
||||
})
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmEnquiryFollowups.enquiryId)
|
||||
]);
|
||||
|
||||
const userIds = [
|
||||
...new Set(
|
||||
enquiryRows.flatMap((row) => [
|
||||
row.createdBy,
|
||||
row.assignedToUserId,
|
||||
row.customerOwnerUserId
|
||||
]).filter((value): value is string => Boolean(value))
|
||||
)
|
||||
];
|
||||
const userRows = userIds.length
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, userIds))
|
||||
: [];
|
||||
|
||||
const branchLabelMap = new Map(branches.map((item) => [item.id, item.name]));
|
||||
const productTypeLabelMap = new Map(productTypes.map((item) => [item.id, item.label]));
|
||||
const leadSourceLabelMap = new Map(leadSources.map((item) => [item.id, item.label]));
|
||||
const quotationMap = new Map(
|
||||
quotationRows
|
||||
.filter((row): row is typeof row & { enquiryId: string } => typeof row.enquiryId === 'string')
|
||||
.map((row) => [
|
||||
row.enquiryId,
|
||||
{
|
||||
quotationCount: Number(row.quotationCount ?? 0),
|
||||
quotationValue: Number(row.quotationValue ?? 0)
|
||||
}
|
||||
])
|
||||
);
|
||||
const followupMap = new Map(followupRows.map((row) => [row.enquiryId, row.lastFollowupDate]));
|
||||
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
||||
|
||||
const rows = enquiryRows
|
||||
.filter((row) => canAccessRecord(context, row))
|
||||
.filter((row) => matchesDateRange(row.createdAt, start, end))
|
||||
.filter((row) => (requiredFilters.branch ? row.branchId === requiredFilters.branch : true))
|
||||
.filter((row) => (requiredFilters.productType ? row.productType === requiredFilters.productType : true))
|
||||
.filter((row) => (requiredFilters.sales ? row.assignedToUserId === requiredFilters.sales : true))
|
||||
.filter((row) => (requiredFilters.customer ? row.customerId === requiredFilters.customer : true))
|
||||
.filter((row) =>
|
||||
requiredFilters.customerOwner ? row.customerOwnerUserId === requiredFilters.customerOwner : true
|
||||
)
|
||||
.filter((row) => (requiredFilters.leadSource ? row.leadChannel === requiredFilters.leadSource : true))
|
||||
.filter((row) => (requiredFilters.pipelineStage ? row.pipelineStage === requiredFilters.pipelineStage : true))
|
||||
.filter((row) => {
|
||||
if (!requiredFilters.outcome) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (requiredFilters.outcome === 'won') {
|
||||
return row.pipelineStage === 'closed_won';
|
||||
}
|
||||
|
||||
if (requiredFilters.outcome === 'lost') {
|
||||
return row.pipelineStage === 'closed_lost';
|
||||
}
|
||||
|
||||
return isOpenOutcome(row.pipelineStage);
|
||||
})
|
||||
.filter((row) => (requiredFilters.lostReason ? row.lostReason === requiredFilters.lostReason : true))
|
||||
.map<PipelineDatasetRecord>((row) => {
|
||||
const quotation = quotationMap.get(row.id);
|
||||
|
||||
return {
|
||||
enquiryId: row.id,
|
||||
enquiryCode: row.code,
|
||||
customerId: row.customerId,
|
||||
customerName: row.customerName,
|
||||
branchId: row.branchId,
|
||||
branchLabel: row.branchId ? (branchLabelMap.get(row.branchId) ?? row.branchId) : '-',
|
||||
productTypeId: row.productType,
|
||||
productTypeLabel: productTypeLabelMap.get(row.productType) ?? row.productType ?? '-',
|
||||
leadSourceId: row.leadChannel,
|
||||
leadSourceLabel: row.leadChannel
|
||||
? (leadSourceLabelMap.get(row.leadChannel) ?? row.leadChannel)
|
||||
: '-',
|
||||
pipelineStage: row.pipelineStage,
|
||||
createdAt: row.createdAt,
|
||||
createdByUserId: row.createdBy,
|
||||
createdByName: userMap.get(row.createdBy) ?? row.createdBy,
|
||||
assignedToUserId: row.assignedToUserId,
|
||||
assignedToName: row.assignedToUserId ? (userMap.get(row.assignedToUserId) ?? row.assignedToUserId) : null,
|
||||
customerOwnerUserId: row.customerOwnerUserId,
|
||||
customerOwnerName: row.customerOwnerUserId
|
||||
? (userMap.get(row.customerOwnerUserId) ?? row.customerOwnerUserId)
|
||||
: '-',
|
||||
quotationCount: quotation?.quotationCount ?? 0,
|
||||
quotationValue: quotation?.quotationValue ?? 0,
|
||||
estimatedValue: Number(row.estimatedValue ?? 0),
|
||||
poAmount: Number(row.poAmount ?? 0),
|
||||
lastFollowupDate: followupMap.get(row.id) ?? null,
|
||||
closedWonAt: row.closedWonAt,
|
||||
closedLostAt: row.closedLostAt,
|
||||
lostReason: row.lostReason
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
filters: requiredFilters,
|
||||
rows
|
||||
};
|
||||
}
|
||||
@@ -28,17 +28,19 @@ export async function auditReportExport(input: {
|
||||
reportCode: string;
|
||||
format: 'csv' | 'xls';
|
||||
filters?: Record<string, unknown>;
|
||||
recordCount?: number;
|
||||
}) {
|
||||
await auditAction({
|
||||
organizationId: input.organizationId,
|
||||
userId: input.userId,
|
||||
entityType: 'crm_report',
|
||||
entityId: input.reportCode,
|
||||
action: input.format === 'xls' ? 'export_excel' : 'export_csv',
|
||||
action: input.format === 'xls' ? 'export_pipeline_report_excel' : 'export_pipeline_report_csv',
|
||||
afterData: {
|
||||
reportCode: input.reportCode,
|
||||
filters: input.filters ?? {},
|
||||
userId: input.userId
|
||||
userId: input.userId,
|
||||
recordCount: input.recordCount ?? 0
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
|
||||
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||
import type { CrmReportFilterMetadata } from '../../api/types';
|
||||
import type { CrmReportFilterMetadata, CrmSharedReportFilters } from '../../api/types';
|
||||
import type { ResolvedReportContext } from '../types';
|
||||
|
||||
const PIPELINE_STAGE_OPTIONS = [
|
||||
@@ -69,3 +69,19 @@ export async function buildSharedReportFilters(
|
||||
lostReasons: lostReasons.map((item) => ({ id: item.id, code: item.code, label: item.label }))
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSharedReportFilters(searchParams: URLSearchParams): CrmSharedReportFilters {
|
||||
return {
|
||||
dateFrom: searchParams.get('dateFrom'),
|
||||
dateTo: searchParams.get('dateTo'),
|
||||
branch: searchParams.get('branch'),
|
||||
productType: searchParams.get('productType'),
|
||||
sales: searchParams.get('sales'),
|
||||
customer: searchParams.get('customer'),
|
||||
customerOwner: searchParams.get('customerOwner'),
|
||||
leadSource: searchParams.get('leadSource'),
|
||||
pipelineStage: searchParams.get('pipelineStage'),
|
||||
outcome: searchParams.get('outcome'),
|
||||
lostReason: searchParams.get('lostReason')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,30 +1,357 @@
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { canViewQuotationPricing } from '@/lib/auth/crm-access';
|
||||
import type {
|
||||
CrmReportCatalogResponse,
|
||||
CrmReportFiltersResponse,
|
||||
CrmReportsResponse
|
||||
CrmReportsResponse,
|
||||
CrmSharedReportFilters,
|
||||
CrmPipelineReportResponse,
|
||||
CrmLeadAgingReportResponse,
|
||||
CrmEnquiryAgingReportResponse
|
||||
} from '../api/types';
|
||||
import { buildReportCatalogGroups } from './builders/catalog-builder';
|
||||
import { resolveCrmAccess } from './context';
|
||||
import { listReportRegistry } from './datasets/catalog';
|
||||
import { listPipelineReportDataset } from './datasets/pipeline-report.dataset';
|
||||
import { buildSharedReportFilters } from './filters/shared';
|
||||
import type { ResolvedReportContext } from './types';
|
||||
import type { ParsedCrmReportFilters, PipelineDatasetRecord, ResolvedReportContext } from './types';
|
||||
|
||||
export function buildResolvedReportContext(input: Parameters<typeof resolveCrmAccess>[0]) {
|
||||
return resolveCrmAccess(input);
|
||||
}
|
||||
|
||||
async function auditReportView(context: ResolvedReportContext, reportCode: string, filters?: Record<string, unknown>) {
|
||||
function getDayDifference(start: Date, end: Date) {
|
||||
const milliseconds = end.getTime() - start.getTime();
|
||||
return Math.max(0, Math.floor(milliseconds / 86_400_000));
|
||||
}
|
||||
|
||||
function toPercent(numerator: number, denominator: number) {
|
||||
if (denominator <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Number(((numerator / denominator) * 100).toFixed(2));
|
||||
}
|
||||
|
||||
function average(values: number[]) {
|
||||
if (!values.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Number((values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(2));
|
||||
}
|
||||
|
||||
function sumValues(items: PipelineDatasetRecord[], selector: (row: PipelineDatasetRecord) => number) {
|
||||
return items.reduce((sum, row) => sum + selector(row), 0);
|
||||
}
|
||||
|
||||
function isConvertedLead(row: PipelineDatasetRecord) {
|
||||
return row.pipelineStage !== 'lead' || Boolean(row.assignedToUserId);
|
||||
}
|
||||
|
||||
function isOpenEnquiry(row: PipelineDatasetRecord) {
|
||||
return row.pipelineStage === 'enquiry';
|
||||
}
|
||||
|
||||
function buildLeadPipelineRows(rows: PipelineDatasetRecord[]) {
|
||||
const grouped = new Map<string, CrmPipelineReportResponse['leadPipeline'][number]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = [
|
||||
row.leadSourceLabel,
|
||||
row.customerOwnerName,
|
||||
row.createdByName,
|
||||
row.branchLabel,
|
||||
row.productTypeLabel
|
||||
].join('|');
|
||||
const current =
|
||||
grouped.get(key) ??
|
||||
{
|
||||
leadSourceLabel: row.leadSourceLabel,
|
||||
customerOwnerName: row.customerOwnerName,
|
||||
createdByName: row.createdByName,
|
||||
branchLabel: row.branchLabel,
|
||||
productTypeLabel: row.productTypeLabel,
|
||||
totalLeads: 0,
|
||||
newLeads: 0,
|
||||
assignedLeads: 0,
|
||||
unassignedLeads: 0,
|
||||
convertedLeads: 0
|
||||
};
|
||||
|
||||
current.totalLeads += 1;
|
||||
current.newLeads += 1;
|
||||
|
||||
if (row.assignedToUserId) {
|
||||
current.assignedLeads += 1;
|
||||
} else {
|
||||
current.unassignedLeads += 1;
|
||||
}
|
||||
|
||||
if (isConvertedLead(row)) {
|
||||
current.convertedLeads += 1;
|
||||
}
|
||||
|
||||
grouped.set(key, current);
|
||||
}
|
||||
|
||||
return [...grouped.values()].toSorted((left, right) => right.totalLeads - left.totalLeads);
|
||||
}
|
||||
|
||||
function buildEnquiryPipelineRows(rows: PipelineDatasetRecord[]) {
|
||||
const grouped = new Map<string, CrmPipelineReportResponse['enquiryPipeline'][number]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = [
|
||||
row.assignedToName ?? 'Unassigned',
|
||||
row.branchLabel,
|
||||
row.productTypeLabel,
|
||||
row.customerOwnerName
|
||||
].join('|');
|
||||
const current =
|
||||
grouped.get(key) ??
|
||||
{
|
||||
salesmanName: row.assignedToName ?? 'Unassigned',
|
||||
branchLabel: row.branchLabel,
|
||||
productTypeLabel: row.productTypeLabel,
|
||||
customerOwnerName: row.customerOwnerName,
|
||||
openEnquiries: 0,
|
||||
convertedToQuotation: 0,
|
||||
won: 0,
|
||||
lost: 0
|
||||
};
|
||||
|
||||
if (isOpenEnquiry(row)) {
|
||||
current.openEnquiries += 1;
|
||||
}
|
||||
|
||||
if (row.quotationCount > 0) {
|
||||
current.convertedToQuotation += 1;
|
||||
}
|
||||
|
||||
if (row.pipelineStage === 'closed_won') {
|
||||
current.won += 1;
|
||||
}
|
||||
|
||||
if (row.pipelineStage === 'closed_lost') {
|
||||
current.lost += 1;
|
||||
}
|
||||
|
||||
grouped.set(key, current);
|
||||
}
|
||||
|
||||
return [...grouped.values()].toSorted(
|
||||
(left, right) =>
|
||||
right.openEnquiries + right.convertedToQuotation + right.won + right.lost -
|
||||
(left.openEnquiries + left.convertedToQuotation + left.won + left.lost)
|
||||
);
|
||||
}
|
||||
|
||||
function buildLeadConversionRows(rows: PipelineDatasetRecord[]) {
|
||||
const grouped = new Map<string, CrmPipelineReportResponse['leadConversion'][number]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = [
|
||||
row.leadSourceLabel,
|
||||
row.branchLabel,
|
||||
row.productTypeLabel,
|
||||
row.createdByName
|
||||
].join('|');
|
||||
const current =
|
||||
grouped.get(key) ??
|
||||
{
|
||||
leadSourceLabel: row.leadSourceLabel,
|
||||
branchLabel: row.branchLabel,
|
||||
productTypeLabel: row.productTypeLabel,
|
||||
marketingUserName: row.createdByName,
|
||||
totalLeads: 0,
|
||||
convertedEnquiries: 0,
|
||||
conversionRate: 0
|
||||
};
|
||||
|
||||
current.totalLeads += 1;
|
||||
if (isConvertedLead(row)) {
|
||||
current.convertedEnquiries += 1;
|
||||
}
|
||||
|
||||
grouped.set(key, current);
|
||||
}
|
||||
|
||||
return [...grouped.values()]
|
||||
.map((row) => ({
|
||||
...row,
|
||||
conversionRate: toPercent(row.convertedEnquiries, row.totalLeads)
|
||||
}))
|
||||
.toSorted((left, right) => right.totalLeads - left.totalLeads);
|
||||
}
|
||||
|
||||
function buildEnquiryConversionRows(rows: PipelineDatasetRecord[]) {
|
||||
const enquiryRows = rows.filter((row) => row.pipelineStage !== 'lead' || row.assignedToUserId);
|
||||
const grouped = new Map<string, CrmPipelineReportResponse['enquiryConversion'][number]>();
|
||||
|
||||
for (const row of enquiryRows) {
|
||||
const key = [(row.assignedToName ?? 'Unassigned'), row.branchLabel, row.productTypeLabel].join('|');
|
||||
const current =
|
||||
grouped.get(key) ??
|
||||
{
|
||||
salesmanName: row.assignedToName ?? 'Unassigned',
|
||||
branchLabel: row.branchLabel,
|
||||
productTypeLabel: row.productTypeLabel,
|
||||
totalEnquiries: 0,
|
||||
enquiriesWithQuotation: 0,
|
||||
conversionRate: 0
|
||||
};
|
||||
|
||||
current.totalEnquiries += 1;
|
||||
if (row.quotationCount > 0) {
|
||||
current.enquiriesWithQuotation += 1;
|
||||
}
|
||||
|
||||
grouped.set(key, current);
|
||||
}
|
||||
|
||||
return [...grouped.values()]
|
||||
.map((row) => ({
|
||||
...row,
|
||||
conversionRate: toPercent(row.enquiriesWithQuotation, row.totalEnquiries)
|
||||
}))
|
||||
.toSorted((left, right) => right.totalEnquiries - left.totalEnquiries);
|
||||
}
|
||||
|
||||
function buildFunnelRows(rows: PipelineDatasetRecord[], canViewRevenueValues: boolean) {
|
||||
const leadCount = rows.length;
|
||||
const enquiryRows = rows.filter((row) => isConvertedLead(row));
|
||||
const quotationRows = rows.filter((row) => row.quotationCount > 0);
|
||||
const wonRows = rows.filter((row) => row.pipelineStage === 'closed_won');
|
||||
const lostRows = rows.filter((row) => row.pipelineStage === 'closed_lost');
|
||||
|
||||
const valueOrNull = (value: number) => (canViewRevenueValues ? value : null);
|
||||
|
||||
return [
|
||||
{
|
||||
stageKey: 'lead' as const,
|
||||
stageLabel: 'Lead',
|
||||
count: leadCount,
|
||||
value: valueOrNull(sumValues(rows, (row) => row.estimatedValue)),
|
||||
conversionRate: null
|
||||
},
|
||||
{
|
||||
stageKey: 'enquiry' as const,
|
||||
stageLabel: 'Enquiry',
|
||||
count: enquiryRows.length,
|
||||
value: valueOrNull(sumValues(enquiryRows, (row) => row.estimatedValue)),
|
||||
conversionRate: toPercent(enquiryRows.length, leadCount)
|
||||
},
|
||||
{
|
||||
stageKey: 'quotation' as const,
|
||||
stageLabel: 'Quotation',
|
||||
count: quotationRows.length,
|
||||
value: valueOrNull(sumValues(quotationRows, (row) => row.quotationValue)),
|
||||
conversionRate: toPercent(quotationRows.length, enquiryRows.length)
|
||||
},
|
||||
{
|
||||
stageKey: 'closed_won' as const,
|
||||
stageLabel: 'Closed Won',
|
||||
count: wonRows.length,
|
||||
value: valueOrNull(sumValues(wonRows, (row) => row.poAmount || row.quotationValue || row.estimatedValue)),
|
||||
conversionRate: toPercent(wonRows.length, quotationRows.length)
|
||||
},
|
||||
{
|
||||
stageKey: 'closed_lost' as const,
|
||||
stageLabel: 'Closed Lost',
|
||||
count: lostRows.length,
|
||||
value: valueOrNull(sumValues(lostRows, (row) => row.estimatedValue || row.quotationValue)),
|
||||
conversionRate: toPercent(lostRows.length, quotationRows.length)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function buildLeadAging(rows: PipelineDatasetRecord[], filters: ParsedCrmReportFilters): CrmLeadAgingReportResponse {
|
||||
const now = new Date();
|
||||
const leadRows = rows
|
||||
.filter((row) => row.pipelineStage === 'lead')
|
||||
.map((row) => ({
|
||||
leadId: row.enquiryId,
|
||||
leadCode: row.enquiryCode,
|
||||
customerName: row.customerName,
|
||||
assignedUserName: row.assignedToName,
|
||||
createdDate: row.createdAt.toISOString(),
|
||||
agingDays: getDayDifference(row.createdAt, now)
|
||||
}))
|
||||
.toSorted((left, right) => right.agingDays - left.agingDays);
|
||||
const ages = leadRows.map((row) => row.agingDays);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'CRM lead aging report loaded successfully',
|
||||
filters,
|
||||
summary: {
|
||||
leadCount: leadRows.length,
|
||||
averageAgingDays: average(ages),
|
||||
maximumAgingDays: ages.length ? Math.max(...ages) : 0
|
||||
},
|
||||
buckets: [
|
||||
{ key: '0_7', label: '0-7 Days', count: leadRows.filter((row) => row.agingDays <= 7).length },
|
||||
{ key: '8_14', label: '8-14 Days', count: leadRows.filter((row) => row.agingDays >= 8 && row.agingDays <= 14).length },
|
||||
{ key: '15_30', label: '15-30 Days', count: leadRows.filter((row) => row.agingDays >= 15 && row.agingDays <= 30).length },
|
||||
{ key: '31_60', label: '31-60 Days', count: leadRows.filter((row) => row.agingDays >= 31 && row.agingDays <= 60).length },
|
||||
{ key: '60_plus', label: '60+ Days', count: leadRows.filter((row) => row.agingDays > 60).length }
|
||||
],
|
||||
rows: leadRows
|
||||
};
|
||||
}
|
||||
|
||||
function buildEnquiryAging(
|
||||
rows: PipelineDatasetRecord[],
|
||||
filters: ParsedCrmReportFilters
|
||||
): CrmEnquiryAgingReportResponse {
|
||||
const now = new Date();
|
||||
const enquiryRows = rows
|
||||
.filter((row) => row.pipelineStage === 'enquiry')
|
||||
.map((row) => ({
|
||||
enquiryId: row.enquiryId,
|
||||
enquiryCode: row.enquiryCode,
|
||||
customerName: row.customerName,
|
||||
salesmanName: row.assignedToName,
|
||||
lastFollowupDate: row.lastFollowupDate?.toISOString() ?? null,
|
||||
agingDays: getDayDifference(row.createdAt, now)
|
||||
}))
|
||||
.toSorted((left, right) => right.agingDays - left.agingDays);
|
||||
const ages = enquiryRows.map((row) => row.agingDays);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'CRM enquiry aging report loaded successfully',
|
||||
filters,
|
||||
summary: {
|
||||
openEnquiries: enquiryRows.length,
|
||||
averageAgingDays: average(ages),
|
||||
maximumAgingDays: ages.length ? Math.max(...ages) : 0
|
||||
},
|
||||
buckets: [
|
||||
{ key: '0_14', label: '0-14 Days', count: enquiryRows.filter((row) => row.agingDays <= 14).length },
|
||||
{ key: '15_30', label: '15-30 Days', count: enquiryRows.filter((row) => row.agingDays >= 15 && row.agingDays <= 30).length },
|
||||
{ key: '31_60', label: '31-60 Days', count: enquiryRows.filter((row) => row.agingDays >= 31 && row.agingDays <= 60).length },
|
||||
{ key: '60_plus', label: '60+ Days', count: enquiryRows.filter((row) => row.agingDays > 60).length }
|
||||
],
|
||||
rows: enquiryRows
|
||||
};
|
||||
}
|
||||
|
||||
async function auditReportView(context: ResolvedReportContext, reportCode: string, filters?: Record<string, unknown>, recordCount?: number) {
|
||||
await auditAction({
|
||||
organizationId: context.organizationId,
|
||||
userId: context.userId,
|
||||
entityType: 'crm_report',
|
||||
entityId: reportCode,
|
||||
action: 'view',
|
||||
action: 'view_pipeline_report',
|
||||
afterData: {
|
||||
reportCode,
|
||||
filters: filters ?? {},
|
||||
userId: context.userId
|
||||
userId: context.userId,
|
||||
recordCount: recordCount ?? 0
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -76,3 +403,58 @@ export async function getCrmReportFiltersResponse(
|
||||
filters
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCrmPipelineReportResponse(
|
||||
context: ResolvedReportContext,
|
||||
filters: CrmSharedReportFilters
|
||||
): Promise<CrmPipelineReportResponse> {
|
||||
const dataset = await listPipelineReportDataset(context, filters);
|
||||
const canViewRevenueValues = canViewQuotationPricing(context);
|
||||
const response: CrmPipelineReportResponse = {
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'CRM pipeline report loaded successfully',
|
||||
filters: dataset.filters,
|
||||
summary: {
|
||||
totalLeads: dataset.rows.length,
|
||||
openEnquiries: dataset.rows.filter((row) => row.pipelineStage === 'enquiry').length,
|
||||
quotations: dataset.rows.filter((row) => row.quotationCount > 0).length,
|
||||
won: dataset.rows.filter((row) => row.pipelineStage === 'closed_won').length,
|
||||
lost: dataset.rows.filter((row) => row.pipelineStage === 'closed_lost').length,
|
||||
canViewRevenueValues
|
||||
},
|
||||
leadPipeline: buildLeadPipelineRows(dataset.rows),
|
||||
enquiryPipeline: buildEnquiryPipelineRows(dataset.rows),
|
||||
leadConversion: buildLeadConversionRows(dataset.rows),
|
||||
enquiryConversion: buildEnquiryConversionRows(dataset.rows),
|
||||
funnel: buildFunnelRows(dataset.rows, canViewRevenueValues)
|
||||
};
|
||||
|
||||
await auditReportView(context, 'pipeline_suite', { ...dataset.filters }, dataset.rows.length);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function getCrmLeadAgingReportResponse(
|
||||
context: ResolvedReportContext,
|
||||
filters: CrmSharedReportFilters
|
||||
): Promise<CrmLeadAgingReportResponse> {
|
||||
const dataset = await listPipelineReportDataset(context, filters);
|
||||
const response = buildLeadAging(dataset.rows, dataset.filters);
|
||||
|
||||
await auditReportView(context, 'lead_aging', { ...dataset.filters }, response.rows.length);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function getCrmEnquiryAgingReportResponse(
|
||||
context: ResolvedReportContext,
|
||||
filters: CrmSharedReportFilters
|
||||
): Promise<CrmEnquiryAgingReportResponse> {
|
||||
const dataset = await listPipelineReportDataset(context, filters);
|
||||
const response = buildEnquiryAging(dataset.rows, dataset.filters);
|
||||
|
||||
await auditReportView(context, 'enquiry_aging', { ...dataset.filters }, response.rows.length);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -23,3 +23,46 @@ export interface ReportRegistryRow {
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedCrmReportFilters {
|
||||
dateFrom: string | null;
|
||||
dateTo: string | null;
|
||||
branch: string | null;
|
||||
productType: string | null;
|
||||
sales: string | null;
|
||||
customer: string | null;
|
||||
customerOwner: string | null;
|
||||
leadSource: string | null;
|
||||
pipelineStage: string | null;
|
||||
outcome: string | null;
|
||||
lostReason: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineDatasetRecord {
|
||||
enquiryId: string;
|
||||
enquiryCode: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
branchId: string | null;
|
||||
branchLabel: string;
|
||||
productTypeId: string | null;
|
||||
productTypeLabel: string;
|
||||
leadSourceId: string | null;
|
||||
leadSourceLabel: string;
|
||||
pipelineStage: string;
|
||||
createdAt: Date;
|
||||
createdByUserId: string;
|
||||
createdByName: string;
|
||||
assignedToUserId: string | null;
|
||||
assignedToName: string | null;
|
||||
customerOwnerUserId: string | null;
|
||||
customerOwnerName: string;
|
||||
quotationCount: number;
|
||||
quotationValue: number;
|
||||
estimatedValue: number;
|
||||
poAmount: number;
|
||||
lastFollowupDate: Date | null;
|
||||
closedWonAt: Date | null;
|
||||
closedLostAt: Date | null;
|
||||
lostReason: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user