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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user