task-k.2
This commit is contained in:
65
docs/implementation/task-k2-pipeline-reports.md
Normal file
65
docs/implementation/task-k2-pipeline-reports.md
Normal 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`
|
||||||
14
drizzle/0019_sleepy_ultron.sql
Normal file
14
drizzle/0019_sleepy_ultron.sql
Normal 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");
|
||||||
4062
drizzle/meta/0019_snapshot.json
Normal file
4062
drizzle/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
549
plans/task-k.2.md
Normal 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
|
||||||
|
```
|
||||||
34
src/app/api/crm/reports/enquiry-aging/route.ts
Normal file
34
src/app/api/crm/reports/enquiry-aging/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/app/api/crm/reports/export/route.ts
Normal file
205
src/app/api/crm/reports/export/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/crm/reports/lead-aging/route.ts
Normal file
31
src/app/api/crm/reports/lead-aging/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/crm/reports/pipeline/route.ts
Normal file
31
src/app/api/crm/reports/pipeline/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/app/dashboard/crm/reports/enquiry-aging/page.tsx
Normal file
72
src/app/dashboard/crm/reports/enquiry-aging/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/app/dashboard/crm/reports/lead-aging/page.tsx
Normal file
72
src/app/dashboard/crm/reports/lead-aging/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'>
|
||||||
|
|||||||
74
src/app/dashboard/crm/reports/pipeline/page.tsx
Normal file
74
src/app/dashboard/crm/reports/pipeline/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
});
|
||||||
|
|||||||
@@ -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}` : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
242
src/features/crm/reports/components/aging-report-view.tsx
Normal file
242
src/features/crm/reports/components/aging-report-view.tsx
Normal 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} />
|
||||||
|
);
|
||||||
|
}
|
||||||
255
src/features/crm/reports/components/pipeline-report-view.tsx
Normal file
255
src/features/crm/reports/components/pipeline-report-view.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
167
src/features/crm/reports/components/report-filter-bar.tsx
Normal file
167
src/features/crm/reports/components/report-filter-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { and, asc, count, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
crmCustomers,
|
||||||
|
crmEnquiries,
|
||||||
|
crmEnquiryFollowups,
|
||||||
|
crmQuotations,
|
||||||
|
users
|
||||||
|
} from '@/db/schema';
|
||||||
|
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||||
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
|
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import type { CrmSharedReportFilters } from '../../api/types';
|
||||||
|
import type {
|
||||||
|
ParsedCrmReportFilters,
|
||||||
|
PipelineDatasetRecord,
|
||||||
|
ResolvedReportContext
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
function buildRequiredFilters(filters: CrmSharedReportFilters): ParsedCrmReportFilters {
|
||||||
|
return {
|
||||||
|
dateFrom: filters.dateFrom ?? null,
|
||||||
|
dateTo: filters.dateTo ?? null,
|
||||||
|
branch: filters.branch ?? null,
|
||||||
|
productType: filters.productType ?? null,
|
||||||
|
sales: filters.sales ?? null,
|
||||||
|
customer: filters.customer ?? null,
|
||||||
|
customerOwner: filters.customerOwner ?? null,
|
||||||
|
leadSource: filters.leadSource ?? null,
|
||||||
|
pipelineStage: filters.pipelineStage ?? null,
|
||||||
|
outcome: filters.outcome ?? null,
|
||||||
|
lostReason: filters.lostReason ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateRange(filters: ParsedCrmReportFilters) {
|
||||||
|
const start = filters.dateFrom ? new Date(`${filters.dateFrom}T00:00:00.000Z`) : null;
|
||||||
|
const end = filters.dateTo ? new Date(`${filters.dateTo}T23:59:59.999Z`) : null;
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesDateRange(value: Date, start: Date | null, end: Date | null) {
|
||||||
|
if (start && value < start) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end && value > end) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenOutcome(stage: string) {
|
||||||
|
return stage !== 'closed_won' && stage !== 'closed_lost';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAccessRecord(
|
||||||
|
context: ResolvedReportContext,
|
||||||
|
row: {
|
||||||
|
branchId: string | null;
|
||||||
|
productType: string;
|
||||||
|
createdBy: string;
|
||||||
|
assignedToUserId: string | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (!hasBranchScopeAccess(context, row.branchId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasProductScopeAccess(context, row.productType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.membershipRole === 'admin' || context.ownershipScope !== 'own') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPipelineReportDataset(
|
||||||
|
context: ResolvedReportContext,
|
||||||
|
filters: CrmSharedReportFilters
|
||||||
|
): Promise<{
|
||||||
|
filters: ParsedCrmReportFilters;
|
||||||
|
rows: PipelineDatasetRecord[];
|
||||||
|
}> {
|
||||||
|
const requiredFilters = buildRequiredFilters(filters);
|
||||||
|
const { start, end } = getDateRange(requiredFilters);
|
||||||
|
const [branches, productTypes, leadSources, enquiryRows, quotationRows, followupRows] =
|
||||||
|
await Promise.all([
|
||||||
|
getUserBranches(),
|
||||||
|
getActiveOptionsByCategory('crm_product_type', { organizationId: context.organizationId }),
|
||||||
|
getActiveOptionsByCategory('crm_lead_channel', { organizationId: context.organizationId }),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: crmEnquiries.id,
|
||||||
|
code: crmEnquiries.code,
|
||||||
|
customerId: crmEnquiries.customerId,
|
||||||
|
branchId: crmEnquiries.branchId,
|
||||||
|
productType: crmEnquiries.productType,
|
||||||
|
leadChannel: crmEnquiries.leadChannel,
|
||||||
|
pipelineStage: crmEnquiries.pipelineStage,
|
||||||
|
createdAt: crmEnquiries.createdAt,
|
||||||
|
createdBy: crmEnquiries.createdBy,
|
||||||
|
assignedToUserId: crmEnquiries.assignedToUserId,
|
||||||
|
estimatedValue: crmEnquiries.estimatedValue,
|
||||||
|
poAmount: crmEnquiries.poAmount,
|
||||||
|
closedWonAt: crmEnquiries.closedWonAt,
|
||||||
|
closedLostAt: crmEnquiries.closedLostAt,
|
||||||
|
lostReason: crmEnquiries.lostReason,
|
||||||
|
customerName: crmCustomers.name,
|
||||||
|
customerOwnerUserId: crmCustomers.ownerUserId
|
||||||
|
})
|
||||||
|
.from(crmEnquiries)
|
||||||
|
.innerJoin(crmCustomers, eq(crmCustomers.id, crmEnquiries.customerId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmEnquiries.organizationId, context.organizationId),
|
||||||
|
isNull(crmEnquiries.deletedAt),
|
||||||
|
eq(crmEnquiries.isActive, true),
|
||||||
|
isNull(crmCustomers.deletedAt),
|
||||||
|
eq(crmCustomers.isActive, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmEnquiries.createdAt)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
enquiryId: crmQuotations.enquiryId,
|
||||||
|
quotationCount: count(crmQuotations.id),
|
||||||
|
quotationValue: sql<number>`coalesce(sum(${crmQuotations.totalAmount}), 0)`
|
||||||
|
})
|
||||||
|
.from(crmQuotations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmQuotations.organizationId, context.organizationId),
|
||||||
|
isNull(crmQuotations.deletedAt),
|
||||||
|
eq(crmQuotations.isActive, true),
|
||||||
|
sql`${crmQuotations.enquiryId} is not null`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(crmQuotations.enquiryId),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
enquiryId: crmEnquiryFollowups.enquiryId,
|
||||||
|
lastFollowupDate: sql<Date | null>`max(${crmEnquiryFollowups.followupDate})`
|
||||||
|
})
|
||||||
|
.from(crmEnquiryFollowups)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
||||||
|
isNull(crmEnquiryFollowups.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(crmEnquiryFollowups.enquiryId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userIds = [
|
||||||
|
...new Set(
|
||||||
|
enquiryRows.flatMap((row) => [
|
||||||
|
row.createdBy,
|
||||||
|
row.assignedToUserId,
|
||||||
|
row.customerOwnerUserId
|
||||||
|
]).filter((value): value is string => Boolean(value))
|
||||||
|
)
|
||||||
|
];
|
||||||
|
const userRows = userIds.length
|
||||||
|
? await db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, userIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const branchLabelMap = new Map(branches.map((item) => [item.id, item.name]));
|
||||||
|
const productTypeLabelMap = new Map(productTypes.map((item) => [item.id, item.label]));
|
||||||
|
const leadSourceLabelMap = new Map(leadSources.map((item) => [item.id, item.label]));
|
||||||
|
const quotationMap = new Map(
|
||||||
|
quotationRows
|
||||||
|
.filter((row): row is typeof row & { enquiryId: string } => typeof row.enquiryId === 'string')
|
||||||
|
.map((row) => [
|
||||||
|
row.enquiryId,
|
||||||
|
{
|
||||||
|
quotationCount: Number(row.quotationCount ?? 0),
|
||||||
|
quotationValue: Number(row.quotationValue ?? 0)
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const followupMap = new Map(followupRows.map((row) => [row.enquiryId, row.lastFollowupDate]));
|
||||||
|
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
||||||
|
|
||||||
|
const rows = enquiryRows
|
||||||
|
.filter((row) => canAccessRecord(context, row))
|
||||||
|
.filter((row) => matchesDateRange(row.createdAt, start, end))
|
||||||
|
.filter((row) => (requiredFilters.branch ? row.branchId === requiredFilters.branch : true))
|
||||||
|
.filter((row) => (requiredFilters.productType ? row.productType === requiredFilters.productType : true))
|
||||||
|
.filter((row) => (requiredFilters.sales ? row.assignedToUserId === requiredFilters.sales : true))
|
||||||
|
.filter((row) => (requiredFilters.customer ? row.customerId === requiredFilters.customer : true))
|
||||||
|
.filter((row) =>
|
||||||
|
requiredFilters.customerOwner ? row.customerOwnerUserId === requiredFilters.customerOwner : true
|
||||||
|
)
|
||||||
|
.filter((row) => (requiredFilters.leadSource ? row.leadChannel === requiredFilters.leadSource : true))
|
||||||
|
.filter((row) => (requiredFilters.pipelineStage ? row.pipelineStage === requiredFilters.pipelineStage : true))
|
||||||
|
.filter((row) => {
|
||||||
|
if (!requiredFilters.outcome) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredFilters.outcome === 'won') {
|
||||||
|
return row.pipelineStage === 'closed_won';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredFilters.outcome === 'lost') {
|
||||||
|
return row.pipelineStage === 'closed_lost';
|
||||||
|
}
|
||||||
|
|
||||||
|
return isOpenOutcome(row.pipelineStage);
|
||||||
|
})
|
||||||
|
.filter((row) => (requiredFilters.lostReason ? row.lostReason === requiredFilters.lostReason : true))
|
||||||
|
.map<PipelineDatasetRecord>((row) => {
|
||||||
|
const quotation = quotationMap.get(row.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enquiryId: row.id,
|
||||||
|
enquiryCode: row.code,
|
||||||
|
customerId: row.customerId,
|
||||||
|
customerName: row.customerName,
|
||||||
|
branchId: row.branchId,
|
||||||
|
branchLabel: row.branchId ? (branchLabelMap.get(row.branchId) ?? row.branchId) : '-',
|
||||||
|
productTypeId: row.productType,
|
||||||
|
productTypeLabel: productTypeLabelMap.get(row.productType) ?? row.productType ?? '-',
|
||||||
|
leadSourceId: row.leadChannel,
|
||||||
|
leadSourceLabel: row.leadChannel
|
||||||
|
? (leadSourceLabelMap.get(row.leadChannel) ?? row.leadChannel)
|
||||||
|
: '-',
|
||||||
|
pipelineStage: row.pipelineStage,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
createdByUserId: row.createdBy,
|
||||||
|
createdByName: userMap.get(row.createdBy) ?? row.createdBy,
|
||||||
|
assignedToUserId: row.assignedToUserId,
|
||||||
|
assignedToName: row.assignedToUserId ? (userMap.get(row.assignedToUserId) ?? row.assignedToUserId) : null,
|
||||||
|
customerOwnerUserId: row.customerOwnerUserId,
|
||||||
|
customerOwnerName: row.customerOwnerUserId
|
||||||
|
? (userMap.get(row.customerOwnerUserId) ?? row.customerOwnerUserId)
|
||||||
|
: '-',
|
||||||
|
quotationCount: quotation?.quotationCount ?? 0,
|
||||||
|
quotationValue: quotation?.quotationValue ?? 0,
|
||||||
|
estimatedValue: Number(row.estimatedValue ?? 0),
|
||||||
|
poAmount: Number(row.poAmount ?? 0),
|
||||||
|
lastFollowupDate: followupMap.get(row.id) ?? null,
|
||||||
|
closedWonAt: row.closedWonAt,
|
||||||
|
closedLostAt: row.closedLostAt,
|
||||||
|
lostReason: row.lostReason
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters: requiredFilters,
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,17 +28,19 @@ export async function auditReportExport(input: {
|
|||||||
reportCode: string;
|
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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user