Files
pn-new-crm/src/app/api/v1/reports/operational/route.ts

99 lines
2.6 KiB
TypeScript
Raw Normal View History

feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1 in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial) remain deferred per the gap audit at the bottom of that doc. Highlights: - Sales performance report: 7 KPI tiles, pipeline funnel + stage velocity + win-rate-over-time + source conversion + rep leaderboard charts, deal-heat section, 5 detail tables, stage / lead-cat / outcome filters. - Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy churn, tenure histogram, signing box plot, occupancy by area, docs in pipeline), 4 tables. Module-OFF banner when tenancies disabled. - Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths, tenancies), column-whitelist composer, date filter, CSV download, save-as-template. Registry-only extension path for the remaining 6 entities documented at src/lib/reports/custom/registry.ts. - Templates: load / modify / save / save-as on Sales / Operational / Custom. ?templateId= URL deep-link hydration via useRef guard. Active-template badge clears when the user drives view-state via wrapped setters; raw setters used on template apply so the badge survives. - Scheduled runs: BullMQ poll fires due schedules, mints report_runs, renders, optionally emails. Recipients optional (zero-recipient schedules archive without sending). PDF-only output for v1. Schedule dialog re-mounts via key prop on schedule.id transitions to avoid setState-in-effect reset patterns. - Server-side PDF endpoint + shared payload renderer (lib/pdf/reports/payload-report.tsx) so client + scheduler share one rendering path. - Shared currency formatter (lib/reports/format-currency.ts) consolidates 5 duplicated formatMoney helpers; fixes hardcoded 'USD' in detail tables; pre-formats money rows so PDF export (which strips column.format callbacks at the JSON boundary) renders consistently with CSV / XLSX. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:41:53 +02:00
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import {
getOperationalKpis,
getUtilisationHeatmap,
getStatusMixOverTime,
getTenancyChurn,
getTenureDistribution,
getSigningBoxPlot,
getOccupancyByArea,
getDocumentsInPipeline,
getTenanciesEndingSoon,
getVacantBerths,
getStuckSigning,
getHighestValueVacant,
} from '@/lib/services/reports/operational.service';
const querySchema = z.object({
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
});
function resolveRange(from?: string, to?: string): { from: Date; to: Date } {
const now = new Date();
const defaultFrom = new Date(now);
defaultFrom.setDate(defaultFrom.getDate() - 30);
return {
from: from ? new Date(from) : defaultFrom,
to: to ? new Date(to) : now,
};
}
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
try {
const params = req.nextUrl.searchParams;
const { from, to } = querySchema.parse({
from: params.get('from') ?? undefined,
to: params.get('to') ?? undefined,
});
const range = resolveRange(from, to);
const [
kpis,
utilisationHeatmap,
statusMix,
tenancyChurn,
tenureDistribution,
signingBoxPlot,
occupancyByArea,
docsInPipeline,
endingSoon,
vacantBerths,
stuckSigning,
highestValueVacant,
] = await Promise.all([
getOperationalKpis(ctx.portId, range),
getUtilisationHeatmap(ctx.portId),
getStatusMixOverTime(ctx.portId),
getTenancyChurn(ctx.portId),
getTenureDistribution(ctx.portId),
getSigningBoxPlot(ctx.portId),
getOccupancyByArea(ctx.portId),
getDocumentsInPipeline(ctx.portId),
getTenanciesEndingSoon(ctx.portId),
getVacantBerths(ctx.portId),
getStuckSigning(ctx.portId),
getHighestValueVacant(ctx.portId),
]);
return NextResponse.json({
data: {
kpis,
utilisationHeatmap,
statusMix,
tenancyChurn,
tenureDistribution,
signingBoxPlot,
occupancyByArea,
docsInPipeline,
endingSoon,
vacantBerths,
stuckSigning,
highestValueVacant,
range: {
from: range.from.toISOString(),
to: range.to.toISOString(),
},
},
});
} catch (error) {
return errorResponse(error);
}
}),
);