Merge branch 'cursor/69519484'
This commit is contained in:
commit
c60d07c5f8
17 changed files with 1454 additions and 184 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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,10 +136,22 @@ 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).
|
||||
- **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).
|
||||
- **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).**~~ — **fechado no as-built 2026-07-28** (`contact.name` where+sort, int verdes).
|
||||
- **`leadershipFilterDefinitions` (tabela de defs multi-filtro à la município).** Revisitar quando: um 3º domínio multi-filtro copiar o molde de lideranças (hoje o toggle DRY + branch config bastam; YAGNI).
|
||||
- **`select` + `depth: 0` no find paginado de lideranças.** Pré-existente (não regressão B29); revisitar quando `/campanha/liderancas` aparecer em traces — o path é page-bounded (25).
|
||||
|
||||
## Já resolvido no simplify (não reabrir)
|
||||
|
||||
- `parseExhaustiveEnumParam` em status/setor (B18: todas → ausente) + teste unit.
|
||||
- Toggles multi via raw params + `strictDecimalInteger` no município; `CampaignTableHead.filter`; HeaderFilter colapsado; labels de acesso em `leadershipLabels`; `isLeadershipSector`; summary aceita `Record`|`Map`; re-export morto de `LeadershipListState` removido.
|
||||
|
||||
## Explicitamente fora (triage pós-simplify 2026-07-28)
|
||||
|
||||
- Flatten do wrapper `filterFacets` de um só campo — higiene sem ROI.
|
||||
- Casts de setor nos `formActions` de ficha/nova — fora do diff; `isLeadershipSector` já existe para o próximo toque.
|
||||
|
||||
## Referências
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
69
src/components/campaign/leadership/LeadershipFilters.tsx
Normal file
69
src/components/campaign/leadership/LeadershipFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
src/components/campaign/leadership/LeadershipHeaderFilter.tsx
Normal file
129
src/components/campaign/leadership/LeadershipHeaderFilter.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
175
src/utilities/leadershipListFilters.ts
Normal file
175
src/utilities/leadershipListFilters.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* 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}`
|
||||
const record = labels as Readonly<Record<number, string>>
|
||||
return record[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
|
||||
}
|
||||
245
src/utilities/leadershipListUrl.ts
Normal file
245
src/utilities/leadershipListUrl.ts
Normal 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,
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
208
tests/unit/leadershipListUrl.unit.spec.ts
Normal file
208
tests/unit/leadershipListUrl.unit.spec.ts
Normal 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§or=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"')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue