B52: add Dobradinhas group to staff home search.

Staff can find state deputies by name or party with municipality counts in the global Início search, mirroring the B49/B50 loader and UI pattern.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 22:51:57 -03:00
parent bacc4b1c41
commit 38459b7426
17 changed files with 347 additions and 57 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B52 entregue em código (2026-07-29):** quarto grupo da busca global do Início — **Dobradinhas** via `searchHomeStateDeputies` + `HomeSearchStateDeputyGroup` (após Assessores); `POST /campanha/home-search` agrega `stateDeputies`; match word-start em nome ou partido; secundário `partido · N municípios`; assessor vê hits (staff read); `municipalityIdsByStateDeputyIds` em `stateDeputyData`. Int `homeSearchStateDeputies.int.spec.ts`. WA = **B55** (sem telefone na entidade). Plano: [`busca-global-resultados-dobradinhas.md`](../../../docs/plans/busca-global-resultados-dobradinhas.md). Próximo na cadeia busca: **B51** atividades.
- **B72 entregue em código (2026-07-29):** gap horizontal da strip de ações do Início (UX-1) — `CampaignHomeActionStrip` `<ul>` `gap-6` → `gap-4` (16px); `w-[4.75rem]`, círculo 48px, `snap-x`, scrollbar oculta e pan B67 ✓ inalterados. Sem migration/action/URL. Unit strip 11/11 em `campaignHomeActionButton.unit.spec.tsx`. Plano: [`espacamento-strip-acoes-inicio.md`](../../../docs/plans/espacamento-strip-acoes-inicio.md).
- **B65 entregue em código (2026-07-29):** âncora inferior mobile no Início — cadeia `h-full min-h-0` em `CampaignPageShell` + `CampaignHomeStaffChrome` + `CampaignHomeLayout`; wrapper `data-slot="home-dock"` agrupa strip B45 + busca B47; spacer `flex-1` empurra o dock acima da bottom nav (`pb-24` intacto); `md+` inalterado; modo focado B47/B66 intacto; sem `fixed`/`mt-auto`. Unit `campaignHomeLayout.unit.spec.tsx` (6 casos). Plano: [`ancorar-busca-acoes-inicio-mobile.md`](../../../docs/plans/ancorar-busca-acoes-inicio-mobile.md). Desbloqueia animação idle de **B66**. Débito adiado: ajuste fino `pb-24` vs altura real da bottom nav se gap >~8 px em device real.
- **B59 entregue em código (2026-07-29):** chassis UX-1 dos wizards de ação — `CampaignWizardShell` (`shared/`: Voltar só mobile com rótulo visível, caption sticky central de município, `contentAlign`, foco no `h1` ao trocar passo); rotas staff `/campanha/acoes/<slug>` (cinco entradas espelhando o catálogo B45, passo ponte "Em qual município?" até **B60**); `campaignActionRoutes.ts` + hrefs em `toHomeActionButtonProps`; layout `acoes/` com gate `staff`. Sem migration/action/Consent. Unit `campaignActionRoutes`, `campaignWizardShell`, `campaignHomeActions` atualizado; e2e Início → `atualizar-votos`. Plano: [`chassis-wizard-campanha.md`](../../../docs/plans/chassis-wizard-campanha.md). Desbloqueia **B60** / **B61** / **B63** / **B64**.
@ -15,7 +16,7 @@ alwaysApply: false
- **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).
- **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).
- **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). ~~Próximo: **B51** atividades.~~ **B52** ✓ dobradinhas; **B51** atividades pendente.
- **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` com slots `data-slot="home-actions"` / `home-search` (ordem mobile ações→busca; desktop busca→ações). Âncora inferior corrigida por **B65** ✓. Plano: [`posicao-botoes-acao-inicio-thumb-zone.md`](../../../docs/plans/posicao-botoes-acao-inicio-thumb-zone.md).

View file

@ -1,7 +1,7 @@
# Busca global — resultados Dobradinhas
Status: rascunho
Atualizado em: 2026-07-29
Status: entregue (2026-07-29)
Atualizado em: 2026-07-29 — as-built: `searchHomeStateDeputies` (staff; word-start em nome ou partido; cap 25; `municipalityIdsByStateDeputyIds` exportado de `stateDeputyData`); `HomeSearchStateDeputyHit` + campo `stateDeputies` no `POST /campanha/home-search`; `HomeSearchStateDeputyGroup` após Assessores; secundário = partido · N municípios; sem WA (B55 — entidade sem telefone); int `homeSearchStateDeputies.int.spec.ts`.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B52 — busca global)
Impeccable: B — grupo de hits no slot B47
Appetite: ~0,5 dia eng; loader + grupo; WA = B55

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-29 — ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B51–B55; wizards B60–B64.
Atualizado em: 2026-07-29 — ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **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**~~ ✓ → ~~**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).
- **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**~~ ✓ → ~~**B52**~~ ✓ → **B51** · **B53–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)
@ -108,7 +108,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- ~~**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)
- **B52** ~~Busca — resultados Dobradinhas~~ — **entregue 2026-07-29** (`searchHomeStateDeputies` + `HomeSearchStateDeputyGroup`; staff incl. assessor; word-start em nome ou partido; secundário partido · contagem de municípios; `POST /campanha/home-search` agrega `stateDeputies`; WA = **B55**) · Depende de B47 ✓ · [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)
- **B54** Busca — layout resultados (mobile empilhado; tablet 2 col; desktop 3 col; cap ao viewport; prioridade Municípios) · Depende de B47 · soft B48 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-layout-resultados.md)
- **B55** Busca — ações de linha (WA em lideranças/assessores/dobradinhas; share nativo mobile / WA no desktop em demandas/atividades) · Depende de B47 + ≥1 de B49–B53 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-acoes-linha.md)

View file

@ -12,6 +12,7 @@ import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearch
import { searchHomeAdvisors } from '@/utilities/homeSearch/searchHomeAdvisors'
import { searchHomeLeaderships } from '@/utilities/homeSearch/searchHomeLeaderships'
import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities'
import { searchHomeStateDeputies } from '@/utilities/homeSearch/searchHomeStateDeputies'
export const dynamic = 'force-dynamic'
@ -29,15 +30,17 @@ export const POST = campaignJsonMutationRoute(
return NextResponse.json<HomeSearchSuccessResponse>(result)
}
const [municipalityResult, advisors, leaderships] = await Promise.all([
const [municipalityResult, advisors, leaderships, stateDeputies] = await Promise.all([
searchHomeMunicipalities(payload, actor, body.query),
searchHomeAdvisors(payload, actor, body.query),
searchHomeLeaderships(payload, actor, body.query),
searchHomeStateDeputies(payload, actor, body.query),
])
return NextResponse.json<HomeSearchSuccessResponse>({
...municipalityResult,
advisors,
leaderships,
stateDeputies,
})
},
)

View file

@ -10,6 +10,7 @@ import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSear
import { HomeSearchLeadershipGroup } from '@/components/campaign/dashboard/HomeSearchLeadershipGroup'
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
import { HomeSearchStateDeputyGroup } from '@/components/campaign/dashboard/HomeSearchStateDeputyGroup'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { loadCampaignHomeSummary } from '@/utilities/campaignDashboardData'
@ -50,6 +51,7 @@ export default async function CampaignHomePage() {
<HomeSearchMunicipalityGroup />
<HomeSearchLeadershipGroup />
<HomeSearchAdvisorGroup />
<HomeSearchStateDeputyGroup />
</HomeSearchResultsShell>
}
summarySlot={summarySlot}

View file

@ -2,11 +2,7 @@
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
const formatMunicipalityCount = (count: number): string => {
if (count === 1) return '1 município'
return `${count} municípios`
}
import { formatHomeSearchMunicipalityCount } from '@/lib/campaignHomeSearchHits'
export const HomeSearchAdvisorGroup = () => {
const { results } = useHomeSearchResults()
@ -27,7 +23,7 @@ export const HomeSearchAdvisorGroup = () => {
<HomeSearchHitRow
href={`/campanha/assessores/${hit.id}`}
primary={hit.name}
secondary={formatMunicipalityCount(hit.municipalityCount)}
secondary={formatHomeSearchMunicipalityCount(hit.municipalityCount)}
/>
</li>
))}

View file

@ -0,0 +1,46 @@
'use client'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
import {
formatHomeSearchMunicipalityCount,
homeSearchStateDeputyGroupHasHits,
} from '@/lib/campaignHomeSearchHits'
const formatStateDeputySecondary = (
party: string | null,
municipalityCount: number,
): string | undefined => {
const parts: string[] = []
if (party) parts.push(party)
if (municipalityCount > 0) parts.push(formatHomeSearchMunicipalityCount(municipalityCount))
return parts.length > 0 ? parts.join(' · ') : undefined
}
export const HomeSearchStateDeputyGroup = () => {
const { results } = useHomeSearchResults()
if (results.status !== 'success') return null
const { stateDeputies } = results.data
if (!homeSearchStateDeputyGroupHasHits(results.data)) return null
return (
<section aria-label="Dobradinhas" className="flex flex-col gap-1">
<h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Dobradinhas
</h2>
<ul className="flex flex-col">
{stateDeputies.map((hit) => (
<li key={`state-deputy-${hit.slug}`}>
<HomeSearchHitRow
href={`/campanha/dobradinhas/${hit.slug}`}
primary={hit.name}
secondary={formatStateDeputySecondary(hit.party, hit.municipalityCount)}
/>
</li>
))}
</ul>
</section>
)
}

View file

@ -4,6 +4,9 @@ export const HOME_SEARCH_DEBOUNCE_MS = 250
/** Minimum trimmed length before focused mode and result providers run. */
export const HOME_SEARCH_MIN_QUERY_LENGTH = 2
/** Cap for capped home-search entity groups (leadership, dobradinhas). */
export const HOME_SEARCH_RESULT_HIT_CAP = 25
export type HomeSearchQuery = {
raw: string
debounced: string

View file

@ -32,6 +32,13 @@ export type HomeSearchLeadershipHit = {
municipalitiesSummary: string
}
export type HomeSearchStateDeputyHit = {
slug: string
name: string
party: string | null
municipalityCount: number
}
type HomeSearchResultKind = 'search' | 'suggest'
export type HomeSearchSuccessResponse = {
@ -41,6 +48,7 @@ export type HomeSearchSuccessResponse = {
territories: HomeSearchTerritoryHit[]
advisors: HomeSearchAdvisorHit[]
leaderships: HomeSearchLeadershipHit[]
stateDeputies: HomeSearchStateDeputyHit[]
}
export const homeSearchMunicipalityGroupHasHits = (
@ -51,6 +59,15 @@ export const homeSearchLeadershipGroupHasHits = (
data: Pick<HomeSearchSuccessResponse, 'leaderships'>,
): boolean => data.leaderships.length > 0
export const homeSearchStateDeputyGroupHasHits = (
data: Pick<HomeSearchSuccessResponse, 'stateDeputies'>,
): boolean => data.stateDeputies.length > 0
export const formatHomeSearchMunicipalityCount = (count: number): string => {
if (count === 1) return '1 município'
return `${count} municípios`
}
export const toHomeSearchMunicipalityHit = (doc: {
slug: string
name: string
@ -68,4 +85,5 @@ export const toHomeSearchMunicipalityHit = (doc: {
export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean =>
homeSearchMunicipalityGroupHasHits(data) ||
data.advisors.length > 0 ||
homeSearchLeadershipGroupHasHits(data)
homeSearchLeadershipGroupHasHits(data) ||
homeSearchStateDeputyGroupHasHits(data)

View file

@ -81,5 +81,6 @@ export const loadHomeSearchSuggestions = async (
territories: [],
advisors: [],
leaderships: [],
stateDeputies: [],
}
}

View file

@ -29,6 +29,7 @@ export const searchHomeAdvisors = async (
const normalizedQuery = normalizeHomeSearchName(query)
// Intentional admin bypass — unrestricted staff gate already ran in the route.
const result = await payload.find({
collection: 'campaignUser',
where: { role: { equals: 'advisor' } },

View file

@ -2,7 +2,11 @@ import 'server-only'
import type { Payload } from 'payload'
import { homeSearchQueryIsActive, normalizeHomeSearchRaw } from '@/lib/campaignHomeSearchContract'
import {
HOME_SEARCH_RESULT_HIT_CAP,
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'
@ -16,8 +20,6 @@ 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 ?? []) {
@ -88,7 +90,7 @@ export const searchHomeLeaderships = async (
return candidates
.sort((left, right) => compareHomeSearchNameRelevance(left, right, normalizedQuery))
.slice(0, HOME_SEARCH_LEADERSHIP_HIT_CAP)
.slice(0, HOME_SEARCH_RESULT_HIT_CAP)
.map(
(row): HomeSearchLeadershipHit => ({
kind: 'leadership',

View file

@ -28,6 +28,7 @@ const emptyResponse = (): HomeSearchSuccessResponse => ({
territories: [],
advisors: [],
leaderships: [],
stateDeputies: [],
})
export const searchHomeMunicipalities = async (
@ -114,5 +115,6 @@ export const searchHomeMunicipalities = async (
territories: territoryHits,
advisors: [],
leaderships: [],
stateDeputies: [],
}
}

View file

@ -0,0 +1,116 @@
import 'server-only'
import type { Payload } from 'payload'
import {
HOME_SEARCH_RESULT_HIT_CAP,
homeSearchQueryIsActive,
normalizeHomeSearchRaw,
} from '@/lib/campaignHomeSearchContract'
import type { HomeSearchStateDeputyHit } from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import {
compareHomeSearchNameRelevance,
normalizeHomeSearchName,
} from '@/lib/homeSearchMunicipalityMatch'
import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter'
import type { CampaignUser } from '@/payload-types'
import { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData'
export const searchHomeStateDeputies = async (
payload: Payload,
user: CampaignUser,
rawQuery: string,
): Promise<HomeSearchStateDeputyHit[]> => {
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` on name; home search also pre-filters party
// then refines to word-start in memory (B49 precedent).
const result = await payload.find({
collection: 'stateDeputy',
where: {
or: [{ name: { contains: query } }, { party: { contains: query } }],
},
depth: 0,
limit: 0,
pagination: false,
sort: 'name',
select: { name: true, slug: true, party: true },
user,
overrideAccess: false,
})
const candidates: Array<{
normalizedName: string
slug: string
name: string
party: string | null
id: number
}> = []
for (const doc of result.docs) {
const normalizedName = normalizeHomeSearchName(doc.name)
const normalizedParty = doc.party ? normalizeHomeSearchName(doc.party) : ''
const nameMatch = matchesNormalizedAtWordStart(normalizedName, normalizedQuery)
const partyMatch =
normalizedParty.length > 0 && matchesNormalizedAtWordStart(normalizedParty, normalizedQuery)
if (!nameMatch && !partyMatch) continue
candidates.push({
normalizedName: nameMatch ? normalizedName : normalizedParty,
slug: doc.slug,
name: doc.name,
party: doc.party ?? null,
id: doc.id,
})
}
if (candidates.length === 0) {
return []
}
const municipalityIdsByDeputy = await municipalityIdsByStateDeputyIds(
payload,
candidates.map((row) => row.id),
)
return candidates
.map((row) => {
const municipalityCount = municipalityIdsByDeputy.get(row.id)?.length ?? 0
return {
normalizedName: row.normalizedName,
tieBreakDesc: municipalityCount,
hit: {
slug: row.slug,
name: row.name,
party: row.party,
municipalityCount,
},
}
})
.sort((left, right) =>
compareHomeSearchNameRelevance(
{
normalizedName: left.normalizedName,
tieBreakDesc: left.tieBreakDesc,
},
{
normalizedName: right.normalizedName,
tieBreakDesc: right.tieBreakDesc,
},
normalizedQuery,
),
)
.slice(0, HOME_SEARCH_RESULT_HIT_CAP)
.map((row) => row.hit)
}

View file

@ -33,6 +33,42 @@ export type StateDeputyListFilterFacets = {
hasNoParty: boolean
}
/**
* Municipality ids per state deputy for the LIST / home search — aggregate
* only, same admin bypass as `loadStateDeputyListPageData`.
*/
export const municipalityIdsByStateDeputyIds = async (
payload: Payload,
stateDeputyIDs: number[],
): Promise<Map<number, number[]>> => {
const byDeputy = new Map<number, number[]>()
if (stateDeputyIDs.length === 0) return byDeputy
const deputyIdSet = new Set(stateDeputyIDs)
const municipalities = await payload.find({
collection: 'municipality',
where: { stateDeputies: { in: stateDeputyIDs } },
depth: 0,
limit: 0,
pagination: false,
select: { stateDeputies: true },
// Intentional admin bypass: aggregate counts only (B34 precedent).
overrideAccess: true,
})
for (const municipality of municipalities.docs) {
for (const deputy of municipality.stateDeputies ?? []) {
const id = relationshipId(deputy)
if (id === null || !deputyIdSet.has(id)) continue
const list = byDeputy.get(id) ?? []
list.push(municipality.id)
byDeputy.set(id, list)
}
}
return byDeputy
}
/**
* Respects the search (an OTHER filter, from the popover's point of view) but
* drops the party filter itself — same contract as
@ -98,47 +134,26 @@ export const loadStateDeputyListPageData = async (
])
const stateDeputyIDs = result.docs.map((doc) => doc.id)
const municipalityIDsByDeputy = new Map<number, number[]>()
const leadershipsByDeputy = new Map<number, LeadershipRelationSummary[]>()
if (stateDeputyIDs.length) {
const [municipalities, leaderships] = await Promise.all([
// Intentional admin bypass: the chips render outside an advisor's scope
// on purpose (B34 precedent) — an advisor must be able to see and undo
// a município link even outside their own portfolio.
payload.find({
collection: 'municipality',
where: { stateDeputies: { in: stateDeputyIDs } },
depth: 0,
limit: 0,
pagination: false,
select: { stateDeputies: true },
overrideAccess: true,
}),
payload.find({
collection: 'leadership',
where: { stateDeputies: { in: stateDeputyIDs } },
depth: 1,
limit: 0,
pagination: false,
select: { stateDeputies: true, contact: true },
// Names ship in the list cell — honour canReadLeadership (unlike
// municipalityCounts above, which stays aggregate-only).
user,
overrideAccess: false,
}),
])
const municipalityIDsByDeputy =
stateDeputyIDs.length > 0
? await municipalityIdsByStateDeputyIds(payload, stateDeputyIDs)
: new Map<number, number[]>()
for (const municipality of municipalities.docs) {
for (const deputy of municipality.stateDeputies ?? []) {
const id = relationshipId(deputy)
if (id !== null && stateDeputyIDs.includes(id)) {
const list = municipalityIDsByDeputy.get(id) ?? []
list.push(municipality.id)
municipalityIDsByDeputy.set(id, list)
}
}
}
if (stateDeputyIDs.length) {
const leaderships = await payload.find({
collection: 'leadership',
where: { stateDeputies: { in: stateDeputyIDs } },
depth: 1,
limit: 0,
pagination: false,
select: { stateDeputies: true, contact: true },
// Names ship in the list cell — honour canReadLeadership (unlike
// municipalityCounts above, which stays aggregate-only).
user,
overrideAccess: false,
})
for (const leadership of leaderships.docs) {
const summary: LeadershipRelationSummary = {

View file

@ -0,0 +1,84 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { setStateDeputyMunicipalitiesBatchRecord } from '@/app/(campaign)/campanha/actions/stateDeputy'
import config from '@/payload.config'
import { searchHomeStateDeputies } from '@/utilities/homeSearch/searchHomeStateDeputies'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('searchHomeStateDeputies (B52)', () => {
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 searchHomeStateDeputies(payload, coordinator, 'a')
expect(result).toEqual([])
})
it('matches state deputies by word-start on the name for staff', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({ name: 'Zeca Oliveira Dobradinha' })
const result = await searchHomeStateDeputies(payload, coordinator, 'Zeca')
expect(result.some((hit) => hit.slug === stateDeputy.slug)).toBe(true)
expect(result.find((hit) => hit.slug === stateDeputy.slug)?.name).toBe(
'Zeca Oliveira Dobradinha',
)
})
it('matches state deputies by word-start on the party when the name does not match', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({
name: 'Maria Silva',
party: 'PSOL',
})
const result = await searchHomeStateDeputies(payload, coordinator, 'PSOL')
expect(result.some((hit) => hit.slug === stateDeputy.slug)).toBe(true)
})
it('returns state deputy hits for advisor role when name matches', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const stateDeputy = await fixtures.createStateDeputy({ name: 'Assessoria Dobradinha' })
const result = await searchHomeStateDeputies(payload, advisor, 'Assessoria')
expect(result.some((hit) => hit.slug === stateDeputy.slug)).toBe(true)
})
it('reports municipality count on matched state deputies', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({ name: 'Carteira Dobradinha' })
const cairu = await fixtures.getMunicipality('cairu')
const feira = await fixtures.getMunicipality('feira-de-santana')
await setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
stateDeputyId: stateDeputy.id,
municipalityIds: [cairu.id, feira.id],
assigned: true,
})
const result = await searchHomeStateDeputies(payload, coordinator, 'Carteira')
const hit = result.find((row) => row.slug === stateDeputy.slug)
expect(hit?.municipalityCount).toBe(2)
expect(hit?.party).toBe(stateDeputy.party ?? null)
})
})

View file

@ -567,7 +567,7 @@ describe('admin bypasses carry a justification comment', () => {
['src/utilities/municipality/municipalityCandidateComparison.ts', 2],
['src/utilities/municipality/municipalityElectoralBaseline.ts', 8],
['src/utilities/onda0Provision.ts', 4],
['src/utilities/stateDeputyData.ts', 1],
['src/utilities/stateDeputyData.ts', 0],
['src/utilities/visit/visitPlannerData.ts', 1],
['src/utilities/access/campaignUsers.ts', 2],
['src/utilities/access/contacts.ts', 2],