task-d5.5.1
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import type { CrmSharedReportFilters } from './types';
|
||||
import {
|
||||
getCrmEnquiryAgingReport,
|
||||
getCrmOpportunityAgingReport,
|
||||
getCrmLeadAgingReport,
|
||||
getCrmPipelineReport,
|
||||
getCrmReportCatalog,
|
||||
@@ -18,8 +18,8 @@ export const crmReportKeys = {
|
||||
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
|
||||
opportunityAgingRoot: () => [...crmReportKeys.all, 'opportunity-aging'] as const,
|
||||
opportunityAging: (filters: CrmSharedReportFilters) => [...crmReportKeys.opportunityAgingRoot(), filters] as const
|
||||
};
|
||||
|
||||
export const crmReportsQueryOptions = () =>
|
||||
@@ -52,8 +52,9 @@ export const crmLeadAgingReportQueryOptions = (filters: CrmSharedReportFilters)
|
||||
queryFn: () => getCrmLeadAgingReport(filters)
|
||||
});
|
||||
|
||||
export const crmEnquiryAgingReportQueryOptions = (filters: CrmSharedReportFilters) =>
|
||||
export const crmOpportunityAgingReportQueryOptions = (filters: CrmSharedReportFilters) =>
|
||||
queryOptions({
|
||||
queryKey: crmReportKeys.enquiryAging(filters),
|
||||
queryFn: () => getCrmEnquiryAgingReport(filters)
|
||||
queryKey: crmReportKeys.opportunityAging(filters),
|
||||
queryFn: () => getCrmOpportunityAgingReport(filters)
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
CrmSharedReportFilters,
|
||||
CrmPipelineReportResponse,
|
||||
CrmLeadAgingReportResponse,
|
||||
CrmEnquiryAgingReportResponse
|
||||
CrmOpportunityAgingReportResponse
|
||||
} from './types';
|
||||
|
||||
function buildReportQuery(filters: CrmSharedReportFilters) {
|
||||
@@ -51,9 +51,10 @@ export async function getCrmLeadAgingReport(filters: CrmSharedReportFilters) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCrmEnquiryAgingReport(filters: CrmSharedReportFilters) {
|
||||
export async function getCrmOpportunityAgingReport(filters: CrmSharedReportFilters) {
|
||||
const query = buildReportQuery(filters);
|
||||
return apiClient<CrmEnquiryAgingReportResponse>(
|
||||
`/crm/reports/enquiry-aging${query ? `?${query}` : ''}`
|
||||
return apiClient<CrmOpportunityAgingReportResponse>(
|
||||
`/crm/reports/opportunity-aging${query ? `?${query}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -87,12 +87,12 @@ export interface CrmPipelineReportRow {
|
||||
convertedLeads: number;
|
||||
}
|
||||
|
||||
export interface CrmEnquiryPipelineReportRow {
|
||||
export interface CrmOpportunityPipelineReportRow {
|
||||
salesmanName: string;
|
||||
branchLabel: string;
|
||||
productTypeLabel: string;
|
||||
customerOwnerName: string;
|
||||
openEnquiries: number;
|
||||
openOpportunities: number;
|
||||
convertedToQuotation: number;
|
||||
won: number;
|
||||
lost: number;
|
||||
@@ -104,21 +104,21 @@ export interface CrmLeadConversionReportRow {
|
||||
productTypeLabel: string;
|
||||
marketingUserName: string;
|
||||
totalLeads: number;
|
||||
convertedEnquiries: number;
|
||||
convertedOpportunities: number;
|
||||
conversionRate: number;
|
||||
}
|
||||
|
||||
export interface CrmEnquiryConversionReportRow {
|
||||
export interface CrmOpportunityConversionReportRow {
|
||||
salesmanName: string;
|
||||
branchLabel: string;
|
||||
productTypeLabel: string;
|
||||
totalEnquiries: number;
|
||||
enquiriesWithQuotation: number;
|
||||
totalOpportunities: number;
|
||||
opportunitiesWithQuotation: number;
|
||||
conversionRate: number;
|
||||
}
|
||||
|
||||
export interface CrmPipelineFunnelRow {
|
||||
stageKey: 'lead' | 'enquiry' | 'quotation' | 'closed_won' | 'closed_lost';
|
||||
stageKey: 'lead' | 'opportunity' | 'quotation' | 'closed_won' | 'closed_lost';
|
||||
stageLabel: string;
|
||||
count: number;
|
||||
value: number | null;
|
||||
@@ -127,7 +127,7 @@ export interface CrmPipelineFunnelRow {
|
||||
|
||||
export interface CrmPipelineKpiSummary {
|
||||
totalLeads: number;
|
||||
openEnquiries: number;
|
||||
openOpportunities: number;
|
||||
quotations: number;
|
||||
won: number;
|
||||
lost: number;
|
||||
@@ -141,9 +141,9 @@ export interface CrmPipelineReportResponse {
|
||||
filters: Required<CrmSharedReportFilters>;
|
||||
summary: CrmPipelineKpiSummary;
|
||||
leadPipeline: CrmPipelineReportRow[];
|
||||
enquiryPipeline: CrmEnquiryPipelineReportRow[];
|
||||
opportunityPipeline: CrmOpportunityPipelineReportRow[];
|
||||
leadConversion: CrmLeadConversionReportRow[];
|
||||
enquiryConversion: CrmEnquiryConversionReportRow[];
|
||||
opportunityConversion: CrmOpportunityConversionReportRow[];
|
||||
funnel: CrmPipelineFunnelRow[];
|
||||
}
|
||||
|
||||
@@ -176,25 +176,26 @@ export interface CrmLeadAgingReportResponse {
|
||||
rows: CrmLeadAgingRow[];
|
||||
}
|
||||
|
||||
export interface CrmEnquiryAgingRow {
|
||||
enquiryId: string;
|
||||
enquiryCode: string;
|
||||
export interface CrmOpportunityAgingRow {
|
||||
opportunityId: string;
|
||||
opportunityCode: string;
|
||||
customerName: string;
|
||||
salesmanName: string | null;
|
||||
lastFollowupDate: string | null;
|
||||
agingDays: number;
|
||||
}
|
||||
|
||||
export interface CrmEnquiryAgingReportResponse {
|
||||
export interface CrmOpportunityAgingReportResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
filters: Required<CrmSharedReportFilters>;
|
||||
summary: {
|
||||
openEnquiries: number;
|
||||
openOpportunities: number;
|
||||
averageAgingDays: number;
|
||||
maximumAgingDays: number;
|
||||
};
|
||||
buckets: CrmAgingBucket[];
|
||||
rows: CrmEnquiryAgingRow[];
|
||||
rows: CrmOpportunityAgingRow[];
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import type {
|
||||
CrmEnquiryAgingReportResponse,
|
||||
CrmOpportunityAgingReportResponse,
|
||||
CrmLeadAgingReportResponse
|
||||
} from '../api/types';
|
||||
import {
|
||||
crmEnquiryAgingReportQueryOptions,
|
||||
crmOpportunityAgingReportQueryOptions,
|
||||
crmLeadAgingReportQueryOptions,
|
||||
crmReportFiltersQueryOptions
|
||||
} from '../api/queries';
|
||||
@@ -32,9 +32,9 @@ function AgingReportLayout({
|
||||
canExport,
|
||||
data
|
||||
}: {
|
||||
variant: 'lead' | 'enquiry';
|
||||
variant: 'lead' | 'opportunity';
|
||||
canExport: boolean;
|
||||
data: CrmLeadAgingReportResponse | CrmEnquiryAgingReportResponse;
|
||||
data: CrmLeadAgingReportResponse | CrmOpportunityAgingReportResponse;
|
||||
}) {
|
||||
const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions());
|
||||
|
||||
@@ -47,8 +47,8 @@ function AgingReportLayout({
|
||||
]
|
||||
: [
|
||||
[
|
||||
'Open Enquiries',
|
||||
formatNumber('openEnquiries' in data.summary ? data.summary.openEnquiries : 0)
|
||||
'Open Opportunities',
|
||||
formatNumber('openOpportunities' in data.summary ? data.summary.openOpportunities : 0)
|
||||
],
|
||||
['Average Aging', `${data.summary.averageAgingDays} days`],
|
||||
['Maximum Aging', `${data.summary.maximumAgingDays} days`]
|
||||
@@ -64,11 +64,11 @@ function AgingReportLayout({
|
||||
: ['dateFrom', 'dateTo', 'branch', 'productType', 'sales']
|
||||
}
|
||||
canExport={canExport}
|
||||
reportCode={variant === 'lead' ? 'lead_aging' : 'enquiry_aging'}
|
||||
reportCode={variant === 'lead' ? 'lead_aging' : 'opportunity_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' }
|
||||
{ href: '/dashboard/crm/reports/opportunity-aging', label: 'Opportunity Aging', active: variant === 'opportunity' }
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -87,7 +87,7 @@ function AgingReportLayout({
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{variant === 'lead' ? 'Lead Aging Buckets' : 'Enquiry Aging Buckets'}</CardTitle>
|
||||
<CardTitle>{variant === 'lead' ? 'Lead Aging Buckets' : 'Opportunity 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'>
|
||||
@@ -102,11 +102,11 @@ function AgingReportLayout({
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{variant === 'lead' ? 'Lead Drill-down' : 'Enquiry Drill-down'}</CardTitle>
|
||||
<CardTitle>{variant === 'lead' ? 'Lead Drill-down' : 'Opportunity Drill-down'}</CardTitle>
|
||||
<CardDescription>
|
||||
{variant === 'lead'
|
||||
? 'Open leads sorted from oldest to newest.'
|
||||
: 'Open enquiries with latest follow-up visibility.'}
|
||||
: 'Open opportunities with latest follow-up visibility.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -124,7 +124,7 @@ function AgingReportLayout({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableHead>Enquiry No</TableHead>
|
||||
<TableHead>Opportunity No</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Salesman</TableHead>
|
||||
<TableHead>Last Follow-Up</TableHead>
|
||||
@@ -136,7 +136,7 @@ function AgingReportLayout({
|
||||
<TableBody>
|
||||
{data.rows.length ? (
|
||||
data.rows.map((row) => (
|
||||
<TableRow key={'leadId' in row ? row.leadId : row.enquiryId}>
|
||||
<TableRow key={'leadId' in row ? row.leadId : row.opportunityId}>
|
||||
{'leadId' in row ? (
|
||||
<>
|
||||
<TableCell>{row.leadCode}</TableCell>
|
||||
@@ -147,7 +147,7 @@ function AgingReportLayout({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableCell>{row.enquiryCode}</TableCell>
|
||||
<TableCell>{row.opportunityCode}</TableCell>
|
||||
<TableCell>{row.customerName}</TableCell>
|
||||
<TableCell>{row.salesmanName ?? '-'}</TableCell>
|
||||
<TableCell>
|
||||
@@ -202,7 +202,7 @@ function LeadAgingReportInner({ canExport }: { canExport: boolean }) {
|
||||
return <AgingReportLayout variant='lead' canExport={canExport} data={data} />;
|
||||
}
|
||||
|
||||
function EnquiryAgingReportInner({ canExport }: { canExport: boolean }) {
|
||||
function OpportunityAgingReportInner({ canExport }: { canExport: boolean }) {
|
||||
const [params] = useQueryStates({
|
||||
dateFrom: parseAsString,
|
||||
dateTo: parseAsString,
|
||||
@@ -222,21 +222,22 @@ function EnquiryAgingReportInner({ canExport }: { canExport: boolean }) {
|
||||
}),
|
||||
[params]
|
||||
);
|
||||
const { data } = useSuspenseQuery(crmEnquiryAgingReportQueryOptions(filters));
|
||||
const { data } = useSuspenseQuery(crmOpportunityAgingReportQueryOptions(filters));
|
||||
|
||||
return <AgingReportLayout variant='enquiry' canExport={canExport} data={data} />;
|
||||
return <AgingReportLayout variant='opportunity' canExport={canExport} data={data} />;
|
||||
}
|
||||
|
||||
export function AgingReportView({
|
||||
variant,
|
||||
canExport
|
||||
}: {
|
||||
variant: 'lead' | 'enquiry';
|
||||
variant: 'lead' | 'opportunity';
|
||||
canExport: boolean;
|
||||
}) {
|
||||
return variant === 'lead' ? (
|
||||
<LeadAgingReportInner canExport={canExport} />
|
||||
) : (
|
||||
<EnquiryAgingReportInner canExport={canExport} />
|
||||
<OpportunityAgingReportInner canExport={canExport} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,14 +124,14 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
|
||||
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' }
|
||||
{ href: '/dashboard/crm/reports/opportunity-aging', label: 'Opportunity 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)],
|
||||
['Open Opportunities', formatNumber(data.summary.openOpportunities)],
|
||||
['Quotations', formatNumber(data.summary.quotations)],
|
||||
['Closed Won', formatNumber(data.summary.won)],
|
||||
['Closed Lost', formatNumber(data.summary.lost)]
|
||||
@@ -193,9 +193,9 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
|
||||
/>
|
||||
|
||||
<SectionTable
|
||||
title='Enquiry Pipeline'
|
||||
title='Opportunity Pipeline'
|
||||
description='Sales pipeline visibility grouped by salesman, branch, product type, and customer owner.'
|
||||
reportCode='enquiry_pipeline'
|
||||
reportCode='opportunity_pipeline'
|
||||
headers={[
|
||||
'Salesman',
|
||||
'Branch',
|
||||
@@ -206,12 +206,12 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
|
||||
'Won',
|
||||
'Lost'
|
||||
]}
|
||||
rows={data.enquiryPipeline.map((row) => [
|
||||
rows={data.opportunityPipeline.map((row) => [
|
||||
row.salesmanName,
|
||||
row.branchLabel,
|
||||
row.productTypeLabel,
|
||||
row.customerOwnerName,
|
||||
formatNumber(row.openEnquiries),
|
||||
formatNumber(row.openOpportunities),
|
||||
formatNumber(row.convertedToQuotation),
|
||||
formatNumber(row.won),
|
||||
formatNumber(row.lost)
|
||||
@@ -220,7 +220,7 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<SectionTable
|
||||
title='Lead to Enquiry Conversion'
|
||||
title='Lead to Opportunity 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 %']}
|
||||
@@ -230,22 +230,22 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
|
||||
row.productTypeLabel,
|
||||
row.marketingUserName,
|
||||
formatNumber(row.totalLeads),
|
||||
formatNumber(row.convertedEnquiries),
|
||||
formatNumber(row.convertedOpportunities),
|
||||
`${row.conversionRate}%`
|
||||
])}
|
||||
/>
|
||||
|
||||
<SectionTable
|
||||
title='Enquiry to Quotation Conversion'
|
||||
title='Opportunity 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) => [
|
||||
reportCode='opportunity_conversion'
|
||||
headers={['Salesman', 'Branch', 'Product Type', 'Total Opportunities', 'With Quotation', 'Conversion %']}
|
||||
rows={data.opportunityConversion.map((row) => [
|
||||
row.salesmanName,
|
||||
row.branchLabel,
|
||||
row.productTypeLabel,
|
||||
formatNumber(row.totalEnquiries),
|
||||
formatNumber(row.enquiriesWithQuotation),
|
||||
formatNumber(row.totalOpportunities),
|
||||
formatNumber(row.opportunitiesWithQuotation),
|
||||
`${row.conversionRate}%`
|
||||
])}
|
||||
/>
|
||||
@@ -253,3 +253,4 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ import { crmReportCatalogQueryOptions, crmReportFiltersQueryOptions } from '../a
|
||||
|
||||
const REPORT_LINKS: Record<string, string> = {
|
||||
lead_pipeline: '/dashboard/crm/reports/pipeline',
|
||||
enquiry_pipeline: '/dashboard/crm/reports/pipeline',
|
||||
opportunity_pipeline: '/dashboard/crm/reports/pipeline',
|
||||
lead_conversion: '/dashboard/crm/reports/pipeline',
|
||||
enquiry_conversion: '/dashboard/crm/reports/pipeline',
|
||||
opportunity_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'
|
||||
opportunity_aging: '/dashboard/crm/reports/opportunity-aging'
|
||||
};
|
||||
|
||||
export function ReportCatalog() {
|
||||
@@ -88,3 +88,4 @@ export function ReportCatalog() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { and, asc, count, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import {
|
||||
crmCustomers,
|
||||
crmEnquiries,
|
||||
crmEnquiryFollowups,
|
||||
crmOpportunities,
|
||||
crmOpportunityFollowups,
|
||||
crmQuotations,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
@@ -89,46 +89,46 @@ export async function listPipelineReportDataset(
|
||||
}> {
|
||||
const requiredFilters = buildRequiredFilters(filters);
|
||||
const { start, end } = getDateRange(requiredFilters);
|
||||
const [branches, productTypes, leadSources, enquiryRows, quotationRows, followupRows] =
|
||||
const [branches, productTypes, leadSources, opportunityRows, 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,
|
||||
id: crmOpportunities.id,
|
||||
code: crmOpportunities.code,
|
||||
customerId: crmOpportunities.customerId,
|
||||
branchId: crmOpportunities.branchId,
|
||||
productType: crmOpportunities.productType,
|
||||
leadChannel: crmOpportunities.leadChannel,
|
||||
pipelineStage: crmOpportunities.pipelineStage,
|
||||
createdAt: crmOpportunities.createdAt,
|
||||
createdBy: crmOpportunities.createdBy,
|
||||
assignedToUserId: crmOpportunities.assignedToUserId,
|
||||
estimatedValue: crmOpportunities.estimatedValue,
|
||||
poAmount: crmOpportunities.poAmount,
|
||||
closedWonAt: crmOpportunities.closedWonAt,
|
||||
closedLostAt: crmOpportunities.closedLostAt,
|
||||
lostReason: crmOpportunities.lostReason,
|
||||
customerName: crmCustomers.name,
|
||||
customerOwnerUserId: crmCustomers.ownerUserId
|
||||
})
|
||||
.from(crmEnquiries)
|
||||
.innerJoin(crmCustomers, eq(crmCustomers.id, crmEnquiries.customerId))
|
||||
.from(crmOpportunities)
|
||||
.innerJoin(crmCustomers, eq(crmCustomers.id, crmOpportunities.customerId))
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.organizationId, context.organizationId),
|
||||
isNull(crmEnquiries.deletedAt),
|
||||
eq(crmEnquiries.isActive, true),
|
||||
eq(crmOpportunities.organizationId, context.organizationId),
|
||||
isNull(crmOpportunities.deletedAt),
|
||||
eq(crmOpportunities.isActive, true),
|
||||
isNull(crmCustomers.deletedAt),
|
||||
eq(crmCustomers.isActive, true)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmEnquiries.createdAt)),
|
||||
.orderBy(asc(crmOpportunities.createdAt)),
|
||||
db
|
||||
.select({
|
||||
enquiryId: crmQuotations.enquiryId,
|
||||
opportunityId: crmQuotations.opportunityId,
|
||||
quotationCount: count(crmQuotations.id),
|
||||
quotationValue: sql<number>`coalesce(sum(${crmQuotations.totalAmount}), 0)`
|
||||
})
|
||||
@@ -138,28 +138,28 @@ export async function listPipelineReportDataset(
|
||||
eq(crmQuotations.organizationId, context.organizationId),
|
||||
isNull(crmQuotations.deletedAt),
|
||||
eq(crmQuotations.isActive, true),
|
||||
sql`${crmQuotations.enquiryId} is not null`
|
||||
sql`${crmQuotations.opportunityId} is not null`
|
||||
)
|
||||
)
|
||||
.groupBy(crmQuotations.enquiryId),
|
||||
.groupBy(crmQuotations.opportunityId),
|
||||
db
|
||||
.select({
|
||||
enquiryId: crmEnquiryFollowups.enquiryId,
|
||||
lastFollowupDate: sql<Date | null>`max(${crmEnquiryFollowups.followupDate})`
|
||||
opportunityId: crmOpportunityFollowups.opportunityId,
|
||||
lastFollowupDate: sql<Date | null>`max(${crmOpportunityFollowups.followupDate})`
|
||||
})
|
||||
.from(crmEnquiryFollowups)
|
||||
.from(crmOpportunityFollowups)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
eq(crmOpportunityFollowups.organizationId, context.organizationId),
|
||||
isNull(crmOpportunityFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmEnquiryFollowups.enquiryId)
|
||||
.groupBy(crmOpportunityFollowups.opportunityId)
|
||||
]);
|
||||
|
||||
const userIds = [
|
||||
...new Set(
|
||||
enquiryRows.flatMap((row) => [
|
||||
opportunityRows.flatMap((row) => [
|
||||
row.createdBy,
|
||||
row.assignedToUserId,
|
||||
row.customerOwnerUserId
|
||||
@@ -178,19 +178,19 @@ export async function listPipelineReportDataset(
|
||||
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')
|
||||
.filter((row): row is typeof row & { opportunityId: string } => typeof row.opportunityId === 'string')
|
||||
.map((row) => [
|
||||
row.enquiryId,
|
||||
row.opportunityId,
|
||||
{
|
||||
quotationCount: Number(row.quotationCount ?? 0),
|
||||
quotationValue: Number(row.quotationValue ?? 0)
|
||||
}
|
||||
])
|
||||
);
|
||||
const followupMap = new Map(followupRows.map((row) => [row.enquiryId, row.lastFollowupDate]));
|
||||
const followupMap = new Map(followupRows.map((row) => [row.opportunityId, row.lastFollowupDate]));
|
||||
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
||||
|
||||
const rows = enquiryRows
|
||||
const rows = opportunityRows
|
||||
.filter((row) => canAccessRecord(context, row))
|
||||
.filter((row) => matchesDateRange(row.createdAt, start, end))
|
||||
.filter((row) => (requiredFilters.branch ? row.branchId === requiredFilters.branch : true))
|
||||
@@ -222,8 +222,8 @@ export async function listPipelineReportDataset(
|
||||
const quotation = quotationMap.get(row.id);
|
||||
|
||||
return {
|
||||
enquiryId: row.id,
|
||||
enquiryCode: row.code,
|
||||
opportunityId: row.id,
|
||||
opportunityCode: row.code,
|
||||
customerId: row.customerId,
|
||||
customerName: row.customerName,
|
||||
branchId: row.branchId,
|
||||
@@ -260,3 +260,4 @@ export async function listPipelineReportDataset(
|
||||
rows
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ResolvedReportContext } from '../types';
|
||||
|
||||
const PIPELINE_STAGE_OPTIONS = [
|
||||
{ id: 'lead', code: 'lead', label: 'Lead' },
|
||||
{ id: 'enquiry', code: 'enquiry', label: 'Enquiry' },
|
||||
{ id: 'opportunity', code: 'opportunity', label: 'Opportunity' },
|
||||
{ id: 'closed_won', code: 'closed_won', label: 'Won' },
|
||||
{ id: 'closed_lost', code: 'closed_lost', label: 'Lost' }
|
||||
] as const;
|
||||
@@ -85,3 +85,4 @@ export function parseSharedReportFilters(searchParams: URLSearchParams): CrmShar
|
||||
lostReason: searchParams.get('lostReason')
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
CrmSharedReportFilters,
|
||||
CrmPipelineReportResponse,
|
||||
CrmLeadAgingReportResponse,
|
||||
CrmEnquiryAgingReportResponse
|
||||
CrmOpportunityAgingReportResponse
|
||||
} from '../api/types';
|
||||
import { buildReportCatalogGroups } from './builders/catalog-builder';
|
||||
import { resolveCrmAccess } from './context';
|
||||
@@ -49,8 +49,8 @@ function isConvertedLead(row: PipelineDatasetRecord) {
|
||||
return row.pipelineStage !== 'lead' || Boolean(row.assignedToUserId);
|
||||
}
|
||||
|
||||
function isOpenEnquiry(row: PipelineDatasetRecord) {
|
||||
return row.pipelineStage === 'enquiry';
|
||||
function isOpenOpportunity(row: PipelineDatasetRecord) {
|
||||
return row.pipelineStage === 'opportunity';
|
||||
}
|
||||
|
||||
function buildLeadPipelineRows(rows: PipelineDatasetRecord[]) {
|
||||
@@ -98,8 +98,8 @@ function buildLeadPipelineRows(rows: PipelineDatasetRecord[]) {
|
||||
return [...grouped.values()].toSorted((left, right) => right.totalLeads - left.totalLeads);
|
||||
}
|
||||
|
||||
function buildEnquiryPipelineRows(rows: PipelineDatasetRecord[]) {
|
||||
const grouped = new Map<string, CrmPipelineReportResponse['enquiryPipeline'][number]>();
|
||||
function buildOpportunityPipelineRows(rows: PipelineDatasetRecord[]) {
|
||||
const grouped = new Map<string, CrmPipelineReportResponse['opportunityPipeline'][number]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = [
|
||||
@@ -115,14 +115,14 @@ function buildEnquiryPipelineRows(rows: PipelineDatasetRecord[]) {
|
||||
branchLabel: row.branchLabel,
|
||||
productTypeLabel: row.productTypeLabel,
|
||||
customerOwnerName: row.customerOwnerName,
|
||||
openEnquiries: 0,
|
||||
openOpportunities: 0,
|
||||
convertedToQuotation: 0,
|
||||
won: 0,
|
||||
lost: 0
|
||||
};
|
||||
|
||||
if (isOpenEnquiry(row)) {
|
||||
current.openEnquiries += 1;
|
||||
if (isOpenOpportunity(row)) {
|
||||
current.openOpportunities += 1;
|
||||
}
|
||||
|
||||
if (row.quotationCount > 0) {
|
||||
@@ -142,8 +142,8 @@ function buildEnquiryPipelineRows(rows: PipelineDatasetRecord[]) {
|
||||
|
||||
return [...grouped.values()].toSorted(
|
||||
(left, right) =>
|
||||
right.openEnquiries + right.convertedToQuotation + right.won + right.lost -
|
||||
(left.openEnquiries + left.convertedToQuotation + left.won + left.lost)
|
||||
right.openOpportunities + right.convertedToQuotation + right.won + right.lost -
|
||||
(left.openOpportunities + left.convertedToQuotation + left.won + left.lost)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,13 +165,13 @@ function buildLeadConversionRows(rows: PipelineDatasetRecord[]) {
|
||||
productTypeLabel: row.productTypeLabel,
|
||||
marketingUserName: row.createdByName,
|
||||
totalLeads: 0,
|
||||
convertedEnquiries: 0,
|
||||
convertedOpportunities: 0,
|
||||
conversionRate: 0
|
||||
};
|
||||
|
||||
current.totalLeads += 1;
|
||||
if (isConvertedLead(row)) {
|
||||
current.convertedEnquiries += 1;
|
||||
current.convertedOpportunities += 1;
|
||||
}
|
||||
|
||||
grouped.set(key, current);
|
||||
@@ -180,16 +180,16 @@ function buildLeadConversionRows(rows: PipelineDatasetRecord[]) {
|
||||
return [...grouped.values()]
|
||||
.map((row) => ({
|
||||
...row,
|
||||
conversionRate: toPercent(row.convertedEnquiries, row.totalLeads)
|
||||
conversionRate: toPercent(row.convertedOpportunities, 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]>();
|
||||
function buildOpportunityConversionRows(rows: PipelineDatasetRecord[]) {
|
||||
const opportunityRows = rows.filter((row) => row.pipelineStage !== 'lead' || row.assignedToUserId);
|
||||
const grouped = new Map<string, CrmPipelineReportResponse['opportunityConversion'][number]>();
|
||||
|
||||
for (const row of enquiryRows) {
|
||||
for (const row of opportunityRows) {
|
||||
const key = [(row.assignedToName ?? 'Unassigned'), row.branchLabel, row.productTypeLabel].join('|');
|
||||
const current =
|
||||
grouped.get(key) ??
|
||||
@@ -197,14 +197,14 @@ function buildEnquiryConversionRows(rows: PipelineDatasetRecord[]) {
|
||||
salesmanName: row.assignedToName ?? 'Unassigned',
|
||||
branchLabel: row.branchLabel,
|
||||
productTypeLabel: row.productTypeLabel,
|
||||
totalEnquiries: 0,
|
||||
enquiriesWithQuotation: 0,
|
||||
totalOpportunities: 0,
|
||||
opportunitiesWithQuotation: 0,
|
||||
conversionRate: 0
|
||||
};
|
||||
|
||||
current.totalEnquiries += 1;
|
||||
current.totalOpportunities += 1;
|
||||
if (row.quotationCount > 0) {
|
||||
current.enquiriesWithQuotation += 1;
|
||||
current.opportunitiesWithQuotation += 1;
|
||||
}
|
||||
|
||||
grouped.set(key, current);
|
||||
@@ -213,14 +213,14 @@ function buildEnquiryConversionRows(rows: PipelineDatasetRecord[]) {
|
||||
return [...grouped.values()]
|
||||
.map((row) => ({
|
||||
...row,
|
||||
conversionRate: toPercent(row.enquiriesWithQuotation, row.totalEnquiries)
|
||||
conversionRate: toPercent(row.opportunitiesWithQuotation, row.totalOpportunities)
|
||||
}))
|
||||
.toSorted((left, right) => right.totalEnquiries - left.totalEnquiries);
|
||||
.toSorted((left, right) => right.totalOpportunities - left.totalOpportunities);
|
||||
}
|
||||
|
||||
function buildFunnelRows(rows: PipelineDatasetRecord[], canViewRevenueValues: boolean) {
|
||||
const leadCount = rows.length;
|
||||
const enquiryRows = rows.filter((row) => isConvertedLead(row));
|
||||
const opportunityRows = 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');
|
||||
@@ -236,18 +236,18 @@ function buildFunnelRows(rows: PipelineDatasetRecord[], canViewRevenueValues: bo
|
||||
conversionRate: null
|
||||
},
|
||||
{
|
||||
stageKey: 'enquiry' as const,
|
||||
stageLabel: 'Enquiry',
|
||||
count: enquiryRows.length,
|
||||
value: valueOrNull(sumValues(enquiryRows, (row) => row.estimatedValue)),
|
||||
conversionRate: toPercent(enquiryRows.length, leadCount)
|
||||
stageKey: 'opportunity' as const,
|
||||
stageLabel: 'Opportunity',
|
||||
count: opportunityRows.length,
|
||||
value: valueOrNull(sumValues(opportunityRows, (row) => row.estimatedValue)),
|
||||
conversionRate: toPercent(opportunityRows.length, leadCount)
|
||||
},
|
||||
{
|
||||
stageKey: 'quotation' as const,
|
||||
stageLabel: 'Quotation',
|
||||
count: quotationRows.length,
|
||||
value: valueOrNull(sumValues(quotationRows, (row) => row.quotationValue)),
|
||||
conversionRate: toPercent(quotationRows.length, enquiryRows.length)
|
||||
conversionRate: toPercent(quotationRows.length, opportunityRows.length)
|
||||
},
|
||||
{
|
||||
stageKey: 'closed_won' as const,
|
||||
@@ -271,8 +271,8 @@ function buildLeadAging(rows: PipelineDatasetRecord[], filters: ParsedCrmReportF
|
||||
const leadRows = rows
|
||||
.filter((row) => row.pipelineStage === 'lead')
|
||||
.map((row) => ({
|
||||
leadId: row.enquiryId,
|
||||
leadCode: row.enquiryCode,
|
||||
leadId: row.opportunityId,
|
||||
leadCode: row.opportunityCode,
|
||||
customerName: row.customerName,
|
||||
assignedUserName: row.assignedToName,
|
||||
createdDate: row.createdAt.toISOString(),
|
||||
@@ -302,41 +302,41 @@ function buildLeadAging(rows: PipelineDatasetRecord[], filters: ParsedCrmReportF
|
||||
};
|
||||
}
|
||||
|
||||
function buildEnquiryAging(
|
||||
function buildOpportunityAging(
|
||||
rows: PipelineDatasetRecord[],
|
||||
filters: ParsedCrmReportFilters
|
||||
): CrmEnquiryAgingReportResponse {
|
||||
): CrmOpportunityAgingReportResponse {
|
||||
const now = new Date();
|
||||
const enquiryRows = rows
|
||||
.filter((row) => row.pipelineStage === 'enquiry')
|
||||
const opportunityRows = rows
|
||||
.filter((row) => row.pipelineStage === 'opportunity')
|
||||
.map((row) => ({
|
||||
enquiryId: row.enquiryId,
|
||||
enquiryCode: row.enquiryCode,
|
||||
opportunityId: row.opportunityId,
|
||||
opportunityCode: row.opportunityCode,
|
||||
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);
|
||||
const ages = opportunityRows.map((row) => row.agingDays);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'CRM enquiry aging report loaded successfully',
|
||||
message: 'CRM opportunity aging report loaded successfully',
|
||||
filters,
|
||||
summary: {
|
||||
openEnquiries: enquiryRows.length,
|
||||
openOpportunities: opportunityRows.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 }
|
||||
{ key: '0_14', label: '0-14 Days', count: opportunityRows.filter((row) => row.agingDays <= 14).length },
|
||||
{ key: '15_30', label: '15-30 Days', count: opportunityRows.filter((row) => row.agingDays >= 15 && row.agingDays <= 30).length },
|
||||
{ key: '31_60', label: '31-60 Days', count: opportunityRows.filter((row) => row.agingDays >= 31 && row.agingDays <= 60).length },
|
||||
{ key: '60_plus', label: '60+ Days', count: opportunityRows.filter((row) => row.agingDays > 60).length }
|
||||
],
|
||||
rows: enquiryRows
|
||||
rows: opportunityRows
|
||||
};
|
||||
}
|
||||
|
||||
@@ -417,16 +417,16 @@ export async function getCrmPipelineReportResponse(
|
||||
filters: dataset.filters,
|
||||
summary: {
|
||||
totalLeads: dataset.rows.length,
|
||||
openEnquiries: dataset.rows.filter((row) => row.pipelineStage === 'enquiry').length,
|
||||
openOpportunities: dataset.rows.filter((row) => row.pipelineStage === 'opportunity').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),
|
||||
opportunityPipeline: buildOpportunityPipelineRows(dataset.rows),
|
||||
leadConversion: buildLeadConversionRows(dataset.rows),
|
||||
enquiryConversion: buildEnquiryConversionRows(dataset.rows),
|
||||
opportunityConversion: buildOpportunityConversionRows(dataset.rows),
|
||||
funnel: buildFunnelRows(dataset.rows, canViewRevenueValues)
|
||||
};
|
||||
|
||||
@@ -447,14 +447,15 @@ export async function getCrmLeadAgingReportResponse(
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function getCrmEnquiryAgingReportResponse(
|
||||
export async function getCrmOpportunityAgingReportResponse(
|
||||
context: ResolvedReportContext,
|
||||
filters: CrmSharedReportFilters
|
||||
): Promise<CrmEnquiryAgingReportResponse> {
|
||||
): Promise<CrmOpportunityAgingReportResponse> {
|
||||
const dataset = await listPipelineReportDataset(context, filters);
|
||||
const response = buildEnquiryAging(dataset.rows, dataset.filters);
|
||||
const response = buildOpportunityAging(dataset.rows, dataset.filters);
|
||||
|
||||
await auditReportView(context, 'enquiry_aging', { ...dataset.filters }, response.rows.length);
|
||||
await auditReportView(context, 'opportunity_aging', { ...dataset.filters }, response.rows.length);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ export interface ParsedCrmReportFilters {
|
||||
}
|
||||
|
||||
export interface PipelineDatasetRecord {
|
||||
enquiryId: string;
|
||||
enquiryCode: string;
|
||||
opportunityId: string;
|
||||
opportunityCode: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
branchId: string | null;
|
||||
@@ -66,3 +66,4 @@ export interface PipelineDatasetRecord {
|
||||
closedLostAt: Date | null;
|
||||
lostReason: string | null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user