feat(reports): rep + source multi-select filters on Sales report

Closes the two cross-cutting filter gaps in launch-readiness (rep
multi-select + source multi-select). The Sales detail tables can now be
narrowed by assigned rep and lead source alongside the existing stage /
lead-category / outcome filters.

- service: thread `assignedTo` + `sources` through the 5 filtered Sales
  queries (rep-performance, stalled, closing-this-month, recent-wins,
  lost-reason); add `getRepFilterOptions` for the rep dropdown's stable
  option list (distinct assigned reps port-wide, window-independent).
- route: extract param parsing into a pure, unit-tested
  `parseSalesFilters` helper (source allowlisted against SOURCES;
  assignedTo passed through as free user-id list); return `repOptions`
  in the payload.
- ui: static Source filter (SOURCES) + dynamic "Assigned to" filter
  (from payload repOptions, hidden until loaded); decouple the query
  builder from dynamic options via a stable FILTER_KEYS list.

TDD: 8 new parseSalesFilters unit tests (allowlist drop, free-list
passthrough, combine). tsc clean; 12/12 reports unit tests; browser-
verified both filters fire `source=`/`assignedTo=` → 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 00:24:27 +02:00
parent c7325010e6
commit b97f6e945c
5 changed files with 264 additions and 57 deletions

View File

@@ -3,9 +3,9 @@ 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 { parseSalesFilters } from '@/lib/services/reports/sales-filters';
import {
getSalesKpis,
getPipelineFunnel,
@@ -13,25 +13,15 @@ import {
getWinRateOverTime,
getSourceConversion,
getRepLeaderboard,
getRepFilterOptions,
getDealHeat,
getRepPerformanceDetail,
getStalledDeals,
getClosingThisMonth,
getRecentWins,
getLostReasonBreakdown,
type SalesFilters,
} from '@/lib/services/reports/sales.service';
const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;
const OUTCOMES = [
'won',
'lost_other_marina',
'lost_unqualified',
'lost_no_response',
'lost_other',
'cancelled',
] as const;
/**
* GET /api/v1/reports/sales?from=&to=
*
@@ -48,31 +38,10 @@ const OUTCOMES = [
const querySchema = z.object({
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
// CSV-style list params. Empty string → undefined → no filter.
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(),
});
/**
* Parse a CSV filter param into a typed allowlist. Unknown values are
* silently dropped — that way a stale bookmark with a removed enum
* value degrades to "no filter" instead of 400.
*/
function parseCsv<T extends string>(
raw: string | undefined,
allowed: ReadonlyArray<T>,
): T[] | undefined {
if (!raw) return undefined;
const parts = raw
.split(',')
.map((s) => s.trim())
.filter((s): s is T => (allowed as ReadonlyArray<string>).includes(s));
return parts.length > 0 ? parts : undefined;
}
function resolveRange(from?: string, to?: string): { from: Date; to: Date } {
const now = new Date();
// Defaults: trailing 30 days. Matches the "Last 30 days" preset on
@@ -90,28 +59,18 @@ export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
try {
const params = req.nextUrl.searchParams;
const { from, to, stage, leadCategory, outcome, compare } = querySchema.parse({
const { from, to, 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);
const leadCategories = parseCsv<(typeof LEAD_CATEGORIES)[number]>(
leadCategory,
LEAD_CATEGORIES,
);
const outcomes = parseCsv<(typeof OUTCOMES)[number]>(outcome, OUTCOMES);
if (!stages && !leadCategories && !outcomes) return undefined;
return { stages, leadCategories, outcomes };
})();
// Detail-table filters: stage / leadCategory / outcome / source /
// assignedTo (rep). Parsed + allowlisted in one pure helper.
const filters = parseSalesFilters(params);
const [
kpis,
@@ -120,6 +79,7 @@ export const GET = withAuth(
winRateOverTime,
sourceConversion,
repLeaderboard,
repOptions,
dealHeat,
repPerformanceDetail,
stalledDeals,
@@ -134,6 +94,7 @@ export const GET = withAuth(
getWinRateOverTime(ctx.portId, range),
getSourceConversion(ctx.portId),
getRepLeaderboard(ctx.portId, range),
getRepFilterOptions(ctx.portId),
getDealHeat(ctx.portId),
getRepPerformanceDetail(ctx.portId, range, filters),
getStalledDeals(ctx.portId, filters),
@@ -166,6 +127,7 @@ export const GET = withAuth(
winRateOverTime,
sourceConversion,
repLeaderboard,
repOptions,
dealHeat,
repPerformanceDetail,
stalledDeals,