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

@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { parseSalesFilters } from '@/lib/services/reports/sales-filters';
function params(init: Record<string, string>): URLSearchParams {
return new URLSearchParams(init);
}
describe('parseSalesFilters', () => {
it('returns undefined when no filter params are present', () => {
expect(parseSalesFilters(params({}))).toBeUndefined();
// from/to/compare are NOT filters — they must not produce a filter object.
expect(
parseSalesFilters(params({ from: '2026-01-01', to: '2026-02-01', compare: '1' })),
).toBeUndefined();
});
it('parses the pre-existing stage / leadCategory / outcome CSV params', () => {
const f = parseSalesFilters(
params({
stage: 'eoi,reservation',
leadCategory: 'hot_lead',
outcome: 'won,cancelled',
}),
);
expect(f?.stages).toEqual(['eoi', 'reservation']);
expect(f?.leadCategories).toEqual(['hot_lead']);
expect(f?.outcomes).toEqual(['won', 'cancelled']);
});
it('parses the new source filter against the SOURCES allowlist', () => {
const f = parseSalesFilters(params({ source: 'website,broker' }));
expect(f?.sources).toEqual(['website', 'broker']);
});
it('drops unknown source values (stale bookmark degrades to no filter)', () => {
// 'tiktok' is not a valid source — it gets stripped; only 'referral' survives.
const f = parseSalesFilters(params({ source: 'tiktok,referral' }));
expect(f?.sources).toEqual(['referral']);
});
it('treats an all-invalid source param as no source filter', () => {
const f = parseSalesFilters(params({ source: 'tiktok,carrierpigeon' }));
expect(f).toBeUndefined();
});
it('parses the new assignedTo (rep) filter as a free list of user ids', () => {
// Rep ids are UUIDs — there is no static allowlist, so any non-empty
// value passes through. Drizzle parameterises inArray, so this is safe.
const f = parseSalesFilters(params({ assignedTo: 'usr_11111111,usr_22222222' }));
expect(f?.assignedTo).toEqual(['usr_11111111', 'usr_22222222']);
});
it('trims whitespace and drops empty entries in assignedTo', () => {
const f = parseSalesFilters(params({ assignedTo: ' usr_a , , usr_b ' }));
expect(f?.assignedTo).toEqual(['usr_a', 'usr_b']);
});
it('combines source + assignedTo with the existing filters', () => {
const f = parseSalesFilters(
params({
stage: 'eoi',
source: 'website',
assignedTo: 'usr_a',
}),
);
expect(f).toEqual({
stages: ['eoi'],
leadCategories: undefined,
outcomes: undefined,
sources: ['website'],
assignedTo: ['usr_a'],
});
});
});