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

@@ -27,7 +27,13 @@ import {
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import { PIPELINE_STAGES, STAGE_LABELS, OUTCOME_LABELS, type PipelineStage } from '@/lib/constants';
import {
PIPELINE_STAGES,
STAGE_LABELS,
OUTCOME_LABELS,
SOURCES,
type PipelineStage,
} from '@/lib/constants';
import { formatMoney } from '@/lib/reports/format-currency';
import type { ReportPayload } from '@/lib/reports/types';
@@ -197,6 +203,7 @@ interface SalesReportPayload {
winRateOverTime: WinRateOverTime;
sourceConversion: SourceConversionRow[];
repLeaderboard: RepLeaderboardRow[];
repOptions: Array<{ userId: string; displayName: string }>;
dealHeat: DealHeatSummary;
repPerformanceDetail: RepPerformanceDetailRow[];
stalledDeals: StalledDealRow[];
@@ -222,7 +229,9 @@ const SOURCE_LABELS: Record<string, string> = {
unknown: 'unknown',
};
const FILTER_DEFS: FilterDefinition[] = [
// Static filters — the rep ("Assigned to") filter is appended at render
// time from the report payload's `repOptions` (dynamic per port).
const STATIC_FILTER_DEFS: FilterDefinition[] = [
{
key: 'stage',
label: 'Stage',
@@ -245,8 +254,19 @@ const FILTER_DEFS: FilterDefinition[] = [
type: 'multi-select',
options: Object.entries(OUTCOME_LABELS).map(([value, label]) => ({ value, label })),
},
{
key: 'source',
label: 'Source',
type: 'multi-select',
options: SOURCES.map((s) => ({ value: s.value, label: s.label })),
},
];
// All filter keys we serialise to the query string. Kept stable here so
// the query builder is decoupled from the dynamic rep options (which
// arrive in the report payload).
const FILTER_KEYS = ['stage', 'leadCategory', 'outcome', 'source', 'assignedTo'] as const;
interface SalesTemplateConfig extends Record<string, unknown> {
kind: 'sales';
range: DateRange;
@@ -299,10 +319,10 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
const filterQs = useMemo(() => {
const parts: string[] = [];
for (const def of FILTER_DEFS) {
const v = filterValues[def.key];
for (const key of FILTER_KEYS) {
const v = filterValues[key];
if (Array.isArray(v) && v.length > 0) {
parts.push(`${def.key}=${encodeURIComponent(v.join(','))}`);
parts.push(`${key}=${encodeURIComponent(v.join(','))}`);
}
}
return parts.length > 0 ? `&${parts.join('&')}` : '';
@@ -334,10 +354,28 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
};
const sourceConversion = query.data?.data.sourceConversion ?? [];
const repLeaderboard = query.data?.data.repLeaderboard ?? [];
const repOptions = query.data?.data.repOptions;
// Locked decision: when only ONE rep has activity in window, the
// leaderboard table is awkward (1-row scoreboard). Hide it; the Rep
// performance detail (Task #32) will pick up the slack.
const showLeaderboard = repLeaderboard.length > 1;
// Append the dynamic rep ("Assigned to") filter once the payload's
// repOptions land. The FilterBar hides any multi-select with no
// options, so the rep filter simply doesn't render until then — and
// not at all for a port whose interests are all unassigned.
const filterDefs = useMemo<FilterDefinition[]>(() => {
if (!repOptions || repOptions.length === 0) return STATIC_FILTER_DEFS;
return [
...STATIC_FILTER_DEFS,
{
key: 'assignedTo',
label: 'Assigned to',
type: 'multi-select',
options: repOptions.map((r) => ({ value: r.userId, label: r.displayName })),
},
];
}, [repOptions]);
const dealHeat = query.data?.data.dealHeat;
const repPerformanceDetail = query.data?.data.repPerformanceDetail ?? [];
const stalledDeals = query.data?.data.stalledDeals ?? [];
@@ -798,7 +836,7 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
<div className="flex items-center justify-between gap-2 pt-2">
<h2 className="text-sm font-semibold text-foreground">Deal detail</h2>
<FilterBar
filters={FILTER_DEFS}
filters={filterDefs}
values={filterValues}
onChange={handleFilterChange}
onClear={handleFiltersClear}