diff --git a/src/components/search/command-search.tsx b/src/components/search/command-search.tsx index 75312ac5..ef30b846 100644 --- a/src/components/search/command-search.tsx +++ b/src/components/search/command-search.tsx @@ -72,6 +72,11 @@ const BUCKETS: BucketConfig[] = [ { type: 'reminders', label: 'Reminders', icon: Bell }, { type: 'brochures', label: 'Brochures', icon: Camera }, { type: 'tags', label: 'Tags', icon: TagIcon }, + // Pipeline-stage shortcuts ("eoi sent" → /interests?pipelineStage=eoi). + // Sits below Tags so direct entity matches always win the dropdown; + // stage filters are macro affordances that surface when the rep types + // a stage name verbatim. + { type: 'stages', label: 'Stages', icon: TrendingUp }, // Notes are noisy content search. { type: 'notes', label: 'Notes', icon: MessageSquare }, // Navigation (settings pages + admin sub-cards) lives at the very bottom — @@ -1123,6 +1128,23 @@ export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { }); } } + // Stage-filter shortcuts. Typing a stage label ("eoi sent", "deposit", + // "qualified") surfaces a row that lands the rep on the filtered + // interests list. count is the live row total so the rep sees how + // many records they're about to scope to. + if (include('stages')) { + for (const st of results.stages) { + rows.push({ + kind: 'result', + key: `stages:${st.stage}`, + bucket: 'stages', + icon: TrendingUp, + label: `Stage: ${st.label}`, + sub: `${st.count} ${st.count === 1 ? 'interest' : 'interests'} in this stage`, + href: `/${portSlug}${st.href}`, + }); + } + } // Notes — content matches inside free-text notes are noisy by nature, so // the user sees them after the entity-specific buckets above have // surfaced their tighter matches. diff --git a/src/components/search/mobile-search-overlay.tsx b/src/components/search/mobile-search-overlay.tsx index 386a9816..e029b6f4 100644 --- a/src/components/search/mobile-search-overlay.tsx +++ b/src/components/search/mobile-search-overlay.tsx @@ -548,6 +548,7 @@ const BUCKET_LABELS: Record = { tags: 'Tags', navigation: 'Settings & navigation', notes: 'Notes', + stages: 'Stages', }; function Section({ diff --git a/src/hooks/use-search.ts b/src/hooks/use-search.ts index 7e1c084a..29b30ee9 100644 --- a/src/hooks/use-search.ts +++ b/src/hooks/use-search.ts @@ -23,7 +23,8 @@ export type BucketType = | 'brochures' | 'tags' | 'navigation' - | 'notes'; + | 'notes' + | 'stages'; /** * Provenance hint for a result row that surfaced via graph expansion @@ -147,6 +148,16 @@ export interface NavResult { label: string; category: 'settings' | 'admin' | 'dashboard'; } +export interface StageSuggestionResult { + /** Canonical pipeline-stage value (matches PIPELINE_STAGES). */ + stage: string; + /** Human label (STAGE_LABELS[stage]). */ + label: string; + /** Live count of non-archived interests in this stage. */ + count: number; + /** Slug-less href. CommandSearch prefixes the portSlug at render time. */ + href: string; +} export interface OtherPortResult { portId: string; portSlug: string; @@ -174,6 +185,7 @@ export interface SearchResults { tags: TagResult[]; navigation: NavResult[]; notes: NoteResult[]; + stages: StageSuggestionResult[]; totals: Record; otherPorts?: OtherPortResult[]; } diff --git a/src/lib/services/search.service.ts b/src/lib/services/search.service.ts index e40b311a..3c0d30df 100644 --- a/src/lib/services/search.service.ts +++ b/src/lib/services/search.service.ts @@ -191,6 +191,25 @@ export interface NavResult { category: 'settings' | 'admin' | 'dashboard'; } +/** + * Stage-filter shortcut. Surfaced when the rep types a stage name + * ("eoi sent", "deposit", "qualified", "reservation"). Clicking lands + * them on //interests filtered by that stage. Legacy stage + * names (eoi_sent, contract_signed, ...) are remapped to their modern + * 7-stage equivalents before display. + */ +export interface StageSuggestionResult { + /** Canonical PIPELINE_STAGES value — drives the URL filter. */ + stage: string; + /** Human-friendly label (STAGE_LABELS[stage]). */ + label: string; + /** Live count of non-archived interests in this stage. */ + count: number; + /** Pre-built link, including the portSlug placeholder so the UI + * doesn't need to wire stage→href separately. */ + href: string; +} + /** * Note-fragment match. Polymorphic across the four note tables * (client / interest / yacht / company). Each row carries enough @@ -226,6 +245,7 @@ export interface SearchResults { tags: TagResult[]; navigation: NavResult[]; notes: NoteResult[]; + stages: StageSuggestionResult[]; /** * Total count BEFORE per-bucket cap. Lets the UI render * "Show 12 more clients" links into the dedicated /search page. @@ -1090,6 +1110,103 @@ async function searchBrochures( })); } +/** + * Stage-suggestion fuzzy match. Returns matching `PIPELINE_STAGES` + * values when the query looks like part of a stage label. + * + * Three match flavours, all case-insensitive: + * 1. Modern label tokens: query token is a prefix of any token in + * `STAGE_LABELS[stage]` ("eoi sent" → 'eoi'; "dep" → 'deposit_paid'). + * 2. Stage key fragments: `eoi`, `dep`, `qua` against the raw enum + * slugs. + * 3. Legacy aliases: "eoi_sent" / "deposit_10pct" / "contract_signed" + * remap to their modern 7-stage equivalent via `LEGACY_STAGE_REMAP`, + * so reps with muscle memory still land somewhere useful. + * + * Each suggestion carries a live count of non-archived interests in that + * stage so the rep sees how many records they're about to filter to. + * Costs one COUNT(*) query — a single `GROUP BY` keeps it O(stages). + */ +async function searchStages( + portId: string, + query: string, + limit: number, +): Promise { + const { PIPELINE_STAGES, STAGE_LABELS, LEGACY_STAGE_REMAP } = await import('@/lib/constants'); + + const normalized = query.trim().toLowerCase(); + if (!normalized) return []; + const queryTokens = normalized.split(/\s+/).filter(Boolean); + if (queryTokens.length === 0) return []; + + // Score each canonical stage. Higher = better match → sort descending. + // Modern label token-prefix and stage-key prefix score above legacy + // alias matches so canonical names sort first. + const scored: Array<{ stage: (typeof PIPELINE_STAGES)[number]; score: number }> = []; + for (const stage of PIPELINE_STAGES) { + const label = STAGE_LABELS[stage].toLowerCase(); + const labelTokens = label.split(/\s+/).filter(Boolean); + + let score = 0; + // (1) modern label tokens — each query token must prefix some label token + const allModernHit = queryTokens.every( + (q) => labelTokens.some((lt) => lt.startsWith(q)) || stage.startsWith(q), + ); + if (allModernHit) score = 100; + + // (2) stage key fragments — substring on the raw enum slug + if (score < 50 && stage.includes(normalized.replace(/\s+/g, '_'))) { + score = 50; + } + + // (3) legacy alias prefix (e.g. "eoi_signed" → eoi) + if (score === 0) { + for (const [legacy, modern] of Object.entries(LEGACY_STAGE_REMAP)) { + if (modern !== stage) continue; + if ( + legacy.startsWith(normalized.replace(/\s+/g, '_')) || + legacy.replace(/_/g, ' ').startsWith(normalized) + ) { + score = 25; + break; + } + } + } + + if (score > 0) scored.push({ stage, score }); + } + + if (scored.length === 0) return []; + scored.sort((a, b) => b.score - a.score); + const ranked = scored.slice(0, limit); + + // Single COUNT(*) over interests grouped by pipeline_stage. Filtered + // to the matched stages only so the planner can use the partial index + // on (port_id, pipeline_stage) where archived_at IS NULL. + const stageNames = ranked.map((r) => r.stage); + const counts = await db.execute<{ pipeline_stage: string; count: string }>(sql` + SELECT pipeline_stage, COUNT(*)::text AS count + FROM interests + WHERE port_id = ${portId} + AND archived_at IS NULL + AND pipeline_stage = ANY(${stageNames}::text[]) + GROUP BY pipeline_stage + `); + const countByStage = new Map(); + for (const row of Array.from(counts)) { + countByStage.set(row.pipeline_stage, Number(row.count) || 0); + } + + return ranked.map(({ stage }) => ({ + stage, + label: STAGE_LABELS[stage], + count: countByStage.get(stage) ?? 0, + // Caller (CommandSearch) prefixes the portSlug — keep this slug-less + // so the search service stays portSlug-agnostic. + href: `/interests?pipelineStage=${stage}`, + })); +} + async function searchTags(portId: string, query: string, limit: number): Promise { const ilikePattern = `%${query}%`; @@ -1825,6 +1942,7 @@ export async function search( brochures, tags, notes, + stages, otherPorts, ] = await Promise.all([ can(opts, 'clients.view') ? searchClients(portId, query, limit) : Promise.resolve([]), @@ -1862,6 +1980,9 @@ export async function search( can(opts, 'companies.view') ? searchNotes(portId, query, limit) : Promise.resolve([]), + // Stage suggestions are gated on interests.view (the destination of + // the filter). Skipped for users who can't see the interests list. + can(opts, 'interests.view') ? searchStages(portId, query, limit) : Promise.resolve([]), opts.includeOtherPorts && opts.isSuperAdmin ? searchOtherPorts(portId, query, limit) : Promise.resolve([]), @@ -1976,6 +2097,7 @@ export async function search( tags, navigation, notes, + stages, totals: { clients: mergedClients.length, residentialClients: residentialClients.length, @@ -1993,6 +2115,7 @@ export async function search( tags: tags.length, navigation: navigation.length, notes: notes.length, + stages: stages.length, }, otherPorts: otherPorts.length > 0 ? otherPorts : undefined, }; @@ -2161,6 +2284,16 @@ async function runSingleBucket( empty.totals.notes = empty.notes.length; return empty; }) + .with('stages', async () => { + // Stage suggestions have no `id` field so applyAffinity is a no-op + // here. The single-bucket runner still routes through it for + // consistency with the other branches, but the recently-touched + // boost doesn't apply. + if (!can(opts, 'interests.view')) return empty; + empty.stages = await searchStages(portId, query, limit); + empty.totals.stages = empty.stages.length; + return empty; + }) .exhaustive(); } @@ -2199,7 +2332,9 @@ function emptyResults(): SearchResults { tags: 0, navigation: 0, notes: 0, + stages: 0, }, + stages: [], }; }