mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge branch 'cursor/339abbdd' — B49 busca global Lideranças
This commit is contained in:
commit
bceab5d7df
12 changed files with 285 additions and 13 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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/<slug>` (`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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-29 — **B71**–**B72** polish busca (bullets/título/hover full-bleed) + strip ações (gap); **B69**–**B70** A4 liderança; ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; **B65**; B48 ✓; B49–B55; wizards B60–B64.
|
||||
Atualizado em: 2026-07-29 — **B71**–**B72** polish busca (bullets/título/hover full-bleed) + strip ações (gap); **B69**–**B70** A4 liderança; ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; **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**~~ ✓ → **B71** (polish resultados busca) · **B72** (gap strip) → **B49–B55** · ~~**B56**~~ ✓ · **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**~~ ✓ → **B71** (polish resultados busca) · **B72** (gap strip) → ~~**B50**~~ ✓ → **B51–B55** · ~~**B56**~~ ✓ · **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,10 +103,10 @@ 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)
|
||||
- **B71** Polimento visual dos resultados de busca (sem bullets no `<ul>`; título “Municípios” discreto sentence-case; hover full-bleed borda a borda) · Depende de B48 ✓ · ~0,25–0,5d · Janela 1 · Paralelizável com B72 / B65 / B49 · Impeccable B · [plano](plans/polimento-resultados-busca-inicio.md)
|
||||
- **B71** Polimento visual dos resultados de busca (sem bullets no `<ul>`; título “Municípios” discreto sentence-case; hover full-bleed borda a borda) · Depende de B48 ✓ · ~0,25–0,5d · Janela 1 · Paralelizável com B72 / B65 / B51 · Impeccable B · [plano](plans/polimento-resultados-busca-inicio.md)
|
||||
- **B72** Reduzir espaçamento horizontal da strip de ações (`gap-6` → `gap-4`) · Depende de B58 ✓ / B67 ✓ (soft) · ~0,25d · Janela 1 · Paralelizável com B71 · Impeccable B · [plano](plans/espacamento-strip-acoes-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)
|
||||
|
|
|
|||
|
|
@ -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<HomeSearchSuccessResponse>(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<HomeSearchSuccessResponse>({ ...municipalityResult, advisors })
|
||||
return NextResponse.json<HomeSearchSuccessResponse>({
|
||||
...municipalityResult,
|
||||
advisors,
|
||||
leaderships,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
<HomeSearchResultsShell>
|
||||
<HomeSearchMunicipalityGroup />
|
||||
<HomeSearchLeadershipGroup />
|
||||
<HomeSearchAdvisorGroup />
|
||||
</HomeSearchResultsShell>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<section aria-label="Lideranças" className="flex flex-col gap-1">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Lideranças
|
||||
</h2>
|
||||
<ul className="flex flex-col">
|
||||
{leaderships.map((hit) => (
|
||||
<li key={`leadership-${hit.id}`}>
|
||||
<HomeSearchHitRow
|
||||
href={`/campanha/liderancas/${hit.id}`}
|
||||
primary={hit.name}
|
||||
secondary={hit.municipalitiesSummary || undefined}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<HomeSearchSuccessResponse, 'municipalities' | 'territories'>,
|
||||
): boolean => data.municipalities.length > 0 || data.territories.length > 0
|
||||
|
||||
export const homeSearchLeadershipGroupHasHits = (
|
||||
data: Pick<HomeSearchSuccessResponse, 'leaderships'>,
|
||||
): 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)
|
||||
|
|
|
|||
|
|
@ -80,5 +80,6 @@ export const loadHomeSearchSuggestions = async (
|
|||
municipalities: municipalityHits,
|
||||
territories: [],
|
||||
advisors: [],
|
||||
leaderships: [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
100
src/utilities/homeSearch/searchHomeLeaderships.ts
Normal file
100
src/utilities/homeSearch/searchHomeLeaderships.ts
Normal file
|
|
@ -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<string>()
|
||||
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<HomeSearchLeadershipHit[]> => {
|
||||
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),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
100
tests/int/homeSearchLeaderships.int.spec.ts
Normal file
100
tests/int/homeSearchLeaderships.int.spec.ts
Normal file
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue