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