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 { 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[];
leadCategories?: 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) {
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;
return and(...conds) as SQL;
}
@@ -746,6 +757,40 @@ export async function getRepLeaderboard(
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) ───────────────
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.
const targetCurrency = await resolvePortCurrency(portId);
const now = Date.now();
const filterWhere = buildSalesFiltersWhere(filters, ['stages', 'leadCategories']);
const filterWhere = buildSalesFiltersWhere(filters, [
'stages',
'leadCategories',
'assignedTo',
'sources',
]);
const dealRows = await db
.select({
id: interests.id,
@@ -1038,7 +1088,12 @@ export async function getStalledDeals(
const now = Date.now();
const targetCurrency = await resolvePortCurrency(portId);
const filterWhere = buildSalesFiltersWhere(filters, ['stages', 'leadCategories']);
const filterWhere = buildSalesFiltersWhere(filters, [
'stages',
'leadCategories',
'assignedTo',
'sources',
]);
const rows = await db
.select({
id: interests.id,
@@ -1150,7 +1205,7 @@ export async function getClosingThisMonth(
);
const stageList: PipelineStage[] =
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
.select({
@@ -1240,7 +1295,7 @@ export async function getRecentWins(
filters?: SalesFilters,
): Promise<RecentWinRow[]> {
const targetCurrency = await resolvePortCurrency(portId);
const filterWhere = buildSalesFiltersWhere(filters, ['leadCategories']);
const filterWhere = buildSalesFiltersWhere(filters, ['leadCategories', 'assignedTo', 'sources']);
const rows = await db
.select({
id: interests.id,
@@ -1338,6 +1393,8 @@ export async function getLostReasonBreakdown(
const filterWhere = buildSalesFiltersWhere({ ...filters, outcomes: lossOutcomes }, [
'leadCategories',
'outcomes',
'assignedTo',
'sources',
]);
const rows = await db
.select({