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:
75
tests/unit/services/reports/sales-filters.test.ts
Normal file
75
tests/unit/services/reports/sales-filters.test.ts
Normal 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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user