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

@@ -0,0 +1,65 @@
# Task K.2 Pipeline Reports
## Delivered
- Added production CRM report APIs for:
- `GET /api/crm/reports/pipeline`
- `GET /api/crm/reports/lead-aging`
- `GET /api/crm/reports/enquiry-aging`
- `GET /api/crm/reports/export`
- Added production CRM report pages:
- `/dashboard/crm/reports/pipeline`
- `/dashboard/crm/reports/lead-aging`
- `/dashboard/crm/reports/enquiry-aging`
- Added shared dataset layer at `src/features/crm/reports/server/datasets/pipeline-report.dataset.ts`
- Extended report query contracts, React Query keys, and API client services for the new report suite
- Added report export support for CSV and Excel with scoped security enforcement
- Added audit logging for:
- `view_pipeline_report`
- `export_pipeline_report_csv`
- `export_pipeline_report_excel`
- Extended CRM report definitions seed with:
- `lead_pipeline`
- `lead_aging`
- `enquiry_pipeline`
- `enquiry_aging`
- `pipeline_funnel`
- `lead_conversion`
- `enquiry_conversion`
- Added report navigation entries under CRM side menu and report catalog deep links
## Security and Scope
- Report data is filtered by:
- organization
- branch scope
- product scope
- own-record scope for `ownershipScope = own`
- Revenue-like funnel values are hidden when the user does not have quotation pricing visibility
- Export uses the same filter contract and resolved access context as on-screen reports
## Report Coverage
- Pipeline suite page includes:
- Lead Pipeline
- Enquiry Pipeline
- Lead to Enquiry Conversion
- Enquiry to Quotation Conversion
- Pipeline Funnel
- Lead Aging page includes bucket summary and drill-down rows
- Enquiry Aging page includes bucket summary and drill-down rows with last follow-up visibility
## Verification
- `npx tsc --noEmit`
- passed
- `npx oxlint src/features/crm/reports src/app/api/crm/reports src/app/dashboard/crm/reports src/config/nav-config.ts`
- passed
- `npm run lint`
- still fails because of pre-existing repository-wide lint issues outside Task K.2 scope
## Notes
- Lead reporting is derived from the frozen lifecycle stored on `crm_enquiries`
- Lead conversion is treated as progression beyond `lead` stage or assignment into active sales handling
- Enquiry to quotation conversion is derived from `crm_quotations.enquiry_id`

View File

@@ -0,0 +1,14 @@
CREATE TABLE "crm_report_definitions" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"code" text NOT NULL,
"name" text NOT NULL,
"description" text,
"category" text NOT NULL,
"is_system" boolean DEFAULT true NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "crm_report_definitions_org_code_idx" ON "crm_report_definitions" USING btree ("organization_id","code");

File diff suppressed because it is too large Load Diff

View File

@@ -134,6 +134,13 @@
"when": 1782799200000, "when": 1782799200000,
"tag": "0018_report_foundation", "tag": "0018_report_foundation",
"breakpoints": true "breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1782137095907,
"tag": "0019_sleepy_ultron",
"breakpoints": true
} }
] ]
} }

549
plans/task-k.2.md Normal file
View File

@@ -0,0 +1,549 @@
# Task K.2: Pipeline Reports
## Objective
Deliver the first production report suite for CRM by providing operational visibility into:
* Leads
* Enquiries
* Quotation Conversion
* Pipeline Aging
using the Report Foundation built in Task K.1.
---
# Background
Completed:
* Lead / Enquiry Ownership Model (D.3)
* Lead / Enquiry Navigation Separation (D.3.1)
* Won / Lost Governance (D.4)
* Dashboard KPI (J)
* Report Foundation (K.1)
Pipeline lifecycle is frozen as:
```txt
Lead
→ Enquiry
→ Quotation
→ Closed Won
→ Closed Lost
```
Task K.2 builds reporting on top of this lifecycle.
---
# Scope K2.1 Lead Pipeline Report
Add:
```txt
Lead Pipeline Report
```
Purpose:
Marketing visibility.
Metrics:
```txt
Total Leads
New Leads
Assigned Leads
Unassigned Leads
Converted Leads
```
Dimensions:
```txt
Lead Source
Customer Owner
Created By
Branch
Product Type
```
Filters:
```txt
Date Range
Lead Source
Branch
Product Type
Customer Owner
```
---
# Scope K2.2 Lead Aging Report
Purpose:
Identify stagnant leads.
Metrics:
```txt
Lead Count
Average Aging Days
Maximum Aging Days
```
Buckets:
```txt
0-7 Days
8-14 Days
15-30 Days
31-60 Days
60+ Days
```
Drill-down:
```txt
Lead Number
Customer
Assigned User
Created Date
Aging Days
```
---
# Scope K2.3 Enquiry Pipeline Report
Purpose:
Sales pipeline visibility.
Metrics:
```txt
Open Enquiries
Converted To Quotation
Won
Lost
```
Dimensions:
```txt
Salesman
Branch
Product Type
Customer Owner
```
Filters:
```txt
Date Range
Salesman
Branch
Product Type
```
---
# Scope K2.4 Enquiry Aging Report
Purpose:
Identify stalled opportunities.
Metrics:
```txt
Open Enquiries
Average Aging
Maximum Aging
```
Buckets:
```txt
0-14 Days
15-30 Days
31-60 Days
60+ Days
```
Drill-down:
```txt
Enquiry No
Customer
Salesman
Last Follow-Up
Aging Days
```
---
# Scope K2.5 Lead → Enquiry Conversion Report
Purpose:
Marketing effectiveness.
Formula:
```txt
Converted Enquiries
/
Total Leads
```
Show:
```txt
Conversion Rate %
```
Dimensions:
```txt
Lead Source
Branch
Product Type
Marketing User
```
---
# Scope K2.6 Enquiry → Quotation Conversion Report
Purpose:
Sales effectiveness.
Formula:
```txt
Enquiries With Quotation
/
Total Enquiries
```
Dimensions:
```txt
Salesman
Branch
Product Type
```
---
# Scope K2.7 Pipeline Funnel Report
Add dedicated report.
Stages:
```txt
Lead
Enquiry
Quotation
Closed Won
Closed Lost
```
Show:
```txt
Count
Value
Conversion %
```
Support:
```txt
Date Range
Branch
Product Type
Salesman
```
---
# Scope K2.8 Pipeline Detail Dataset
Create reusable dataset:
```txt
pipeline-report.dataset.ts
```
Shared by:
```txt
Lead Report
Enquiry Report
Funnel Report
```
No duplicate SQL.
---
# Scope K2.9 Report Pages
Add:
```txt
/dashboard/crm/reports/pipeline
/dashboard/crm/reports/lead-aging
/dashboard/crm/reports/enquiry-aging
```
Use:
```txt
Report Foundation
Shared Filters
Export Foundation
```
from K.1
---
# Scope K2.10 Export Support
All Pipeline Reports support:
```txt
CSV
Excel
```
Exports must respect:
```txt
ResolvedReportContext
CRM Authorization
Branch Scope
Product Scope
Ownership Scope
```
---
# Scope K2.11 Security
Marketing:
Can see:
```txt
Lead Reports
Pipeline Reports
Outcome Summary
```
Cannot see:
```txt
Quotation Pricing
Revenue Values
```
---
Sales:
Can see:
```txt
Own / Scoped Pipeline
```
---
Managers:
Can see:
```txt
Team Pipeline
```
---
CRM Admin:
Can see:
```txt
Organization-wide Pipeline
```
---
# Scope K2.12 Audit Logging
Entity:
```txt
crm_report
```
Actions:
```txt
view_pipeline_report
export_pipeline_report_csv
export_pipeline_report_excel
```
Payload:
```txt
reportCode
filters
recordCount
```
---
# Scope K2.13 Report Definitions
Register:
```txt
lead_pipeline
lead_aging
enquiry_pipeline
enquiry_aging
pipeline_funnel
lead_conversion
enquiry_conversion
```
in:
```txt
crm_report_definitions
```
---
# Verification
Scenario 1
```txt
Lead
→ Assign
→ Enquiry
```
Expected:
```txt
Lead Conversion Report updates
```
---
Scenario 2
```txt
Enquiry
→ Create Quotation
```
Expected:
```txt
Quotation Conversion updates
```
---
Scenario 3
```txt
Won
Lost
```
Expected:
```txt
Pipeline Funnel updates
```
---
Scenario 4
Marketing User
Expected:
```txt
No Revenue Values
```
---
Scenario 5
Sales User
Expected:
```txt
Scoped Pipeline Only
```
---
# Deliverables
1. Lead Pipeline Report
2. Lead Aging Report
3. Enquiry Pipeline Report
4. Enquiry Aging Report
5. Conversion Reports
6. Pipeline Funnel Report
7. Shared Dataset Layer
8. Export Support
9. Security Enforcement
10. Report Registration
---
# Definition of Done
Task K.2 is complete when:
* Lead reports work
* Enquiry reports work
* Funnel report works
* Conversion reports work
* Aging reports work
* Export works
* Security works
* Audit logging works
Result:
```txt
Marketing Pipeline Analytics = READY
Sales Pipeline Analytics = READY
Ready for:
K.3 Outcome Analytics
```

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { resolveCrmAccess } from '@/features/crm/reports/server/context';
import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared';
import { getCrmEnquiryAgingReportResponse } from '@/features/crm/reports/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmReportRead
});
return NextResponse.json(
await getCrmEnquiryAgingReportResponse(
resolveCrmAccess({
organizationId: organization.id,
userId: session.user.id,
membership
}),
parseSharedReportFilters(request.nextUrl.searchParams)
)
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json(
{ message: 'Unable to load CRM enquiry aging report' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,205 @@
import { NextRequest, NextResponse } from 'next/server';
import { resolveCrmAccess } from '@/features/crm/reports/server/context';
import {
auditReportExport,
buildReportCsv,
buildReportExcel
} from '@/features/crm/reports/server/exports/service';
import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared';
import {
getCrmEnquiryAgingReportResponse,
getCrmLeadAgingReportResponse,
getCrmPipelineReportResponse
} from '@/features/crm/reports/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
function buildExportRows(input: {
reportCode: string;
pipeline?: Awaited<ReturnType<typeof getCrmPipelineReportResponse>>;
leadAging?: Awaited<ReturnType<typeof getCrmLeadAgingReportResponse>>;
enquiryAging?: Awaited<ReturnType<typeof getCrmEnquiryAgingReportResponse>>;
}) {
switch (input.reportCode) {
case 'lead_pipeline':
return [
['Lead Source', 'Customer Owner', 'Created By', 'Branch', 'Product Type', 'Total Leads', 'New Leads', 'Assigned Leads', 'Unassigned Leads', 'Converted Leads'],
...(input.pipeline?.leadPipeline ?? []).map((row) => [
row.leadSourceLabel,
row.customerOwnerName,
row.createdByName,
row.branchLabel,
row.productTypeLabel,
String(row.totalLeads),
String(row.newLeads),
String(row.assignedLeads),
String(row.unassignedLeads),
String(row.convertedLeads)
])
];
case 'enquiry_pipeline':
return [
['Salesman', 'Branch', 'Product Type', 'Customer Owner', 'Open Enquiries', 'Converted To Quotation', 'Won', 'Lost'],
...(input.pipeline?.enquiryPipeline ?? []).map((row) => [
row.salesmanName,
row.branchLabel,
row.productTypeLabel,
row.customerOwnerName,
String(row.openEnquiries),
String(row.convertedToQuotation),
String(row.won),
String(row.lost)
])
];
case 'lead_conversion':
return [
['Lead Source', 'Branch', 'Product Type', 'Marketing User', 'Total Leads', 'Converted Enquiries', 'Conversion Rate %'],
...(input.pipeline?.leadConversion ?? []).map((row) => [
row.leadSourceLabel,
row.branchLabel,
row.productTypeLabel,
row.marketingUserName,
String(row.totalLeads),
String(row.convertedEnquiries),
String(row.conversionRate)
])
];
case 'enquiry_conversion':
return [
['Salesman', 'Branch', 'Product Type', 'Total Enquiries', 'Enquiries With Quotation', 'Conversion Rate %'],
...(input.pipeline?.enquiryConversion ?? []).map((row) => [
row.salesmanName,
row.branchLabel,
row.productTypeLabel,
String(row.totalEnquiries),
String(row.enquiriesWithQuotation),
String(row.conversionRate)
])
];
case 'pipeline_funnel':
return [
['Stage', 'Count', 'Value', 'Conversion Rate %'],
...(input.pipeline?.funnel ?? []).map((row) => [
row.stageLabel,
String(row.count),
row.value === null ? '' : String(row.value),
row.conversionRate === null ? '' : String(row.conversionRate)
])
];
case 'lead_aging':
return [
['Lead Number', 'Customer', 'Assigned User', 'Created Date', 'Aging Days'],
...(input.leadAging?.rows ?? []).map((row) => [
row.leadCode,
row.customerName,
row.assignedUserName ?? '',
row.createdDate,
String(row.agingDays)
])
];
case 'enquiry_aging':
return [
['Enquiry No', 'Customer', 'Salesman', 'Last Follow-Up', 'Aging Days'],
...(input.enquiryAging?.rows ?? []).map((row) => [
row.enquiryCode,
row.customerName,
row.salesmanName ?? '',
row.lastFollowupDate ?? '',
String(row.agingDays)
])
];
default:
return null;
}
}
function getRecordCount(input: {
reportCode: string;
pipeline?: Awaited<ReturnType<typeof getCrmPipelineReportResponse>>;
leadAging?: Awaited<ReturnType<typeof getCrmLeadAgingReportResponse>>;
enquiryAging?: Awaited<ReturnType<typeof getCrmEnquiryAgingReportResponse>>;
}) {
switch (input.reportCode) {
case 'lead_pipeline':
return input.pipeline?.leadPipeline.length ?? 0;
case 'enquiry_pipeline':
return input.pipeline?.enquiryPipeline.length ?? 0;
case 'lead_conversion':
return input.pipeline?.leadConversion.length ?? 0;
case 'enquiry_conversion':
return input.pipeline?.enquiryConversion.length ?? 0;
case 'pipeline_funnel':
return input.pipeline?.funnel.length ?? 0;
case 'lead_aging':
return input.leadAging?.rows.length ?? 0;
case 'enquiry_aging':
return input.enquiryAging?.rows.length ?? 0;
default:
return 0;
}
}
export async function GET(request: NextRequest) {
try {
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmReportExport
});
const reportCode = request.nextUrl.searchParams.get('reportCode') ?? '';
const format = request.nextUrl.searchParams.get('format') === 'xls' ? 'xls' : 'csv';
const filters = parseSharedReportFilters(request.nextUrl.searchParams);
const context = resolveCrmAccess({
organizationId: organization.id,
userId: session.user.id,
membership
});
const pipelineCodes = new Set([
'lead_pipeline',
'enquiry_pipeline',
'lead_conversion',
'enquiry_conversion',
'pipeline_funnel'
]);
const pipeline = pipelineCodes.has(reportCode)
? await getCrmPipelineReportResponse(context, filters)
: undefined;
const leadAging =
reportCode === 'lead_aging' ? await getCrmLeadAgingReportResponse(context, filters) : undefined;
const enquiryAging =
reportCode === 'enquiry_aging'
? await getCrmEnquiryAgingReportResponse(context, filters)
: undefined;
const rows = buildExportRows({ reportCode, pipeline, leadAging, enquiryAging });
if (!rows) {
return NextResponse.json({ message: 'Unsupported report code' }, { status: 400 });
}
const body = format === 'xls' ? buildReportExcel(rows) : buildReportCsv(rows);
const extension = format === 'xls' ? 'xls' : 'csv';
const contentType =
format === 'xls' ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
await auditReportExport({
organizationId: organization.id,
userId: session.user.id,
reportCode,
format,
filters: { ...filters },
recordCount: getRecordCount({ reportCode, pipeline, leadAging, enquiryAging })
});
return new NextResponse(body, {
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="crm-report-${reportCode}.${extension}"`
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to export CRM report' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { resolveCrmAccess } from '@/features/crm/reports/server/context';
import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared';
import { getCrmLeadAgingReportResponse } from '@/features/crm/reports/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmReportRead
});
return NextResponse.json(
await getCrmLeadAgingReportResponse(
resolveCrmAccess({
organizationId: organization.id,
userId: session.user.id,
membership
}),
parseSharedReportFilters(request.nextUrl.searchParams)
)
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load CRM lead aging report' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { resolveCrmAccess } from '@/features/crm/reports/server/context';
import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared';
import { getCrmPipelineReportResponse } from '@/features/crm/reports/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmReportRead
});
return NextResponse.json(
await getCrmPipelineReportResponse(
resolveCrmAccess({
organizationId: organization.id,
userId: session.user.id,
membership
}),
parseSharedReportFilters(request.nextUrl.searchParams)
)
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load CRM pipeline report' }, { status: 500 });
}
}

View File

@@ -0,0 +1,72 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import {
crmEnquiryAgingReportQueryOptions,
crmReportFiltersQueryOptions
} from '@/features/crm/reports/api/queries';
import { AgingReportView } from '@/features/crm/reports/components/aging-report-view';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
export const metadata = {
title: 'CRM Enquiry Aging Report'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
function getFilterValue(value: string | string[] | undefined): string | null {
return typeof value === 'string' ? value : null;
}
export default async function CrmEnquiryAgingRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmReportRead)));
const canExport =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmReportExport)));
const queryClient = getQueryClient();
const filters = {
dateFrom: getFilterValue(searchParams.dateFrom),
dateTo: getFilterValue(searchParams.dateTo),
branch: getFilterValue(searchParams.branch),
productType: getFilterValue(searchParams.productType),
sales: getFilterValue(searchParams.sales),
customerOwner: null,
leadSource: null
};
if (canRead) {
void queryClient.prefetchQuery(crmReportFiltersQueryOptions());
void queryClient.prefetchQuery(crmEnquiryAgingReportQueryOptions(filters));
}
return (
<PageContainer
pageTitle='Enquiry Aging Report'
pageDescription='Identify stalled enquiries that need follow-up or progression.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM enquiry aging reports.
</div>
}
>
{canRead ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<AgingReportView variant='enquiry' canExport={canExport} />
</HydrationBoundary>
) : null}
</PageContainer>
);
}

View File

@@ -0,0 +1,72 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import {
crmLeadAgingReportQueryOptions,
crmReportFiltersQueryOptions
} from '@/features/crm/reports/api/queries';
import { AgingReportView } from '@/features/crm/reports/components/aging-report-view';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
export const metadata = {
title: 'CRM Lead Aging Report'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
function getFilterValue(value: string | string[] | undefined): string | null {
return typeof value === 'string' ? value : null;
}
export default async function CrmLeadAgingRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmReportRead)));
const canExport =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmReportExport)));
const queryClient = getQueryClient();
const filters = {
dateFrom: getFilterValue(searchParams.dateFrom),
dateTo: getFilterValue(searchParams.dateTo),
branch: getFilterValue(searchParams.branch),
productType: getFilterValue(searchParams.productType),
sales: null,
customerOwner: getFilterValue(searchParams.customerOwner),
leadSource: getFilterValue(searchParams.leadSource)
};
if (canRead) {
void queryClient.prefetchQuery(crmReportFiltersQueryOptions());
void queryClient.prefetchQuery(crmLeadAgingReportQueryOptions(filters));
}
return (
<PageContainer
pageTitle='Lead Aging Report'
pageDescription='Identify stagnant leads before they move deeper into the pipeline.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM lead aging reports.
</div>
}
>
{canRead ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<AgingReportView variant='lead' canExport={canExport} />
</HydrationBoundary>
) : null}
</PageContainer>
);
}

View File

@@ -30,7 +30,7 @@ export default async function CrmReportsRoute() {
return ( return (
<PageContainer <PageContainer
pageTitle='CRM Reports' pageTitle='CRM Reports'
pageDescription='Report center foundation, shared filters, and catalog for upcoming CRM analytics modules.' pageDescription='CRM report center with production pipeline reports, aging views, and shared analytics filters.'
access={canRead} access={canRead}
accessFallback={ accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>

View File

@@ -0,0 +1,74 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import {
crmPipelineReportQueryOptions,
crmReportFiltersQueryOptions
} from '@/features/crm/reports/api/queries';
import { PipelineReportView } from '@/features/crm/reports/components/pipeline-report-view';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
export const metadata = {
title: 'CRM Pipeline Reports'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
function getFilterValue(
value: string | string[] | undefined
): string | null {
return typeof value === 'string' ? value : null;
}
export default async function CrmPipelineReportsRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmReportRead)));
const canExport =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmReportExport)));
const queryClient = getQueryClient();
const filters = {
dateFrom: getFilterValue(searchParams.dateFrom),
dateTo: getFilterValue(searchParams.dateTo),
branch: getFilterValue(searchParams.branch),
productType: getFilterValue(searchParams.productType),
sales: getFilterValue(searchParams.sales),
customerOwner: getFilterValue(searchParams.customerOwner),
leadSource: getFilterValue(searchParams.leadSource)
};
if (canRead) {
void queryClient.prefetchQuery(crmReportFiltersQueryOptions());
void queryClient.prefetchQuery(crmPipelineReportQueryOptions(filters));
}
return (
<PageContainer
pageTitle='Pipeline Reports'
pageDescription='Lead, enquiry, conversion, and funnel analytics for the CRM pipeline lifecycle.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM pipeline reports.
</div>
}
>
{canRead ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<PipelineReportView canExport={canExport} />
</HydrationBoundary>
) : null}
</PageContainer>
);
}

View File

@@ -136,7 +136,23 @@ export const navGroups: NavGroup[] = [
url: "/dashboard/crm/reports", url: "/dashboard/crm/reports",
icon: "dashboard", icon: "dashboard",
isActive: false, isActive: false,
items: [], items: [
{
title: "Pipeline Suite",
url: "/dashboard/crm/reports/pipeline",
access: { requireOrg: true, permission: "crm.report.read" },
},
{
title: "Lead Aging",
url: "/dashboard/crm/reports/lead-aging",
access: { requireOrg: true, permission: "crm.report.read" },
},
{
title: "Enquiry Aging",
url: "/dashboard/crm/reports/enquiry-aging",
access: { requireOrg: true, permission: "crm.report.read" },
},
],
access: { requireOrg: true, permission: "crm.report.read" }, access: { requireOrg: true, permission: "crm.report.read" },
}, },
{ {

View File

@@ -358,13 +358,43 @@ const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [
{ {
code: 'lead_pipeline', code: 'lead_pipeline',
name: 'Lead Pipeline', name: 'Lead Pipeline',
description: 'Foundation catalog entry for future lead pipeline reporting.', description: 'Operational report for marketing lead intake, assignment, and conversion status.',
category: 'pipeline'
},
{
code: 'lead_aging',
name: 'Lead Aging',
description: 'Operational report for stagnant lead monitoring and follow-up prioritization.',
category: 'pipeline' category: 'pipeline'
}, },
{ {
code: 'enquiry_pipeline', code: 'enquiry_pipeline',
name: 'Enquiry Pipeline', name: 'Enquiry Pipeline',
description: 'Foundation catalog entry for opportunity stage reporting.', description: 'Operational report for open enquiries, quotation progress, and sales pipeline health.',
category: 'pipeline'
},
{
code: 'enquiry_aging',
name: 'Enquiry Aging',
description: 'Operational report for stalled enquiries and aging opportunity follow-ups.',
category: 'pipeline'
},
{
code: 'pipeline_funnel',
name: 'Pipeline Funnel',
description: 'Lifecycle funnel from lead through quotation to won and lost outcomes.',
category: 'pipeline'
},
{
code: 'lead_conversion',
name: 'Lead Conversion',
description: 'Marketing conversion report from lead creation into active enquiries.',
category: 'pipeline'
},
{
code: 'enquiry_conversion',
name: 'Enquiry Conversion',
description: 'Sales conversion report from enquiry creation into quotation generation.',
category: 'pipeline' category: 'pipeline'
}, },
{ {

View File

@@ -1,11 +1,25 @@
import { queryOptions } from '@tanstack/react-query'; 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 = { export const crmReportKeys = {
all: ['crm-reports'] as const, all: ['crm-reports'] as const,
list: () => [...crmReportKeys.all, 'list'] as const, list: () => [...crmReportKeys.all, 'list'] as const,
catalog: () => [...crmReportKeys.all, 'catalog'] 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 = () => export const crmReportsQueryOptions = () =>
@@ -25,3 +39,21 @@ export const crmReportFiltersQueryOptions = () =>
queryKey: crmReportKeys.filters(), queryKey: crmReportKeys.filters(),
queryFn: () => getCrmReportFilters() 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 { import type {
CrmReportCatalogResponse, CrmReportCatalogResponse,
CrmReportFiltersResponse, CrmReportFiltersResponse,
CrmReportsResponse CrmReportsResponse,
CrmSharedReportFilters,
CrmPipelineReportResponse,
CrmLeadAgingReportResponse,
CrmEnquiryAgingReportResponse
} from './types'; } 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() { export async function getCrmReports() {
return apiClient<CrmReportsResponse>('/crm/reports'); return apiClient<CrmReportsResponse>('/crm/reports');
} }
@@ -16,3 +38,22 @@ export async function getCrmReportCatalog() {
export async function getCrmReportFilters() { export async function getCrmReportFilters() {
return apiClient<CrmReportFiltersResponse>('/crm/reports/filters'); 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; message: string;
filters: CrmReportFilterMetadata; 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'; 'use client';
import Link from 'next/link';
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { crmReportCatalogQueryOptions, crmReportFiltersQueryOptions } from '../api/queries'; 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() { export function ReportCatalog() {
const { data: catalog } = useSuspenseQuery(crmReportCatalogQueryOptions()); const { data: catalog } = useSuspenseQuery(crmReportCatalogQueryOptions());
const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions()); const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions());
@@ -48,7 +60,7 @@ export function ReportCatalog() {
<CardHeader> <CardHeader>
<CardTitle>{group.categoryLabel}</CardTitle> <CardTitle>{group.categoryLabel}</CardTitle>
<CardDescription> <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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-3'> <CardContent className='space-y-3'>
@@ -62,6 +74,11 @@ export function ReportCatalog() {
{item.isSystem ? <Badge variant='secondary'>System</Badge> : null} {item.isSystem ? <Badge variant='secondary'>System</Badge> : null}
</div> </div>
<div className='text-muted-foreground mt-3 text-xs'>{item.code}</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> </div>
))} ))}
</CardContent> </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; reportCode: string;
format: 'csv' | 'xls'; format: 'csv' | 'xls';
filters?: Record<string, unknown>; filters?: Record<string, unknown>;
recordCount?: number;
}) { }) {
await auditAction({ await auditAction({
organizationId: input.organizationId, organizationId: input.organizationId,
userId: input.userId, userId: input.userId,
entityType: 'crm_report', entityType: 'crm_report',
entityId: input.reportCode, entityId: input.reportCode,
action: input.format === 'xls' ? 'export_excel' : 'export_csv', action: input.format === 'xls' ? 'export_pipeline_report_excel' : 'export_pipeline_report_csv',
afterData: { afterData: {
reportCode: input.reportCode, reportCode: input.reportCode,
filters: input.filters ?? {}, 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 { getUserBranches } from '@/features/foundation/branch-scope/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; 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'; import type { ResolvedReportContext } from '../types';
const PIPELINE_STAGE_OPTIONS = [ 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 })) 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 { auditAction } from '@/features/foundation/audit-log/service';
import { canViewQuotationPricing } from '@/lib/auth/crm-access';
import type { import type {
CrmReportCatalogResponse, CrmReportCatalogResponse,
CrmReportFiltersResponse, CrmReportFiltersResponse,
CrmReportsResponse CrmReportsResponse,
CrmSharedReportFilters,
CrmPipelineReportResponse,
CrmLeadAgingReportResponse,
CrmEnquiryAgingReportResponse
} from '../api/types'; } from '../api/types';
import { buildReportCatalogGroups } from './builders/catalog-builder'; import { buildReportCatalogGroups } from './builders/catalog-builder';
import { resolveCrmAccess } from './context'; import { resolveCrmAccess } from './context';
import { listReportRegistry } from './datasets/catalog'; import { listReportRegistry } from './datasets/catalog';
import { listPipelineReportDataset } from './datasets/pipeline-report.dataset';
import { buildSharedReportFilters } from './filters/shared'; 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]) { export function buildResolvedReportContext(input: Parameters<typeof resolveCrmAccess>[0]) {
return resolveCrmAccess(input); 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({ await auditAction({
organizationId: context.organizationId, organizationId: context.organizationId,
userId: context.userId, userId: context.userId,
entityType: 'crm_report', entityType: 'crm_report',
entityId: reportCode, entityId: reportCode,
action: 'view', action: 'view_pipeline_report',
afterData: { afterData: {
reportCode, reportCode,
filters: filters ?? {}, filters: filters ?? {},
userId: context.userId userId: context.userId,
recordCount: recordCount ?? 0
} }
}); });
} }
@@ -76,3 +403,58 @@ export async function getCrmReportFiltersResponse(
filters 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; isSystem: boolean;
isActive: 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;
}