Deliver B29: header sort and filters on the leaderships list.

Bring B15/B16-style URL sort/filter to /campanha/liderancas on the B33 mold, with shared CampaignTableHead filter slot and B18 exhaustive enum canonicalize from /simplify.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 01:43:44 -03:00
parent 96926bd718
commit 3536eb02d3
17 changed files with 1440 additions and 183 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,14 +1,18 @@
# B29 — Ordenação e filtro no header da lista de lideranças
Status: rascunho
Atualizado em: 2026-07-25
Status: entregue em código
Atualizado em: 2026-07-28
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B29)
Impeccable: B — encaixe em tela existente (`/campanha/liderancas`), sem rota nova; herda o header rico de `/campanha/municipios` (B15 ✓ + B16 ✓)
Appetite: ~1–1,5 dia eng; sem migration — módulo de URL + filtros no loader + duas colunas; o chrome compartilhado já foi entregue pelo B21
Impeccable: B — encaixe em tela existente (`/campanha/liderancas`), sem rota nova; herda o header rico (B15 ✓ + B16 ✓) via chrome compartilhado do B21 ✓; molde de domínio = B33 ✓ (`StateDeputy*`)
Appetite: ~1–1,5 dia eng; sem migration — módulo de URL + filtros no loader + duas colunas + wrappers; chrome já pago
Responsável: —
**As-built 2026-07-28:** join `contact.name` funcionou para `where` e `sort` (int tests verdes); `LeadershipFilters` substitui `CampaignSearchForm` (busca + resumo/Limpar, sem barra mobile empilhada); colunas Setor + Última atualização (idade relativa visível; absoluto em `title`); facet de municípios cross-filtrado; empty via `CampaignTable empty`; harden/optimize out. Critique P1s fechados (copy do header Municípios; freshness relativa).
**Atualização B21 (2026-07-25):** a extração deixou de fazer parte do appetite deste item. `shared/CampaignSortableHead` e `CampaignHeaderFilterPopover` já existem, e os wrappers de municípios já foram migrados. B29 implementa apenas os wrappers/política de liderança e o contrato de URL/filtros próprios.
**Revisão 2026-07-28 (auditoria pré-implementação):** (1) template real = B33 dobradinhas (`StateDeputySortableHead`/`StateDeputyHeaderFilter`/`StateDeputyFilters` + `useCampaignListFilterNavigation`), não re-extrair chrome; (2) Adiado "editar supportStatus na célula" fechado por **B32 ✓**; (3) `CampaignSearchForm` em lideranças é substituído por `LeadershipFilters` (preserva params de URL); (4) mobile v1 = header em todos os breakpoints + busca/resumo, sem barra empilhada.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípios 2, 3 e 4; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`CampaignTable`, `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPendingBoundary`) · header rico entregue em B15 ✓/B16 ✓ (`MunicipalitySortableHead`, `MunicipalityHeaderFilter`).
@ -20,7 +24,7 @@ Brief compacto:
- **Persona / contexto:** Assessor com carteira de municípios (e o CG na mesa) abrindo a lista de lideranças para trabalhar uma fatia — "quem ainda está `a abordar` em Feira de Santana?", "quais lideranças religiosas de Irecê ainda não têm acesso ao app?". Hoje a única ferramenta é a busca por nome, e a ordem é sempre a última escrita.
- **Job principal:** recortar a lista de pessoas pelo critério do dia e ordená-la pela coluna que importa, sem sair da tela nem decorar quem é quem.
- **Estratégia de cor:** Restrained. O único acento continua sendo o `SupportStatusBadge` existente; filtro ativo é o funil preenchido do B16, não cor nova.
- **Edit where you see:** **não neste item.** A lista de lideranças ainda é leitura + navegação para a ficha; edição rápida de `supportStatus` na célula é candidata natural (mesmo padrão do B9/B24), mas é outra decisão, com outro appetite — registrada em "Adiado com gatilho", não semeada aqui.
- **Edit where you see:** fora do escopo de sort/filtro deste item. A edição rápida de `supportStatus` na célula já foi entregue por **B32 ✓** (`LeadershipListSupportStatusControl`); este item não toca mutação.
- **Anti-goals:** score/nível sintético de liderança na tela (é **E14**); KPI strip / overview de lideranças; filtro por telefone ou qualquer recorte de PII sensível; segunda gramática de lista (chips próprios, sort client-side, estado fora da URL).
## Dados → decisão → apresentação
@ -42,7 +46,7 @@ Com o seed do E4R ✓ a base já entrou com lideranças nominais vindas da plani
Há ainda um defeito adjacente na busca atual: como o nome vive em `Contact`, `loadLeadershipListPageData` pré-resolve os contatos com `contains` e **`limit: 200`**, e depois filtra as lideranças por esses ids. Uma busca por sobrenome comum trunca silenciosamente. Este item toca exatamente esse caminho de query, então a correção entra junto (ver Objetivos).
Do lado da UI, o header rico hoje é **exclusivo do domínio `municipality`**: `MunicipalitySortableHead` importa `municipalityListUrl`, e `MunicipalityHeaderFilter` importa `municipalityListFilters`. As outras cinco listas (`demandas`, `dobradinhas`, `organizacoes`, `liderancas`, `apoiadores`) usam o `CampaignTableHead` simples. O B21 (página dos Territórios de Identidade) registrou "head/URL genéricos em `shared/`" como **adiado até o 3º call site**; com este item o terceiro consumidor deixa de ser hipótese.
Do lado da UI, o chrome compartilhado (`CampaignSortableHead` / `CampaignHeaderFilterPopover`) já existe (B21 ✓) e é consumido por municípios, territórios e dobradinhas (B33 ✓). Lideranças ainda usa `CampaignTableHead` simples + `CampaignSearchForm` (só `q`+`page`).
## Objetivos
@ -50,8 +54,8 @@ Do lado da UI, o header rico hoje é **exclusivo do domínio `municipality`**: `
- **Filtrar pelo header** (Popover, padrão B16 ✓) em Status de apoio (multi), Setor (multi), Município (multi, com facet) e Acesso ao app (com/sem) — mais chips de filtro ativo e "Limpar" pelos shells compartilhados.
- **Toda chave de ordenação e todo filtro pousam numa coluna visível**: entram as colunas "Setor" e "Última atualização" (ambas já no `LeadershipRowViewModel`, hoje não renderizadas), para não existir ordenação invisível.
- **Ordenação e filtro executados no banco** (`payload.find` com `sort`/`where`), nunca em memória sobre a página de 25 linhas.
- **Header e filtros permanecem montados com zero resultados** — a lista passa a usar a prop `empty` do `CampaignTable` (precedente B16 ✓), em vez de trocar a tabela inteira pelo empty state.
- **Par head/filtro extraído para `src/components/campaign/shared/`** e `/campanha/municipios` migrado junto, sem mudança de comportamento observável lá (mesmos hrefs, mesmo `aria-sort`, mesmo optimistic).
- **Header e filtros permanecem montados com zero resultados** — a lista passa a usar a prop `empty` do `CampaignTable` (precedente B16 ✓ / B33 ✓), em vez de trocar a tabela inteira pelo empty state.
- **Wrappers de domínio** (`LeadershipSortableHead` / `LeadershipHeaderFilter` / `LeadershipFilters`) consomem o chrome compartilhado já entregue — sem re-extração.
- **Teto de 200 na busca por nome eliminado** — a busca passa pela mesma query da lista se o caminho relacional funcionar no adapter; caso contrário, permanece a pré-resolução e o teto vira defeito nomeado, não silencioso (ver Decisões travadas).
- Guardrails: **sem migration, sem collection, sem `Consent`, sem server action**; access inalterado (`isCampaignStaff` na rota, `overrideAccess: false` na leitura, escopo do assessor pelo access da collection); liderança segue sem acesso à área.
@ -61,7 +65,7 @@ Do lado da UI, o header rico hoje é **exclusivo do domínio `municipality`**: `
- **Ordenação por nome via join na query, não por desnormalização.** O adapter Postgres resolve `sort: 'contact.name'` por caminho pontilhado (`buildOrderBy` → `getTableColumnFromPath`, que adiciona o join), então o nome — que vive em `Contact`, a fonte única de pessoa — pode ordenar sem duplicar dado. **Rejeitado:** copiar `contact.name` para um campo `leadership.name` mantido por hook (migration + segunda fonte de verdade de nome, exatamente o cadastro paralelo que o AGENTS proíbe, e por um sort); carregar todas as lideranças do escopo e ordenar em memória (não escala com a base nominal real da C2 e duplica a paginação); ordenar só a página carregada (ordenação que mente). **Fallback nomeado:** se na implementação o caminho relacional não sair no adapter, a v1 entrega apenas as chaves locais (`supportStatus`, `sector`, `updatedAt`) e o sort por nome vira Adiado com gatilho — a desnormalização continua rejeitada.
- **Filtros v1 = as colunas visíveis** (Status, Setor, Município, Acesso ao app), todos sobre campos indexados da própria `leadership` (`supportStatus`, `sector`, `municipalities`, `user`). **Rejeitado:** filtro por organização e por dobradinha (não têm decisão nomeada hoje e cada popover custa uma query de facet — adiados com gatilho); filtro por telefone/e-mail preenchido (recorte de PII sem decisão, e o telefone é justamente o que o seed do E4R não trouxe); filtro por "criada por mim".
- **Opções dos filtros vêm de facet no escopo do ator**, unindo sempre o valor já selecionado (precedente `loadMunicipalityListFilterFacets`, `src/utilities/municipalityPageData.ts`). O assessor só vê no popover os municípios em que existem lideranças suas. **Rejeitado:** listar os 435 municípios do catálogo (o assessor veria opções que zeram a lista dele — e o payload RSC carregaria 435 pares por render); options fixas sem cross-filtragem (o B16 já pagou o preço de aprender que filtro que zera a lista é filtro quebrado).
- **O par head/filtro é extraído para `shared/` neste item, com municípios migrado junto** — `CampaignSortableHead` (âncora de sort + `aria-sort` + slot de filtro + seam de `description` para o B22) e `CampaignHeaderFilterPopover` (chrome do popover: busca, linhas com checkbox, "Limpar", `min-h-11`, a11y). A política — estado, optimistic, hrefs canônicos — **fica no wrapper de domínio** (`LeadershipHeaderFilter`, `MunicipalityHeaderFilter`), não no componente compartilhado. Este é o 3º call site que o B21 nomeou como gatilho. **Rejeitado:** terceira cópia do head (a próxima métrica/tooltip entraria em uma cópia só — foi assim que o `tooltip` do B22 nasceu meio implementado); item de refactor isolado (abstração sem consumidor, zero valor de usuário, e desenhada sem o terceiro caso na mão); esperar o B21 (empurra a decisão e garante três cópias em vez de duas).
- **Chrome compartilhado já existe (B21 ✓)** — B29 só escreve wrappers de domínio. Molde: B33 (`StateDeputy*`). **Rejeitado:** re-extrair ou duplicar o head.
- **Sem métrica, score ou nível de liderança nesta tela.** Níveis N0–N4 são **E14**, com histerese e vocabulário duplo. **Rejeitado:** coluna "engajamento" ad hoc derivada de `supportStatus` (criaria um segundo vocabulário de nível que o E14 teria de reconciliar).
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`leadershipListUrl.ts`, `LeadershipListState`, `LeadershipListSortKey`, `buildLeadershipSortHref`, `leadershipFilterDefinitions`, `LeadershipSortableHead`, `CampaignSortableHead`, `CampaignHeaderFilterPopover`), strings visíveis em pt-BR ("Status", "Setor", "Acesso ao app", "Última atualização", "Limpar").
@ -76,32 +80,34 @@ Do lado da UI, o header rico hoje é **exclusivo do domínio `municipality`**: `
```mermaid
flowchart LR
Url["leadershipListUrl.ts<br/>(state · parse · where · sort · hrefs)"]
Filt["leadershipListFilters.ts<br/>(definições · toggles · chips)"]
Loader["leadershipData.ts<br/>(find paginado + facets)"]
Page["/campanha/liderancas/page.tsx (RSC)"]
Dom["LeadershipSortableHead / LeadershipHeaderFilter<br/>(estado + optimistic + hrefs)"]
MunDom["MunicipalitySortableHead / MunicipalityHeaderFilter<br/>(migrados)"]
Shared["shared/CampaignSortableHead<br/>shared/CampaignHeaderFilterPopover"]
Table["CampaignTable (colunas como dado)"]
Url --> Loader --> Page --> Table
Url --> Dom
Filt --> Dom
Dom --> Shared
MunDom --> Shared
Dom --> Table
Url["leadershipListUrl.ts"]
Filt["leadershipListFilters.ts"]
Loader["leadershipData.ts"]
Page["liderancas/page.tsx"]
Head["LeadershipSortableHead"]
Filter["LeadershipHeaderFilter"]
Shell["LeadershipFilters"]
Shared["CampaignSortableHead + CampaignHeaderFilterPopover"]
Nav["useCampaignListFilterNavigation"]
Url --> Loader --> Page
Url --> Head
Filt --> Filter
Filt --> Shell
Head --> Shared
Filter --> Shared
Shell --> Nav
Page --> Head
Page --> Shell
```
Componentes:
Componentes (molde B33 — não re-extrair chrome):
- **`src/utilities/leadershipListUrl.ts`** (novo): `LeadershipListState`, `parseLeadershipListParams` (movido de `leadershipData.ts` e estendido), `buildLeadershipListWhere`, `resolveLeadershipListSort`, serialização canônica, `buildLeadershipListHref` / `buildLeadershipSortHref` / `buildLeadershipFilterOptionHref` e `resolveLeadershipListUrl` (redirect canônico), tudo sobre `campaignListUrl.ts` (`firstValue`, `allParamValues`, `normalizedText`, `strictDecimalInteger`, `buildListHref`, `resolveListUrl`). Espelha `municipalityListUrl.ts` sem importá-lo.
- **`src/utilities/leadershipListFilters.ts`** (novo): `leadershipFilterDefinitions` (label, seleção multi/toggle, options estáticas para status/setor), helpers de toggle/clear, `formatLeadershipActiveFiltersSummary` para os chips e o empty state. Espelha `municipalityListFilters.ts`.
- **`src/utilities/leadershipData.ts`** (alterado, `server-only`): `loadLeadershipListPageData` passa a receber o estado completo, montar `where` a partir de `buildLeadershipListWhere`, aplicar `sort` resolvido e devolver também `filterFacets` (municípios presentes no escopo filtrado). Queries seguem com `user` + `overrideAccess: false`; a resolução de nomes de exibição continua no `namesForIds` (bypass já documentado). `parseLeadershipListParams` sai daqui para o módulo de URL.
- **`src/app/(campaign)/campanha/(app)/liderancas/page.tsx`** (alterado): `resolveLeadershipListUrl` + `redirect` canônico (molde de `municipios/page.tsx`), colunas ganham `LeadershipSortableHead` com `filterParam`, entram as colunas `sector` e `updatedAt`, `CampaignFilterChips` + `CampaignTable empty={…}` no lugar da troca de árvore, footer inalterado.
- **`src/components/campaign/leadership/LeadershipSortableHead.tsx` / `LeadershipHeaderFilter.tsx`** (novos, client): wrappers finos — `useOptimistic(state)`, hrefs pelo módulo de URL, e render delegado aos compartilhados.
- **`src/components/campaign/shared/CampaignSortableHead.tsx` / `CampaignHeaderFilterPopover.tsx`** (novos, extraídos de `MunicipalitySortableHead`/`MunicipalityHeaderFilter`): chrome, a11y (`aria-sort`, `aria-label`, `min-h-11`), busca acima do limiar de opções, linhas exclusivas + multi com checkbox, "Limpar". **Depth check:** só chrome; nenhuma regra de URL, nenhum `state` de domínio, nenhum `useOptimistic` dentro. O tooltip do header (**B22**) e o `cellTooltip` (**B23**) passam a ter um único lugar onde pousar.
- **`MunicipalitySortableHead` / `MunicipalityHeaderFilter`** (alterados): mantêm nome, props e comportamento; passam a compor os compartilhados. O "selecionados no topo" (fill-in) continua sendo helper puro em `src/lib/`, consumido pelo popover compartilhado.
- **Testes:** unit de `leadershipListUrl` (parse → serialize idempotente, defaults omitidos, valores inválidos descartados — molde de `municipalityListUrl.unit.spec.ts`); int de `loadLeadershipListPageData` (cada filtro, ordenação por nome, e o assessor não vendo liderança fora da carteira mesmo com `?municipality=` de outro município).
- **`src/utilities/leadershipListUrl.ts`** (novo): contrato `q`/`status`/`sector`/`municipality`/`access`/`sort`/`dir`/`page`; where/sort/hrefs/`resolveLeadershipListUrl`. Espelha `stateDeputyListUrl.ts`.
- **`src/utilities/leadershipListFilters.ts`** (novo): toggles/clear/summary; options estáticas status/setor; municipality por id; access `com`|`sem`.
- **`src/utilities/leadershipData.ts`** (alterado): where+sort+facet de municípios; busca via `'contact.name'`; parse/hrefs saem para o módulo de URL (re-export se preciso).
- **`LeadershipSortableHead` / `LeadershipHeaderFilter` / `LeadershipFilters`** (novos): wrappers + busca que preserva URL (substitui `CampaignSearchForm`).
- **`liderancas/page.tsx`**: redirect canônico; colunas Setor + Última atualização; heads ricos; `CampaignTable empty={…}`.
- **Testes:** unit `leadershipListUrl`; int filtros + sort por nome + escopo do assessor.
- **Sem migration, sem collection, sem `Consent`, sem server action.**
## Dependências
@ -130,9 +136,9 @@ Componentes:
## Adiado com gatilho
- **Edição rápida de `supportStatus` na célula** (Popover + auto-save, padrão B9/B24). Revisitar quando: a mesa reportar que abre a ficha só para mudar o status, ou ≥1 pedido em sessão/R6.
- ~~**Edição rápida de `supportStatus` na célula**~~ — **fechado por B32 ✓** (2026-07-26).
- **Filtro por organização e por dobradinha.** Revisitar quando: houver decisão nomeada em sessão (ex.: "quero as lideranças do sindicato X") ou quando a cobertura de `organizations` na base real passar a ser a regra, não a exceção.
- **Select compacto de ordenação no mobile.** Revisitar quando: uso real em celular mostrar que rolar o header na horizontal atrapalha (mesmo gatilho que produziu o select compacto do B15).
- **Select compacto / barra empilhada de ordenação no mobile.** Revisitar quando: uso real em celular mostrar que rolar o header na horizontal atrapalha (mesmo gatilho que produziu o select compacto do B15; B33 optou por paridade mobile — lideranças não).
- **Sort por nome (se o join não sair no adapter).** Revisitar quando: houver evidência de que a ordem alfabética é bloqueante — e ainda assim sem desnormalizar (a alternativa seria uma view/consulta dedicada).
## Referências

File diff suppressed because one or more lines are too long

View file

@ -5,15 +5,19 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
import { LeadershipFilters } from '@/components/campaign/leadership/LeadershipFilters'
import { LeadershipListSupportStatusControl } from '@/components/campaign/leadership/LeadershipListSupportStatusControl'
import {
LeadershipFilterHead,
LeadershipSortableHead,
} from '@/components/campaign/leadership/LeadershipSortableHead'
import { CampaignCopyableCell } from '@/components/campaign/shared/CampaignCopyableCell'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
import {
CampaignTable,
CampaignTableHead,
@ -26,19 +30,39 @@ import {
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Button, buttonVariants } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership'
import { MAX_LEADERSHIP_MUNICIPALITIES, isLeadershipSector } from '@/lib/schemas/leadership'
import { cn } from '@/lib/utils'
import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import { formatRelativeAge } from '@/utilities/formatRelativeAge'
import { loadLeadershipListPageData, type LeadershipRowViewModel } from '@/utilities/leadershipData'
import { leadershipAccessFilterLabels } from '@/utilities/leadershipLabels'
import {
buildLeadershipFilterHref,
clearLeadershipListFilters,
type LeadershipFilterOption,
} from '@/utilities/leadershipListFilters'
import {
buildLeadershipListHref,
loadLeadershipListPageData,
parseLeadershipListParams,
type LeadershipRowViewModel,
} from '@/utilities/leadershipData'
formatLeadershipListSortSummary,
resolveLeadershipListSort,
resolveLeadershipListUrl,
type LeadershipListState,
} from '@/utilities/leadershipListUrl'
import { leadershipSectorLabels } from '@/utilities/leadershipUi'
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipalityPortfolioIndex'
import {
@ -50,20 +74,63 @@ type LeadershipsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
const LeadershipListEmptyState = ({ state }: { state: LeadershipListState }) => (
<Empty className="min-h-56">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma liderança encontrada</EmptyTitle>
<EmptyDescription>
Cadastre a primeira liderança ou ajuste a busca e os filtros. Você só vê lideranças dos seus
municípios.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<CampaignTransitionAnchor
href={buildLeadershipFilterHref(clearLeadershipListFilters(state))}
replace
scroll={false}
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
>
Limpar busca e filtros
</CampaignTransitionAnchor>
<Button asChild className="min-h-11">
<Link href="/campanha/liderancas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
</EmptyContent>
</Empty>
)
const leadershipColumns = ({
state,
stateDeputyOptions,
municipalityIndex,
municipalityFilterOptions,
addableMunicipalityIds,
nowMs,
}: {
state: LeadershipListState
stateDeputyOptions: RelationCellOption[]
municipalityIndex: MunicipalityPortfolioIndexEntry[]
municipalityFilterOptions: LeadershipFilterOption[]
/** Advisors may only link municipalities they administer; staff: undefined. */
addableMunicipalityIds?: ReadonlySet<number>
nowMs: number
}): Array<CampaignTableColumn<LeadershipRowViewModel>> => [
{
id: 'name',
mandatory: true,
head: <CampaignTableHead>Nome</CampaignTableHead>,
head: (
<LeadershipSortableHead state={state} sortKey="name">
Nome
</LeadershipSortableHead>
),
cell: (row) => (
<Link
href={`/campanha/liderancas/${row.id}`}
@ -93,17 +160,38 @@ const leadershipColumns = ({
},
{
id: 'supportStatus',
head: <CampaignTableHead>Status</CampaignTableHead>,
head: (
<LeadershipSortableHead state={state} sortKey="supportStatus" filterParam="supportStatus">
Status
</LeadershipSortableHead>
),
cell: (row) => (
<LeadershipListSupportStatusControl leadershipID={row.id} status={row.supportStatus} />
),
},
{
id: 'sector',
head: (
<LeadershipSortableHead state={state} sortKey="sector" filterParam="sector">
Setor
</LeadershipSortableHead>
),
cell: (row) => {
if (!row.sector || !isLeadershipSector(row.sector)) return row.sector || '—'
return leadershipSectorLabels[row.sector]
},
},
{
id: 'municipalities',
head: (
<CampaignTableHead description="Edite aqui: passe o mouse em um chip para remover, ou busque para adicionar. Um território ou ZE entra e sai como um bloco. Cada liderança fica entre 1 e 30 municípios.">
<LeadershipFilterHead
state={state}
filterParam="municipality"
options={municipalityFilterOptions}
description="Recorte por um ou mais municípios da carteira. Na célula, os chips editam o vínculo (fine pointer) ou abrem o drawer (touch)."
>
Municípios
</CampaignTableHead>
</LeadershipFilterHead>
),
cellClassName: 'max-w-64 whitespace-normal',
cell: (row) => (
@ -149,13 +237,35 @@ const leadershipColumns = ({
},
{
id: 'appAccess',
head: <CampaignTableHead>Acesso ao app</CampaignTableHead>,
head: (
<LeadershipFilterHead state={state} filterParam="access">
Acesso ao app
</LeadershipFilterHead>
),
cell: (row) => (
<Badge variant={row.hasAppAccess ? 'estimate-confirmed' : 'outline'}>
{row.hasAppAccess ? 'Com acesso' : 'Sem acesso'}
{row.hasAppAccess ? leadershipAccessFilterLabels.com : leadershipAccessFilterLabels.sem}
</Badge>
),
},
{
id: 'updatedAt',
head: (
<LeadershipSortableHead state={state} sortKey="updatedAt">
Última atualização
</LeadershipSortableHead>
),
cell: (row) => {
const absolute = formatBahiaDateTimeLabel(row.updatedAt)
const relative = formatRelativeAge(new Date(row.updatedAt).getTime(), nowMs)
return (
<time dateTime={row.updatedAt} title={absolute} className="text-muted-foreground">
<span className="capitalize">{relative}</span>
<span className="sr-only"> ({dateFormatter.format(new Date(row.updatedAt))})</span>
</time>
)
},
},
{
id: 'actions',
head: (
@ -204,19 +314,51 @@ const leadershipColumns = ({
export default async function LeadershipsPage({ searchParams }: LeadershipsPageProps) {
const rawSearchParams = await searchParams
const canonicalUrl = resolveLeadershipListUrl(rawSearchParams)
if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref)
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
const state = parseLeadershipListParams(rawSearchParams)
const [{ rows, totalDocs, totalPages }, stateDeputyOptions, municipalityIndex, administeredIds] =
await Promise.all([
loadLeadershipListPageData(payload, user, state),
loadStateDeputyOptions(payload, user),
loadMunicipalityPortfolioIndex(payload),
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
])
const [
{ rows, totalDocs, totalPages, filterFacets },
stateDeputyOptions,
municipalityIndex,
administeredIds,
] = await Promise.all([
loadLeadershipListPageData(payload, user, canonicalUrl.state),
loadStateDeputyOptions(payload, user),
loadMunicipalityPortfolioIndex(payload),
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
])
const resolvedUrl = resolveLeadershipListUrl(rawSearchParams, totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
const municipalityById = new Map(municipalityIndex.map((entry) => [entry.id, entry]))
const municipalityFilterOptions: LeadershipFilterOption[] = filterFacets.municipalityIDs
.map((id) => {
const entry = municipalityById.get(id)
return entry ? { value: String(id), label: entry.name } : null
})
.filter((option): option is LeadershipFilterOption => option !== null)
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
const municipalityLabelsById: Record<number, string> = {}
for (const option of municipalityFilterOptions) {
municipalityLabelsById[Number(option.value)] = option.label
}
for (const id of state.municipalities ?? []) {
if (!municipalityLabelsById[id]) {
municipalityLabelsById[id] = municipalityById.get(id)?.name ?? `Município #${id}`
}
}
const { sort, dir } = resolveLeadershipListSort(state)
const sortSummary = formatLeadershipListSortSummary(sort, dir)
const columns = leadershipColumns({
state,
stateDeputyOptions: stateDeputyOptions.map((option) => ({
id: option.id,
searchLabel: option.name,
@ -228,7 +370,9 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
},
})),
municipalityIndex,
municipalityFilterOptions,
...(administeredIds ? { addableMunicipalityIds: new Set(administeredIds) } : {}),
nowMs: Date.now(),
})
return (
@ -249,40 +393,29 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
</header>
<CampaignListPendingBoundary>
<CampaignSearchForm
ariaLabel="Buscar liderança por nome"
placeholder="Buscar por nome…"
initialQuery={state.q ?? ''}
basePath="/campanha/liderancas"
/>
<LeadershipFilters state={state} municipalityLabelsById={municipalityLabelsById} />
<CampaignListResults>
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
<CampaignTable
caption={`${sortSummary}. Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações.`}
columns={columns}
rows={rows}
rowKey={(row) => row.id}
empty={<LeadershipListEmptyState state={state} />}
/>
{rows.length ? (
<>
<CampaignTable columns={columns} rows={rows} rowKey={(row) => row.id} />
<CampaignListFooter
totalDocs={totalDocs}
singular="liderança"
plural="lideranças"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildLeadershipListHref(state, page)}
/>
</>
) : (
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhuma liderança encontrada"
description="Cadastre a primeira liderança ou ajuste a busca. Você só vê lideranças dos seus municípios."
>
<Button asChild className="min-h-11">
<Link href="/campanha/liderancas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
</CampaignListEmptyState>
)}
<CampaignListFooter
totalDocs={totalDocs}
singular="liderança"
plural="lideranças"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildLeadershipListHref(state, page)}
/>
) : null}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>

View file

@ -0,0 +1,69 @@
'use client'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Button } from '@/components/ui/button'
import {
buildLeadershipFilterHref,
clearLeadershipListFilters,
formatLeadershipActiveFiltersSummary,
} from '@/utilities/leadershipListFilters'
import { type LeadershipListState } from '@/utilities/leadershipListUrl'
export const LeadershipFilters = ({
state,
municipalityLabelsById,
}: {
state: LeadershipListState
/** Labels for the active municipality filter chips in the summary. */
municipalityLabelsById?: Readonly<Record<number, string>>
}) => {
const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } =
useCampaignListFilterNavigation({ state, toHref: buildLeadershipFilterHref })
const activeSummary = formatLeadershipActiveFiltersSummary(
{ ...state, q: draftQ },
municipalityLabelsById,
)
return (
<form
role="search"
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending}
aria-busy={isPending}
onSubmit={(event) => {
event.preventDefault()
navigateWithSearch(state)
}}
>
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<CampaignSearchInput
id="leadership-search"
label="Buscar liderança por nome"
placeholder="Buscar por nome…"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
{activeSummary ? (
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2">
{activeSummary}
</p>
) : null}
{activeSummary ? (
<Button
type="button"
variant="ghost"
className="min-h-11 md:self-end"
onClick={() => clearSearchAndNavigate(clearLeadershipListFilters(state))}
>
Limpar
</Button>
) : null}
</div>
{activeSummary ? (
<p className="text-sm text-muted-foreground md:hidden">{activeSummary}</p>
) : null}
</form>
)
}

View file

@ -0,0 +1,129 @@
'use client'
import { useOptimistic } from 'react'
import {
CampaignHeaderFilterPopover,
type CampaignHeaderFilterRow,
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
import {
buildLeadershipFilterHref,
clearLeadershipAccessFilter,
clearLeadershipMunicipalityFilter,
clearLeadershipSectorFilter,
clearLeadershipStatusFilter,
isLeadershipColumnFilterActive,
leadershipAccessFilterOptions,
leadershipSectorFilterOptions,
leadershipStatusFilterOptions,
toggleLeadershipAccessFilter,
toggleLeadershipMunicipalityFilter,
toggleLeadershipSectorFilter,
toggleLeadershipStatusFilter,
type LeadershipFilterOption,
type LeadershipFilterParam,
} from '@/utilities/leadershipListFilters'
import { type LeadershipListState } from '@/utilities/leadershipListUrl'
const FILTER_LABELS: Record<LeadershipFilterParam, string> = {
supportStatus: 'Status',
sector: 'Setor',
municipality: 'Municípios',
access: 'Acesso ao app',
}
type FilterBranch = {
options: LeadershipFilterOption[]
toggle: (state: LeadershipListState, value: string) => LeadershipListState
clear: (state: LeadershipListState) => LeadershipListState
selected: (state: LeadershipListState, value: string) => boolean
checkbox?: boolean
closeOnChoose?: boolean
}
const resolveFilterBranch = (
filterParam: LeadershipFilterParam,
options: LeadershipFilterOption[] | undefined,
): FilterBranch => {
switch (filterParam) {
case 'supportStatus':
return {
options: leadershipStatusFilterOptions,
toggle: toggleLeadershipStatusFilter,
clear: clearLeadershipStatusFilter,
selected: (state, value) => Boolean(state.statuses?.some((status) => status === value)),
checkbox: true,
}
case 'sector':
return {
options: leadershipSectorFilterOptions,
toggle: toggleLeadershipSectorFilter,
clear: clearLeadershipSectorFilter,
selected: (state, value) => Boolean(state.sectors?.some((sector) => sector === value)),
checkbox: true,
}
case 'municipality':
return {
options: options ?? [],
toggle: toggleLeadershipMunicipalityFilter,
clear: clearLeadershipMunicipalityFilter,
selected: (state, value) => Boolean(state.municipalities?.includes(Number(value))),
checkbox: true,
}
case 'access':
return {
options: leadershipAccessFilterOptions,
toggle: toggleLeadershipAccessFilter,
clear: clearLeadershipAccessFilter,
selected: (state, value) => state.access === value,
closeOnChoose: true,
}
}
}
export const LeadershipHeaderFilter = ({
state,
filterParam,
options,
}: {
state: LeadershipListState
filterParam: LeadershipFilterParam
/** Facet options for Município; closed enums ignore this. */
options?: LeadershipFilterOption[]
}) => {
const [viewState, setOptimisticState] = useOptimistic(state)
const active = isLeadershipColumnFilterActive(viewState, filterParam)
const branch = resolveFilterBranch(filterParam, options)
const optionRows: CampaignHeaderFilterRow[] = branch.options.map((option) => {
const next = branch.toggle(viewState, option.value)
return {
value: option.value,
label: option.label,
href: buildLeadershipFilterHref(next),
selected: branch.selected(viewState, option.value),
...(branch.checkbox ? { checkbox: true as const } : {}),
onChoose: () => setOptimisticState(next),
}
})
const cleared = active ? branch.clear(viewState) : null
return (
<CampaignHeaderFilterPopover
id={`leadership-filter-${filterParam}`}
label={FILTER_LABELS[filterParam]}
active={active}
optionRows={optionRows}
closeOnChoose={branch.closeOnChoose}
clear={
cleared
? {
href: buildLeadershipFilterHref(cleared),
onChoose: () => setOptimisticState(cleared),
}
: undefined
}
/>
)
}

View file

@ -0,0 +1,84 @@
'use client'
import type { ReactNode } from 'react'
import { LeadershipHeaderFilter } from '@/components/campaign/leadership/LeadershipHeaderFilter'
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
import { CampaignTableHead } from '@/components/campaign/shared/CampaignTable'
import type {
LeadershipFilterOption,
LeadershipFilterParam,
} from '@/utilities/leadershipListFilters'
import {
buildLeadershipSortHref,
defaultLeadershipListSortDir,
leadershipListSortLabels,
resolveLeadershipListSort,
type LeadershipListSortKey,
type LeadershipListState,
} from '@/utilities/leadershipListUrl'
type LeadershipSortableHeadProps = {
state: LeadershipListState
sortKey: LeadershipListSortKey
children?: ReactNode
align?: 'left' | 'center' | 'right'
className?: string
filterParam?: LeadershipFilterParam
filterOptions?: LeadershipFilterOption[]
}
export const LeadershipSortableHead = ({
state,
sortKey,
children,
align = 'left',
className,
filterParam,
filterOptions,
}: LeadershipSortableHeadProps) => {
const { sort: activeSort, dir } = resolveLeadershipListSort(state)
const active = activeSort === sortKey
const nextDir = active ? (dir === 'asc' ? 'desc' : 'asc') : defaultLeadershipListSortDir(sortKey)
return (
<CampaignSortableHead
active={active}
align={align}
className={className}
dir={dir}
href={buildLeadershipSortHref(state, sortKey)}
nextDir={nextDir}
sortLabel={leadershipListSortLabels[sortKey]}
filter={
filterParam ? (
<LeadershipHeaderFilter state={state} filterParam={filterParam} options={filterOptions} />
) : null
}
>
{children ?? leadershipListSortLabels[sortKey]}
</CampaignSortableHead>
)
}
/** Filter-only column head (Municípios / Acesso) — no sort control. */
export const LeadershipFilterHead = ({
state,
filterParam,
children,
options,
description,
}: {
state: LeadershipListState
filterParam: LeadershipFilterParam
children: ReactNode
options?: LeadershipFilterOption[]
description?: ReactNode
}) => (
<CampaignTableHead
description={description}
filter={<LeadershipHeaderFilter state={state} filterParam={filterParam} options={options} />}
>
{children}
</CampaignTableHead>
)

View file

@ -59,43 +59,68 @@ export type CampaignTableColumn<Row> = {
* information, so the wrapping `<span tabIndex={0}>` is a deliberate new tab
* stop; the `<th>` itself stays non-interactive. Without `description` the
* render is byte-for-byte what it was before B22.
*
* Optional `filter` mirrors `CampaignSortableHead`'s slot for filter-only
* columns (no sort control) so domain wrappers don't re-copy the tooltip chrome.
*/
export const CampaignTableHead = ({
align = 'left',
className,
children,
description,
filter,
}: {
align?: 'left' | 'center' | 'right'
className?: string
children?: ReactNode
description?: ReactNode
}) => (
<TableHead
className={cn(
'text-muted-foreground',
align === 'right' && 'text-right',
align === 'center' && 'text-center',
className,
)}
>
{description ? (
<CampaignHoverTooltip content={description} align={campaignHoverTooltipAlign(align)}>
<span
tabIndex={0}
filter?: ReactNode
}) => {
const label = description ? (
<CampaignHoverTooltip content={description} align={campaignHoverTooltipAlign(align)}>
<span
tabIndex={0}
className={cn(
campaignHoverExplanationClassName,
filter && 'inline-flex min-h-11 items-center',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
{children}
</span>
</CampaignHoverTooltip>
) : filter ? (
<span className="inline-flex min-h-11 items-center">{children}</span>
) : (
children
)
return (
<TableHead
className={cn(
'text-muted-foreground',
align === 'right' && 'text-right',
align === 'center' && 'text-center',
className,
)}
>
{filter ? (
<div
className={cn(
campaignHoverExplanationClassName,
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'flex items-center',
align === 'right' && 'justify-end',
align === 'center' && 'justify-center',
)}
>
{children}
</span>
</CampaignHoverTooltip>
) : (
children
)}
</TableHead>
)
{label}
{filter}
</div>
) : (
label
)}
</TableHead>
)
}
type CampaignTableProps<Row> = {
columns: Array<CampaignTableColumn<Row>>

View file

@ -31,10 +31,14 @@ export const leadershipSupportStatuses = [
] as const
export type SupportStatus = (typeof leadershipSupportStatuses)[number]
export type LeadershipSector = (typeof leadershipSectors)[number]
export const isSupportStatus = (value: unknown): value is SupportStatus =>
typeof value === 'string' && leadershipSupportStatuses.some((status) => status === value)
export const isLeadershipSector = (value: unknown): value is LeadershipSector =>
typeof value === 'string' && leadershipSectors.some((sector) => sector === value)
export const MAX_LEADERSHIP_MUNICIPALITIES = 30
const MAX_LEADERSHIP_ORGANIZATIONS = 20
export const MAX_LEADERSHIP_STATE_DEPUTIES = 20

View file

@ -7,17 +7,15 @@ import { isSupportStatus } from '@/lib/schemas/leadership'
import type { CampaignUser, Leadership } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import {
buildListHref,
firstValue,
normalizedText,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
buildLeadershipListWhere,
leadershipPageSize,
resolveLeadershipListPayloadSort,
resolveLeadershipListSort,
type LeadershipListState,
} from '@/utilities/leadershipListUrl'
import { relationshipId } from '@/utilities/relationship'
import { loadStateDeputySummaries, type StateDeputySummary } from '@/utilities/stateDeputyData'
const leadershipPageSize = 25
export type LeadershipRowViewModel = {
id: number
contactID: number
@ -168,70 +166,79 @@ export const loadFreshestMunicipalityLeaderships = async (
}
}
export type LeadershipListState = {
page: number
q?: string
export type LeadershipListFilterFacets = {
/** Municipality ids present under the other active filters (cross-filtered). */
municipalityIDs: number[]
}
export const parseLeadershipListParams = (searchParams: RawSearchParams): LeadershipListState => {
const q = normalizedText(firstValue(searchParams.q))
/**
* Respects every OTHER filter (search, status, sector, access) but drops the
* municipality filter itself — same contract as `loadStateDeputyPartyFacet`:
* a selected id is unioned in so it stays visible to undo.
*/
const loadLeadershipMunicipalityFacet = async (
payload: Payload,
user: CampaignUser,
state: LeadershipListState,
): Promise<LeadershipListFilterFacets> => {
const result = await payload.find({
collection: 'leadership',
where: buildLeadershipListWhere({ ...state, municipalities: undefined }),
depth: 0,
limit: 0,
pagination: false,
select: { municipalities: true },
user,
overrideAccess: false,
})
const available = new Set<number>(state.municipalities ?? [])
for (const doc of result.docs) {
for (const municipality of doc.municipalities ?? []) {
const id = relationshipId(municipality)
if (id !== null) available.add(id)
}
}
return {
page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1,
...(q ? { q } : {}),
municipalityIDs: [...available].sort((left, right) => left - right),
}
}
const buildLeadershipListSearchParams = (
state: LeadershipListState,
page = state.page,
): URLSearchParams => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (page > 1) params.set('page', String(page))
return params
}
export const buildLeadershipListHref = (state: LeadershipListState, page: number): string =>
buildListHref(state, buildLeadershipListSearchParams, '/campanha/liderancas', page)
export const loadLeadershipListPageData = async (
payload: Payload,
user: CampaignUser,
state: LeadershipListState,
): Promise<{ rows: LeadershipRowViewModel[]; totalDocs: number; totalPages: number }> => {
if (!isCampaignStaff(user)) return { rows: [], totalDocs: 0, totalPages: 0 }
let contactFilter: { contact: { in: number[] } } | null = null
if (state.q) {
// Names live on Contact — resolve matching contact ids first.
const contacts = await payload.find({
collection: 'contact',
where: { name: { contains: state.q } },
depth: 0,
limit: 200,
pagination: false,
select: { name: true },
overrideAccess: true,
})
contactFilter = { contact: { in: contacts.docs.map((contact) => contact.id) } }
if (contacts.docs.length === 0) return { rows: [], totalDocs: 0, totalPages: 0 }
): Promise<{
rows: LeadershipRowViewModel[]
totalDocs: number
totalPages: number
filterFacets: LeadershipListFilterFacets
}> => {
if (!isCampaignStaff(user)) {
return { rows: [], totalDocs: 0, totalPages: 0, filterFacets: { municipalityIDs: [] } }
}
const result = await payload.find({
collection: 'leadership',
where: contactFilter ?? {},
depth: 1,
limit: leadershipPageSize,
page: state.page,
sort: '-updatedAt',
user,
overrideAccess: false,
})
const { sort, dir } = resolveLeadershipListSort(state)
const [result, filterFacets] = await Promise.all([
payload.find({
collection: 'leadership',
where: buildLeadershipListWhere(state),
depth: 1,
limit: leadershipPageSize,
page: state.page,
sort: resolveLeadershipListPayloadSort(sort, dir),
user,
overrideAccess: false,
}),
loadLeadershipMunicipalityFacet(payload, user, state),
])
return {
rows: await toLeadershipRows(payload, result.docs as Leadership[]),
totalDocs: result.totalDocs,
totalPages: result.totalPages,
filterFacets,
}
}

View file

@ -10,3 +10,8 @@ export const supportStatusLabels: Record<SupportStatus, string> = {
em_disputa: 'Em disputa',
negativo: 'Negativo',
}
export const leadershipAccessFilterLabels: Record<'com' | 'sem', string> = {
com: 'Com acesso',
sem: 'Sem acesso',
}

View file

@ -0,0 +1,174 @@
/**
* Leadership list filter affordances: Status / Setor / Município / Acesso
* toggles, active-filter summary and clear-filters.
*/
import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership'
import { strictDecimalInteger, truncatedNamesLabel } from '@/utilities/campaignListUrl'
import { leadershipAccessFilterLabels, supportStatusLabels } from '@/utilities/leadershipLabels'
import {
buildLeadershipListHref,
leadershipListStateToRawParams,
parseLeadershipListParams,
type LeadershipListState,
} from '@/utilities/leadershipListUrl'
import { leadershipSectorLabels } from '@/utilities/leadershipUi'
export type LeadershipFilterParam = 'supportStatus' | 'sector' | 'municipality' | 'access'
export type LeadershipFilterOption = {
value: string
label: string
}
type LeadershipMultiFilterParam = 'status' | 'sector' | 'municipality'
export const leadershipStatusFilterOptions: LeadershipFilterOption[] =
leadershipSupportStatuses.map((value) => ({ value, label: supportStatusLabels[value] }))
export const leadershipSectorFilterOptions: LeadershipFilterOption[] = leadershipSectors.map(
(value) => ({ value, label: leadershipSectorLabels[value] }),
)
export const leadershipAccessFilterOptions: LeadershipFilterOption[] = (
Object.keys(leadershipAccessFilterLabels) as Array<keyof typeof leadershipAccessFilterLabels>
).map((value) => ({ value, label: leadershipAccessFilterLabels[value] }))
const withLeadershipListPageReset = (state: LeadershipListState): LeadershipListState =>
parseLeadershipListParams(leadershipListStateToRawParams({ ...state, page: 1 }, 1))
/**
* Writes the RAW param and lets `parseLeadershipListParams` validate, so no
* branch has to cast a `string[]` into the state's narrow value types.
*/
const setLeadershipMultiFilterValues = (
state: LeadershipListState,
param: LeadershipMultiFilterParam,
values: string[],
): LeadershipListState =>
parseLeadershipListParams({
...leadershipListStateToRawParams({ ...state, page: 1 }, 1),
[param]: values,
})
const getLeadershipMultiFilterValues = (
state: LeadershipListState,
param: LeadershipMultiFilterParam,
): string[] => {
if (param === 'status') return state.statuses ?? []
if (param === 'sector') return state.sectors ?? []
return (state.municipalities ?? []).map(String)
}
const toggleLeadershipMultiFilterValue = (
state: LeadershipListState,
param: LeadershipMultiFilterParam,
value: string,
): LeadershipListState => {
const current = getLeadershipMultiFilterValues(state, param)
return setLeadershipMultiFilterValues(
state,
param,
current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value],
)
}
export const toggleLeadershipStatusFilter = (
state: LeadershipListState,
value: string,
): LeadershipListState => toggleLeadershipMultiFilterValue(state, 'status', value)
export const clearLeadershipStatusFilter = (state: LeadershipListState): LeadershipListState =>
setLeadershipMultiFilterValues(state, 'status', [])
export const toggleLeadershipSectorFilter = (
state: LeadershipListState,
value: string,
): LeadershipListState => toggleLeadershipMultiFilterValue(state, 'sector', value)
export const clearLeadershipSectorFilter = (state: LeadershipListState): LeadershipListState =>
setLeadershipMultiFilterValues(state, 'sector', [])
export const toggleLeadershipMunicipalityFilter = (
state: LeadershipListState,
value: string,
): LeadershipListState => {
if (strictDecimalInteger(value) === undefined) return state
return toggleLeadershipMultiFilterValue(state, 'municipality', value)
}
export const clearLeadershipMunicipalityFilter = (
state: LeadershipListState,
): LeadershipListState => setLeadershipMultiFilterValues(state, 'municipality', [])
/** Exclusive toggle — selecting the active value clears; selecting the other replaces. */
export const toggleLeadershipAccessFilter = (
state: LeadershipListState,
value: string,
): LeadershipListState => {
if (value !== 'com' && value !== 'sem') return state
return withLeadershipListPageReset({
...state,
access: state.access === value ? undefined : value,
})
}
export const clearLeadershipAccessFilter = (state: LeadershipListState): LeadershipListState =>
withLeadershipListPageReset({ ...state, access: undefined })
/** Drop every filter and the search; keep the sort. */
export const clearLeadershipListFilters = (state: LeadershipListState): LeadershipListState => ({
page: 1,
sort: state.sort,
dir: state.dir,
})
export const buildLeadershipFilterHref = (next: LeadershipListState): string =>
buildLeadershipListHref(next, 1)
export const isLeadershipColumnFilterActive = (
state: LeadershipListState,
filterParam: LeadershipFilterParam,
): boolean => {
switch (filterParam) {
case 'supportStatus':
return Boolean(state.statuses?.length)
case 'sector':
return Boolean(state.sectors?.length)
case 'municipality':
return Boolean(state.municipalities?.length)
case 'access':
return Boolean(state.access)
}
}
const municipalityLabel = (
id: number,
labels?: ReadonlyMap<number, string> | Readonly<Record<number, string>>,
): string => {
if (!labels) return `Município #${id}`
if (labels instanceof Map) return labels.get(id) ?? `Município #${id}`
return labels[id] ?? `Município #${id}`
}
export const formatLeadershipActiveFiltersSummary = (
state: LeadershipListState,
municipalityLabelsById?: ReadonlyMap<number, string> | Readonly<Record<number, string>>,
): string | null => {
const parts: string[] = []
if (state.statuses?.length) {
parts.push(truncatedNamesLabel(state.statuses.map((status) => supportStatusLabels[status])))
}
if (state.sectors?.length) {
parts.push(truncatedNamesLabel(state.sectors.map((sector) => leadershipSectorLabels[sector])))
}
if (state.municipalities?.length) {
parts.push(
truncatedNamesLabel(
state.municipalities.map((id) => municipalityLabel(id, municipalityLabelsById)),
),
)
}
if (state.access) parts.push(leadershipAccessFilterLabels[state.access])
if (state.q) parts.push(`Busca "${state.q}"`)
return parts.length ? parts.join(' · ') : null
}

View file

@ -0,0 +1,245 @@
/**
* Leadership list URL contract: state, param parse/canonicalize, Payload
* `where`, sort and hrefs. Own module (same shape as `stateDeputyListUrl`) —
* does not import municipality/territory list modules.
*/
import type { Where } from 'payload'
import {
leadershipSectors,
leadershipSupportStatuses,
type SupportStatus,
} from '@/lib/schemas/leadership'
import {
allParamValues,
buildListHref,
firstValue,
normalizedText,
parseExhaustiveEnumParam,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
export const leadershipPageSize = 25
export type LeadershipListSortKey = 'name' | 'supportStatus' | 'sector' | 'updatedAt'
export type LeadershipListSortDirection = 'asc' | 'desc'
/** Acesso ao app — exclusive filter (com | sem), same shape as municipality coverage. */
export type LeadershipListAccessFilter = 'com' | 'sem'
type LeadershipSector = (typeof leadershipSectors)[number]
export type LeadershipListState = {
page: number
q?: string
statuses?: SupportStatus[]
sectors?: LeadershipSector[]
municipalities?: number[]
access?: LeadershipListAccessFilter
sort?: LeadershipListSortKey
dir?: LeadershipListSortDirection
}
export type LeadershipListSearchParams = RawSearchParams
const leadershipListParamNames = [
'q',
'status',
'sector',
'municipality',
'access',
'sort',
'dir',
'page',
] as const
const leadershipListParamNameSet = new Set<string>(leadershipListParamNames)
const DEFAULT_LEADERSHIP_LIST_SORT_KEY: LeadershipListSortKey = 'updatedAt'
const DEFAULT_LEADERSHIP_LIST_SORT_DIR: LeadershipListSortDirection = 'desc'
export const leadershipListSortLabels: Record<LeadershipListSortKey, string> = {
name: 'Nome',
supportStatus: 'Status',
sector: 'Setor',
updatedAt: 'Última atualização',
}
const leadershipListSortKeySet = new Set<string>(Object.keys(leadershipListSortLabels))
const leadershipListSortDirSet = new Set<LeadershipListSortDirection>(['asc', 'desc'])
const supportStatusSet = new Set<string>(leadershipSupportStatuses)
const sectorSet = new Set<string>(leadershipSectors)
const accessFilterSet = new Set<LeadershipListAccessFilter>(['com', 'sem'])
/** Temporal = newest first; categorical/name = A–Z. */
export const defaultLeadershipListSortDir = (
key: LeadershipListSortKey,
): LeadershipListSortDirection => (key === 'updatedAt' ? 'desc' : 'asc')
export const resolveLeadershipListSort = (
state: LeadershipListState,
): { sort: LeadershipListSortKey; dir: LeadershipListSortDirection } => {
const sort = state.sort ?? DEFAULT_LEADERSHIP_LIST_SORT_KEY
return { sort, dir: state.dir ?? defaultLeadershipListSortDir(sort) }
}
/** Dotted path for Contact name join; `-` prefix for descending. */
export const resolveLeadershipListPayloadSort = (
sort: LeadershipListSortKey,
dir: LeadershipListSortDirection,
): string => {
const path = sort === 'name' ? 'contact.name' : sort
return dir === 'desc' ? `-${path}` : path
}
export const formatLeadershipListSortSummary = (
sort: LeadershipListSortKey,
dir: LeadershipListSortDirection,
): string => {
const label = leadershipListSortLabels[sort]
if (sort === 'updatedAt') {
return dir === 'desc'
? `Ordenado por ${label} (mais recente)`
: `Ordenado por ${label} (mais antigo)`
}
return dir === 'asc' ? `Ordenado por ${label} (A–Z)` : `Ordenado por ${label} (Z–A)`
}
export const leadershipListStateToRawParams = (
state: LeadershipListState,
page = state.page,
): LeadershipListSearchParams => ({
page: String(page),
q: state.q,
status: state.statuses,
sector: state.sectors,
municipality: state.municipalities?.map(String),
access: state.access,
sort: state.sort,
dir: state.dir,
})
export const parseLeadershipListParams = (
params: LeadershipListSearchParams,
): LeadershipListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const q = normalizedText(firstValue(params.q))
const statuses = parseExhaustiveEnumParam<SupportStatus>(params.status, supportStatusSet)
const sectors = parseExhaustiveEnumParam<LeadershipSector>(params.sector, sectorSet)
const municipalities = allParamValues(params.municipality)
.map((token) => strictDecimalInteger(token))
.filter((id): id is number => typeof id === 'number' && id > 0)
const rawAccess = firstValue(params.access)
const access =
rawAccess && accessFilterSet.has(rawAccess as LeadershipListAccessFilter)
? (rawAccess as LeadershipListAccessFilter)
: undefined
const rawSort = firstValue(params.sort)
const sort =
rawSort && leadershipListSortKeySet.has(rawSort)
? (rawSort as LeadershipListSortKey)
: undefined
const rawDir = firstValue(params.dir)
const dir =
rawDir && leadershipListSortDirSet.has(rawDir as LeadershipListSortDirection)
? (rawDir as LeadershipListSortDirection)
: undefined
return {
page: rawPage ?? 1,
...(q ? { q } : {}),
...(statuses.length ? { statuses } : {}),
...(sectors.length ? { sectors } : {}),
...(municipalities.length ? { municipalities } : {}),
...(access ? { access } : {}),
...(sort ? { sort } : {}),
...(dir ? { dir } : {}),
}
}
export const buildLeadershipListWhere = (state: LeadershipListState): Where => {
const filters: Where[] = []
if (state.q) filters.push({ 'contact.name': { contains: state.q } })
if (state.statuses?.length) filters.push({ supportStatus: { in: state.statuses } })
if (state.sectors?.length) filters.push({ sector: { in: state.sectors } })
if (state.municipalities?.length) {
filters.push({ municipalities: { in: state.municipalities } })
}
if (state.access) {
filters.push({ user: { exists: state.access === 'com' } })
}
return filters.length ? { and: filters } : {}
}
/** Expects already-canonical state (from parse or a rule-preserving toggle). */
export const serializeCanonicalLeadershipListSearchParams = (
canonicalState: LeadershipListState,
): URLSearchParams => {
const params = new URLSearchParams()
const resolvedSort = canonicalState.sort ?? DEFAULT_LEADERSHIP_LIST_SORT_KEY
const resolvedDir = canonicalState.dir ?? defaultLeadershipListSortDir(resolvedSort)
const isSortDefault =
resolvedSort === DEFAULT_LEADERSHIP_LIST_SORT_KEY &&
resolvedDir === DEFAULT_LEADERSHIP_LIST_SORT_DIR
if (canonicalState.q) params.set('q', canonicalState.q)
for (const status of canonicalState.statuses ?? []) params.append('status', status)
for (const sector of canonicalState.sectors ?? []) params.append('sector', sector)
for (const municipality of canonicalState.municipalities ?? []) {
params.append('municipality', String(municipality))
}
if (canonicalState.access) params.set('access', canonicalState.access)
if (!isSortDefault) {
params.set('sort', resolvedSort)
if (resolvedDir !== defaultLeadershipListSortDir(resolvedSort)) {
params.set('dir', resolvedDir)
}
}
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
return params
}
const buildLeadershipListSearchParams = (
state: LeadershipListState,
page = state.page,
): URLSearchParams =>
serializeCanonicalLeadershipListSearchParams(
parseLeadershipListParams(leadershipListStateToRawParams(state, page)),
)
export const buildLeadershipListHref = (state: LeadershipListState, page: number): string =>
buildListHref(state, buildLeadershipListSearchParams, '/campanha/liderancas', page)
export const buildLeadershipSortHref = (
state: LeadershipListState,
nextKey: LeadershipListSortKey,
): string => {
const current = resolveLeadershipListSort(state)
const dir =
current.sort === nextKey
? current.dir === 'asc'
? 'desc'
: 'asc'
: defaultLeadershipListSortDir(nextKey)
return buildLeadershipListHref({ ...state, sort: nextKey, dir, page: 1 }, 1)
}
export const resolveLeadershipListUrl = (
params: LeadershipListSearchParams,
totalPages?: number,
): {
state: LeadershipListState
href: string
redirectHref?: string
} =>
resolveListUrl({
params,
paramNameSet: leadershipListParamNameSet,
parse: parseLeadershipListParams,
buildSearchParams: buildLeadershipListSearchParams,
basePath: '/campanha/liderancas',
totalPages,
})

View file

@ -2,8 +2,8 @@
* State deputy ("dobradinha") list URL contract: state type, param
* parsing/canonicalization, Payload `where`, sort resolution and hrefs.
* Mirrors `municipalityListUrl.ts` (paginated, `resolveListUrl` clamp) and
* `territoryListUrl.ts` (lean sort-key/label shape) without importing either
* (B29's `leadershipListUrl.ts` doesn't exist yet — nothing to mirror there).
* `territoryListUrl.ts` / `leadershipListUrl.ts` (lean sort-key/label shape)
* without importing either.
*/
import type { Where } from 'payload'

View file

@ -52,6 +52,7 @@ describe('loadLeadershipListPageData', () => {
municipalityIDs: [municipality.id],
hasAppAccess: false,
})
expect(result.filterFacets.municipalityIDs).toContain(municipality.id)
})
it('short-circuits to an empty page when no contact matches the query', async () => {
@ -63,7 +64,12 @@ describe('loadLeadershipListPageData', () => {
q: fixtures.value('sem-resultado'),
})
expect(result).toEqual({ rows: [], totalDocs: 0, totalPages: 0 })
expect(result).toEqual({
rows: [],
totalDocs: 0,
totalPages: 0,
filterFacets: { municipalityIDs: [] },
})
})
it('returns an empty page for leaders (staff guard, no throw)', async () => {
@ -72,7 +78,173 @@ describe('loadLeadershipListPageData', () => {
const result = await loadLeadershipListPageData(payload, leader, { page: 1 })
expect(result).toEqual({ rows: [], totalDocs: 0, totalPages: 0 })
expect(result).toEqual({
rows: [],
totalDocs: 0,
totalPages: 0,
filterFacets: { municipalityIDs: [] },
})
})
it('filters by supportStatus, sector, access and municipality', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipalityA = await fixtures.getMunicipality()
const municipalityB = await fixtures.getMunicipality()
const marker = fixtures.value('lead-filter')
const matchContact = await fixtures.createContact({ name: `Ana ${marker}` })
const missContact = await fixtures.createContact({ name: `Bruno ${marker}` })
const match = await fixtures.createLeadership({
contact: matchContact.id,
municipalities: [municipalityA.id],
supportStatus: 'a_abordar',
sector: 'religioso',
})
await fixtures.createLeadership({
contact: missContact.id,
municipalities: [municipalityB.id],
supportStatus: 'engajado',
sector: 'sindical',
})
const byStatus = await loadLeadershipListPageData(payload, coordinator, {
page: 1,
q: marker,
statuses: ['a_abordar'],
})
expect(byStatus.rows.map((row) => row.id)).toEqual([match.id])
const bySector = await loadLeadershipListPageData(payload, coordinator, {
page: 1,
q: marker,
sectors: ['religioso'],
})
expect(bySector.rows.map((row) => row.id)).toEqual([match.id])
const byMunicipality = await loadLeadershipListPageData(payload, coordinator, {
page: 1,
q: marker,
municipalities: [municipalityA.id],
})
expect(byMunicipality.rows.map((row) => row.id)).toEqual([match.id])
const withoutAccess = await loadLeadershipListPageData(payload, coordinator, {
page: 1,
q: marker,
access: 'sem',
})
expect(withoutAccess.totalDocs).toBe(2)
expect(withoutAccess.rows.every((row) => !row.hasAppAccess)).toBe(true)
const withAccess = await loadLeadershipListPageData(payload, coordinator, {
page: 1,
q: marker,
access: 'com',
})
expect(withAccess.totalDocs).toBe(0)
})
it('sorts by contact.name in both directions via the dotted join path', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const marker = fixtures.value('lead-sort')
const alpha = await fixtures.createContact({ name: `Aaa ${marker}` })
const omega = await fixtures.createContact({ name: `Zzz ${marker}` })
const first = await fixtures.createLeadership({
contact: alpha.id,
municipalities: [municipality.id],
})
const last = await fixtures.createLeadership({
contact: omega.id,
municipalities: [municipality.id],
})
const asc = await loadLeadershipListPageData(payload, coordinator, {
page: 1,
q: marker,
sort: 'name',
dir: 'asc',
})
expect(asc.rows.map((row) => row.id)).toEqual([first.id, last.id])
const desc = await loadLeadershipListPageData(payload, coordinator, {
page: 1,
q: marker,
sort: 'name',
dir: 'desc',
})
expect(desc.rows.map((row) => row.id)).toEqual([last.id, first.id])
})
it('keeps the municipality facet reachable under other filters and unions the selection', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipalityA = await fixtures.getMunicipality()
const municipalityB = await fixtures.getMunicipality()
const marker = fixtures.value('lead-facet')
await fixtures.createLeadership({
contact: (await fixtures.createContact({ name: `A ${marker}` })).id,
municipalities: [municipalityA.id],
supportStatus: 'a_abordar',
})
await fixtures.createLeadership({
contact: (await fixtures.createContact({ name: `B ${marker}` })).id,
municipalities: [municipalityB.id],
supportStatus: 'engajado',
})
const unfiltered = await loadLeadershipListPageData(payload, coordinator, {
page: 1,
q: marker,
})
expect(unfiltered.filterFacets.municipalityIDs).toEqual(
expect.arrayContaining([municipalityA.id, municipalityB.id]),
)
const filtered = await loadLeadershipListPageData(payload, coordinator, {
page: 1,
q: marker,
statuses: ['a_abordar'],
municipalities: [municipalityB.id],
})
// Own filter ignored for the facet — A stays; B stays because it is selected.
expect(filtered.filterFacets.municipalityIDs).toEqual(
expect.arrayContaining([municipalityA.id, municipalityB.id]),
)
})
it('does not leak out-of-scope leaderships to an advisor even with a foreign municipality filter', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const inScope = await fixtures.getMunicipality()
const outOfScope = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(inScope.id, [advisor.id])
const marker = fixtures.value('lead-scope')
const visible = await fixtures.createLeadership({
contact: (await fixtures.createContact({ name: `In ${marker}` })).id,
municipalities: [inScope.id],
})
await fixtures.createLeadership({
contact: (await fixtures.createContact({ name: `Out ${marker}` })).id,
municipalities: [outOfScope.id],
})
const foreignFilter = await loadLeadershipListPageData(payload, advisor, {
page: 1,
q: marker,
municipalities: [outOfScope.id],
})
expect(foreignFilter.totalDocs).toBe(0)
expect(foreignFilter.rows).toEqual([])
const scoped = await loadLeadershipListPageData(payload, advisor, {
page: 1,
q: marker,
})
expect(scoped.rows.map((row) => row.id)).toEqual([visible.id])
})
})

View file

@ -1,22 +1,17 @@
/**
* Pins the "simple" entity list parsers ahead of the Pass 2 list-system
* consolidation (W1): lideranças, organizações, demandas and assessores.
* Their URL contracts must survive the migration byte-identical. Dobradinhas
* left this group in B33 — its parser grew a full sort/filter contract of its
* own and moved to `stateDeputyListUrl.ts`, pinned in
* `stateDeputyListUrl.unit.spec.ts`.
* consolidation (W1): organizações, demandas and assessores. Lideranças left
* this group in B29 — its parser grew a full sort/filter contract of its own
* and moved to `leadershipListUrl.ts`, pinned in
* `leadershipListUrl.unit.spec.ts`. Dobradinhas left earlier in B33.
*/
import { describe, expect, it } from 'vitest'
import { advisorListHrefForPage, parseAdvisorListParams } from '@/utilities/advisorData'
import { parseDemandListParams } from '@/utilities/campaignDemandData'
import { parseLeadershipListParams } from '@/utilities/leadershipData'
import { parseOrganizationListParams } from '@/utilities/organizationData'
const qAndPageParsers = [
['leadership', parseLeadershipListParams],
['advisor', parseAdvisorListParams],
] as const
const qAndPageParsers = [['advisor', parseAdvisorListParams]] as const
describe.each(qAndPageParsers)('%s list parser (q + page)', (_name, parse) => {
it('defaults to page 1 without q', () => {

View file

@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest'
import {
clearLeadershipAccessFilter,
clearLeadershipListFilters,
clearLeadershipMunicipalityFilter,
clearLeadershipStatusFilter,
formatLeadershipActiveFiltersSummary,
toggleLeadershipAccessFilter,
toggleLeadershipMunicipalityFilter,
toggleLeadershipStatusFilter,
} from '@/utilities/leadershipListFilters'
import {
buildLeadershipListHref,
buildLeadershipSortHref,
parseLeadershipListParams,
resolveLeadershipListPayloadSort,
resolveLeadershipListSort,
resolveLeadershipListUrl,
serializeCanonicalLeadershipListSearchParams,
} from '@/utilities/leadershipListUrl'
describe('leadership list URL contract', () => {
it('defaults to page 1 with no sort/filters', () => {
expect(parseLeadershipListParams({})).toEqual({ page: 1 })
})
it('validates page as a positive decimal integer', () => {
expect(parseLeadershipListParams({ page: '4' }).page).toBe(4)
expect(parseLeadershipListParams({ page: '0' }).page).toBe(1)
expect(parseLeadershipListParams({ page: '-2' }).page).toBe(1)
expect(parseLeadershipListParams({ page: 'abc' }).page).toBe(1)
})
it('trims q, omits it when empty and takes the first repeated cell', () => {
expect(parseLeadershipListParams({ q: ' ana ' }).q).toBe('ana')
expect(parseLeadershipListParams({ q: ' ' }).q).toBeUndefined()
expect(parseLeadershipListParams({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro')
})
it('parses and dedupes known status/sector values and integer municipality ids', () => {
expect(
parseLeadershipListParams({
status: ['engajado', 'a_abordar', 'engajado', 'unknown'],
sector: ['religioso', 'clube', 'sindical'],
municipality: ['12', '12', '0', 'abc', '3'],
}),
).toEqual({
page: 1,
statuses: ['engajado', 'a_abordar'],
sectors: ['religioso', 'sindical'],
municipalities: [12, 3],
})
})
it('canonicalizes selecting every status/sector member to the absent filter (B18)', () => {
expect(
parseLeadershipListParams({
status: ['engajado', 'a_abordar', 'em_disputa', 'negativo'],
sector: [
'religioso',
'sindical',
'comunitario',
'rural',
'empresarial',
'juventude',
'saude',
'educacao',
'cultura',
'outro',
],
}),
).toEqual({ page: 1 })
})
it('keeps only known access / sort / dir tokens', () => {
expect(parseLeadershipListParams({ access: 'sem', sort: 'name', dir: 'asc' })).toEqual({
page: 1,
access: 'sem',
sort: 'name',
dir: 'asc',
})
expect(parseLeadershipListParams({ access: 'maybe', sort: 'phone', dir: 'sideways' })).toEqual({
page: 1,
})
})
it('omits the default sort pair (updatedAt+desc) from the canonical query', () => {
const state = parseLeadershipListParams({ sort: 'updatedAt', dir: 'desc' })
expect(serializeCanonicalLeadershipListSearchParams(state).toString()).toBe('')
expect(resolveLeadershipListSort(state)).toEqual({ sort: 'updatedAt', dir: 'desc' })
})
it('serializes a non-default sort pair and repeated filters', () => {
const state = parseLeadershipListParams({
q: 'ana',
status: ['a_abordar'],
sector: ['religioso'],
municipality: ['7'],
access: 'sem',
sort: 'name',
dir: 'asc',
})
expect(buildLeadershipListHref(state, 1)).toBe(
'/campanha/liderancas?q=ana&status=a_abordar&sector=religioso&municipality=7&access=sem&sort=name',
)
})
it('preserves filters while toggling sort direction and resets to page 1', () => {
const state = parseLeadershipListParams({ q: 'ana', page: '3' })
expect(buildLeadershipSortHref(state, 'name')).toBe('/campanha/liderancas?q=ana&sort=name')
expect(buildLeadershipSortHref({ ...state, sort: 'name', dir: 'asc' }, 'name')).toContain(
'dir=desc',
)
})
it('resolves the payload sort string with the contact.name join and descending prefix', () => {
expect(resolveLeadershipListPayloadSort('name', 'asc')).toBe('contact.name')
expect(resolveLeadershipListPayloadSort('name', 'desc')).toBe('-contact.name')
expect(resolveLeadershipListPayloadSort('updatedAt', 'desc')).toBe('-updatedAt')
expect(resolveLeadershipListPayloadSort('supportStatus', 'asc')).toBe('supportStatus')
})
it('clamps an out-of-range page and redirects non-canonical query strings', () => {
expect(resolveLeadershipListUrl({ page: '9' }, 2)).toEqual({
state: { page: 2 },
href: '/campanha/liderancas?page=2',
redirectHref: '/campanha/liderancas?page=2',
})
expect(resolveLeadershipListUrl({ q: ' ana ', unknown: 'x' })).toEqual({
state: { page: 1, q: 'ana' },
href: '/campanha/liderancas?q=ana',
redirectHref: '/campanha/liderancas?q=ana',
})
})
})
describe('leadership list filter state', () => {
it('toggles status and municipality filters and resets the page', () => {
const withStatus = toggleLeadershipStatusFilter({ page: 3 }, 'engajado')
expect(withStatus).toEqual({ page: 1, statuses: ['engajado'] })
expect(toggleLeadershipStatusFilter(withStatus, 'engajado')).toEqual({ page: 1 })
const withMunicipality = toggleLeadershipMunicipalityFilter({ page: 2 }, '15')
expect(withMunicipality).toEqual({ page: 1, municipalities: [15] })
expect(toggleLeadershipMunicipalityFilter(withMunicipality, '15')).toEqual({ page: 1 })
})
it('rejects padded municipality ids that strictDecimalInteger would drop', () => {
expect(toggleLeadershipMunicipalityFilter({ page: 1 }, '01')).toEqual({ page: 1 })
expect(toggleLeadershipMunicipalityFilter({ page: 1 }, '1.5')).toEqual({ page: 1 })
})
it('toggles access exclusively and clears only that filter', () => {
const withAccess = toggleLeadershipAccessFilter({ page: 1 }, 'sem')
expect(withAccess).toEqual({ page: 1, access: 'sem' })
expect(toggleLeadershipAccessFilter(withAccess, 'sem')).toEqual({ page: 1 })
expect(toggleLeadershipAccessFilter(withAccess, 'com')).toEqual({ page: 1, access: 'com' })
expect(clearLeadershipAccessFilter(withAccess)).toEqual({ page: 1 })
})
it('clears only one filter, keeping q and sort', () => {
expect(
clearLeadershipStatusFilter({
page: 2,
q: 'ana',
statuses: ['engajado'],
sort: 'name',
dir: 'asc',
}),
).toEqual({ page: 1, q: 'ana', sort: 'name', dir: 'asc' })
expect(
clearLeadershipMunicipalityFilter({
page: 1,
municipalities: [1, 2],
access: 'sem',
}),
).toEqual({ page: 1, access: 'sem' })
})
it('clears every filter and the search, keeping sort', () => {
expect(
clearLeadershipListFilters({
page: 4,
q: 'ana',
statuses: ['engajado'],
sort: 'sector',
dir: 'asc',
}),
).toEqual({ page: 1, sort: 'sector', dir: 'asc' })
})
it('summarizes active filters with municipality labels when provided', () => {
expect(
formatLeadershipActiveFiltersSummary(
{
page: 1,
statuses: ['a_abordar'],
access: 'sem',
q: 'maria',
municipalities: [9],
},
new Map([[9, 'Feira de Santana']]),
),
).toBe('A abordar · Feira de Santana · Sem acesso · Busca "maria"')
})
})