This commit is contained in:
phaichayon
2026-06-22 21:26:43 +07:00
parent 5c28080e9b
commit 99a4087099
27 changed files with 6863 additions and 17 deletions

View File

@@ -1,11 +1,25 @@
import { queryOptions } from '@tanstack/react-query';
import { getCrmReportCatalog, getCrmReportFilters, getCrmReports } from './service';
import type { CrmSharedReportFilters } from './types';
import {
getCrmEnquiryAgingReport,
getCrmLeadAgingReport,
getCrmPipelineReport,
getCrmReportCatalog,
getCrmReportFilters,
getCrmReports
} from './service';
export const crmReportKeys = {
all: ['crm-reports'] as const,
list: () => [...crmReportKeys.all, 'list'] as const,
catalog: () => [...crmReportKeys.all, 'catalog'] as const,
filters: () => [...crmReportKeys.all, 'filters'] as const
filters: () => [...crmReportKeys.all, 'filters'] as const,
pipelineRoot: () => [...crmReportKeys.all, 'pipeline'] as const,
pipeline: (filters: CrmSharedReportFilters) => [...crmReportKeys.pipelineRoot(), filters] as const,
leadAgingRoot: () => [...crmReportKeys.all, 'lead-aging'] as const,
leadAging: (filters: CrmSharedReportFilters) => [...crmReportKeys.leadAgingRoot(), filters] as const,
enquiryAgingRoot: () => [...crmReportKeys.all, 'enquiry-aging'] as const,
enquiryAging: (filters: CrmSharedReportFilters) => [...crmReportKeys.enquiryAgingRoot(), filters] as const
};
export const crmReportsQueryOptions = () =>
@@ -25,3 +39,21 @@ export const crmReportFiltersQueryOptions = () =>
queryKey: crmReportKeys.filters(),
queryFn: () => getCrmReportFilters()
});
export const crmPipelineReportQueryOptions = (filters: CrmSharedReportFilters) =>
queryOptions({
queryKey: crmReportKeys.pipeline(filters),
queryFn: () => getCrmPipelineReport(filters)
});
export const crmLeadAgingReportQueryOptions = (filters: CrmSharedReportFilters) =>
queryOptions({
queryKey: crmReportKeys.leadAging(filters),
queryFn: () => getCrmLeadAgingReport(filters)
});
export const crmEnquiryAgingReportQueryOptions = (filters: CrmSharedReportFilters) =>
queryOptions({
queryKey: crmReportKeys.enquiryAging(filters),
queryFn: () => getCrmEnquiryAgingReport(filters)
});

View File

@@ -2,9 +2,31 @@ import { apiClient } from '@/lib/api-client';
import type {
CrmReportCatalogResponse,
CrmReportFiltersResponse,
CrmReportsResponse
CrmReportsResponse,
CrmSharedReportFilters,
CrmPipelineReportResponse,
CrmLeadAgingReportResponse,
CrmEnquiryAgingReportResponse
} from './types';
function buildReportQuery(filters: CrmSharedReportFilters) {
const searchParams = new URLSearchParams();
if (filters.dateFrom) searchParams.set('dateFrom', filters.dateFrom);
if (filters.dateTo) searchParams.set('dateTo', filters.dateTo);
if (filters.branch) searchParams.set('branch', filters.branch);
if (filters.productType) searchParams.set('productType', filters.productType);
if (filters.sales) searchParams.set('sales', filters.sales);
if (filters.customer) searchParams.set('customer', filters.customer);
if (filters.customerOwner) searchParams.set('customerOwner', filters.customerOwner);
if (filters.leadSource) searchParams.set('leadSource', filters.leadSource);
if (filters.pipelineStage) searchParams.set('pipelineStage', filters.pipelineStage);
if (filters.outcome) searchParams.set('outcome', filters.outcome);
if (filters.lostReason) searchParams.set('lostReason', filters.lostReason);
return searchParams.toString();
}
export async function getCrmReports() {
return apiClient<CrmReportsResponse>('/crm/reports');
}
@@ -16,3 +38,22 @@ export async function getCrmReportCatalog() {
export async function getCrmReportFilters() {
return apiClient<CrmReportFiltersResponse>('/crm/reports/filters');
}
export async function getCrmPipelineReport(filters: CrmSharedReportFilters) {
const query = buildReportQuery(filters);
return apiClient<CrmPipelineReportResponse>(`/crm/reports/pipeline${query ? `?${query}` : ''}`);
}
export async function getCrmLeadAgingReport(filters: CrmSharedReportFilters) {
const query = buildReportQuery(filters);
return apiClient<CrmLeadAgingReportResponse>(
`/crm/reports/lead-aging${query ? `?${query}` : ''}`
);
}
export async function getCrmEnquiryAgingReport(filters: CrmSharedReportFilters) {
const query = buildReportQuery(filters);
return apiClient<CrmEnquiryAgingReportResponse>(
`/crm/reports/enquiry-aging${query ? `?${query}` : ''}`
);
}

View File

@@ -73,3 +73,128 @@ export interface CrmReportFiltersResponse {
message: string;
filters: CrmReportFilterMetadata;
}
export interface CrmPipelineReportRow {
leadSourceLabel: string;
customerOwnerName: string;
createdByName: string;
branchLabel: string;
productTypeLabel: string;
totalLeads: number;
newLeads: number;
assignedLeads: number;
unassignedLeads: number;
convertedLeads: number;
}
export interface CrmEnquiryPipelineReportRow {
salesmanName: string;
branchLabel: string;
productTypeLabel: string;
customerOwnerName: string;
openEnquiries: number;
convertedToQuotation: number;
won: number;
lost: number;
}
export interface CrmLeadConversionReportRow {
leadSourceLabel: string;
branchLabel: string;
productTypeLabel: string;
marketingUserName: string;
totalLeads: number;
convertedEnquiries: number;
conversionRate: number;
}
export interface CrmEnquiryConversionReportRow {
salesmanName: string;
branchLabel: string;
productTypeLabel: string;
totalEnquiries: number;
enquiriesWithQuotation: number;
conversionRate: number;
}
export interface CrmPipelineFunnelRow {
stageKey: 'lead' | 'enquiry' | 'quotation' | 'closed_won' | 'closed_lost';
stageLabel: string;
count: number;
value: number | null;
conversionRate: number | null;
}
export interface CrmPipelineKpiSummary {
totalLeads: number;
openEnquiries: number;
quotations: number;
won: number;
lost: number;
canViewRevenueValues: boolean;
}
export interface CrmPipelineReportResponse {
success: boolean;
time: string;
message: string;
filters: Required<CrmSharedReportFilters>;
summary: CrmPipelineKpiSummary;
leadPipeline: CrmPipelineReportRow[];
enquiryPipeline: CrmEnquiryPipelineReportRow[];
leadConversion: CrmLeadConversionReportRow[];
enquiryConversion: CrmEnquiryConversionReportRow[];
funnel: CrmPipelineFunnelRow[];
}
export interface CrmAgingBucket {
key: string;
label: string;
count: number;
}
export interface CrmLeadAgingRow {
leadId: string;
leadCode: string;
customerName: string;
assignedUserName: string | null;
createdDate: string;
agingDays: number;
}
export interface CrmLeadAgingReportResponse {
success: boolean;
time: string;
message: string;
filters: Required<CrmSharedReportFilters>;
summary: {
leadCount: number;
averageAgingDays: number;
maximumAgingDays: number;
};
buckets: CrmAgingBucket[];
rows: CrmLeadAgingRow[];
}
export interface CrmEnquiryAgingRow {
enquiryId: string;
enquiryCode: string;
customerName: string;
salesmanName: string | null;
lastFollowupDate: string | null;
agingDays: number;
}
export interface CrmEnquiryAgingReportResponse {
success: boolean;
time: string;
message: string;
filters: Required<CrmSharedReportFilters>;
summary: {
openEnquiries: number;
averageAgingDays: number;
maximumAgingDays: number;
};
buckets: CrmAgingBucket[];
rows: CrmEnquiryAgingRow[];
}

View File

@@ -0,0 +1,242 @@
'use client';
import { useMemo } from 'react';
import { parseAsString, useQueryStates } from 'nuqs';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import type {
CrmEnquiryAgingReportResponse,
CrmLeadAgingReportResponse
} from '../api/types';
import {
crmEnquiryAgingReportQueryOptions,
crmLeadAgingReportQueryOptions,
crmReportFiltersQueryOptions
} from '../api/queries';
import { ReportFilterBar } from './report-filter-bar';
function formatNumber(value: number) {
return new Intl.NumberFormat('en-US').format(value);
}
function AgingReportLayout({
variant,
canExport,
data
}: {
variant: 'lead' | 'enquiry';
canExport: boolean;
data: CrmLeadAgingReportResponse | CrmEnquiryAgingReportResponse;
}) {
const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions());
const summaryCards =
variant === 'lead' && 'leadCount' in data.summary
? [
['Lead Count', formatNumber(data.summary.leadCount)],
['Average Aging', `${data.summary.averageAgingDays} days`],
['Maximum Aging', `${data.summary.maximumAgingDays} days`]
]
: [
[
'Open Enquiries',
formatNumber('openEnquiries' in data.summary ? data.summary.openEnquiries : 0)
],
['Average Aging', `${data.summary.averageAgingDays} days`],
['Maximum Aging', `${data.summary.maximumAgingDays} days`]
];
return (
<div className='space-y-6'>
<ReportFilterBar
filterMetadata={filterData.filters}
filterKeys={
variant === 'lead'
? ['dateFrom', 'dateTo', 'branch', 'productType', 'customerOwner', 'leadSource']
: ['dateFrom', 'dateTo', 'branch', 'productType', 'sales']
}
canExport={canExport}
reportCode={variant === 'lead' ? 'lead_aging' : 'enquiry_aging'}
pageLinks={[
{ href: '/dashboard/crm/reports/pipeline', label: 'Pipeline Suite' },
{ href: '/dashboard/crm/reports/lead-aging', label: 'Lead Aging', active: variant === 'lead' },
{ href: '/dashboard/crm/reports/enquiry-aging', label: 'Enquiry Aging', active: variant === 'enquiry' }
]}
/>
<div className='grid gap-4 md:grid-cols-3'>
{summaryCards.map(([label, value]) => (
<Card key={String(label)}>
<CardHeader className='pb-2'>
<CardDescription>{label}</CardDescription>
</CardHeader>
<CardContent>
<div className='text-2xl font-semibold'>{value}</div>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>{variant === 'lead' ? 'Lead Aging Buckets' : 'Enquiry Aging Buckets'}</CardTitle>
<CardDescription>Bucketed view to spot stagnant records quickly.</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
{data.buckets.map((bucket) => (
<div key={bucket.key} className='rounded-lg border p-4'>
<div className='text-muted-foreground text-sm'>{bucket.label}</div>
<div className='mt-2 text-2xl font-semibold'>{formatNumber(bucket.count)}</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{variant === 'lead' ? 'Lead Drill-down' : 'Enquiry Drill-down'}</CardTitle>
<CardDescription>
{variant === 'lead'
? 'Open leads sorted from oldest to newest.'
: 'Open enquiries with latest follow-up visibility.'}
</CardDescription>
</CardHeader>
<CardContent>
<div className='overflow-x-auto rounded-lg border'>
<Table>
<TableHeader>
<TableRow>
{variant === 'lead' ? (
<>
<TableHead>Lead Number</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Assigned User</TableHead>
<TableHead>Created Date</TableHead>
<TableHead>Aging Days</TableHead>
</>
) : (
<>
<TableHead>Enquiry No</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Salesman</TableHead>
<TableHead>Last Follow-Up</TableHead>
<TableHead>Aging Days</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>
{data.rows.length ? (
data.rows.map((row) => (
<TableRow key={'leadId' in row ? row.leadId : row.enquiryId}>
{'leadId' in row ? (
<>
<TableCell>{row.leadCode}</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.assignedUserName ?? '-'}</TableCell>
<TableCell>{new Date(row.createdDate).toLocaleDateString('en-CA')}</TableCell>
<TableCell>{formatNumber(row.agingDays)}</TableCell>
</>
) : (
<>
<TableCell>{row.enquiryCode}</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.salesmanName ?? '-'}</TableCell>
<TableCell>
{row.lastFollowupDate
? new Date(row.lastFollowupDate).toLocaleDateString('en-CA')
: '-'}
</TableCell>
<TableCell>{formatNumber(row.agingDays)}</TableCell>
</>
)}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className='text-muted-foreground text-center'>
No records found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
function LeadAgingReportInner({ canExport }: { canExport: boolean }) {
const [params] = useQueryStates({
dateFrom: parseAsString,
dateTo: parseAsString,
branch: parseAsString,
productType: parseAsString,
customerOwner: parseAsString,
leadSource: parseAsString
});
const filters = useMemo(
() => ({
dateFrom: params.dateFrom,
dateTo: params.dateTo,
branch: params.branch,
productType: params.productType,
sales: null,
customerOwner: params.customerOwner,
leadSource: params.leadSource
}),
[params]
);
const { data } = useSuspenseQuery(crmLeadAgingReportQueryOptions(filters));
return <AgingReportLayout variant='lead' canExport={canExport} data={data} />;
}
function EnquiryAgingReportInner({ canExport }: { canExport: boolean }) {
const [params] = useQueryStates({
dateFrom: parseAsString,
dateTo: parseAsString,
branch: parseAsString,
productType: parseAsString,
sales: parseAsString
});
const filters = useMemo(
() => ({
dateFrom: params.dateFrom,
dateTo: params.dateTo,
branch: params.branch,
productType: params.productType,
sales: params.sales,
customerOwner: null,
leadSource: null
}),
[params]
);
const { data } = useSuspenseQuery(crmEnquiryAgingReportQueryOptions(filters));
return <AgingReportLayout variant='enquiry' canExport={canExport} data={data} />;
}
export function AgingReportView({
variant,
canExport
}: {
variant: 'lead' | 'enquiry';
canExport: boolean;
}) {
return variant === 'lead' ? (
<LeadAgingReportInner canExport={canExport} />
) : (
<EnquiryAgingReportInner canExport={canExport} />
);
}

View File

@@ -0,0 +1,255 @@
'use client';
import { useMemo } from 'react';
import { parseAsString, useQueryStates } from 'nuqs';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import {
crmPipelineReportQueryOptions,
crmReportFiltersQueryOptions
} from '../api/queries';
import { ReportFilterBar } from './report-filter-bar';
function formatNumber(value: number) {
return new Intl.NumberFormat('en-US').format(value);
}
function formatValue(value: number | null) {
if (value === null) {
return 'Hidden';
}
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}).format(value);
}
function SectionTable({
title,
description,
headers,
rows,
reportCode
}: {
title: string;
description: string;
headers: string[];
rows: string[][];
reportCode?: string;
}) {
return (
<Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
{reportCode ? <div className='text-muted-foreground text-xs'>{reportCode}</div> : null}
</CardHeader>
<CardContent>
<div className='overflow-x-auto rounded-lg border'>
<Table>
<TableHeader>
<TableRow>
{headers.map((header) => (
<TableHead key={header}>{header}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.length ? (
rows.map((row, index) => (
<TableRow key={`${title}-${index}`}>
{row.map((cell, cellIndex) => (
<TableCell key={`${title}-${index}-${cellIndex}`}>{cell}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={headers.length} className='text-muted-foreground text-center'>
No records found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}
export function PipelineReportView({ canExport }: { canExport: boolean }) {
const [params] = useQueryStates({
dateFrom: parseAsString,
dateTo: parseAsString,
branch: parseAsString,
productType: parseAsString,
sales: parseAsString,
customerOwner: parseAsString,
leadSource: parseAsString
});
const filters = useMemo(
() => ({
dateFrom: params.dateFrom,
dateTo: params.dateTo,
branch: params.branch,
productType: params.productType,
sales: params.sales,
customerOwner: params.customerOwner,
leadSource: params.leadSource
}),
[params]
);
const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions());
const { data } = useSuspenseQuery(crmPipelineReportQueryOptions(filters));
return (
<div className='space-y-6'>
<ReportFilterBar
filterMetadata={filterData.filters}
filterKeys={['dateFrom', 'dateTo', 'branch', 'productType', 'sales', 'customerOwner', 'leadSource']}
canExport={canExport}
reportCode='pipeline_funnel'
pageLinks={[
{ href: '/dashboard/crm/reports/pipeline', label: 'Pipeline Suite', active: true },
{ href: '/dashboard/crm/reports/lead-aging', label: 'Lead Aging' },
{ href: '/dashboard/crm/reports/enquiry-aging', label: 'Enquiry Aging' }
]}
/>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-5'>
{[
['Total Leads', formatNumber(data.summary.totalLeads)],
['Open Enquiries', formatNumber(data.summary.openEnquiries)],
['Quotations', formatNumber(data.summary.quotations)],
['Closed Won', formatNumber(data.summary.won)],
['Closed Lost', formatNumber(data.summary.lost)]
].map(([label, value]) => (
<Card key={String(label)}>
<CardHeader className='pb-2'>
<CardDescription>{label}</CardDescription>
</CardHeader>
<CardContent>
<div className='text-2xl font-semibold'>{value}</div>
</CardContent>
</Card>
))}
</div>
<SectionTable
title='Pipeline Funnel'
description={
data.summary.canViewRevenueValues
? 'Lead to won/lost funnel with count, value, and conversion rate.'
: 'Lead to won/lost funnel with count and conversion rate. Revenue values are hidden by access policy.'
}
reportCode='pipeline_funnel'
headers={['Stage', 'Count', 'Value', 'Conversion %']}
rows={data.funnel.map((row) => [
row.stageLabel,
formatNumber(row.count),
formatValue(row.value),
row.conversionRate === null ? '-' : `${row.conversionRate}%`
])}
/>
<SectionTable
title='Lead Pipeline'
description='Marketing view grouped by source, owner, creator, branch, and product type.'
reportCode='lead_pipeline'
headers={[
'Lead Source',
'Customer Owner',
'Created By',
'Branch',
'Product Type',
'Total',
'Assigned',
'Unassigned',
'Converted'
]}
rows={data.leadPipeline.map((row) => [
row.leadSourceLabel,
row.customerOwnerName,
row.createdByName,
row.branchLabel,
row.productTypeLabel,
formatNumber(row.totalLeads),
formatNumber(row.assignedLeads),
formatNumber(row.unassignedLeads),
formatNumber(row.convertedLeads)
])}
/>
<SectionTable
title='Enquiry Pipeline'
description='Sales pipeline visibility grouped by salesman, branch, product type, and customer owner.'
reportCode='enquiry_pipeline'
headers={[
'Salesman',
'Branch',
'Product Type',
'Customer Owner',
'Open',
'With Quotation',
'Won',
'Lost'
]}
rows={data.enquiryPipeline.map((row) => [
row.salesmanName,
row.branchLabel,
row.productTypeLabel,
row.customerOwnerName,
formatNumber(row.openEnquiries),
formatNumber(row.convertedToQuotation),
formatNumber(row.won),
formatNumber(row.lost)
])}
/>
<div className='grid gap-6 xl:grid-cols-2'>
<SectionTable
title='Lead to Enquiry Conversion'
description='Marketing effectiveness by source, branch, product type, and creator.'
reportCode='lead_conversion'
headers={['Lead Source', 'Branch', 'Product Type', 'Marketing User', 'Total Leads', 'Converted', 'Conversion %']}
rows={data.leadConversion.map((row) => [
row.leadSourceLabel,
row.branchLabel,
row.productTypeLabel,
row.marketingUserName,
formatNumber(row.totalLeads),
formatNumber(row.convertedEnquiries),
`${row.conversionRate}%`
])}
/>
<SectionTable
title='Enquiry to Quotation Conversion'
description='Sales effectiveness by salesman, branch, and product type.'
reportCode='enquiry_conversion'
headers={['Salesman', 'Branch', 'Product Type', 'Total Enquiries', 'With Quotation', 'Conversion %']}
rows={data.enquiryConversion.map((row) => [
row.salesmanName,
row.branchLabel,
row.productTypeLabel,
formatNumber(row.totalEnquiries),
formatNumber(row.enquiriesWithQuotation),
`${row.conversionRate}%`
])}
/>
</div>
</div>
);
}

View File

@@ -1,10 +1,22 @@
'use client';
import Link from 'next/link';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { crmReportCatalogQueryOptions, crmReportFiltersQueryOptions } from '../api/queries';
const REPORT_LINKS: Record<string, string> = {
lead_pipeline: '/dashboard/crm/reports/pipeline',
enquiry_pipeline: '/dashboard/crm/reports/pipeline',
lead_conversion: '/dashboard/crm/reports/pipeline',
enquiry_conversion: '/dashboard/crm/reports/pipeline',
pipeline_funnel: '/dashboard/crm/reports/pipeline',
lead_aging: '/dashboard/crm/reports/lead-aging',
enquiry_aging: '/dashboard/crm/reports/enquiry-aging'
};
export function ReportCatalog() {
const { data: catalog } = useSuspenseQuery(crmReportCatalogQueryOptions());
const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions());
@@ -48,7 +60,7 @@ export function ReportCatalog() {
<CardHeader>
<CardTitle>{group.categoryLabel}</CardTitle>
<CardDescription>
{group.items.length} report definition{group.items.length === 1 ? '' : 's'} ready for future modules.
{group.items.length} report definition{group.items.length === 1 ? '' : 's'} available in the CRM report center.
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
@@ -62,6 +74,11 @@ export function ReportCatalog() {
{item.isSystem ? <Badge variant='secondary'>System</Badge> : null}
</div>
<div className='text-muted-foreground mt-3 text-xs'>{item.code}</div>
{REPORT_LINKS[item.code] ? (
<Button asChild variant='outline' size='sm' className='mt-3'>
<Link href={REPORT_LINKS[item.code]}>Open Report</Link>
</Button>
) : null}
</div>
))}
</CardContent>

View File

@@ -0,0 +1,167 @@
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import { parseAsString, useQueryStates } from 'nuqs';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import type { CrmReportFilterMetadata } from '../api/types';
type FilterKey =
| 'dateFrom'
| 'dateTo'
| 'branch'
| 'productType'
| 'sales'
| 'customerOwner'
| 'leadSource';
const FILTER_LABELS: Record<Exclude<FilterKey, 'dateFrom' | 'dateTo'>, string> = {
branch: 'Branch',
productType: 'Product Type',
sales: 'Salesman',
customerOwner: 'Customer Owner',
leadSource: 'Lead Source'
};
export function ReportFilterBar({
filterMetadata,
filterKeys,
canExport,
reportCode,
pageLinks
}: {
filterMetadata: CrmReportFilterMetadata;
filterKeys: FilterKey[];
canExport: boolean;
reportCode?: string;
pageLinks?: Array<{ href: string; label: string; active?: boolean }>;
}) {
const [params, setParams] = useQueryStates({
dateFrom: parseAsString,
dateTo: parseAsString,
branch: parseAsString,
productType: parseAsString,
sales: parseAsString,
customerOwner: parseAsString,
leadSource: parseAsString
});
const exportQuery = useMemo(() => {
const searchParams = new URLSearchParams();
if (params.dateFrom) searchParams.set('dateFrom', params.dateFrom);
if (params.dateTo) searchParams.set('dateTo', params.dateTo);
if (params.branch) searchParams.set('branch', params.branch);
if (params.productType) searchParams.set('productType', params.productType);
if (params.sales) searchParams.set('sales', params.sales);
if (params.customerOwner) searchParams.set('customerOwner', params.customerOwner);
if (params.leadSource) searchParams.set('leadSource', params.leadSource);
if (reportCode) searchParams.set('reportCode', reportCode);
return searchParams.toString();
}, [params, reportCode]);
const optionMap = {
branch: filterMetadata.branches.map((item) => ({ value: item.id, label: item.label })),
productType: filterMetadata.productTypes.map((item) => ({ value: item.id, label: item.label })),
sales: filterMetadata.sales.map((item) => ({ value: item.id, label: item.name })),
customerOwner: filterMetadata.customerOwners.map((item) => ({ value: item.id, label: item.name })),
leadSource: filterMetadata.leadSources.map((item) => ({ value: item.id, label: item.label }))
} as const;
return (
<div className='space-y-3 rounded-xl border bg-card p-4'>
{pageLinks?.length ? (
<div className='flex flex-wrap gap-2'>
{pageLinks.map((link) => (
<Button key={link.href} asChild variant={link.active ? 'default' : 'outline'} size='sm'>
<Link href={link.href}>{link.label}</Link>
</Button>
))}
</div>
) : null}
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-6'>
{filterKeys.includes('dateFrom') ? (
<Input
type='date'
value={params.dateFrom ?? ''}
onChange={(event) => void setParams({ dateFrom: event.target.value || null })}
/>
) : null}
{filterKeys.includes('dateTo') ? (
<Input
type='date'
value={params.dateTo ?? ''}
onChange={(event) => void setParams({ dateTo: event.target.value || null })}
/>
) : null}
{(
['branch', 'productType', 'sales', 'customerOwner', 'leadSource'] as const
).filter((key) => filterKeys.includes(key)).map((key) => (
<Select
key={key}
value={params[key] ?? '__all__'}
onValueChange={(value) => void setParams({ [key]: value === '__all__' ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder={FILTER_LABELS[key]} />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All {FILTER_LABELS[key]}</SelectItem>
{optionMap[key].map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
))}
</div>
<div className='flex flex-wrap items-center gap-2'>
<Button
variant='outline'
onClick={() =>
void setParams({
dateFrom: null,
dateTo: null,
branch: null,
productType: null,
sales: null,
customerOwner: null,
leadSource: null
})
}
>
Clear Filters
</Button>
{canExport && reportCode ? (
<>
<Button asChild variant='outline'>
<Link href={`/api/crm/reports/export?format=csv${exportQuery ? `&${exportQuery}` : ''}`}>
<Icons.download className='mr-2 h-4 w-4' />
Export CSV
</Link>
</Button>
<Button asChild variant='outline'>
<Link href={`/api/crm/reports/export?format=xls${exportQuery ? `&${exportQuery}` : ''}`}>
<Icons.download className='mr-2 h-4 w-4' />
Export Excel
</Link>
</Button>
</>
) : null}
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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