diff --git a/docs/plans/pessoas-ordenacao-filtros-ausencia-impl.md b/docs/plans/pessoas-ordenacao-filtros-ausencia-impl.md new file mode 100644 index 00000000..fe7c4c7b --- /dev/null +++ b/docs/plans/pessoas-ordenacao-filtros-ausencia-impl.md @@ -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:|`, grupo "Ordenação", keywords `ordenar|ordenacao|ordem|sort`, não visíveis com query vazia — molde territórios) e seeds "Ausência" (`ausencia:`, `emptyQueryVisible: true` — atalho de triage, mesmo padrão de capacidade/status); chips `ausencia:` ("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) diff --git a/docs/plans/pessoas-pos-c117-ux.md b/docs/plans/pessoas-pos-c117-ux.md new file mode 100644 index 00000000..dcbcbf30 --- /dev/null +++ b/docs/plans/pessoas-pos-c117-ux.md @@ -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). diff --git a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx index 250d6f46..6da90182 100644 --- a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx @@ -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> } -const PeopleListEmptyState = ({ hasFilters, scoped }: { hasFilters: boolean; scoped: boolean }) => ( +const PeopleListEmptyState = ({ + hasFilters, + scoped, + state, +}: { + hasFilters: boolean + scoped: boolean + state: PeopleListState +}) => ( @@ -90,7 +103,7 @@ const PeopleListEmptyState = ({ hasFilters, scoped }: { hasFilters: boolean; sco {hasFilters ? ( , cell: (row) => (
, cell: (row) => ( , cell: (row) => ( , cell: (row) => ( , cell: (row) => ( , cell: (row) => ( , cell: (row) => ( + } /> {/* 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={ - + } /> diff --git a/src/components/campaign/people/PeopleFilters.tsx b/src/components/campaign/people/PeopleFilters.tsx index eb1244d3..f689d450 100644 --- a/src/components/campaign/people/PeopleFilters.tsx +++ b/src/components/campaign/people/PeopleFilters.tsx @@ -103,7 +103,7 @@ export const PeopleFilters = ({ runAction(removePeopleOmniboxChip({ state, chipId })) }} onClearAll={() => { - runAction(clearPeopleOmnibox()) + runAction(clearPeopleOmnibox(state)) }} trailing={ <> diff --git a/src/components/campaign/people/PeopleSortableHead.tsx b/src/components/campaign/people/PeopleSortableHead.tsx new file mode 100644 index 00000000..2b8eb9b5 --- /dev/null +++ b/src/components/campaign/people/PeopleSortableHead.tsx @@ -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 ( + + {children ?? peopleListSortLabels[sortKey]} + + ) +} diff --git a/src/utilities/people/peopleData.ts b/src/utilities/people/peopleData.ts index f6cfdd3d..a903ae2e 100644 --- a/src/utilities/people/peopleData.ts +++ b/src/utilities/people/peopleData.ts @@ -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) diff --git a/src/utilities/people/peopleListFilters.ts b/src/utilities/people/peopleListFilters.ts index 1a34f9b4..cdfb0cf0 100644 --- a/src/utilities/people/peopleListFilters.ts +++ b/src/utilities/people/peopleListFilters.ts @@ -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 } diff --git a/src/utilities/people/peopleListUrl.ts b/src/utilities/people/peopleListUrl.ts index 1357d86f..1f506cb3 100644 --- a/src/utilities/people/peopleListUrl.ts +++ b/src/utilities/people/peopleListUrl.ts @@ -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 = { + 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(peopleListParamNames) const peopleCapacitySet = new Set(PEOPLE_CAPACITIES) const supportStatusSet = new Set(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 = { + name: 'Nome', + contact: 'Contato', + assessora: 'Assessora', + lidera: 'Lidera', + aliada: 'Aliada em', + assessorado: 'Assessorado', + base: 'Base', +} + +const peopleListSortKeySet = new Set(Object.keys(peopleListSortLabels)) +const peopleListSortDirSet = new Set(['asc', 'desc']) +const peopleAbsenceSet = new Set(PEOPLE_ABSENCES) + +/** Categorical/textual keys open A–Z; count keys open on the biggest first. */ +const peopleSortKeysWithDescDefault = new Set([ + '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(params.status, supportStatusSet) + const ausencias = parseExhaustiveEnumParam(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({ + 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, diff --git a/src/utilities/people/peopleOmnibox.ts b/src/utilities/people/peopleOmnibox.ts index bc428850..d1e95d77 100644 --- a/src/utilities/people/peopleOmnibox.ts +++ b/src/utilities/people/peopleOmnibox.ts @@ -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), }) diff --git a/tests/e2e/campaignPeople.e2e.spec.ts b/tests/e2e/campaignPeople.e2e.spec.ts index f2e41119..1ff7ba5e 100644 --- a/tests/e2e/campaignPeople.e2e.spec.ts +++ b/tests/e2e/campaignPeople.e2e.spec.ts @@ -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() + }) }) diff --git a/tests/unit/peopleListUrl.unit.spec.ts b/tests/unit/peopleListUrl.unit.spec.ts index 2478c595..a8e9dea8 100644 --- a/tests/unit/peopleListUrl.unit.spec.ts +++ b/tests/unit/peopleListUrl.unit.spec.ts @@ -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) diff --git a/tests/unit/peopleMerge.unit.spec.ts b/tests/unit/peopleMerge.unit.spec.ts index 15a3e0d7..2e8acf64 100644 --- a/tests/unit/peopleMerge.unit.spec.ts +++ b/tests/unit/peopleMerge.unit.spec.ts @@ -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', () => {