feat(reports): prior-period comparison toggle on the Sales report
Adds a "Compare to prior period" toggle to the Sales report header. When on, the API recomputes the KPI window for the equal-length window immediately preceding the selected range (previousPeriodBounds) behind `?compare=1`, and the five window-derived KPI tiles (Won, Lost, Win rate, Avg time-to-close, New leads) render colour-correct "vs prior" deltas. Point-in-time tiles (Active interests, Pipeline value) have no prior-window analogue and intentionally show no delta. The prior-window query runs in parallel with the main batch and resolves to null when the toggle is off (zero cost). Toggle state persists in the saved-template config. Closes the spec's "period comparison on every report" gap for Sales; Operational already rendered period-start deltas. Pure helpers TDD'd: previousPeriodBounds (range.ts) + computeSalesKpiComparison (sales-comparison.ts), 7 unit tests. tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ import { z } from 'zod';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
||||
import { previousPeriodBounds } from '@/lib/analytics/range';
|
||||
import { computeSalesKpiComparison } from '@/lib/services/reports/sales-comparison';
|
||||
import {
|
||||
getSalesKpis,
|
||||
getPipelineFunnel,
|
||||
@@ -50,6 +52,8 @@ const querySchema = z.object({
|
||||
stage: z.string().optional(),
|
||||
leadCategory: z.string().optional(),
|
||||
outcome: z.string().optional(),
|
||||
// "1" / "true" enables the prior-period comparison (adds per-KPI deltas).
|
||||
compare: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -86,14 +90,17 @@ export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
try {
|
||||
const params = req.nextUrl.searchParams;
|
||||
const { from, to, stage, leadCategory, outcome } = querySchema.parse({
|
||||
const { from, to, stage, leadCategory, outcome, compare } = querySchema.parse({
|
||||
from: params.get('from') ?? undefined,
|
||||
to: params.get('to') ?? undefined,
|
||||
stage: params.get('stage') ?? undefined,
|
||||
leadCategory: params.get('leadCategory') ?? undefined,
|
||||
outcome: params.get('outcome') ?? undefined,
|
||||
compare: params.get('compare') ?? undefined,
|
||||
});
|
||||
const range = resolveRange(from, to);
|
||||
const compareEnabled = compare === '1' || compare === 'true';
|
||||
const priorBounds = compareEnabled ? previousPeriodBounds(range) : null;
|
||||
|
||||
const filters: SalesFilters | undefined = (() => {
|
||||
const stages = parseCsv<PipelineStage>(stage, PIPELINE_STAGES);
|
||||
@@ -119,6 +126,7 @@ export const GET = withAuth(
|
||||
closingThisMonth,
|
||||
recentWins,
|
||||
lostReasonBreakdown,
|
||||
priorKpis,
|
||||
] = await Promise.all([
|
||||
getSalesKpis(ctx.portId, range),
|
||||
getPipelineFunnel(ctx.portId),
|
||||
@@ -132,11 +140,27 @@ export const GET = withAuth(
|
||||
getClosingThisMonth(ctx.portId, filters),
|
||||
getRecentWins(ctx.portId, filters),
|
||||
getLostReasonBreakdown(ctx.portId, range, filters),
|
||||
// Prior-window KPIs for the comparison toggle. Runs in parallel
|
||||
// with the main batch (depends only on the derived priorBounds);
|
||||
// resolves to null when the toggle is off so we pay nothing.
|
||||
priorBounds ? getSalesKpis(ctx.portId, priorBounds) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const comparison =
|
||||
priorKpis && priorBounds
|
||||
? {
|
||||
deltas: computeSalesKpiComparison(kpis, priorKpis),
|
||||
priorRange: {
|
||||
from: priorBounds.from.toISOString(),
|
||||
to: priorBounds.to.toISOString(),
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
kpis,
|
||||
comparison,
|
||||
funnel,
|
||||
stageVelocity,
|
||||
winRateOverTime,
|
||||
|
||||
Reference in New Issue
Block a user