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
src/lib/services/reports/sales-filters.ts
Normal file
75
src/lib/services/reports/sales-filters.ts
Normal 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 };
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user