mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
bacc4b1c41
commit
38459b7426
17 changed files with 347 additions and 57 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -81,5 +81,6 @@ export const loadHomeSearchSuggestions = async (
|
|||
territories: [],
|
||||
advisors: [],
|
||||
leaderships: [],
|
||||
stateDeputies: [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' } },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
116
src/utilities/homeSearch/searchHomeStateDeputies.ts
Normal file
116
src/utilities/homeSearch/searchHomeStateDeputies.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
84
tests/int/homeSearchStateDeputies.int.spec.ts
Normal file
84
tests/int/homeSearchStateDeputies.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in a new issue