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

View File

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

View File

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

View File

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

View File

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