Merge pull request #674 from fsolla/C117-pessoas-ordenacao-header-omnibox-e-filtros-de-ausencia-

C117 — Pessoas: ordenação (header + omnibox) e filtros de ausência
This commit is contained in:
Francisco Solla 2026-08-11 18:51:16 +00:00 committed by GitHub
commit 93e57a6ed7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 734 additions and 23 deletions

View file

@ -0,0 +1,77 @@
# Impl: Pessoas — ordenação (omnibox + header) e filtros de ausência
Status: aprovado
Atualizado em: 2026-08-11
Issue: #656
Intenção: docs/plans/pessoas-ordenacao-filtros-ausencia.md
Appetite restante: herdado (~1–1,5 dia eng)
## Leitura da intenção
- **Outcome:** ordenar a lista de pessoas por qualquer coluna visível (header desktop + omnibox em todos os breakpoints) e filtrar ausência ("Sem assessor", "Sem base", "Sem contato"), com estado na URL como os demais recortes; default inalterado (nome A–Z).
- **O que NÃO negociar:** toda chave de ordenação pousa numa coluna visível; filtro de ausência só expõe presença/ausência (telefone nunca vira facet); sort sobre o conjunto filtrado (não a página); sem migration/collection; escopo do assessor preservado no merge; leader segue fora da rota.
- **O que reavaliar:** a intenção deixa em aberto a _semântica_ dos três filtros de ausência (combináveis entre si — AND ou OR?) e não fixa quais colunas são sortáveis (o anti-goal "coluna visível" colide com `email`, oculta por padrão). Decisões abaixo.
## Abordagem recomendada
```mermaid
flowchart LR
URL[peopleListUrl: sort/dir/ausencia] --> PARSE[parse + serialize canônico]
PARSE --> PAGE[pessoas/page.tsx]
PAGE --> DATA[peopleData: filter → sortPeopleRows → paginar]
PAGE --> HEAD[PeopleSortableHead + CampaignSortableHead]
PAGE --> OMNI[peopleOmnibox + PeopleFilters]
```
**Opções consideradas:** A (esta) | B (absence como 3 params booleanos AND) | C (email na lista de sort keys)
**Recomendação:** A — espelha os precedentes B15 (header) + territórios (omnibox) sem inventar contrato novo; ausência como um facet multi-valor OR (mesma semântica de todo facet do repo).
**Rejeitadas:** B porque cada absence vira um param novo sem precedente, e a leitura operacional ("quero ver quem está sem _algum_ destes dados") é união — com AND, marcar os três reduz a quase nada; C porque `email` é oculta por padrão (visibilidade de coluna `pessoas: ['email']`) e o anti-goal da intenção é explícito: chave de ordenação que não pousa em coluna visível é estado que mente.
### Decisões de engenharia
1. **Contrato de URL** (`peopleListUrl.ts`): params `sort`, `dir`, `ausencia` somam-se a `q|capacity|municipality|status|page`. `PeopleListSortKey = 'name' | 'contact' | 'assessora' | 'lidera' | 'aliada' | 'assessorado' | 'base'` (as colunas visíveis por padrão; `actions` inerte, `email` oculta). `ausencia` é facet multi-valor `sem_assessor | sem_base | sem_contato` via `parseExhaustiveEnumParam` — selecionar todos = "todas" = ausente (canonicalização grátis, contrato B18). Serializer omite `sort`/`dir` quando default (molde territórios); `dir` só quando difere do default da chave. `buildPeopleSortHref = createSortToggleHref` (algoritmo P3-F). `resolvePeopleListUrl` ganha os novos params no set.
2. **Semântica dos filtros de ausência**: um único facet `ausencia`, OR dentro do facet (padrão do repo), AND com os demais filtros. Rótulos: "Sem assessor" (= coluna Assessorado vazia — união dos advisor IDs de leadership+dobradinha), "Sem base" (= `city` nulo), "Sem contato" (= `phone` nulo, gate da intenção).
3. **Sort em memória sobre o conjunto filtrado, antes da paginação** (`peopleData.ts`): função pura `sortPeopleRows(rows, sort, dir)` exportada para testes. Nulls SEMPRE no fim (precedente B15 `sortByNullableValue`), tiebreak por nome → contactID (comportamento atual). Por chave: `name`/`contact`/`base` comparam string (`localeCompare` pt-BR); `assessora`/`lidera`/`aliada` ordenam pela contagem de municípios da coluna; `assessorado` pela contagem da união dos advisor IDs (`new Set([...leadershipAdvisorIDs, ...deputyAdvisorIDs]).size`) — **os nomes (`assessoradoNames`) só são resolvidos para a página visível** (otimização existente), então o sort NÃO pode depender deles.
4. **Default dir por chave**: textuais (name/contact/base) → `asc`; contagens (assessora/lidera/aliada/assessorado) → `desc` (leitura de ranking, "quem tem mais rede?" primeiro — precedente `sortKeysWithDescDefault` de municípios). Default da lista inteira: name asc (inalterado).
5. **`clearPeopleListFilters(state)` preserva o sort** (precedente municípios/territórios): assinatura muda de `() => state` para `(state) => state` — 2 call sites (`clearPeopleOmnibox`, empty state da página; `SavePeopleFilterControl` só usa `buildPeopleFilterHref`). Resumo de filtros ativos ganha os rótulos de ausência; sort NÃO entra no resumo (precedente territórios — recorte só-de-ordenação não gera "Salvar filtro", mesmo comportamento).
6. **UI**: novo `PeopleSortableHead` ('use client', forma simples sem header-filters — os filtros da lista vivem só na omnibox), molde `LeadershipSortableHead`/`StateDeputySortableHead`, colado via slot `head` de `CampaignTableColumn`. Omnibox: seeds "Ordenação" (`sort:<key>|<dir>`, grupo "Ordenação", keywords `ordenar|ordenacao|ordem|sort`, não visíveis com query vazia — molde territórios) e seeds "Ausência" (`ausencia:<value>`, `emptyQueryVisible: true` — atalho de triage, mesmo padrão de capacidade/status); chips `ausencia:<value>` ("Ausência: Sem assessor") e chip `sort` ("Ordenação: …") quando não-default; remoção de chip desfaz o facet / zera o sort.
### Componentes / mudanças
- **`PeopleListState` + parse/serialize + `buildPeopleSortHref`** (`src/utilities/people/peopleListUrl.ts`): contrato de URL estendido; sem migration (param de URL).
- **`sortPeopleRows`** (`src/utilities/people/peopleData.ts`): sort puro sobre `MergedPerson[]`; chamado em `loadPeopleListPageData` no lugar do sort fixo de nome.
- **`PeopleSortableHead`** (`src/components/campaign/people/PeopleSortableHead.tsx`, novo): header sortável com `aria-sort` + seta, reusa `CampaignSortableHead` + `buildPeopleSortHref`.
- **`peopleListFilters.ts` / `peopleOmnibox.ts`**: toggle de ausência, summary, chips/seeds/apply/remove de `ausencia` e `sort`.
- **`PeopleFilters.tsx` / `pessoas/page.tsx`**: colunas com `head` sortável (nome, contato, assessora, lidera, aliada, assessorado, base); `hasFilters` inclui `ausencias`; empty state usa o novo `clearPeopleListFilters(state)`.
- **Migration:** sem migration. **Access / Consent:** nenhum — escopo do assessor segue no merge; nenhuma chave nova.
- **UI:** Impeccable B (encaixe em tela existente). Shells reusados: `CampaignTable` slot `head`, `CampaignSortableHead`, `CampaignListOmnibox`, `createSortToggleHref`, `parseExhaustiveEnumParam`, `resolveListUrl`.
### Dados → forma (se aplicável)
- Nenhum dado novo: recorte e ordenação sobre a tabela existente (intenção, pergunta 3 do data-presentation). Ausência é predicado, não métrica exibida — sem KPI no topo.
## Fases verificáveis
1. **Contrato de URL + filtros + omnibox (puro)** — `peopleListUrl` (parse/serialize/sort toggle), `peopleListFilters` (ausência), `peopleData.sortPeopleRows`, `peopleOmnibox` (chips/seeds/apply); testes unitários do puro. Verificação: `pnpm test` focado.
2. **UI** — `PeopleSortableHead` + fiação em `pessoas/page.tsx` + `PeopleFilters` (nada novo de props: seeds são estáticas); testes unitários existentes ajustados (o teste que hoje rejeita `sort` como param desconhecido inverte de expectativa); e2e leve (`campaignPeople.e2e.spec.ts`: ordenar por header com `aria-sort` + filtrar ausência), se o appetite permitir.
3. **Gates** — `pnpm gate:fast` (tsc, lint zero-warnings, format:check, knip, check:cycles, unit+int, e2e) + `pnpm build` local; commit e PR com auto-merge em main.
## Rabbit holes / Não escopo (engenharia)
- Sort client-side da página (faz ranking falso) — proibido; sort sempre no conjunto filtrado completo.
- Resolver `assessoradoNames` para todas as linhas só para ordenar — desperdício; contagem da união de IDs já ordena.
- Ordenar por `email` — oculta por padrão, anti-goal explícito (gatilho de revisitação: se `email` voltar a ser visível, adicionar a chave é um diff de uma linha no record de labels).
- Chave `sort` canônica nova em URLs salvas (saved filters guardam href — nada a migrar).
- Header-filters por coluna (popover de filtro no header) — os filtros de pessoas vivem na omnibox; não inventar segundo caminho.
## Riscos e mitigação
- **Canonicalização do redirect**: `sort`/`dir`/`ausencia` entram no set de params conhecidos e no serializer — URL com `sort=name` (default) deve redirecionar para a URL limpa. Coberto por testes de `resolvePeopleListUrl`.
- **`clearPeopleListFilters` muda de assinatura**: 3 call sites (página, omnibox, navegação) — tsc pega qualquer call site esquecido.
- **Sort × visibilidade de coluna**: chaves fixadas nas colunas visíveis por padrão; se a mesa esconder outra coluna no futuro, o set de chaves é revisado com ela.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (ordenação header + omnibox, ausência combinável, default nome A–Z, escopo assessor no merge)
- [x] Invariantes AGENTS/engineering-standards (sem migration/collection, sem access novo, identificadores EN / copy pt-BR)
- [x] Testes de domínio previstos (unit do contrato de URL + `sortPeopleRows` + omnibox; e2e leve se couber no appetite)

View file

@ -0,0 +1,37 @@
# Pessoas pós-C117 — descoberta de ordenação no mobile e facet de ausência completo
Status: registrado (blocked até plano em main)
Atualizado em: 2026-08-11
Issue: #681
Priority: P3
Model: composer-2.5
Impeccable: B — encaixe no omnibox de `/campanha/pessoas`; sem rota nova
Appetite: ~0,5–1 dia eng; dois fixes de UX no mesmo recorte
Responsável: —
## Intenção
Dois achados P2 do /simplify da entrega C117 (#656), mesma superfície (omnibox de pessoas):
1. **Descoberta de ordenação no mobile (S12).** No mobile os headers sortáveis não existem (cards), e o grupo "Ordenação" só aparece digitando keyword; ao digitar o genérico "ordenar", o cap de 8 por grupo (`campaignListOmnibox.ts`) corta em `Nome/Contato/Assessora/Lidera`, escondendo `Aliada em`, `Assessorado` e `Base` — o usuário mobile não descobre que pode ordenar por essas colunas.
2. **Facet de ausência "completo" (S14).** Marcar a 3ª ausência zera o facet inteiro (contrato B18 "selecionar todos = todas" = ausente): a consulta "quem está sem _algum_ dado" (fichas incompletas) é inexpressível — o terceiro clique faz os três chips sumirem no meio da interação.
## Fases (ordem de ROI)
1. **Facet de ausência completo** — decidir e implementar uma forma de "qualquer ausência" (ex.: 4º valor `qualquer_ausencia` no enum, OU toggle de tri-state) sem quebrar a canonicalização B18; o filtro casa `sem_assessor OR sem_base OR sem_contato`. Testar a interação "marcar os três" e a nova consulta.
2. **Descoberta de ordenação no mobile** — revisar cap/ordem/emptyQueryVisible do grupo "Ordenação" (ex.: 7 seeds de direção primária visíveis com query vazia no mobile, ou busca por coluna no desktop) para que todas as 7 chaves sejam alcançáveis por keyword e visíveis na tela mobile.
## Já resolvido no simplify (não reabrir)
- Perf do sort (Map pré-computado de contagem de assessorado).
- Hardening de e2e/unit apontado pelos revisores (indexOfRow, `dir`-sem-`sort`, doc de call sites).
## Explicitamente fora (skips + descartes + defers com gatilho)
- **S2 — comparador nulls-last duplicado** (`peopleData.sortPeopleRows` × `municipalityPageData.sortByNullableValue`): defer com gatilho — extrair helper compartilhado quando houver um 3º sort in-memory.
- **S3 — hook `useCampaignSortHeadState`** (5º wrapper re-derivando a regra de flip): defer com gatilho — refactor de headers quando houver o 6º wrapper ou um pass de headers.
- **S4 — labels de coluna duplicadas** (sort record × column config): descartado, precedente territórios.
- **S6 — tiebreak A–Z mesmo em desc**: decisão travada (precedente B15), unit-pinned.
- **S9/S10 — regex sem escape no e2e e fallback morto do chip**: descartados (precedente no próprio arquivo/espelho de territórios).
- **S11 — staff aparece em "Sem assessor"**: semântica da coluna Assessorado (staff nunca tem assessorado); unit-pinned; info para a mesa.
- **S13 — "Sem contato" = só telefone**: decisão travada no gate C117 (coluna Contato = telefone desde B197).

View file

@ -10,6 +10,7 @@ import { PeopleAssessoradoCell } from '@/components/campaign/people/PeopleAssess
import { PeopleFilters } from '@/components/campaign/people/PeopleFilters'
import { PeopleListPageChrome } from '@/components/campaign/people/PeopleListPageChrome'
import { PeopleMunicipalityCell } from '@/components/campaign/people/PeopleMunicipalityCell'
import { PeopleSortableHead } from '@/components/campaign/people/PeopleSortableHead'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
@ -59,7 +60,11 @@ import {
clearPeopleListFilters,
type PeopleFilterOption,
} from '@/utilities/people/peopleListFilters'
import { buildPeopleListHref, resolvePeopleListUrl } from '@/utilities/people/peopleListUrl'
import {
buildPeopleListHref,
resolvePeopleListUrl,
type PeopleListState,
} from '@/utilities/people/peopleListUrl'
import {
setPersonAssessoraFormAction,
@ -75,7 +80,15 @@ type PeoplePageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
const PeopleListEmptyState = ({ hasFilters, scoped }: { hasFilters: boolean; scoped: boolean }) => (
const PeopleListEmptyState = ({
hasFilters,
scoped,
state,
}: {
hasFilters: boolean
scoped: boolean
state: PeopleListState
}) => (
<Empty className="min-h-56">
<EmptyHeader>
<EmptyMedia variant="icon">
@ -90,7 +103,7 @@ const PeopleListEmptyState = ({ hasFilters, scoped }: { hasFilters: boolean; sco
{hasFilters ? (
<EmptyContent>
<CampaignTransitionAnchor
href={buildPeopleFilterHref(clearPeopleListFilters())}
href={buildPeopleFilterHref(clearPeopleListFilters(state))}
replace
scroll={false}
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
@ -140,11 +153,13 @@ const buildPeopleEditability = (
}
const peopleColumns = ({
state,
municipalityIndex,
canDelete,
editability,
advisorOptions,
}: {
state: PeopleListState
municipalityIndex: readonly MunicipalityPortfolioIndexEntry[]
canDelete: boolean
editability: PeopleEditability
@ -154,6 +169,7 @@ const peopleColumns = ({
id: 'name',
label: 'Nome',
mandatory: true,
head: <PeopleSortableHead state={state} sortKey="name" />,
cell: (row) => (
<div className="flex min-w-0 items-baseline gap-1">
<CampaignInlineEditableCell
@ -176,6 +192,7 @@ const peopleColumns = ({
id: 'contact',
label: 'Contato',
cellClassName: 'whitespace-normal',
head: <PeopleSortableHead state={state} sortKey="contact" />,
cell: (row) => (
<CampaignInlineEditableCell
recordId={row.contactID}
@ -211,6 +228,7 @@ const peopleColumns = ({
id: 'assessora',
label: 'Assessora',
cellClassName: 'max-w-56 whitespace-normal',
head: <PeopleSortableHead state={state} sortKey="assessora" />,
cell: (row) => (
<PeopleMunicipalityCell
ownerId={editability.canEditAssessora(row) ? (row.staff[0]?.id ?? null) : null}
@ -228,6 +246,7 @@ const peopleColumns = ({
id: 'lidera',
label: 'Lidera',
cellClassName: 'max-w-56 whitespace-normal',
head: <PeopleSortableHead state={state} sortKey="lidera" />,
cell: (row) => (
<PeopleMunicipalityCell
ownerId={row.leadershipID}
@ -247,6 +266,7 @@ const peopleColumns = ({
id: 'aliada',
label: 'Aliada em',
cellClassName: 'max-w-56 whitespace-normal',
head: <PeopleSortableHead state={state} sortKey="aliada" />,
cell: (row) => (
<PeopleMunicipalityCell
ownerId={row.deputyID}
@ -265,6 +285,7 @@ const peopleColumns = ({
id: 'assessorado',
label: 'Assessorado',
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
head: <PeopleSortableHead state={state} sortKey="assessorado" />,
cell: (row) => (
<PeopleAssessoradoCell
ownerId={row.contactID}
@ -279,6 +300,7 @@ const peopleColumns = ({
{
id: 'base',
label: 'Base',
head: <PeopleSortableHead state={state} sortKey="base" />,
cell: (row) => (
<CampaignInlineEditableCell
recordId={row.contactID}
@ -513,13 +535,18 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
: []
const canDelete = isUnrestrictedCampaignRole(user.role)
const columns = peopleColumns({
state,
municipalityIndex,
canDelete,
editability,
advisorOptions,
})
const hasFilters = Boolean(
state.q || state.capacities?.length || state.municipalities?.length || state.statuses?.length,
state.q ||
state.capacities?.length ||
state.municipalities?.length ||
state.statuses?.length ||
state.ausencias?.length,
)
return (
@ -543,7 +570,11 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
municipalityIndex={resolvedIndex}
canDelete={canDelete}
empty={
<PeopleListEmptyState hasFilters={hasFilters} scoped={user.role === 'advisor'} />
<PeopleListEmptyState
hasFilters={hasFilters}
scoped={user.role === 'advisor'}
state={state}
/>
}
/>
{/* One shared Drawer for every chip-cell sheet on coarse pointers
@ -557,7 +588,11 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
rows={listData.rows}
rowKey={(row) => row.contactID}
empty={
<PeopleListEmptyState hasFilters={hasFilters} scoped={user.role === 'advisor'} />
<PeopleListEmptyState
hasFilters={hasFilters}
scoped={user.role === 'advisor'}
state={state}
/>
}
/>
</CampaignListSheetProvider>

View file

@ -103,7 +103,7 @@ export const PeopleFilters = ({
runAction(removePeopleOmniboxChip({ state, chipId }))
}}
onClearAll={() => {
runAction(clearPeopleOmnibox())
runAction(clearPeopleOmnibox(state))
}}
trailing={
<>

View file

@ -0,0 +1,50 @@
'use client'
import type { ReactNode } from 'react'
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
import {
buildPeopleSortHref,
defaultPeopleListSortDir,
peopleListSortLabels,
resolvePeopleListSort,
type PeopleListSortKey,
type PeopleListState,
} from '@/utilities/people/peopleListUrl'
type PeopleSortableHeadProps = {
state: PeopleListState
sortKey: PeopleListSortKey
children?: ReactNode
align?: 'left' | 'center' | 'right'
className?: string
}
/** Sortable header for the people table (C117). People filters live in the
* omnibox only — no header filter popovers, so this is the bare shell form
* (StateDeputy precedent, minus the filter slot). */
export const PeopleSortableHead = ({
state,
sortKey,
children,
align = 'left',
className,
}: PeopleSortableHeadProps) => {
const { sort: activeSort, dir } = resolvePeopleListSort(state)
const active = activeSort === sortKey
const nextDir = active ? (dir === 'asc' ? 'desc' : 'asc') : defaultPeopleListSortDir(sortKey)
return (
<CampaignSortableHead
active={active}
align={align}
className={className}
dir={dir}
href={buildPeopleSortHref(state, sortKey)}
nextDir={nextDir}
sortLabel={peopleListSortLabels[sortKey]}
>
{children ?? peopleListSortLabels[sortKey]}
</CampaignSortableHead>
)
}

View file

@ -18,6 +18,9 @@ import {
buildPeopleLeadershipSourceWhere,
buildPeopleStaffSourceWhere,
peoplePageSize,
resolvePeopleListSort,
type PeopleListSortDirection,
type PeopleListSortKey,
type PeopleListState,
} from '@/utilities/people/peopleListUrl'
import { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData'
@ -233,7 +236,9 @@ export const scopePeopleRows = (
/**
* In-memory list filters (C100): capacities OR within the facet (same as every
* other list facet), municipalities intersect any capacity, statuses only
* match leadership rows. `q` already ran at the source level.
* match leadership rows. `q` already ran at the source level. C117 absence
* facets are OR within the facet: a row matches when it satisfies ANY selected
* absence ("Sem assessor" OR "Sem base" OR "Sem contato").
*/
export const filterPeopleRows = (
rows: readonly MergedPerson[],
@ -255,9 +260,73 @@ export const filterPeopleRows = (
if (state.statuses?.length) {
if (row.supportStatus === null || !state.statuses.includes(row.supportStatus)) return false
}
if (state.ausencias?.length) {
const wanted = new Set(state.ausencias)
const hasAdvisor = row.leadershipAdvisorIDs.length > 0 || row.deputyAdvisorIDs.length > 0
const matches =
(wanted.has('sem_assessor') && !hasAdvisor) ||
(wanted.has('sem_base') && row.city === null) ||
(wanted.has('sem_contato') && row.phone === null)
if (!matches) return false
}
return true
})
const mergedPersonAdvisorCount = (person: MergedPerson): number =>
new Set([...person.leadershipAdvisorIDs, ...person.deputyAdvisorIDs]).size
/**
* C117 — global sort over the FILTERED set (never the page), applied before
* pagination. Keys are exactly the visible-by-default columns; municipality
* columns sort by their count ("quem tem mais rede?"), text columns by value.
* Nulls ("Sem…") always land last (B15 precedent), ties break by name, then
* contact id — the same order the list opened with. The advisor-count map is
* precomputed once so the comparator never allocates per comparison.
*/
export const sortPeopleRows = (
rows: readonly MergedPerson[],
sort: PeopleListSortKey,
dir: PeopleListSortDirection,
): MergedPerson[] => {
const direction = dir === 'asc' ? 1 : -1
const advisorCounts = new Map(
rows.map((person) => [person.contactID, mergedPersonAdvisorCount(person)] as const),
)
const sortValue = (person: MergedPerson): string | number | null => {
switch (sort) {
case 'name':
return person.name
case 'contact':
return person.phone
case 'base':
return person.city
case 'assessora':
return person.assessoraMunicipalityIDs.length || null
case 'lidera':
return person.leadershipMunicipalityIDs.length || null
case 'aliada':
return person.deputyMunicipalityIDs.length || null
case 'assessorado':
return advisorCounts.get(person.contactID) || null
}
}
const byNameThenId = (left: MergedPerson, right: MergedPerson): number =>
left.name.localeCompare(right.name, 'pt-BR') || left.contactID - right.contactID
const byValue = (left: string | number, right: string | number): number =>
typeof left === 'string'
? left.localeCompare(right as string, 'pt-BR')
: left - (right as number)
return [...rows].sort((left, right) => {
const leftValue = sortValue(left)
const rightValue = sortValue(right)
if (leftValue === null && rightValue === null) return byNameThenId(left, right)
if (leftValue === null) return 1
if (rightValue === null) return -1
return byValue(leftValue, rightValue) * direction || byNameThenId(left, right)
})
}
export type PeopleListFilterFacets = {
/** Municipality ids present across the scoped rows (selected values unioned in). */
municipalityIDs: number[]
@ -432,10 +501,8 @@ export const loadPeopleListPageData = async (
const scoped = scopePeopleRows(merged, accessibleMunicipalityIds)
const filterFacets = peopleFilterFacetsFromRows(scoped, state)
const filtered = filterPeopleRows(scoped, state)
const sorted = [...filtered].sort(
(left, right) =>
left.name.localeCompare(right.name, 'pt-BR') || left.contactID - right.contactID,
)
const { sort: sortKey, dir: sortDir } = resolvePeopleListSort(state)
const sorted = sortPeopleRows(filtered, sortKey, sortDir)
const totalDocs = sorted.length
const totalPages = Math.ceil(totalDocs / peoplePageSize)

View file

@ -10,6 +10,8 @@ import { PEOPLE_CAPACITIES, peopleCapacityLabels } from '@/utilities/people/peop
import {
buildPeopleListHref,
parsePeopleListParams,
PEOPLE_ABSENCES,
peopleAbsenceLabels,
peopleListStateToRawParams,
type PeopleListState,
} from '@/utilities/people/peopleListUrl'
@ -19,7 +21,7 @@ export type PeopleFilterOption = {
label: string
}
type PeopleMultiFilterParam = 'capacity' | 'municipality' | 'status'
type PeopleMultiFilterParam = 'capacity' | 'municipality' | 'status' | 'ausencia'
export const peopleCapacityFilterOptions: PeopleFilterOption[] = PEOPLE_CAPACITIES.map((value) => ({
value,
@ -30,6 +32,11 @@ export const peopleStatusFilterOptions: PeopleFilterOption[] = leadershipSupport
(value) => ({ value, label: supportStatusLabels[value] }),
)
export const peopleAbsenceFilterOptions: PeopleFilterOption[] = PEOPLE_ABSENCES.map((value) => ({
value,
label: peopleAbsenceLabels[value],
}))
/**
* Writes the RAW param and lets `parsePeopleListParams` validate, so no branch
* has to cast a `string[]` into the state's narrow value types.
@ -50,6 +57,7 @@ const getPeopleMultiFilterValues = (
): string[] => {
if (param === 'capacity') return state.capacities ?? []
if (param === 'status') return state.statuses ?? []
if (param === 'ausencia') return state.ausencias ?? []
return (state.municipalities ?? []).map(String)
}
@ -90,8 +98,20 @@ export const togglePeopleStatusFilter = (
return togglePeopleMultiFilterValue(state, 'status', value)
}
/** Drop every filter and the search. */
export const clearPeopleListFilters = (): PeopleListState => ({ page: 1 })
export const togglePeopleAbsenceFilter = (
state: PeopleListState,
value: string,
): PeopleListState => {
if (!PEOPLE_ABSENCES.some((absence) => absence === value)) return state
return togglePeopleMultiFilterValue(state, 'ausencia', value)
}
/** Drop every filter and the search; keep the ordering (municipios precedent). */
export const clearPeopleListFilters = (state: PeopleListState): PeopleListState => ({
page: 1,
sort: state.sort,
dir: state.dir,
})
export const buildPeopleFilterHref = (next: PeopleListState): string => buildPeopleListHref(next, 1)
@ -125,6 +145,9 @@ export const formatPeopleActiveFiltersSummary = (
if (state.statuses?.length) {
parts.push(truncatedNamesLabel(state.statuses.map((status) => supportStatusLabels[status])))
}
if (state.ausencias?.length) {
parts.push(truncatedNamesLabel(state.ausencias.map((absence) => peopleAbsenceLabels[absence])))
}
if (state.q) parts.push(`Busca "${state.q}"`)
return parts.length ? parts.join(' · ') : null
}

View file

@ -1,7 +1,9 @@
/**
* People list URL contract (C100): state, param parse/canonicalize, source-level
* Payload `where`s and hrefs. Own module, same shape as `leadershipListUrl` —
* no sort in v1: the merged list is name-ordered by construction.
* People list URL contract (C100, C117): state, param parse/canonicalize,
* source-level Payload `where`s and hrefs. Own module, same shape as
* `leadershipListUrl`. Sort (`sort`/`dir`) and absence filters (`ausencia`)
* are C117; the sort is applied in memory over the filtered merge
* (`peopleData.ts`), never pushed to a source where.
*
* The three source-level wheres only cover what a single domain collection can
* express (`q` everywhere, `statuses`/`municipalities` on leadership). Capacity
@ -14,6 +16,7 @@ import { leadershipSupportStatuses, type SupportStatus } from '@/lib/schemas/lea
import {
allParamValues,
buildListHref,
createSortToggleHref,
firstValue,
normalizedText,
parseExhaustiveEnumParam,
@ -25,22 +28,102 @@ import { PEOPLE_CAPACITIES, type PeopleCapacity } from '@/utilities/people/peopl
export const peoplePageSize = 25
export type PeopleListSortKey =
| 'name'
| 'contact'
| 'assessora'
| 'lidera'
| 'aliada'
| 'assessorado'
| 'base'
export type PeopleListSortDirection = 'asc' | 'desc'
/**
* C117 — absence facets ("Sem assessor", "Sem base", "Sem contato"). Each
* value is a pure absence predicate over the merged row; the facet is OR
* within itself (same semantics as every multi-select facet) and AND with the
* other filters.
*/
export const PEOPLE_ABSENCES = ['sem_assessor', 'sem_base', 'sem_contato'] as const
export type PeopleAbsence = (typeof PEOPLE_ABSENCES)[number]
export const peopleAbsenceLabels: Record<PeopleAbsence, string> = {
sem_assessor: 'Sem assessor',
sem_base: 'Sem base',
sem_contato: 'Sem contato',
}
export type PeopleListState = {
page: number
q?: string
capacities?: PeopleCapacity[]
municipalities?: number[]
statuses?: SupportStatus[]
ausencias?: PeopleAbsence[]
sort?: PeopleListSortKey
dir?: PeopleListSortDirection
}
export type PeopleListSearchParams = RawSearchParams
const peopleListParamNames = ['q', 'capacity', 'municipality', 'status', 'page'] as const
const peopleListParamNames = [
'q',
'capacity',
'municipality',
'status',
'ausencia',
'sort',
'dir',
'page',
] as const
const peopleListParamNameSet = new Set<string>(peopleListParamNames)
const peopleCapacitySet = new Set<string>(PEOPLE_CAPACITIES)
const supportStatusSet = new Set<string>(leadershipSupportStatuses)
/**
* Sort keys are exactly the columns visible by default (C117 anti-goal: no
* ordering by hidden columns — `email` is hidden since B197). Derived from the
* label record so a new key is one line here.
*/
export const peopleListSortLabels: Record<PeopleListSortKey, string> = {
name: 'Nome',
contact: 'Contato',
assessora: 'Assessora',
lidera: 'Lidera',
aliada: 'Aliada em',
assessorado: 'Assessorado',
base: 'Base',
}
const peopleListSortKeySet = new Set<string>(Object.keys(peopleListSortLabels))
const peopleListSortDirSet = new Set<PeopleListSortDirection>(['asc', 'desc'])
const peopleAbsenceSet = new Set<string>(PEOPLE_ABSENCES)
/** Categorical/textual keys open A–Z; count keys open on the biggest first. */
const peopleSortKeysWithDescDefault = new Set<PeopleListSortKey>([
'assessora',
'lidera',
'aliada',
'assessorado',
])
export const defaultPeopleListSortDir = (key: PeopleListSortKey): PeopleListSortDirection =>
peopleSortKeysWithDescDefault.has(key) ? 'desc' : 'asc'
export const resolvePeopleListSort = (
state: PeopleListState,
): { sort: PeopleListSortKey; dir: PeopleListSortDirection } => {
const sort = state.sort ?? 'name'
return { sort, dir: state.dir ?? defaultPeopleListSortDir(sort) }
}
export const isDefaultPeopleListSort = (state: PeopleListState): boolean => {
const { sort, dir } = resolvePeopleListSort(state)
return sort === 'name' && dir === defaultPeopleListSortDir('name')
}
export const parsePeopleListParams = (params: PeopleListSearchParams): PeopleListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const q = normalizedText(firstValue(params.q))
@ -49,6 +132,15 @@ export const parsePeopleListParams = (params: PeopleListSearchParams): PeopleLis
.map((token) => strictDecimalInteger(token))
.filter((id): id is number => typeof id === 'number' && id > 0)
const statuses = parseExhaustiveEnumParam<SupportStatus>(params.status, supportStatusSet)
const ausencias = parseExhaustiveEnumParam<PeopleAbsence>(params.ausencia, peopleAbsenceSet)
const rawSort = firstValue(params.sort)
const sort =
rawSort && peopleListSortKeySet.has(rawSort) ? (rawSort as PeopleListSortKey) : undefined
const rawDir = firstValue(params.dir)
const dir =
rawDir && peopleListSortDirSet.has(rawDir as PeopleListSortDirection)
? (rawDir as PeopleListSortDirection)
: undefined
return {
page: rawPage ?? 1,
@ -56,6 +148,9 @@ export const parsePeopleListParams = (params: PeopleListSearchParams): PeopleLis
...(capacities.length ? { capacities } : {}),
...(municipalities.length ? { municipalities } : {}),
...(statuses.length ? { statuses } : {}),
...(ausencias.length ? { ausencias } : {}),
...(sort ? { sort } : {}),
...(dir ? { dir } : {}),
}
}
@ -68,6 +163,9 @@ export const peopleListStateToRawParams = (
capacity: state.capacities,
municipality: state.municipalities?.map(String),
status: state.statuses,
ausencia: state.ausencias,
sort: state.sort,
dir: state.dir,
})
/** Expects already-canonical state (from parse or a rule-preserving toggle). */
@ -81,6 +179,12 @@ export const serializeCanonicalPeopleListSearchParams = (
params.append('municipality', String(municipality))
}
for (const status of canonicalState.statuses ?? []) params.append('status', status)
for (const ausencia of canonicalState.ausencias ?? []) params.append('ausencia', ausencia)
if (!isDefaultPeopleListSort(canonicalState)) {
const { sort, dir } = resolvePeopleListSort(canonicalState)
params.set('sort', sort)
if (dir !== defaultPeopleListSortDir(sort)) params.set('dir', dir)
}
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
return params
}
@ -93,6 +197,26 @@ const buildPeopleListSearchParams = (state: PeopleListState, page = state.page):
export const buildPeopleListHref = (state: PeopleListState, page: number): string =>
buildListHref(state, buildPeopleListSearchParams, '/campanha/pessoas', page)
export const buildPeopleSortHref = createSortToggleHref<PeopleListState, PeopleListSortKey>({
resolveCurrentSort: resolvePeopleListSort,
defaultDir: defaultPeopleListSortDir,
buildHref: (state) => buildPeopleListHref(state, 1),
})
const peopleSortOptionLabel = (key: PeopleListSortKey, dir: PeopleListSortDirection): string => {
const label = peopleListSortLabels[key]
if (key === 'name' || key === 'contact' || key === 'base') {
return `${label} (${dir === 'asc' ? 'A–Z' : 'Z–A'})`
}
return `${label} (${dir === 'asc' ? 'menor → maior' : 'maior → menor'})`
}
export const peopleListSortOptions = (
Object.keys(peopleListSortLabels) as PeopleListSortKey[]
).flatMap((key) =>
(['asc', 'desc'] as const).map((dir) => ({ key, dir, label: peopleSortOptionLabel(key, dir) })),
)
export const resolvePeopleListUrl = (
params: PeopleListSearchParams,
totalPages?: number,

View file

@ -11,14 +11,22 @@ import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
import { peopleCapacityLabels } from '@/utilities/people/peopleLabels'
import {
clearPeopleListFilters,
peopleAbsenceFilterOptions,
peopleCapacityFilterOptions,
peopleStatusFilterOptions,
togglePeopleAbsenceFilter,
togglePeopleCapacityFilter,
togglePeopleMunicipalityFilter,
togglePeopleStatusFilter,
type PeopleFilterOption,
} from '@/utilities/people/peopleListFilters'
import type { PeopleListState } from '@/utilities/people/peopleListUrl'
import {
isDefaultPeopleListSort,
peopleAbsenceLabels,
peopleListSortOptions,
resolvePeopleListSort,
type PeopleListState,
} from '@/utilities/people/peopleListUrl'
export type PeopleOmniboxAction =
| { kind: 'url'; state: PeopleListState }
@ -58,6 +66,22 @@ export const buildPeopleOmniboxChips = ({
chips.push({ id: `status:${status}`, label: chipLabel('Apoio', supportStatusLabels[status]) })
}
for (const ausencia of state.ausencias ?? []) {
chips.push({
id: `ausencia:${ausencia}`,
label: chipLabel('Ausência', peopleAbsenceLabels[ausencia]),
})
}
if (!isDefaultPeopleListSort(state)) {
const { sort, dir } = resolvePeopleListSort(state)
const option = peopleListSortOptions.find((entry) => entry.key === sort && entry.dir === dir)
chips.push({
id: 'sort',
label: chipLabel('Ordenação', option?.label ?? `${sort} ${dir}`),
})
}
return chips
}
@ -107,6 +131,31 @@ export const buildPeopleOmniboxSuggestionSeeds = ({
)
}
for (const option of peopleAbsenceFilterOptions) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `ausencia:${option.value}`,
group: 'Ausência',
label: option.label,
keywords: ['sem', 'ausencia', 'faltando', 'faltante', 'vazio'],
},
{ emptyQueryVisible: true },
),
)
}
for (const option of peopleListSortOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `sort:${option.key}|${option.dir}`,
group: 'Ordenação',
label: option.label,
keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'],
}),
)
}
return seeds
}
@ -139,6 +188,22 @@ export const applyPeopleOmniboxSuggestion = ({
return { kind: 'url', state: togglePeopleStatusFilter(state, suggestionId.slice(7)) }
}
if (suggestionId.startsWith('ausencia:')) {
return { kind: 'url', state: togglePeopleAbsenceFilter(state, suggestionId.slice(9)) }
}
if (suggestionId.startsWith('sort:')) {
const raw = suggestionId.slice(5)
const [key, dir] = raw.split('|')
const option = peopleListSortOptions.find((entry) => entry.key === key && entry.dir === dir)
if (!option) return { kind: 'url', state }
const next = { ...state, page: 1, sort: option.key, dir: option.dir }
if (isDefaultPeopleListSort(next)) {
return { kind: 'url', state: { ...state, page: 1, sort: undefined, dir: undefined } }
}
return { kind: 'url', state: next }
}
return { kind: 'url', state }
}
@ -163,10 +228,18 @@ export const removePeopleOmniboxChip = ({
return { kind: 'url', state: togglePeopleStatusFilter(state, chipId.slice(7)) }
}
if (chipId.startsWith('ausencia:')) {
return { kind: 'url', state: togglePeopleAbsenceFilter(state, chipId.slice(9)) }
}
if (chipId === 'sort') {
return { kind: 'url', state: { ...state, page: 1, sort: undefined, dir: undefined } }
}
return { kind: 'url', state }
}
export const clearPeopleOmnibox = (): PeopleOmniboxAction => ({
export const clearPeopleOmnibox = (state: PeopleListState): PeopleOmniboxAction => ({
kind: 'clear',
state: clearPeopleListFilters(),
state: clearPeopleListFilters(state),
})

View file

@ -53,4 +53,80 @@ test.describe('Pessoas — lista unificada', () => {
await expect(page).toHaveURL(/\/campanha\/contatos/)
await expect(page.getByRole('heading', { name: 'Contatos' })).toBeVisible()
})
test('staff sorts by a column header and filters absence via the omnibox (C117)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora C117'),
})
const first = await fixtures.claimMunicipality()
const second = await fixtures.claimMunicipality()
const oneCity = await fixtures.createStaffLeadership({
namePrefix: 'C117 Ordenação Uma',
municipalities: [first],
})
const twoCities = await fixtures.createStaffLeadership({
namePrefix: 'C117 Ordenação Duas',
municipalities: [first, second],
})
// A person without a phone — the "Sem contato" absence facet target.
const noPhoneName = fixtures.value('C117 Sem Contato')
const noPhoneContact = await fixtures.payload.create({
collection: 'contact',
data: { name: noPhoneName, state: 'BA', city: 'Salvador' },
depth: 0,
})
await fixtures.payload.create({
collection: 'leadership',
data: { contact: noPhoneContact.id, municipalities: [second.id], supportStatus: 'a_abordar' },
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/pessoas')
// Narrow the recorte to this spec's rows, so the relative order is ours.
const omnibox = page.getByRole('combobox', { name: 'Filtrar pessoas' })
await omnibox.fill('C117')
await omnibox.press('Enter')
await expect(page).toHaveURL(/q=C117/)
const tableRows = page.locator('tbody tr')
const indexOfRow = async (name: string) => {
const texts = await tableRows.allTextContents()
return texts.findIndex((text) => text.includes(name))
}
const lideraHead = page.getByRole('columnheader').filter({ hasText: 'Lidera' })
// First click: Lidera opens desc (most municipalities first).
await lideraHead.getByRole('link').click()
await expect(page).toHaveURL(/q=C117&sort=lidera$/)
await expect(lideraHead).toHaveAttribute('aria-sort', 'descending')
const descTwoCities = await indexOfRow(twoCities.contactName)
const descOneCity = await indexOfRow(oneCity.contactName)
expect(descTwoCities).toBeGreaterThanOrEqual(0)
expect(descOneCity).toBeGreaterThanOrEqual(0)
expect(descTwoCities).toBeLessThan(descOneCity)
// Second click: same column inverts.
await lideraHead.getByRole('link').click()
await expect(page).toHaveURL(/q=C117&sort=lidera&dir=asc$/)
await expect(lideraHead).toHaveAttribute('aria-sort', 'ascending')
const ascOneCity = await indexOfRow(oneCity.contactName)
const ascTwoCities = await indexOfRow(twoCities.contactName)
expect(ascOneCity).toBeGreaterThanOrEqual(0)
expect(ascTwoCities).toBeGreaterThanOrEqual(0)
expect(ascOneCity).toBeLessThan(ascTwoCities)
// Absence facet through the omnibox: only the no-phone person remains.
await omnibox.fill('sem contato')
await page.getByRole('option', { name: 'Sem contato', exact: true }).click()
await expect(page).toHaveURL(/q=C117&ausencia=sem_contato&sort=lidera&dir=asc$/)
await expect(page.getByText('Ausência: Sem contato')).toBeVisible()
await expect(page.getByRole('row', { name: new RegExp(noPhoneName) })).toBeVisible()
await expect(page.getByRole('row', { name: new RegExp(twoCities.contactName) })).toBeHidden()
})
})

View file

@ -4,6 +4,7 @@ import {
buildPeopleDeputySourceWhere,
buildPeopleLeadershipSourceWhere,
buildPeopleListHref,
buildPeopleSortHref,
buildPeopleStaffSourceWhere,
parsePeopleListParams,
resolvePeopleListUrl,
@ -50,6 +51,30 @@ describe('people list URL contract', () => {
).toEqual({ page: 1, municipalities: [12, 3], statuses: ['engajado', 'em_disputa'] })
})
it('parses known sort keys and directions, dropping anything else', () => {
expect(parsePeopleListParams({ sort: 'lidera', dir: 'asc' })).toEqual({
page: 1,
sort: 'lidera',
dir: 'asc',
})
expect(parsePeopleListParams({ sort: 'email', dir: 'up' })).toEqual({ page: 1 })
expect(parsePeopleListParams({ sort: 'name' })).toEqual({ page: 1, sort: 'name' })
})
it('parses absence facets with OR values, dropping unknown and canonicalizing "todas"', () => {
expect(parsePeopleListParams({ ausencia: 'sem_contato' })).toEqual({
page: 1,
ausencias: ['sem_contato'],
})
expect(
parsePeopleListParams({ ausencia: ['sem_base', 'sem_assessor', 'sem_base', 'nada'] }),
).toEqual({ page: 1, ausencias: ['sem_base', 'sem_assessor'] })
expect(parsePeopleListParams({ ausencia: 'sem_e_mail' })).toEqual({ page: 1 })
expect(
parsePeopleListParams({ ausencia: ['sem_assessor', 'sem_base', 'sem_contato'] }),
).toEqual({ page: 1 })
})
it('canonicalizes selecting every status member to the absent filter (C119)', () => {
expect(
parsePeopleListParams({
@ -71,6 +96,27 @@ describe('people list URL contract', () => {
)
})
it('serializes absence and sort params, omitting the default sort', () => {
expect(
serializeCanonicalPeopleListSearchParams({
page: 1,
ausencias: ['sem_base', 'sem_contato'],
}).toString(),
).toBe('ausencia=sem_base&ausencia=sem_contato')
expect(
serializeCanonicalPeopleListSearchParams({ page: 1, sort: 'name', dir: 'asc' }).toString(),
).toBe('')
expect(
serializeCanonicalPeopleListSearchParams({ page: 1, sort: 'lidera', dir: 'desc' }).toString(),
).toBe('sort=lidera')
expect(
serializeCanonicalPeopleListSearchParams({ page: 1, sort: 'base', dir: 'desc' }).toString(),
).toBe('sort=base&dir=desc')
})
it('builds hrefs through the canonical serializer and drops page 1', () => {
expect(buildPeopleListHref({ page: 1, capacities: ['dobradinha'] }, 1)).toBe(
'/campanha/pessoas?capacity=dobradinha',
@ -84,10 +130,44 @@ describe('people list URL contract', () => {
expect(resolved.href).toBe('/campanha/pessoas?q=ana')
expect(resolved.redirectHref).toBeUndefined()
const unsupported = resolvePeopleListUrl({ q: 'ana', sort: 'name' })
const unsupported = resolvePeopleListUrl({ q: 'ana', sort: 'email' })
expect(unsupported.redirectHref).toBe('/campanha/pessoas?q=ana')
})
it('canonicalizes a default sort away and keeps a non-default one', () => {
const defaulted = resolvePeopleListUrl({ q: 'ana', sort: 'name' })
expect(defaulted.state).toEqual({ page: 1, q: 'ana', sort: 'name' })
expect(defaulted.redirectHref).toBe('/campanha/pessoas?q=ana')
const kept = resolvePeopleListUrl({ sort: 'lidera', dir: 'desc' })
expect(kept.redirectHref).toBe('/campanha/pessoas?sort=lidera')
expect(kept.href).toBe('/campanha/pessoas?sort=lidera')
const explicitDir = resolvePeopleListUrl({ sort: 'base', dir: 'desc' })
expect(explicitDir.href).toBe('/campanha/pessoas?sort=base&dir=desc')
// A bare dir resolves against the default key and canonicalizes without a loop.
const bareDir = resolvePeopleListUrl({ dir: 'desc' })
expect(bareDir.state).toEqual({ page: 1, dir: 'desc' })
expect(bareDir.redirectHref).toBe('/campanha/pessoas?sort=name&dir=desc')
})
it('builds sort-toggle hrefs: flips the active key, applies the new key default, resets to page 1', () => {
expect(buildPeopleSortHref({ page: 1 }, 'lidera')).toBe('/campanha/pessoas?sort=lidera')
expect(buildPeopleSortHref({ page: 1, sort: 'lidera', dir: 'desc' }, 'lidera')).toBe(
'/campanha/pessoas?sort=lidera&dir=asc',
)
expect(buildPeopleSortHref({ page: 1, sort: 'lidera', dir: 'desc' }, 'base')).toBe(
'/campanha/pessoas?sort=base',
)
expect(buildPeopleSortHref({ page: 3, sort: 'lidera', dir: 'desc' }, 'base')).toBe(
'/campanha/pessoas?sort=base',
)
expect(buildPeopleSortHref({ page: 1, sort: 'lidera', dir: 'desc' }, 'name')).toBe(
'/campanha/pessoas',
)
})
it('clamps an out-of-range page to totalPages', () => {
const resolved = resolvePeopleListUrl({ page: '9' }, 3)
expect(resolved.state.page).toBe(3)

View file

@ -5,6 +5,7 @@ import {
mergePeopleSources,
peopleFilterFacetsFromRows,
scopePeopleRows,
sortPeopleRows,
type MergedPerson,
type PeopleMergeSources,
} from '@/utilities/people/peopleData'
@ -212,6 +213,74 @@ describe('filterPeopleRows', () => {
).toEqual([100])
expect(filterPeopleRows(rows, { page: 1, statuses: ['negativo'] })).toEqual([])
})
it('filters absence with OR semantics within the facet (C117)', () => {
expect(
filterPeopleRows(rows, { page: 1, ausencias: ['sem_contato'] })
.map((row) => row.contactID)
.sort(),
).toEqual([101, 102, 103])
expect(
filterPeopleRows(rows, { page: 1, ausencias: ['sem_base'] })
.map((row) => row.contactID)
.sort(),
).toEqual([102, 103])
expect(
filterPeopleRows(rows, { page: 1, ausencias: ['sem_assessor'] })
.map((row) => row.contactID)
.sort(),
).toEqual([102, 103])
expect(
filterPeopleRows(rows, { page: 1, ausencias: ['sem_contato', 'sem_base'] })
.map((row) => row.contactID)
.sort(),
).toEqual([101, 102, 103])
})
it('combines absence facets with the other filters (AND across facets)', () => {
expect(
filterPeopleRows(rows, { page: 1, capacities: ['assessora'], ausencias: ['sem_base'] }).map(
(row) => row.contactID,
),
).toEqual([103])
expect(
filterPeopleRows(rows, { page: 1, statuses: ['engajado'], ausencias: ['sem_contato'] }),
).toEqual([])
})
})
describe('sortPeopleRows (C117)', () => {
const rows = mergePeopleSources(source)
const ids = (sorted: readonly MergedPerson[]) => sorted.map((row) => row.contactID)
it('sorts by name asc by default contract, desc inverts', () => {
expect(ids(sortPeopleRows(rows, 'name', 'asc'))).toEqual([101, 102, 103, 100])
expect(ids(sortPeopleRows(rows, 'name', 'desc'))).toEqual([100, 103, 102, 101])
})
it('sorts text columns with nulls always last', () => {
expect(ids(sortPeopleRows(rows, 'contact', 'asc'))).toEqual([100, 101, 102, 103])
expect(ids(sortPeopleRows(rows, 'base', 'asc'))).toEqual([101, 100, 102, 103])
expect(ids(sortPeopleRows(rows, 'base', 'desc'))).toEqual([100, 101, 102, 103])
})
it('sorts municipality columns by count, ties and nulls breaking by name', () => {
expect(ids(sortPeopleRows(rows, 'assessora', 'desc'))).toEqual([100, 103, 101, 102])
expect(ids(sortPeopleRows(rows, 'lidera', 'desc'))).toEqual([100, 101, 102, 103])
expect(ids(sortPeopleRows(rows, 'aliada', 'desc'))).toEqual([101, 102, 103, 100])
expect(ids(sortPeopleRows(rows, 'assessora', 'asc'))).toEqual([103, 100, 101, 102])
})
it('sorts assessorado by the union advisor count without resolving names', () => {
expect(ids(sortPeopleRows(rows, 'assessorado', 'desc'))).toEqual([101, 100, 102, 103])
expect(ids(sortPeopleRows(rows, 'assessorado', 'asc'))).toEqual([100, 101, 102, 103])
})
it('does not mutate the input rows', () => {
const before = ids(rows)
sortPeopleRows(rows, 'lidera', 'desc')
expect(ids(rows)).toEqual(before)
})
})
describe('peopleFilterFacetsFromRows', () => {