feat(C117): ordenação por coluna (header + omnibox) e filtros de ausência em Pessoas
- sort/dir/ausencia no contrato de URL de /campanha/pessoas (molde territórios/B15) - sortPeopleRows sobre o conjunto filtrado, nulls no fim, tiebreak nome→contactID - PeopleSortableHead (CampaignSortableHead) com aria-sort nas 7 colunas visíveis - omnibox: grupo Ordenação (chave×direção) e facet Ausência (Sem assessor/base/contato) - clear preserva o sort; tests unit + e2e; sem migration/collection
This commit is contained in:
parent
442378d7c8
commit
b42de3e7ec
11 changed files with 699 additions and 25 deletions
77
docs/plans/pessoas-ordenacao-filtros-ausencia-impl.md
Normal file
77
docs/plans/pessoas-ordenacao-filtros-ausencia-impl.md
Normal 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)
|
||||
|
|
@ -8,6 +8,7 @@ import { LeadershipInviteRowAction } from '@/components/campaign/invite/Leadersh
|
|||
import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton'
|
||||
import { PeopleFilters } from '@/components/campaign/people/PeopleFilters'
|
||||
import { PeopleListPageChrome } from '@/components/campaign/people/PeopleListPageChrome'
|
||||
import { PeopleSortableHead } from '@/components/campaign/people/PeopleSortableHead'
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
|
|
@ -49,7 +50,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'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('pessoas')
|
||||
|
||||
|
|
@ -57,7 +62,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">
|
||||
|
|
@ -72,7 +85,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')}
|
||||
|
|
@ -115,9 +128,11 @@ const PeopleMunicipalityCell = ({
|
|||
}
|
||||
|
||||
const peopleColumns = ({
|
||||
state,
|
||||
municipalityIndex,
|
||||
canDelete,
|
||||
}: {
|
||||
state: PeopleListState
|
||||
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
|
||||
canDelete: boolean
|
||||
}): Array<CampaignTableColumn<PeopleRowViewModel>> => [
|
||||
|
|
@ -125,6 +140,7 @@ const peopleColumns = ({
|
|||
id: 'name',
|
||||
label: 'Nome',
|
||||
mandatory: true,
|
||||
head: <PeopleSortableHead state={state} sortKey="name" />,
|
||||
cell: (row) => (
|
||||
<span className="font-medium">
|
||||
{row.name}
|
||||
|
|
@ -136,6 +152,7 @@ const peopleColumns = ({
|
|||
id: 'contact',
|
||||
label: 'Contato',
|
||||
cellClassName: 'whitespace-normal',
|
||||
head: <PeopleSortableHead state={state} sortKey="contact" />,
|
||||
cell: (row) =>
|
||||
row.phone ? (
|
||||
<span className="font-medium tabular-nums">{formatBrazilianPhoneInput(row.phone)}</span>
|
||||
|
|
@ -146,7 +163,8 @@ const peopleColumns = ({
|
|||
{
|
||||
// B197 — the email left the "Contato" cell (which now carries only the
|
||||
// phone) and became its own column, hidden by default. The mobile cards
|
||||
// still show it as the no-phone fallback, untouched.
|
||||
// still show it as the no-phone fallback, untouched. C117 keeps it out of
|
||||
// the sort keys on purpose: no ordering by hidden columns.
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
|
|
@ -156,6 +174,7 @@ const peopleColumns = ({
|
|||
id: 'assessora',
|
||||
label: 'Assessora',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
head: <PeopleSortableHead state={state} sortKey="assessora" />,
|
||||
cell: (row) => (
|
||||
<PeopleMunicipalityCell ids={row.assessoraMunicipalityIDs} index={municipalityIndex} />
|
||||
),
|
||||
|
|
@ -164,6 +183,7 @@ const peopleColumns = ({
|
|||
id: 'lidera',
|
||||
label: 'Lidera',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
head: <PeopleSortableHead state={state} sortKey="lidera" />,
|
||||
cell: (row) => (
|
||||
<PeopleMunicipalityCell ids={row.leadershipMunicipalityIDs} index={municipalityIndex} />
|
||||
),
|
||||
|
|
@ -172,6 +192,7 @@ const peopleColumns = ({
|
|||
id: 'aliada',
|
||||
label: 'Aliada em',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
head: <PeopleSortableHead state={state} sortKey="aliada" />,
|
||||
cell: (row) => (
|
||||
<PeopleMunicipalityCell ids={row.deputyMunicipalityIDs} index={municipalityIndex} />
|
||||
),
|
||||
|
|
@ -180,11 +201,13 @@ const peopleColumns = ({
|
|||
id: 'assessorado',
|
||||
label: 'Assessorado',
|
||||
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
|
||||
head: <PeopleSortableHead state={state} sortKey="assessorado" />,
|
||||
cell: (row) => row.assessoradoNames.join(', ') || '—',
|
||||
},
|
||||
{
|
||||
id: 'base',
|
||||
label: 'Base',
|
||||
head: <PeopleSortableHead state={state} sortKey="base" />,
|
||||
cell: (row) => row.city ?? '—',
|
||||
},
|
||||
{
|
||||
|
|
@ -356,9 +379,13 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
|
|||
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
|
||||
|
||||
const canDelete = isUnrestrictedCampaignRole(user.role)
|
||||
const columns = peopleColumns({ municipalityIndex: resolvedIndex, canDelete })
|
||||
const columns = peopleColumns({ state, municipalityIndex: resolvedIndex, canDelete })
|
||||
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 (
|
||||
|
|
@ -382,7 +409,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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<CampaignTable
|
||||
|
|
@ -393,7 +424,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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{listData.rows.length ? (
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export const PeopleFilters = ({
|
|||
runAction(removePeopleOmniboxChip({ state, chipId }))
|
||||
}}
|
||||
onClearAll={() => {
|
||||
runAction(clearPeopleOmnibox())
|
||||
runAction(clearPeopleOmnibox(state))
|
||||
}}
|
||||
trailing={
|
||||
<>
|
||||
|
|
|
|||
50
src/components/campaign/people/PeopleSortableHead.tsx
Normal file
50
src/components/campaign/people/PeopleSortableHead.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@ import {
|
|||
buildPeopleLeadershipSourceWhere,
|
||||
buildPeopleStaffSourceWhere,
|
||||
peoplePageSize,
|
||||
resolvePeopleListSort,
|
||||
type PeopleListSortDirection,
|
||||
type PeopleListSortKey,
|
||||
type PeopleListState,
|
||||
} from '@/utilities/people/peopleListUrl'
|
||||
import { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData'
|
||||
|
|
@ -225,7 +228,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[],
|
||||
|
|
@ -247,9 +252,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[]
|
||||
|
|
@ -426,10 +495,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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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('serializes canonical params in stable order and omits defaults', () => {
|
||||
const params = serializeCanonicalPeopleListSearchParams({
|
||||
page: 1,
|
||||
|
|
@ -63,6 +88,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',
|
||||
|
|
@ -76,10 +122,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)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue