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

@@ -3,9 +3,9 @@ import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors'; import { errorResponse } from '@/lib/errors';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import { previousPeriodBounds } from '@/lib/analytics/range'; import { previousPeriodBounds } from '@/lib/analytics/range';
import { computeSalesKpiComparison } from '@/lib/services/reports/sales-comparison'; import { computeSalesKpiComparison } from '@/lib/services/reports/sales-comparison';
import { parseSalesFilters } from '@/lib/services/reports/sales-filters';
import { import {
getSalesKpis, getSalesKpis,
getPipelineFunnel, getPipelineFunnel,
@@ -13,25 +13,15 @@ import {
getWinRateOverTime, getWinRateOverTime,
getSourceConversion, getSourceConversion,
getRepLeaderboard, getRepLeaderboard,
getRepFilterOptions,
getDealHeat, getDealHeat,
getRepPerformanceDetail, getRepPerformanceDetail,
getStalledDeals, getStalledDeals,
getClosingThisMonth, getClosingThisMonth,
getRecentWins, getRecentWins,
getLostReasonBreakdown, getLostReasonBreakdown,
type SalesFilters,
} from '@/lib/services/reports/sales.service'; } 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= * GET /api/v1/reports/sales?from=&to=
* *
@@ -48,31 +38,10 @@ const OUTCOMES = [
const querySchema = z.object({ const querySchema = z.object({
from: z.string().datetime().optional(), from: z.string().datetime().optional(),
to: 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). // "1" / "true" enables the prior-period comparison (adds per-KPI deltas).
compare: z.string().optional(), 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 } { function resolveRange(from?: string, to?: string): { from: Date; to: Date } {
const now = new Date(); const now = new Date();
// Defaults: trailing 30 days. Matches the "Last 30 days" preset on // 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) => { withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
try { try {
const params = req.nextUrl.searchParams; 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, from: params.get('from') ?? undefined,
to: params.get('to') ?? 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, compare: params.get('compare') ?? undefined,
}); });
const range = resolveRange(from, to); const range = resolveRange(from, to);
const compareEnabled = compare === '1' || compare === 'true'; const compareEnabled = compare === '1' || compare === 'true';
const priorBounds = compareEnabled ? previousPeriodBounds(range) : null; const priorBounds = compareEnabled ? previousPeriodBounds(range) : null;
const filters: SalesFilters | undefined = (() => { // Detail-table filters: stage / leadCategory / outcome / source /
const stages = parseCsv<PipelineStage>(stage, PIPELINE_STAGES); // assignedTo (rep). Parsed + allowlisted in one pure helper.
const leadCategories = parseCsv<(typeof LEAD_CATEGORIES)[number]>( const filters = parseSalesFilters(params);
leadCategory,
LEAD_CATEGORIES,
);
const outcomes = parseCsv<(typeof OUTCOMES)[number]>(outcome, OUTCOMES);
if (!stages && !leadCategories && !outcomes) return undefined;
return { stages, leadCategories, outcomes };
})();
const [ const [
kpis, kpis,
@@ -120,6 +79,7 @@ export const GET = withAuth(
winRateOverTime, winRateOverTime,
sourceConversion, sourceConversion,
repLeaderboard, repLeaderboard,
repOptions,
dealHeat, dealHeat,
repPerformanceDetail, repPerformanceDetail,
stalledDeals, stalledDeals,
@@ -134,6 +94,7 @@ export const GET = withAuth(
getWinRateOverTime(ctx.portId, range), getWinRateOverTime(ctx.portId, range),
getSourceConversion(ctx.portId), getSourceConversion(ctx.portId),
getRepLeaderboard(ctx.portId, range), getRepLeaderboard(ctx.portId, range),
getRepFilterOptions(ctx.portId),
getDealHeat(ctx.portId), getDealHeat(ctx.portId),
getRepPerformanceDetail(ctx.portId, range, filters), getRepPerformanceDetail(ctx.portId, range, filters),
getStalledDeals(ctx.portId, filters), getStalledDeals(ctx.portId, filters),
@@ -166,6 +127,7 @@ export const GET = withAuth(
winRateOverTime, winRateOverTime,
sourceConversion, sourceConversion,
repLeaderboard, repLeaderboard,
repOptions,
dealHeat, dealHeat,
repPerformanceDetail, repPerformanceDetail,
stalledDeals, stalledDeals,

View File

@@ -27,7 +27,13 @@ import {
import { rangeToBounds, type DateRange } from '@/lib/analytics/range'; import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils'; 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 { formatMoney } from '@/lib/reports/format-currency';
import type { ReportPayload } from '@/lib/reports/types'; import type { ReportPayload } from '@/lib/reports/types';
@@ -197,6 +203,7 @@ interface SalesReportPayload {
winRateOverTime: WinRateOverTime; winRateOverTime: WinRateOverTime;
sourceConversion: SourceConversionRow[]; sourceConversion: SourceConversionRow[];
repLeaderboard: RepLeaderboardRow[]; repLeaderboard: RepLeaderboardRow[];
repOptions: Array<{ userId: string; displayName: string }>;
dealHeat: DealHeatSummary; dealHeat: DealHeatSummary;
repPerformanceDetail: RepPerformanceDetailRow[]; repPerformanceDetail: RepPerformanceDetailRow[];
stalledDeals: StalledDealRow[]; stalledDeals: StalledDealRow[];
@@ -222,7 +229,9 @@ const SOURCE_LABELS: Record<string, string> = {
unknown: 'unknown', 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', key: 'stage',
label: 'Stage', label: 'Stage',
@@ -245,8 +254,19 @@ const FILTER_DEFS: FilterDefinition[] = [
type: 'multi-select', type: 'multi-select',
options: Object.entries(OUTCOME_LABELS).map(([value, label]) => ({ value, label })), 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> { interface SalesTemplateConfig extends Record<string, unknown> {
kind: 'sales'; kind: 'sales';
range: DateRange; range: DateRange;
@@ -299,10 +319,10 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
const filterQs = useMemo(() => { const filterQs = useMemo(() => {
const parts: string[] = []; const parts: string[] = [];
for (const def of FILTER_DEFS) { for (const key of FILTER_KEYS) {
const v = filterValues[def.key]; const v = filterValues[key];
if (Array.isArray(v) && v.length > 0) { 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('&')}` : ''; return parts.length > 0 ? `&${parts.join('&')}` : '';
@@ -334,10 +354,28 @@ export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string })
}; };
const sourceConversion = query.data?.data.sourceConversion ?? []; const sourceConversion = query.data?.data.sourceConversion ?? [];
const repLeaderboard = query.data?.data.repLeaderboard ?? []; const repLeaderboard = query.data?.data.repLeaderboard ?? [];
const repOptions = query.data?.data.repOptions;
// Locked decision: when only ONE rep has activity in window, the // Locked decision: when only ONE rep has activity in window, the
// leaderboard table is awkward (1-row scoreboard). Hide it; the Rep // leaderboard table is awkward (1-row scoreboard). Hide it; the Rep
// performance detail (Task #32) will pick up the slack. // performance detail (Task #32) will pick up the slack.
const showLeaderboard = repLeaderboard.length > 1; 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 dealHeat = query.data?.data.dealHeat;
const repPerformanceDetail = query.data?.data.repPerformanceDetail ?? []; const repPerformanceDetail = query.data?.data.repPerformanceDetail ?? [];
const stalledDeals = query.data?.data.stalledDeals ?? []; 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"> <div className="flex items-center justify-between gap-2 pt-2">
<h2 className="text-sm font-semibold text-foreground">Deal detail</h2> <h2 className="text-sm font-semibold text-foreground">Deal detail</h2>
<FilterBar <FilterBar
filters={FILTER_DEFS} filters={filterDefs}
values={filterValues} values={filterValues}
onChange={handleFilterChange} onChange={handleFilterChange}
onClear={handleFiltersClear} onClear={handleFiltersClear}

View File

@@ -0,0 +1,75 @@
import { PIPELINE_STAGES, type PipelineStage, SOURCES, type SourceValue } from '@/lib/constants';
import type { SalesFilters } from './sales.service';
/**
* Lead-category + outcome allowlists for the Sales report filters. Kept
* here (rather than inline in the route) so the parsing logic is a pure,
* unit-testable function — the route just hands it the query params.
*/
export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;
export const OUTCOMES = [
'won',
'lost_other_marina',
'lost_unqualified',
'lost_no_response',
'lost_other',
'cancelled',
] as const;
const SOURCE_VALUES = SOURCES.map((s) => s.value) as ReadonlyArray<SourceValue>;
/**
* 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 a 400.
*/
function parseCsvAllowed<T extends string>(
raw: string | null,
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;
}
/**
* Parse a CSV filter param into a free list of ids (no allowlist). Used
* for the rep filter — `assigned_to` holds user ids, which have no static
* enum. Empty / whitespace entries are dropped. Drizzle parameterises the
* downstream `inArray`, so unvalidated ids are injection-safe.
*/
function parseCsvFree(raw: string | null): string[] | undefined {
if (!raw) return undefined;
const parts = raw
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0);
return parts.length > 0 ? parts : undefined;
}
/**
* Parse the Sales report filter query params into a `SalesFilters`
* object, or `undefined` when no filters are active. Pure — takes a
* `URLSearchParams` so it round-trips trivially in tests.
*
* Recognised params (all CSV): `stage`, `leadCategory`, `outcome`,
* `source`, `assignedTo`. Date-range (`from`/`to`) and `compare` are NOT
* filters and are ignored here.
*/
export function parseSalesFilters(params: URLSearchParams): SalesFilters | undefined {
const stages = parseCsvAllowed<PipelineStage>(params.get('stage'), PIPELINE_STAGES);
const leadCategories = parseCsvAllowed<(typeof LEAD_CATEGORIES)[number]>(
params.get('leadCategory'),
LEAD_CATEGORIES,
);
const outcomes = parseCsvAllowed<(typeof OUTCOMES)[number]>(params.get('outcome'), OUTCOMES);
const sources = parseCsvAllowed<SourceValue>(params.get('source'), SOURCE_VALUES);
const assignedTo = parseCsvFree(params.get('assignedTo'));
if (!stages && !leadCategories && !outcomes && !sources && !assignedTo) return undefined;
return { stages, leadCategories, outcomes, sources, assignedTo };
}

View File

@@ -25,6 +25,11 @@ export interface SalesFilters {
stages?: PipelineStage[]; stages?: PipelineStage[];
leadCategories?: string[]; leadCategories?: string[];
outcomes?: string[]; outcomes?: string[];
/** Filter to interests assigned to these user IDs (the "rep" filter). */
assignedTo?: string[];
/** Filter to interests with these `source` values (website / referral
* / broker / manual / other). */
sources?: string[];
} }
/** /**
@@ -56,6 +61,12 @@ function buildSalesFiltersWhere(
if (applies.includes('outcomes') && filters.outcomes && filters.outcomes.length > 0) { if (applies.includes('outcomes') && filters.outcomes && filters.outcomes.length > 0) {
conds.push(inArray(interests.outcome, filters.outcomes)); conds.push(inArray(interests.outcome, filters.outcomes));
} }
if (applies.includes('assignedTo') && filters.assignedTo && filters.assignedTo.length > 0) {
conds.push(inArray(interests.assignedTo, filters.assignedTo));
}
if (applies.includes('sources') && filters.sources && filters.sources.length > 0) {
conds.push(inArray(interests.source, filters.sources));
}
if (conds.length === 0) return undefined; if (conds.length === 0) return undefined;
return and(...conds) as SQL; return and(...conds) as SQL;
} }
@@ -746,6 +757,40 @@ export async function getRepLeaderboard(
return result.sort((a, b) => b.pipelineValue - a.pipelineValue); return result.sort((a, b) => b.pipelineValue - a.pipelineValue);
} }
// ─── Rep filter options ──────────────────────────────────────────────────────
export interface RepFilterOption {
userId: string;
displayName: string;
}
/**
* The distinct set of reps that have at least one interest assigned in
* the port — the option list for the Sales report's "Assigned to" (rep)
* filter. Deliberately independent of the active window and the other
* filters so the dropdown stays stable as the user narrows the report.
* Unassigned interests are excluded (the inner join drops null
* `assigned_to`); the filter targets named reps.
*/
export async function getRepFilterOptions(portId: string): Promise<RepFilterOption[]> {
const rows = await db
.selectDistinct({
userId: interests.assignedTo,
displayName: userProfiles.displayName,
})
.from(interests)
.innerJoin(userProfiles, eq(userProfiles.userId, interests.assignedTo))
.where(eq(interests.portId, portId))
.orderBy(userProfiles.displayName);
const options: RepFilterOption[] = [];
for (const r of rows) {
if (!r.userId) continue; // belt-and-braces: inner join already excludes nulls
options.push({ userId: r.userId, displayName: r.displayName ?? 'Unknown' });
}
return options;
}
// ─── Deal heat (Section between leaderboard and detail tables) ─────────────── // ─── Deal heat (Section between leaderboard and detail tables) ───────────────
export type HeatBucket = 'hot' | 'warm' | 'cold'; export type HeatBucket = 'hot' | 'warm' | 'cold';
@@ -937,7 +982,12 @@ export async function getRepPerformanceDetail(
// One query for all open deals across all reps; bucket in JS. // One query for all open deals across all reps; bucket in JS.
const targetCurrency = await resolvePortCurrency(portId); const targetCurrency = await resolvePortCurrency(portId);
const now = Date.now(); const now = Date.now();
const filterWhere = buildSalesFiltersWhere(filters, ['stages', 'leadCategories']); const filterWhere = buildSalesFiltersWhere(filters, [
'stages',
'leadCategories',
'assignedTo',
'sources',
]);
const dealRows = await db const dealRows = await db
.select({ .select({
id: interests.id, id: interests.id,
@@ -1038,7 +1088,12 @@ export async function getStalledDeals(
const now = Date.now(); const now = Date.now();
const targetCurrency = await resolvePortCurrency(portId); const targetCurrency = await resolvePortCurrency(portId);
const filterWhere = buildSalesFiltersWhere(filters, ['stages', 'leadCategories']); const filterWhere = buildSalesFiltersWhere(filters, [
'stages',
'leadCategories',
'assignedTo',
'sources',
]);
const rows = await db const rows = await db
.select({ .select({
id: interests.id, id: interests.id,
@@ -1150,7 +1205,7 @@ export async function getClosingThisMonth(
); );
const stageList: PipelineStage[] = const stageList: PipelineStage[] =
userStages && userStages.length > 0 ? userStages : ['reservation', 'deposit_paid', 'contract']; userStages && userStages.length > 0 ? userStages : ['reservation', 'deposit_paid', 'contract'];
const filterWhere = buildSalesFiltersWhere(filters, ['leadCategories']); const filterWhere = buildSalesFiltersWhere(filters, ['leadCategories', 'assignedTo', 'sources']);
const rows = await db const rows = await db
.select({ .select({
@@ -1240,7 +1295,7 @@ export async function getRecentWins(
filters?: SalesFilters, filters?: SalesFilters,
): Promise<RecentWinRow[]> { ): Promise<RecentWinRow[]> {
const targetCurrency = await resolvePortCurrency(portId); const targetCurrency = await resolvePortCurrency(portId);
const filterWhere = buildSalesFiltersWhere(filters, ['leadCategories']); const filterWhere = buildSalesFiltersWhere(filters, ['leadCategories', 'assignedTo', 'sources']);
const rows = await db const rows = await db
.select({ .select({
id: interests.id, id: interests.id,
@@ -1338,6 +1393,8 @@ export async function getLostReasonBreakdown(
const filterWhere = buildSalesFiltersWhere({ ...filters, outcomes: lossOutcomes }, [ const filterWhere = buildSalesFiltersWhere({ ...filters, outcomes: lossOutcomes }, [
'leadCategories', 'leadCategories',
'outcomes', 'outcomes',
'assignedTo',
'sources',
]); ]);
const rows = await db const rows = await db
.select({ .select({

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'],
});
});
});