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:
2026-05-31 18:49:35 +02:00
parent 172af02f81
commit 681b94a8ef
7 changed files with 333 additions and 9 deletions

View File

@@ -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,