From 2c80d9b54fa929c521b609c809176c677444d13c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 29 Jul 2026 22:12:36 -0300 Subject: [PATCH] =?UTF-8?q?B49:=20home=20search=20Lideran=C3=A7as=20group?= =?UTF-8?q?=20on=20staff=20In=C3=ADcio.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends POST /campanha/home-search with leadership hits (word-start, advisor scope, cap 25) and HomeSearchLeadershipGroup below Municípios. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 5 +- AGENTS.md | 2 + .../busca-global-resultados-liderancas.md | 17 ++- docs/roadmap.md | 8 +- .../campanha/(app)/home-search/route.ts | 10 +- src/app/(campaign)/campanha/(app)/page.tsx | 2 + .../dashboard/HomeSearchLeadershipGroup.tsx | 33 ++++++ src/lib/campaignHomeSearchHits.ts | 16 ++- .../homeSearch/loadHomeSearchSuggestions.ts | 1 + .../homeSearch/searchHomeLeaderships.ts | 100 ++++++++++++++++++ .../homeSearch/searchHomeMunicipalities.ts | 2 + tests/int/homeSearchLeaderships.int.spec.ts | 100 ++++++++++++++++++ 12 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 src/components/campaign/dashboard/HomeSearchLeadershipGroup.tsx create mode 100644 src/utilities/homeSearch/searchHomeLeaderships.ts create mode 100644 tests/int/homeSearchLeaderships.int.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 5794c8b6..9866b1b9 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -12,8 +12,9 @@ alwaysApply: false - **B67 entregue em código (2026-07-29):** pan na strip de ações do Início (UX-1) — `CampaignHomeActionStrip` com drag-to-scroll em `pointer-fine` (`HOME_ACTION_STRIP_DRAG_THRESHOLD_PX` 8, `cursor-grab`/`grabbing`); scrollbar oculta (B58) mantida; touch `pan-x` nativo; `onClickCapture` suprime link após arraste. Unit em `campaignHomeActionButton.unit.spec.tsx`. Sem migration. Plano: [`restaurar-pan-strip-acoes-inicio.md`](../../../docs/plans/restaurar-pan-strip-acoes-inicio.md). Fecha Adiado drag desktop de B44. - **B58 entregue em código (2026-07-29):** polimento visual da strip de ações do Início (UX-1) — rótulos curtos no catálogo (`Ajustar votos`, `Registrar sinal`, `Ver esquecidos`, …); removido o `h2` "O que você quer fazer?" (`aria-label` da strip = "Ações rápidas"); scrollbar horizontal oculta (pan nativo touch; drag fine em **B67** ✓); escala ~1,05 no círculo (`pointer-fine` hover / `pointer-coarse` `data-pressing`) + `active:opacity-90`; e2e B47 retarget para `[data-slot="home-actions"]`. Sem migration/action/URL. Plano: [`polimento-strip-acoes-inicio.md`](../../../docs/plans/polimento-strip-acoes-inicio.md). - **Fill-in Cenário junto aos filtros entregue em código (2026-07-29):** em `/campanha/municipios` (staff), o seletor **Cenário** (`VoteEstimateScenarioField`) saiu do header do `MunicipalityListOverview` e entrou na barra slim de `MunicipalityFilters` (desktop: antes de Salvar/Limpar; mobile: após filtros staff, antes de Ordenar). `MunicipalityEstimateScenarioProvider` passou a envolver filtros + resultados; cenário continua estado local (fora da URL e do **Limpar**). Hint `VOTE_ESTIMATE_SCENARIO_FILTERS_HINT`. Sem migration/action/Consent. Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md). -- **B48 entregue em código (2026-07-29):** primeiro provider da busca global do Início — grupo **Municípios** (municípios scoped + TIs no mesmo bloco) via `POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup` no slot B47; readout 2022 alinhado à lista (`MunicipalityVotePositionReadout`). Próximo: **B49** lideranças; **B50** assessores ✓; **B54** grid/cap. Plano: [`busca-global-resultados-municipios.md`](../../../docs/plans/busca-global-resultados-municipios.md). -- **B50 entregue em código (2026-07-29):** grupo **Assessores** na busca global do Início — `searchHomeAdvisors` (só CG/candidato; assessor recebe `[]`); match word-start no nome; secundário = N municípios; `HomeSearchAdvisorGroup` após Municípios; `homeSearchHasAnyHits` no empty; `municipalityIdsByAdvisorIds` exportado de `advisorData`. Int `homeSearchAdvisors.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-assessores.md`](../../../docs/plans/busca-global-resultados-assessores.md). +- **B49 entregue em código (2026-07-29):** segundo grupo da busca global do Início — **Lideranças** via `searchHomeLeaderships` + `HomeSearchLeadershipGroup` (entre Municípios e Assessores); payload `leaderships` no mesmo `POST /campanha/home-search`; empty state considera todos os grupos (`homeSearchHasAnyHits`); linha → `/campanha/liderancas/[id]`; `HomeSearchHitRow` compartilhado. Plano: [`busca-global-resultados-liderancas.md`](../../../docs/plans/busca-global-resultados-liderancas.md). Próximo: **B51** atividades. +- **B50 entregue em código (2026-07-29):** grupo **Assessores** na busca global do Início — `searchHomeAdvisors` (só CG/candidato; assessor recebe `[]`); match word-start no nome; secundário = N municípios; `HomeSearchAdvisorGroup` após Lideranças; `municipalityIdsByAdvisorIds` exportado de `advisorData`. Int `homeSearchAdvisors.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-assessores.md`](../../../docs/plans/busca-global-resultados-assessores.md). +- **B48 entregue em código (2026-07-29):** primeiro provider da busca global do Início — grupo **Municípios** (municípios scoped + TIs no mesmo bloco) via `POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup` no slot B47; readout 2022 alinhado à lista (`MunicipalityVotePositionReadout`). **B54** grid/cap. Plano: [`busca-global-resultados-municipios.md`](../../../docs/plans/busca-global-resultados-municipios.md). - **B47 entregue em código (2026-07-29):** busca global do Início (UX-1) — input staff-only com debounce 250 ms (`HOME_SEARCH_DEBOUNCE_MS`), sem botão Pesquisar; modo focado quando query debounced trim ≥2 chars esconde strip B45 e spacer thumb-zone (`CampaignHomeLayout` `focused`). Ilha `CampaignHomeStaffChrome` + `CampaignHomeSearch` + `HomeSearchContext` / `useHomeSearch` para grupos B48+; contrato em `src/lib/campaignHomeSearchContract.ts`. Leader continua sem omnibox (`CampaignHomeLayout` seco). Escape limpa. Sem migration. Plano: [`busca-global-inicio-input.md`](../../../docs/plans/busca-global-inicio-input.md). ~~Próximo: **B48** resultados Municípios.~~ - **B46 entregue em código (2026-07-29):** thumb-zone do Início (UX-1 chassis) — `CampaignHomeLayout` ancora `CampaignHomeActions` na metade inferior do viewport em mobile (`min-h-full` no shell + spacer `flex-1` acima da strip; clearance existente `pb-24` + bottom nav fixa, sem `position: fixed` na strip) e no topo em `md+` (`md:min-h-0`). Slots `data-slot="home-actions"` e opcional `searchSlot` → `data-slot="home-search"` (ordem documento mobile ações→busca; desktop busca→ações via `order-1`/`order-2`/`order-3`) para **B47** ✓. Wire em `campanha/(app)/page.tsx`. Unit `campaignHomeLayout.unit.spec.ts`; e2e `campaignHomeActions` verde. Sem migration. Plano: [`posicao-botoes-acao-inicio-thumb-zone.md`](../../../docs/plans/posicao-botoes-acao-inicio-thumb-zone.md). - **B45 entregue em código (2026-07-29):** catálogo de ações do Início por persona (UX-1 chassis) — `src/lib/campaignHomeActions.ts` (`homeActionsForRole`, `toHomeActionButtonProps`) + ilha client `CampaignHomeActions` (ícones Lucide no client; RSC passa `role` + `uncoveredMunicipalitiesHref` de `buildMunicipalityListHref`). Staff (coordinator/candidate/advisor): 6 ações; `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; demais inertes (`cursor-default`). Leader: 2 ações; `my-contacts` → `/campanha/contatos`. Seção "O que você quer fazer?" acima da strip B44. Unit `campaignHomeActions.unit.spec.ts`; e2e `campaignHomeActions.e2e.spec.ts`. Sem migration. Plano: [`catalogo-acoes-inicio-por-persona.md`](../../../docs/plans/catalogo-acoes-inicio-por-persona.md). diff --git a/AGENTS.md b/AGENTS.md index c33ea8f0..8b79eeb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-29):** **B59** Chassis do wizard de campanha — rotas staff em `/campanha/acoes/` (`campaignActionRoutes.ts`, `campaignActionEntryHref`), layout `acoes/layout.tsx` (gate staff, coluna `max-w-lg`), `CampaignWizardShell` (voltar mobile, caption de município sticky, `trailingAction`, foco no `h1` ao trocar passo), cinco páginas-ponte até B60 e strip do Início com `href` nos fluxos wizard. Sem migration. Plano: [`docs/plans/chassis-wizard-campanha.md`](docs/plans/chassis-wizard-campanha.md). +**Recently resolved (2026-07-29):** **B49** Busca global — resultados Lideranças no Início staff: o mesmo `POST /campanha/home-search` agora inclui `searchHomeLeaderships` em paralelo com municípios e assessores; word-start no `contact.name` com pré-filtro `buildLeadershipListWhere` (`contains`) refinado em memória, cap 25, `municipalitiesSummary` via `truncatedNamesLabel`, desempate por `updatedAt`; assessor vê só a carteira (`overrideAccess: false`). Client: `HomeSearchLeadershipGroup` entre Municípios e Assessores, `HomeSearchHitRow` compartilhado, empty state via `homeSearchHasAnyHits`; linha → `/campanha/liderancas/[id]`. Sem migration. Plano: [`docs/plans/busca-global-resultados-liderancas.md`](docs/plans/busca-global-resultados-liderancas.md). + **Recently resolved (2026-07-29):** **B48** Busca global — resultados Municípios (+ TIs) no Início staff: `POST /campanha/home-search` (`campaignJsonMutationRoute` + `homeSearchBodySchema`) chama `searchHomeMunicipalities` — municípios via `loadMunicipalityScope` + `matchesAtWordStart` + readout A11; territórios no mesmo grupo via `loadTerritoryOverview` (contexto regional E17, não carteira do assessor). Client: `HomeSearchResultsContext` (fetch único, extensível B49+), `HomeSearchMunicipalityGroup`, empty “Nenhum resultado.”; `MunicipalityVotePositionReadout` extraído de `MunicipalityList`. Sem migration. Plano: [`docs/plans/busca-global-resultados-municipios.md`](docs/plans/busca-global-resultados-municipios.md). **Recently resolved (2026-07-28):** **E11** Motor de sugestões v1 — the 8 highest-confidence data→decision patterns of the research report §6.1 (P1 reduto dormente/ameaçado, P2 ataque, P3 correligionário na frente, P5 expansão, P6 grande sem rede, P7 pledges estagnados, K-A não responde, K-B perdida vestida) now run as a human-in-the-loop decision queue: a "Sugestões" panel on the staff dashboard (top-5 by triage + "pauta do silêncio") and a card on the municipality Overview, with aceitar/adiar/descartar writing `allocationDecision`. Three layers: **`src/lib/suggestionCatalog.ts`** (client-safe — pure predicates over `MunicipalityTriggerInput`, a numbers-only input type, plus the exact card copy: probable reading, the "descartes" that become the dismiss form's quick picks, cheap-before-expensive menu, contraindication, statute; anchors are illustrative and private, calibration = E15); **`src/utilities/municipalityTriggers.ts`** (`server-only`, on the E13 planner's mold — own municipality read because the shared scope select lacks `engagementLevel`/`lastUpdateAt`, six reads in one `Promise.all`, and per-year LQ using E10's exact formula rather than a third LQ writing); **`resolveSuggestionRecord`** which RE-EVALUATES the pattern server-side before writing — the snapshot records decision-time factors, never render-time ones, and a pattern that left the queue refuses with the safe `SUGGESTION_STALE_MESSAGE`; advisory lock `municipality-suggestion:{id}:{patternId}` is taken before re-reading the latest decision. **The audit killed the plan's "sem migration":** the real outcome enum was `aceita|descarta|movimento` (the plan's `descartada|adiada` values never existed), so "Adiar" cost migration `20260728_041547_add_allocation_decision_adiada_outcome` (E14's `movimento` precedent), with `postponeDays` derived from the recomputed triage (7d; 14d on levels 4–5) and `suppressUntil` in the snapshot instead of a column. Suppression is pure and unit-pinned (`isSuggestionSuppressedByDecision`): aceita 14d, descarta 30d, adiada until snooze, malformed snapshot falls back to the shortest postpone — and **"nível 1 fura a fila"**: a trigger returning as confirmed risk pierces a suppression recorded at lower urgency. Structural differential is code (§6.3): P2 yields to P3/K-B, P7 yields to K-A when effort was recorded. P3 fires from the artifact alone (`runningAgain2026` is 100% `desconhecido` with no read path — "concorre de novo?" is the menu's first item, not a trigger). Silence review = alta/N2+ with no FIRED pattern (a suppressed one was answered, not silence) and no signal ≥30d; includes `priority: alta` because E14 shipped without backfill. UI: explicit submit on all three outcomes (the `campanha-edit-where-you-see` exception for note/confirmation flows), native radios (`accent-primary` — first radio precedent in the repo), managed focus across the trigger↔form swap, level 1 is the only red. **Two facts worth keeping:** the public-site base `ul { my-6 ml-6 list-disc }` leaks into campaign surfaces (the factors list needed B20's reset), and artifact-anchored patterns cannot be staged from DB rows — int tests that need an artifact property claim municípios through the fixture allocator until one qualifies (measured: 109/435 top-quartile, 205/435 quiet, and reduto+coreBlock is only 13/435, which moved P1's trigger coverage to the 45-case unit spec; explicit slugs would race the allocator's wrapping sequence). E18 concepts `triagem-de-sugestoes` + `pauta-do-silencio`; `/campanha` First Load JS 268 → 277 kB. Adjacent follow-ups live in the plan: wiring E13's "não vá" override to `resolveSuggestion`; catalog fase 2 (T/I/J, P4/P8–P12, K-C) after 2 weeks of real use + 20 decisions; a dedicated route if >15 recurring active suggestions. Plano: [`docs/plans/motor-de-sugestoes.md`](docs/plans/motor-de-sugestoes.md). diff --git a/docs/plans/busca-global-resultados-liderancas.md b/docs/plans/busca-global-resultados-liderancas.md index 3ff2d00b..da673f47 100644 --- a/docs/plans/busca-global-resultados-liderancas.md +++ b/docs/plans/busca-global-resultados-liderancas.md @@ -1,7 +1,7 @@ # Busca global — resultados Lideranças -Status: rascunho -Atualizado em: 2026-07-29 +Status: entregue (2026-07-29) +Atualizado em: 2026-07-29 — as-built: `searchHomeLeaderships` (`utilities/homeSearch/`); campo `leaderships` no `POST /campanha/home-search`; `HomeSearchLeadershipGroup` abaixo de Municípios; `HomeSearchHitRow` compartilhado; `homeSearchHasAnyHits` no empty state; word-start em `contact.name` + pré-filtro `contains`; secundário = municípios (`truncatedNamesLabel`); cap 25; sem WA (B55). Int: `homeSearchLeaderships.int.spec.ts`. Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B49 — busca global) Impeccable: B — grupo de hits no slot B47 Appetite: ~0,5–0,75 dia eng; loader + grupo UI; sem WA (B55) @@ -64,7 +64,18 @@ flowchart LR ## Adiado com gatilho -Nenhum neste item. +- **Scan completo de lideranças** (`limit: 0` + `contains` no DB, word-start em memória). **Gatilho:** base de lideranças crescer além do confortável da planilha / latência perceptível no Início — considerar `pg_trgm` ou prefixo SQL. +- **Staff gate duplicado** (`isStaffCampaignRole` em cada loader vs. só na rota). **Gatilho:** 3º provider no mesmo POST ou refactor do orquestrador. +- **Rename `homeSearchMunicipalityMatch.ts`** → nome genérico (`homeSearchNameMatch.ts`). **Gatilho:** 3º domínio além de município/liderança/assessor no mesmo helper. +- **`HomeSearchGroup` wrapper** genérico para os grupos B48–B53. **Gatilho:** 3º grupo com a mesma casca (título + lista). +- **Orquestrador único do POST** e carga duplicada herdada de B48/B50. Ver [busca-global-resultados-municipios.md](busca-global-resultados-municipios.md) § Adiado e [busca-global-resultados-assessores.md](busca-global-resultados-assessores.md) § Adiado. + +## Já resolvido no simplify (2026-07-29 — não reabrir) + +- `populatedContactName` em vez de helper local; nomes de município via `depth: 1` (sem `loadMunicipalityLabelsByIds` extra). +- Sort + `slice(25)` antes de montar `municipalitiesSummary`; `select` no find; `Number.isFinite` em `Date.parse(updatedAt)`. +- `homeSearchLeadershipGroupHasHits`; empty state e shell alinhados ao padrão B50/B68. +- Rebase com **B50** + **B68**: `tieBreakDesc` (não `tieBreaker`); `showPriority` no `HomeSearchHitRow`; `resultKind === 'search'` no empty; ordem Municípios → Lideranças → Assessores. ## Referências diff --git a/docs/roadmap.md b/docs/roadmap.md index 6d1a1b1a..f30462f4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-29 — **B69**–**B70** A4 liderança (exclusivo+limpeza → wizard grid); ~~**B56**~~ ✓ resumo; ~~**B67**~~ ✓; ~~**B68**~~ ✓ sugestões na busca aberta; **B66**; ~~**B59**~~ ✓ chassis wizard; ~~**B58**~~ ✓; **B65**; B48 ✓; B49–B55; wizards B60–B64. +Atualizado em: 2026-07-29 — **B69**–**B70** A4 liderança (exclusivo+limpeza → wizard grid); ~~**B56**~~ ✓ resumo; ~~**B67**~~ ✓; ~~**B68**~~ ✓ sugestões na busca aberta; **B66**; ~~**B59**~~ ✓ chassis wizard; ~~**B58**~~ ✓; **B65**; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B51–B55; wizards B60–B64. Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -83,7 +83,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re ### Demais itens abertos -- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → **B65** (âncora inferior mobile) → **B66** (focus limpa tela + animação) → ~~**B48**~~ ✓ → **B68** (sugestões empty/focus por papel) → **B49–B55** · ~~**B56**~~ ✓ (resumo) · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → **B60**/**B61**/**B64** · **B62** → **B63** · **B69** → **B70** (A4 liderança). +- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → **B65** (âncora inferior mobile) → **B66** (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ (sugestões empty/focus por papel) → ~~**B50**~~ ✓ → **B51–B55** · ~~**B56**~~ ✓ (resumo) · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → **B60**/**B61**/**B64** · **B62** → **B63** · **B69** → **B70** (A4 liderança). - ~~**B43** Início em branco + Quadro~~ — **entregue 2026-07-29** (`/campanha` blank; staff dashboard em `/campanha/quadro`; liderança em `/campanha/contatos`; nav Início+Quadro / Início+Meus contatos; `requireCampaignPageActor` manda leader bloqueado → `/campanha/contatos`; sem migration) · [plano](plans/inicio-em-branco-quadro.md) - ~~**B44** Botão de ação do Início + lista horizontal~~ — **entregue 2026-07-29** (`CampaignHomeActionButton` + `CampaignHomeActionStrip`; Tooltip fine / long-press Drawer coarse; montado no Início por B45 ✓) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md) - ~~**B45** Catálogo de ações por persona~~ — **entregue 2026-07-29** (`src/lib/campaignHomeActions.ts` + `CampaignHomeActions`; staff 6 ações com `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; leader 2 com `my-contacts` → `/campanha/contatos`; demais inertes; ilha client por limite RSC↔ícones Lucide) · [plano](plans/catalogo-acoes-inicio-por-persona.md) @@ -103,8 +103,8 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **B65** Ancorar busca + ações do Início no limite inferior (mobile) — B46 ✓ entregou ordem/slots mas o spacer `flex-1` não empurra: `min-h-full` no filho do scrollport não cria altura livre; preencher cadeia (`h-full`/flex fill) no chrome do Início; `md+` inalterado; modo focado B47/B66 intacto · Depende de B46 ✓ / B47 ✓ (código) · soft B66 (animação parte do dock) · ~0,25–0,5d · Janela 1 · Paralelizável com B58 / B49 / B59 · Impeccable B · [plano](plans/ancorar-busca-acoes-inicio-mobile.md) - ~~**B48** Busca — resultados Municípios (+ TIs no mesmo grupo; flag de prioridade alinhada; “2022” / agregado à direita; sem card)~~ — **entregue 2026-07-29** (`POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup`; word-start; assessor scoped; TIs via `loadTerritoryOverview`) · [plano](plans/busca-global-resultados-municipios.md) - ~~**B68** Sugestões na busca aberta (empty state por papel)~~ — **entregue 2026-07-29** (`POST /campanha/home-search` com `mode: 'suggest'`; `loadHomeSearchSuggestions` + `rankHomeSearchSuggestMunicipalities`; assessor = carteira fria; CG/candidato = `priority === 'alta'` por déficit central + frescor; `homeSearchUiFocused` + seção **Sugestões**; `resultKind`; sem migration) · [plano](plans/sugestoes-busca-vazia-inicio.md) -- **B49** Busca — resultados Lideranças · Depende de B47 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-liderancas.md) -- **B50** ~~Busca — resultados Assessores (access B19)~~ — **entregue 2026-07-29** (`searchHomeAdvisors` + `HomeSearchAdvisorGroup`; unrestricted only; word-start no nome; contagem de municípios na linha; `POST /campanha/home-search` agrega `advisors`) · Depende de B47 ✓ · [plano](plans/busca-global-resultados-assessores.md) +- ~~**B49** Busca — resultados Lideranças~~ — **entregue 2026-07-29** (`searchHomeLeaderships`, `HomeSearchLeadershipGroup`, `leaderships` no POST `/campanha/home-search`; word-start; assessor scoped; municípios no secundário; cap 25; sem WA — **B55**) · Depende de B47 ✓ · [plano](plans/busca-global-resultados-liderancas.md) +- ~~**B50** Busca — resultados Assessores (access B19)~~ — **entregue 2026-07-29** (`searchHomeAdvisors` + `HomeSearchAdvisorGroup`; unrestricted only; word-start no nome; contagem de municípios na linha; `POST /campanha/home-search` agrega `advisors`) · Depende de B47 ✓ · [plano](plans/busca-global-resultados-assessores.md) - **B51** Busca — resultados Atividades · Depende de B47 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-atividades.md) - **B52** Busca — resultados Dobradinhas · Depende de B47 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-dobradinhas.md) - **B53** Busca — resultados Demandas · Depende de B47 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-demandas.md) diff --git a/src/app/(campaign)/campanha/(app)/home-search/route.ts b/src/app/(campaign)/campanha/(app)/home-search/route.ts index 8950207c..d3312428 100644 --- a/src/app/(campaign)/campanha/(app)/home-search/route.ts +++ b/src/app/(campaign)/campanha/(app)/home-search/route.ts @@ -10,6 +10,7 @@ import { getCampaignActionContext } from '@/utilities/campaignActionContext' import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearchSuggestions' import { searchHomeAdvisors } from '@/utilities/homeSearch/searchHomeAdvisors' +import { searchHomeLeaderships } from '@/utilities/homeSearch/searchHomeLeaderships' import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities' export const dynamic = 'force-dynamic' @@ -28,10 +29,15 @@ export const POST = campaignJsonMutationRoute( return NextResponse.json(result) } - const [municipalityResult, advisors] = await Promise.all([ + const [municipalityResult, advisors, leaderships] = await Promise.all([ searchHomeMunicipalities(payload, actor, body.query), searchHomeAdvisors(payload, actor, body.query), + searchHomeLeaderships(payload, actor, body.query), ]) - return NextResponse.json({ ...municipalityResult, advisors }) + return NextResponse.json({ + ...municipalityResult, + advisors, + leaderships, + }) }, ) diff --git a/src/app/(campaign)/campanha/(app)/page.tsx b/src/app/(campaign)/campanha/(app)/page.tsx index b77ab592..5b1a1654 100644 --- a/src/app/(campaign)/campanha/(app)/page.tsx +++ b/src/app/(campaign)/campanha/(app)/page.tsx @@ -7,6 +7,7 @@ import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHome import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/CampaignHomeStaffChrome' import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary' import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSearchAdvisorGroup' +import { HomeSearchLeadershipGroup } from '@/components/campaign/dashboard/HomeSearchLeadershipGroup' import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup' import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' @@ -47,6 +48,7 @@ export default async function CampaignHomePage() { searchResults={ + } diff --git a/src/components/campaign/dashboard/HomeSearchLeadershipGroup.tsx b/src/components/campaign/dashboard/HomeSearchLeadershipGroup.tsx new file mode 100644 index 00000000..58be3502 --- /dev/null +++ b/src/components/campaign/dashboard/HomeSearchLeadershipGroup.tsx @@ -0,0 +1,33 @@ +'use client' + +import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow' +import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext' +import { homeSearchLeadershipGroupHasHits } from '@/lib/campaignHomeSearchHits' + +export const HomeSearchLeadershipGroup = () => { + const { results } = useHomeSearchResults() + + if (results.status !== 'success') return null + + const { leaderships } = results.data + if (!homeSearchLeadershipGroupHasHits(results.data)) return null + + return ( +
+

+ Lideranças +

+
    + {leaderships.map((hit) => ( +
  • + +
  • + ))} +
+
+ ) +} diff --git a/src/lib/campaignHomeSearchHits.ts b/src/lib/campaignHomeSearchHits.ts index 9da673ca..e803103b 100644 --- a/src/lib/campaignHomeSearchHits.ts +++ b/src/lib/campaignHomeSearchHits.ts @@ -25,6 +25,13 @@ export type HomeSearchAdvisorHit = { municipalityCount: number } +export type HomeSearchLeadershipHit = { + kind: 'leadership' + id: number + name: string + municipalitiesSummary: string +} + type HomeSearchResultKind = 'search' | 'suggest' export type HomeSearchSuccessResponse = { @@ -33,12 +40,17 @@ export type HomeSearchSuccessResponse = { municipalities: HomeSearchMunicipalityHit[] territories: HomeSearchTerritoryHit[] advisors: HomeSearchAdvisorHit[] + leaderships: HomeSearchLeadershipHit[] } export const homeSearchMunicipalityGroupHasHits = ( data: Pick, ): boolean => data.municipalities.length > 0 || data.territories.length > 0 +export const homeSearchLeadershipGroupHasHits = ( + data: Pick, +): boolean => data.leaderships.length > 0 + export const toHomeSearchMunicipalityHit = (doc: { slug: string name: string @@ -54,4 +66,6 @@ export const toHomeSearchMunicipalityHit = (doc: { }) export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean => - homeSearchMunicipalityGroupHasHits(data) || data.advisors.length > 0 + homeSearchMunicipalityGroupHasHits(data) || + data.advisors.length > 0 || + homeSearchLeadershipGroupHasHits(data) diff --git a/src/utilities/homeSearch/loadHomeSearchSuggestions.ts b/src/utilities/homeSearch/loadHomeSearchSuggestions.ts index 0f2530e4..aac390c9 100644 --- a/src/utilities/homeSearch/loadHomeSearchSuggestions.ts +++ b/src/utilities/homeSearch/loadHomeSearchSuggestions.ts @@ -80,5 +80,6 @@ export const loadHomeSearchSuggestions = async ( municipalities: municipalityHits, territories: [], advisors: [], + leaderships: [], } } diff --git a/src/utilities/homeSearch/searchHomeLeaderships.ts b/src/utilities/homeSearch/searchHomeLeaderships.ts new file mode 100644 index 00000000..9989b053 --- /dev/null +++ b/src/utilities/homeSearch/searchHomeLeaderships.ts @@ -0,0 +1,100 @@ +import 'server-only' + +import type { Payload } from 'payload' + +import { homeSearchQueryIsActive, normalizeHomeSearchRaw } from '@/lib/campaignHomeSearchContract' +import type { HomeSearchLeadershipHit } from '@/lib/campaignHomeSearchHits' +import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages' +import { isStaffCampaignRole } from '@/lib/campaignRoles' +import { + compareHomeSearchNameRelevance, + normalizeHomeSearchName, +} from '@/lib/homeSearchMunicipalityMatch' +import { populatedContactName } from '@/lib/relationship' +import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter' +import type { CampaignUser, Leadership } from '@/payload-types' +import { truncatedNamesLabel } from '@/utilities/campaignListUrl' +import { buildLeadershipListWhere } from '@/utilities/leadership/leadershipListUrl' + +const HOME_SEARCH_LEADERSHIP_HIT_CAP = 25 + +const municipalityNamesFromLeadership = (doc: Leadership): string[] => { + const names = new Set() + for (const municipality of doc.municipalities ?? []) { + if (typeof municipality === 'object' && municipality !== null && 'name' in municipality) { + const name = municipality.name + if (typeof name === 'string' && name.length > 0) names.add(name) + } + } + return [...names].sort((left, right) => left.localeCompare(right, 'pt-BR')) +} + +export const searchHomeLeaderships = async ( + payload: Payload, + user: CampaignUser, + rawQuery: string, +): Promise => { + if (!isStaffCampaignRole(user.role)) { + throw new Error(HOME_SEARCH_STAFF_ONLY_MESSAGE) + } + + const query = normalizeHomeSearchRaw(rawQuery) + if (!homeSearchQueryIsActive(query)) { + return [] + } + + const normalizedQuery = normalizeHomeSearchName(query) + + // List search uses `contains`; home search refines to word-start in memory (B49). + const result = await payload.find({ + collection: 'leadership', + where: buildLeadershipListWhere({ page: 1, q: query }), + depth: 1, + limit: 0, + pagination: false, + select: { + id: true, + updatedAt: true, + contact: true, + municipalities: true, + }, + user, + overrideAccess: false, + }) + + const candidates: Array<{ + normalizedName: string + tieBreakDesc: number + id: number + name: string + municipalityNames: string[] + }> = [] + + for (const doc of result.docs as Leadership[]) { + const name = populatedContactName(doc.contact) + const normalizedName = normalizeHomeSearchName(name) + if (!matchesNormalizedAtWordStart(normalizedName, normalizedQuery)) { + continue + } + const tieBreakDesc = Date.parse(doc.updatedAt) + candidates.push({ + normalizedName, + tieBreakDesc: Number.isFinite(tieBreakDesc) ? tieBreakDesc : 0, + id: doc.id, + name, + municipalityNames: municipalityNamesFromLeadership(doc), + }) + } + + return candidates + .sort((left, right) => compareHomeSearchNameRelevance(left, right, normalizedQuery)) + .slice(0, HOME_SEARCH_LEADERSHIP_HIT_CAP) + .map( + (row): HomeSearchLeadershipHit => ({ + kind: 'leadership', + id: row.id, + name: row.name, + municipalitiesSummary: truncatedNamesLabel(row.municipalityNames), + }), + ) +} diff --git a/src/utilities/homeSearch/searchHomeMunicipalities.ts b/src/utilities/homeSearch/searchHomeMunicipalities.ts index e15b2b42..b34df2b3 100644 --- a/src/utilities/homeSearch/searchHomeMunicipalities.ts +++ b/src/utilities/homeSearch/searchHomeMunicipalities.ts @@ -27,6 +27,7 @@ const emptyResponse = (): HomeSearchSuccessResponse => ({ municipalities: [], territories: [], advisors: [], + leaderships: [], }) export const searchHomeMunicipalities = async ( @@ -112,5 +113,6 @@ export const searchHomeMunicipalities = async ( municipalities: municipalityHits, territories: territoryHits, advisors: [], + leaderships: [], } } diff --git a/tests/int/homeSearchLeaderships.int.spec.ts b/tests/int/homeSearchLeaderships.int.spec.ts new file mode 100644 index 00000000..49fbe6be --- /dev/null +++ b/tests/int/homeSearchLeaderships.int.spec.ts @@ -0,0 +1,100 @@ +// @vitest-environment node + +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import { beforeAll, describe, expect, it } from 'vitest' + +import config from '@/payload.config' +import { searchHomeLeaderships } from '@/utilities/homeSearch/searchHomeLeaderships' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +describe('searchHomeLeaderships (B49)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('returns empty hits when query is shorter than the minimum', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + + const result = await searchHomeLeaderships(payload, coordinator, 'a') + expect(result).toEqual([]) + }) + + it('matches leaderships by word-start on contact name', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality('cairu') + const contact = await fixtures.createContact({ name: 'Zeca Pagodinho' }) + const leadership = await fixtures.createLeadership({ + contact: contact.id, + municipalities: [municipality.id], + }) + + const result = await searchHomeLeaderships(payload, coordinator, 'Zeca') + const hit = result.find((row) => row.id === leadership.id) + expect(hit?.name).toBe('Zeca Pagodinho') + expect(hit?.municipalitiesSummary).toContain('Cairu') + }) + + it('does not match mid-word without word-start boundary', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality('cairu') + const contact = await fixtures.createContact({ name: 'Maria Silva' }) + await fixtures.createLeadership({ + contact: contact.id, + municipalities: [municipality.id], + }) + + const result = await searchHomeLeaderships(payload, coordinator, 'ari') + expect(result).toEqual([]) + }) + + it('scopes leadership hits to the advisor portfolio', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const coordinator = await fixtures.createCampaignUser('coordinator') + + const administered = await fixtures.getMunicipality('cairu') + const other = await fixtures.getMunicipality('feira-de-santana') + await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) + + const inScope = await fixtures.createContact({ name: 'Lider Cairu' }) + const outOfScope = await fixtures.createContact({ name: 'Lider Feira' }) + const scopedLeadership = await fixtures.createLeadership({ + contact: inScope.id, + municipalities: [administered.id], + }) + const otherLeadership = await fixtures.createLeadership({ + contact: outOfScope.id, + municipalities: [other.id], + }) + + const advisorResult = await searchHomeLeaderships(payload, advisor, 'Lider') + expect(advisorResult.map((hit) => hit.id)).toEqual([scopedLeadership.id]) + + const coordinatorResult = await searchHomeLeaderships(payload, coordinator, 'Lider') + expect(coordinatorResult.map((hit) => hit.id).sort((a, b) => a - b)).toEqual( + [scopedLeadership.id, otherLeadership.id].sort((a, b) => a - b), + ) + }) + + it('rejects leaders from home search', async () => { + const fixtures = campaignFixtures() + const leader = await fixtures.createCampaignUser('leader') + + await expect(searchHomeLeaderships(payload, leader, 'Lider')).rejects.toThrow( + /equipe de campanha/i, + ) + }) +})