C130: ajustes da tabela desktop de pessoas (contato completo, base sob o nome, caret, tooltips, Dobra em, filtro/ordenação por partido)

This commit is contained in:
Francisco Solla 2026-08-11 23:03:34 -03:00
parent 98f8ec9b9b
commit 65659bac2f
23 changed files with 573 additions and 110 deletions

View file

@ -5,6 +5,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-12):** **Remoção do `migration-lock`** (CI) — o check de `ci-pr.yml` que falhava quando >1 PR aberto tocava `src/migrations/`/`payload-types.ts` foi **removido** (deadlock de CI: com 2 PRs de schema abertos, AMBOS falhavam o check e nenhum mergeava sem bypass manual — 9 de 13 runs de CI falhos desde 11/08 foram por isso). A ameaça que ele serializava (cadeia de migrations com drift de snapshot) já é coberta pelo CI em todo PR e na main: `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation.int.spec.ts`, checksum do schema migrado vs config); o conflito real (index.ts/payload-types.ts) resolve no rebase. Removido: job `migration-lock` do `ci-pr.yml` + `needs` do rollup `checks` + contexto required da branch protection (`pnpm configure:branch-protection` agora aplica só `checks`); pool mantém a serialização de spawn de issues `needs:migration` (`countOpenSchemaPrs` em `agent-pool-github.mjs` — independe do CI). Docs/skills atualizados (AGENT-OPS contrato de PR, GUARDRAILS, engineering-standards, agent-pool, work-issue, agent-pr-workflow). Sem migration/access/Consent/UI/schema.
**Recently resolved (2026-08-11):** **C130** (Issue #697) — `/campanha/pessoas` desktop ajustada para leitura/edição: **Contato completo** (coluna `min-w-40` + nowrap — o telefone nunca corta; quem sofre com a largura é o resto), **coluna Base removida** (some do picker B17; hidden no cookie fica inerte) e a **cidade vira 2ª linha discreta do Nome** (`flex-col`, `truncate text-xs text-muted-foreground`, sem label, sem traço quando vazia — a precedência "legenda sobrepõe base" é da C129), **Nome com `max-w-72`** (outliers truncam no link já existente), **colunas de município estreitas quando vazias** (`minWidthClassName` opcional no `RelationChipCell`/`MunicipalityPortfolioCell`/`PeopleMunicipalityCell`, default `min-w-56` preserva as outras listas; pessoas passa `min-w-32` + `size={1}` no input do combobox — o input HTML tem largura intrínseca ~20ch que esticava a coluna vazia no auto-layout; verificado 224→153px com chips intactos), **"Aliada em" → "Dobra em"** só nos labels (coluna, cards mobile, menu de ordenação; chave `aliada` e URL intactas), **tooltip em TODAS as ações inclusive desabilitadas** ("WhatsApp indisponível — sem celular" / "Convidar indisponível — sem celular"): descoberto que Chromium não dispara pointer events sobre botão `disabled` → wrapper `<span>` + `pointer-events-none` no botão (também no `LeadershipInviteRowAction`), e que o tooltip "Apagar" nunca funcionou (o componente não repassa o ref do trigger — o span resolve), **caret visível ao editar o Nome** (troca de papéis no foco: link `opacity-0 pointer-events-none tabIndex=-1`, input `z-20 text-primary font-medium px-0 caret-foreground` — o input é o display enquanto focado, caret alinhado; fora do foco volta o mecanismo C116: link z-10 clicável, input z-0 invisível), **filtro e ordenação por Partido** (facet dinâmico na omnibox — grupo "Partido", valores do recorte unionados com selecionados, precedente município; chave `party` na URL `≤32` chars — maxLength do campo; sort "Partido (A–Z)" com nulls por último; partido segue como sufixo do nome, sem coluna), e **facet "Sem base" + ordenação por Base permanecem** (decisão do gate). **Bugfix C116 no caminho:** Escape **discartava mas salvava o draft velho** — `setDraft` não roda antes do `blur()` síncrono e o blur persistia o closure stale (observado e reproduzido: nome "Seed Lideranç Xa Dois" salvo no DB local); fix `pendingDiscardRef` consumido no blur + `requestId` bump (muta um save debounced já despachado) + `select()` só no name-link (telefone/e-mail precisam posicionar o caret) + span "Salvo." `pointer-events-none`. **Orfandade removida** (coluna Base): `field: 'city'` sai de `CampaignInlineEditableField`, de `contactFieldUpdateSchema` e do branch do `updatePersonContactRecord`/formAction (int `campaignPersonCellEditing` ajustado). Sem migration/access/Consent/rota nova. Testes: unit 2055 (party parse/serialize/sort/facet + Escape-discard do permanent name cell), int 746 (party facet no recorte), e2e `campaignPeople` 8 (novo teste C130: Dobra em, sem Base, cidade na 2ª linha, `party=PT` filtra) + picker/lideranças 6/6; verificação visual no browser (caret, larguras, tooltips, 2ª linha, filtro). Nota ambiente: e2e dev-mode flakeou sob carga da máquina (load ~68, vários worktrees) — suite rodada em `E2E_PROD=1`; flake pré-existente `googleCalendarSync.int` (reproduz no main). Intenção [`pessoas-tabela-desktop-ajustes.md`](plans/pessoas-tabela-desktop-ajustes.md); impl [`pessoas-tabela-desktop-ajustes-impl.md`](plans/pessoas-tabela-desktop-ajustes-impl.md).
**Recently resolved (2026-08-11):** **C131** (Issue #698) — "Salvador" como opção agregada no dropdown de municípios das células de capacidade (pessoas ×3, assessores, lideranças, dobradinhas): o hit "Salvador — Todas as zonas" entra **primeiro** na busca (`searchMunicipalityPortfolio` ganha `kind: 'city'` derivado do catálogo — constante `NORMALIZED_ZONE_CITIES` com a contagem de zonas por cidade-zona; um toque adiciona as 19 ZEs num lote via máquina existente do `RelationChipCell` — delta otimista, cap/floor, undo, colapso "Salvador (19)" B178, e o exact-match C116 faz "salvador"+Enter eleger o agregado). **Gate de escopo por contagem:** o agregado só aparece quando a cidade inteira está no escopo `addableIds` do ator (assessor com carteira parcial segue com as opções individuais — a label decidida no gate "Todas as zonas" nunca mente); o matcher de word-start é query-prefixo-de-label, então um query "Salvador — ZE N" só surfacia o hit exato da ZE (o agregado nunca rouba o keyboard contract do teste B34 — verificado, sem guard); sem `count` no hit (descrição fixa); célula ganhou só um branch em `hitDescription`. Sem migration/access/Consent/UI shape; superfícies fora da célula não usam essa busca (wizard tem busca própria). Testes: unit busca pura (19 ids, soma com selecionado, supressão por escopo parcial, supressão tudo-atribuído, primeiro da lista), cell render (opção "Salvador"/"Todas as zonas"), e2e novo em `campaignPeople` (Lidera → agregado → chip "Remover Salvador — 19 municípios" → 19 zonas no DB, assert condicional se o claim caiu em zona). Gates: unit+int 2084/748 verdes, e2e pessoas+lideranças 11/11 (modo prod local `E2E_PROD=1` — prewarm dev estourou o orçamento de 180s sob carga, flake de contenção conhecida; C117 flakeou 1× em paralelo e passou isolado), build OK; knip segue na falha conhecida P3 de ambiente (payload.config.ts). Intenção [`pessoas-salvador-completa-dropdown.md`](plans/pessoas-salvador-completa-dropdown.md); impl [`pessoas-salvador-completa-dropdown-impl.md`](plans/pessoas-salvador-completa-dropdown-impl.md).
**Recently resolved (2026-08-11):** **C115** (Issue #636) — **Google Calendar → Teqo**: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). **Motor único bidirecional** no `runSyncPass` do C114 (estende o dono): para cada evento nosso — conteúdo difere? → **regra do relógio**: Google mais novo aplica **título + horário** (all-day incluído; prefixo `[Município] ` só é destripado quando bate com o município — desfazemos só a nossa prefixação; cap 160) com **registro auditável** no `updates` feed (`Google Calendar: …`, `author: null`, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta. **Baseline do relógio = `activity.lastMirroredChangeAt`** (novo campo system-write, carimbado no beforeChange só quando um campo espelhado muda): task toggle/update/resultado bumpam `updatedAt` sem tocar o espelho — comparar contra `updatedAt` silenciaria uma edição pendente do Google. **Escrita reversa sem usuário** (`req.user` stripped): staff que dispara a passada não ganha autoria do registro nem passa pelo gate de re-agendamento do deputado. **Cancelamento:** `listEvents` com `showDeleted=true` — evento `status: 'cancelled'` (lixeira) com atividade `confirmado` → cancela **se o cancelamento for mais novo que o último carimbo** (senão é reopen do staff: o trashed é limpo e o evento recriado — nunca re-cancela); `realizado` é histórico protegido. **Remoção permanente** distinguida de "nunca criado" por snapshot `lastSeenEventIds` (JSONB `{calendarId, ids}`, gravado só em passada bem-sucedida e **excluindo ids que o próprio motor deletou** — senão o reopen virava re-cancelamento fantasma); troca de `calendarId` → snapshot stale → recria sem cancelar. **Webhook público** `POST /campanha/agenda/google-webhook/[secret]` (fora de `(app)`, precedente `ical/[secret]`, `maxDuration=60`, `force-dynamic`, allowlist do `campaignJsonMutationRoute` com razão): pre-filter barato (secret ≥32 + headers) ANTES do bootstrap do Payload + validação fail-closed em lib pura testada (`isGooglePushNotificationValid` — secret da URL + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, constant-time, 404; `not_exists` → estado de erro; `sync` → ignora); ping sem payload → passada completa; 200 mesmo se a passada falhar (sem retry storm). **Canal lazy sem cron (Hobby):** `ensureGoogleCalendarPushChannel` em toda passada — watch com UUID novo, TTL 30d, **segredo rotaciona a cada criação** (URL vazada auto-cura na renovação), **novo canal persistido ANTES do stop do antigo** (falha do stop não orfana o novo); expira em <48h → watch com id novo + `channels.stop` do antigo; troca de calendário → re-watch; falha → `pushChannelError` (state), nunca pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na `googleCalendarSync` (system-write; **identity fields com `read: false`** — o conjunto de credenciais do webhook nunca sai por path de usuário; create admin-only + leitura prefere linha configurada); migration **hand-written** (mesmo motivo do C114). **Título editável pelo Google** exigiu `mutationKind: 'googleCalendarSync'` dedicado (título muda, slug canônico congelado) + **skip do hook de sync na própria escrita reversa** (sem passada aninhada). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, dialog/feed render 6, allowlist), int (13 novos no motor com stub: Google novo aplica+converge, task toggle não silencia edição pendente, Teqo novo re-asserta, cancelled cancela+limpa, reopen pós-cancelamento não re-cancela, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, all-day ida-e-volta, realizado protegido, description-only sem registro, ciclo do canal cria/renova/rotaciona/stop, falha de canal não pausa — asserções herméticas a atividades ambiente de arquivos irmãos no DB compartilhado), e2e (webhook 404 fail-closed + caminhos 200 validados: sync/not_exists/change). Suíte completa verde (unit 2064, int 749, e2e 5/5). Intenção [`c115-google-calendar-edicao-bidirecional.md`](plans/c115-google-calendar-edicao-bidirecional.md); impl [`c115-google-calendar-edicao-bidirecional-impl.md`](plans/c115-google-calendar-edicao-bidirecional-impl.md).

View file

@ -0,0 +1,76 @@
# Escala e DRY pós-C130 (registro de facets da omnibox)
Status: rascunho
Atualizado em: 2026-08-11
Item do roadmap: fill-in **C130+** pós-[#697](https://github.com/fsolla/teqo/issues/697)
Impeccable: A — sem superfície UI nova (DRY/estrutura)
Appetite: ~0,5–1 dia eng
Responsável: —
## Contexto
O C130 adicionou o **5º facet** da lista de pessoas (`party`), cada um com o mesmo
bloco em `peopleOmnibox.ts` (seeds com `createOmniboxSuggestionSeed`, chips,
branches de prefixo em `applyPeopleOmniboxSuggestion`/`removePeopleOmniboxChip`)
mais o toggle correspondente em `peopleListFilters.ts` e o param no
`peopleListUrl.ts`. O padrão já era repetido para capacity/municipality/status/
ausência — e a mesma forma existe nas outras listas (lideranças, municípios,
apoiadores). A cada facet novo, o custo de adição é ~5 edições em 3 módulos, com
risco de esquecer um branch (facet que aparece nas sugestões mas não filtra).
## Objetivos
- **Registro declarativo de facet** (um lugar por facet: prefixo do id, grupo,
label, keywords, toggle/parse): `peopleOmnibox.ts` e `peopleListFilters.ts`
derivam seeds/chips/branches do registro; a adição de um facet novo vira uma
entrada, não cinco edições.
- Sem mudança de URL/contrato (params, chaves e labels pt-BR iguais).
- Escopo mínimo: **pessoas** (os 5 facets). Generalizar para as outras listas
só quando a segunda lista adotar (2º consumidor) — mesmo corte do C130.
- **Hazard documentado no registro:** valores de facet de texto livre (partido)
não podem conter `:` (separador do id de sugestão) — validação no registro.
## Já resolvido no simplify/critique (não reabrir)
- Parse do `party` simplificado para `allParamValues(...).filter(≤32)` (o
helper já trima/deduplica) — redundância removida.
- Comentários de sort key (`peopleListUrl`/`peopleData`) reconciliados com a
exceção C130 (base/party invisíveis vs `email` oculta-por-toggle).
- Comentários "Aliada em" → "Dobra em" (3 pontos).
- Orfandade `field: 'city'` removida (type/schema/record/formAction).
- Tooltip span-wrappers documentados por site (disabled vs ref-forwarding).
## Explicitamente fora
- **Flake `googleCalendarSync.int.spec.ts`** (falha intermitente, reproduz no
main limpo) — classe de teste instável pré-existente, fora deste lote.
- **Flakes e2e sob carga da máquina** (setup prewarm socket hang-up; picker
"2022"/B34 em runs combinados com load ~60+; passam isolados e no main) —
classe já documentada no changelog B197+; reavaliar com harness dev
estabilizado.
- **Tooltip `openOnTouch` nas ações da tabela de pessoas** — a tabela é
desktop-only (`md:block`); touch não a alcança. Gatilho: se a tabela ganhar
cobertura coarse, seguir o precedente B23 (`openOnTouch={false}` em
triggers que abrem Popover/Dialog).
- **Label mobile "Base:" vs 2ª linha desktop sem label** — decisão de gate
(mobile muda só o nome da coluna; C130 anti-goal). Gatilho: redesign dos
cards mobile.
- **Janela do save já-despachado no Escape** (draft descartado localmente mas
o save em voo persiste) — mitigada com `requestId` bump; janela
completada-save irrecuperável client-side, documentada no código. Gatilho:
se o autosave da célula virar transacional.
## Fases
1. **Registro de facets de pessoas** — declarar os 5 facets existentes; derivar
seeds/chips/apply/remove/toggles do registro; unit tests do
`peopleOmnibox`/`peopleListFilters` inalterados em comportamento.
2. **Gates** — `pnpm gate:fast`, e2e `campaignPeople`.
## Guardrails
- Identificadores em inglês; labels pt-BR intactos (grupos "Capacidade",
"Município", "Apoio", "Ausência", "Partido", "Ordenação").
- Sem migration/access/Consent/URL nova.
- Precedentes: `escala-dry-pos-c6.md` (F3 — DRY de filtros/forms),
`escala-dry-pos-c8.md` (facet de município de apoiadores).

View file

@ -0,0 +1,90 @@
# Impl: Pessoas — ajustes da tabela desktop (contato completo, base sob o nome, caret, tooltips, "Dobra em", filtro/ordenação por partido)
Status: aprovado
Atualizado em: 2026-08-11
Issue: #697
Intenção: docs/plans/pessoas-tabela-desktop-ajustes.md
Appetite restante: herdado (~0,5–1 dia eng)
## Leitura da intenção
- **Outcome:** a mesa lê a tabela desktop de `/campanha/pessoas` sem perder dado (telefone cortado, base espremida, caret sumido) e encontra pessoas por partido — cinco ajustes de superfície, sem fluxo novo, sem migration, sem mudança de modelo.
- **O que NÃO negociar:** renomear só o LABEL "Aliada em" → "Dobra em" (chave de URL/ordenação `aliada` e `sort` intactas); facet de ausência "Sem base" e ordenação por Base **permanecem**; partido continua exibido como sufixo do nome (sem coluna própria); mobile muda apenas o label; leader lockdown / escopo de assessor intactos.
- **O que reavaliar:**
- A hipótese "larguras por coluna" generalizada: **rejeitada** — corte do próprio rabbit hole da intenção ("generalizar só com 2º consumidor"). Ajustes via `cellClassName` nas colunas de pessoas (padrão já usado: `max-w-56 whitespace-normal` do e-mail) + **uma prop mínima** no shell compartilhado de chips (`RelationChipCell`) onde o piso `min-w-56` é o culpado das colunas de município largas quando vazias. O mecanismo de largura do `CampaignTable` não ganha nada novo.
- A hipótese "caret via CSS" no `CampaignInlineEditableCell`: o mecanismo C116 (input invisível sobre o link) exige **troca de papéis no focus** (link some de vista, input vira o display com caret real) — alinhamento exato do caret, sem z-index puzzle.
- C129 não está nesta entrega: a 2ª linha do Nome é **só a base (cidade)**; a precedência "legenda sobrepõe base" é da entrega C129 quando ela entrar.
- Consequência de produto do gate (coluna Base removida): a edição inline da cidade some da tabela (fica no detalhe da pessoa / admin) — a linha vira display-only, como o card mobile já era.
## Abordagem recomendada
```mermaid
flowchart LR
A[peopleListUrl: param party + sort key Partido] --> B[peopleData: filter/sort/facet party]
B --> C[peopleListFilters + peopleOmnibox + PeopleFilters]
C --> D[page.tsx: colunas — remove base, 2a linha cidade, larguras, tooltips, Dobra em]
D --> E[RelationChipCell: prop min-width p/ células de município vazias]
D --> F[CampaignInlineEditableCell: caret visível no foco]
```
**Opções consideradas:**
- **Larguras:** A) mecanismo novo de largura por coluna no `CampaignTable` — rejeitado (2º consumidor inexistente; toca todas as listas); B) `cellClassName` localizado + prop `minWidthClassName` (default `min-w-56`) no `RelationChipCell`/`MunicipalityPortfolioCell`/`PeopleMunicipalityCell`, pessoas passa `min-w-32` — **recomendado**.
- **Caret:** A) z-index swap (caret do input acima do link) — alinhamento quebra para nomes longos (input rola internamente, link truncado não); B) **troca de papéis no foco** — link `opacity-0 pointer-events-none`, input mostra texto `text-primary font-medium px-0` + `caret-foreground` — caret e texto do MESMO elemento, alinhamento exato; **recomendado**. C) contenteditable/fake-caret — rejeitado (rabbit hole do C116).
- **Tooltip em desabilitados:** botão `disabled` não dispara mouse events — envolver num `<span>` receptor do `CampaignHoverTooltip` (padrão Radix documentado); **recomendado**.
- **Partido (facet dinâmico):** precedente exato do facet de município (valores do recorte + selecionados unionados, sem canonicalização "todas") — sem enum, sem lista fixa; **recomendado**.
**Rejeitadas (gerais):** renomear a chave `aliada`/`ausencia=sem_base` (contrato de URL público); remover a ordenação por Base (decisão do gate); coluna própria de Partido (anti-goal).
### Componentes / mudanças
- **`src/utilities/people/peopleListUrl.ts`**: estado `parties?: string[]`; param `party` no parse/serialize (valores não-vazios, ≤ 40 chars, dedupe — precedente município, sem enum); `peopleListSortLabels.party = 'Partido'`; `peopleSortOptionLabel` ganha `party` no ramo de texto (A–Z/Z–A); default asc. `base` permanece (sort). Chave `aliada` intacta.
- **`src/utilities/people/peopleData.ts`**: `filterPeopleRows` — facet `parties` OR (match `row.party`); `sortPeopleRows` — case `'party'` (nulls por último, padrão B15); `PeopleListFilterFacets` ganha `parties: string[]` + `peopleFilterFacetsFromRows` (union sobre rows escopadas + selecionados).
- **`src/utilities/people/peopleListFilters.ts`**: `PeopleMultiFilterParam` += `'party'`; `togglePeoplePartyFilter` (valida 1..40 chars); `formatPeopleActiveFiltersSummary` lista partidos (filtro salvo B18 descreve o recorte).
- **`src/utilities/people/peopleOmnibox.ts`**: seeds grupo `Partido` (keywords `partido`, `sigla`), chips `Partido: <sigla>`, apply/remove.
- **`src/components/campaign/people/PeopleFilters.tsx`**: nova prop `partyFilterOptions` → seeds (como município).
- **`src/app/(campaign)/campanha/(app)/pessoas/page.tsx`**:
- coluna `base` **removida** (some do picker B17 automaticamente; hidden no cookie fica inerte);
- Nome: `cellClassName: 'max-w-72'` (outliers truncam — o link já tem `truncate`); célula vira `flex-col` com 2ª linha display-only `truncate text-xs text-muted-foreground` com `row.city` (sem label, sem traço quando vazia); sufixo partido inalterado;
- Contato: `cellClassName: 'min-w-40'` e **sem** `whitespace-normal` (volta ao `whitespace-nowrap` do TableCell — telefone nunca quebra; `min-w` garante a largura contra o input `w-full`);
- municípios: `minWidthClassName="min-w-32"` nas três `PeopleMunicipalityCell` (vazias ficam estreitas; com chips a largura segue o conteúdo até o `max-w-56`);
- Ações: WhatsApp desabilitado ganha `CampaignHoverTooltip content="WhatsApp indisponível — sem celular"` sobre `<span>`; Convidar desabilitado idem com "Convidar indisponível — sem celular" (o tooltip atual "Convidar" não dispara em botão disabled);
- cards mobile: label `'Aliada em'` → `'Dobra em'` (só isso no mobile);
- `hasFilters` inclui `parties`.
- **`src/components/campaign/shared/RelationChipCell.tsx` + `MunicipalityPortfolioCell.tsx` + `PeopleMunicipalityCell.tsx`**: prop `minWidthClassName` (default `'min-w-56'`) no wrapper do RelationChipCell (2 ocorrências); pessoas passa `'min-w-32'`. Demais listas intactas.
- **`src/components/campaign/shared/CampaignInlineEditableCell.tsx`**: ramo permanent name-link — estado `focused` (onFocus/onBlur); focado → link `opacity-0 pointer-events-none`, input `text-primary font-medium px-0 caret-foreground`; desfocado → classes atuais (`text-transparent caret-transparent px-1`). Input não é o display quando não-focado — mecanismo C116 preservado.
- **Migration:** nenhuma. **Access / Consent:** nenhum (staff-only, sem escrita nova).
- **UI:** Impeccable B — encaixe na tabela existente; shape → craft → critique → polish do caret e larguras no browser (playwright).
### Dados → forma (se aplicável)
- Não se aplica (vínculos/texto, sem métrica). O facet de partido usa a forma padrão do sistema (omnibox, OR na facet) — mesma escolha já aceita nos outros facets.
## Fases verificáveis
1. **URL + merge (partido)** — `peopleListUrl.ts`, `peopleData.ts` + unit tests (`peopleListUrl.unit.spec.ts`, `peopleMerge.unit.spec.ts`).
2. **Filtros/omnibox (partido)** — `peopleListFilters.ts`, `peopleOmnibox.ts`, `PeopleFilters.tsx`, `page.tsx` (options + hasFilters).
3. **Tabela (C130 core)** — `page.tsx`: colunas/larguras/2ª linha/renome/tooltips; props de min-width no shell; caret no `CampaignInlineEditableCell`.
4. **Verificação visual** — browser (playwright): caret ao digitar no Nome, telefone inteiro, colunas vazias estreitas, tooltips (inclusive desabilitados), "Dobra em".
5. **Gates** — `pnpm gate:fast` (tsc, lint, format, knip, cycles, unit+int), e2e `campaignPeople`, `pnpm build` local.
## Rabbit holes / Não escopo (engenharia)
- Não mexer em `min-w-56`/`max-w-56` das outras listas (lideranças/dobradinhas/assessores) — prop com default preserva o comportamento.
- Não canonicalizar "todos os partidos" como sem-filtro (precedente município — o facet é dinâmico, o parse não conhece o conjunto).
- Não mudar `field="party"`/`field="city"` do `CampaignInlineEditableField` (dobradinhas usa `party`; `city` sai de uso nesta página mas o membro fica — outros consumidores futuros).
- Sem tooltip custom em `DeletePersonButton` (já coberto, sempre habilitado).
- Caret em nomes muito longos (truncados): aceito como edge do mecanismo — o input é o display enquanto focado, então caret e texto nunca divergem.
## Riscos e mitigação
- **`max-w`/`min-w` em `td` auto-layout:** padrão já comprovado nas colunas atuais (`max-w-56` no e-mail/municípios). Mitigação: verificação visual no browser em tela 1280px com dados largos.
- **Troca de papéis do caret ≠ mecanismo travado no gate C116:** o contrato (draft no texto do link, input sem estado de edição, link clicável) é preservado — só o estado focado muda a camada visível. Documentar no changelog.
- **Tooltip em span envolve controle desabilitado:** acessibilidade mantida via `aria-label` existente no botão; o span não ganha tab stop.
- **E2E:** nenhuma asserção existente usa "Aliada em"/"Base" — rename seguro. Int `peopleList.int.spec.ts` ganha cobertura do facet de partido (a fixture de dobradinha tem `party`).
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (todos os 7 itens)
- [x] Invariantes AGENTS/engineering-standards (sem migration, sem access, staff-only)
- [x] Testes de domínio previstos: unit (URL party + sort party + filter/facet party), int (facet partido no recorte)

View file

@ -94,12 +94,6 @@ export const updatePersonContactFormAction = async (
field: 'phone',
phone: nullableFormText(formData, 'phone'),
})
} else if (field === 'city') {
await updatePersonContact({
id: contactId,
field: 'city',
city: requiredFormText(formData, 'city'),
})
} else {
throw new Error(PERSON_CELL_NOT_IN_SCOPE_MESSAGE)
}
@ -170,7 +164,7 @@ export const setPersonLeadershipMunicipalitiesFormAction = async (
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
})
/** Aliada em column (C128) — person-centric lifecycle: creates/removes the dobradinha. */
/** Dobra em column (C128) — person-centric lifecycle: creates/removes the dobradinha. */
export const setPersonStateDeputyMunicipalitiesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,

View file

@ -122,7 +122,7 @@ const PeopleListEmptyState = ({
/**
* C116/C128 editability matrix by actor. The advisor rule mirrors each owner
* collection's access: Lidera/Aliada em follow
* collection's access: Lidera/Dobra em follow
* `canManageLeadership`/`canReadStateDeputy` (the row's own municipalities
* must cross the carteira — the row may be visible via another capacity), and
* C128 opens the cells for people WITHOUT the capacity yet (the first
@ -179,9 +179,12 @@ const peopleColumns = ({
id: 'name',
label: 'Nome',
mandatory: true,
cellClassName: 'max-w-72',
head: <PeopleSortableHead state={state} sortKey="name" />,
cell: (row) => {
const subline = peopleNameSubline(row)
const sublineLabel =
row.ballotName && row.ballotName !== row.name ? 'Nome de legenda' : 'Base'
return (
<div className="flex min-w-0 flex-col">
<div className="flex min-w-0 items-baseline gap-1">
@ -199,7 +202,7 @@ const peopleColumns = ({
<span className="shrink-0 text-muted-foreground">({row.party})</span>
) : null}
</div>
<CampaignNameSubline value={subline} srLabel="Nome de legenda" />
<CampaignNameSubline value={subline} srLabel={sublineLabel} />
</div>
)
},
@ -207,7 +210,7 @@ const peopleColumns = ({
{
id: 'contact',
label: 'Contato',
cellClassName: 'whitespace-normal',
cellClassName: 'min-w-40',
head: <PeopleSortableHead state={state} sortKey="contact" />,
cell: (row) => (
<CampaignInlineEditableCell
@ -257,6 +260,7 @@ const peopleColumns = ({
drawerTitle="Municípios assessorados"
updateErrorMessage="Não foi possível atualizar a carteira. Tente novamente."
readOnly={!editability.canEditAssessora(row)}
minWidthClassName="min-w-32"
/>
),
},
@ -278,12 +282,13 @@ const peopleColumns = ({
drawerTitle="Municípios liderados"
updateErrorMessage="Não foi possível atualizar os municípios. Tente novamente."
readOnly={!editability.canEditLidera(row)}
minWidthClassName="min-w-32"
/>
),
},
{
id: 'aliada',
label: 'Aliada em',
label: 'Dobra em',
cellClassName: 'max-w-56 whitespace-normal',
head: <PeopleSortableHead state={state} sortKey="aliada" />,
cell: (row) => (
@ -299,6 +304,7 @@ const peopleColumns = ({
drawerTitle="Municípios da dobradinha"
updateErrorMessage="Não foi possível atualizar os municípios. Tente novamente."
readOnly={!editability.canEditAliada(row)}
minWidthClassName="min-w-32"
/>
),
},
@ -318,22 +324,6 @@ const peopleColumns = ({
/>
),
},
{
id: 'base',
label: 'Base',
head: <PeopleSortableHead state={state} sortKey="base" />,
cell: (row) => (
<CampaignInlineEditableCell
recordId={row.contactID}
recordIdField="contactId"
field="city"
value={row.city}
label="Base"
formAction={updatePersonContactFormAction}
permanent
/>
),
},
{
id: 'actions',
label: 'Ações',
@ -348,12 +338,18 @@ const peopleColumns = ({
return (
<div className="inline-flex items-center justify-end gap-1">
{row.leadershipID !== null ? (
<CampaignHoverTooltip content="Convidar">
<LeadershipInviteRowAction
leadershipID={row.leadershipID}
name={row.name}
hasValidPhone={whatsAppHref !== null}
/>
// C130 — the span wrapper keeps the hover tooltip reachable when
// the button is disabled (disabled buttons swallow pointer events).
<CampaignHoverTooltip
content={whatsAppHref !== null ? 'Convidar' : 'Convidar indisponível — sem celular'}
>
<span>
<LeadershipInviteRowAction
leadershipID={row.leadershipID}
name={row.name}
hasValidPhone={whatsAppHref !== null}
/>
</span>
</CampaignHoverTooltip>
) : null}
{whatsAppHref ? (
@ -370,20 +366,34 @@ const peopleColumns = ({
</Button>
</CampaignHoverTooltip>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
disabled
aria-label={`WhatsApp indisponível — ${row.name} sem celular`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</Button>
<CampaignHoverTooltip content="WhatsApp indisponível — sem celular">
{/* C130 — `pointer-events-none` on the DISABLED button: Chromium
excludes disabled form controls from pointer hit-testing, so
the hover would otherwise never reach this span (the tooltip
trigger). The span keeps the aria-label button semantics. */}
<span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-10 pointer-events-none"
disabled
aria-label={`WhatsApp indisponível — ${row.name} sem celular`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</Button>
</span>
</CampaignHoverTooltip>
)}
{canDelete ? (
<CampaignHoverTooltip content="Apagar">
<DeletePersonButton personName={row.name} contactId={row.contactID} />
{/* C130 — span wrapper: `DeletePersonButton` is a component and
does not forward the tooltip trigger's ref/pointer props, so
the hover would never reach the trigger (same reason as the
disabled buttons above). */}
<span>
<DeletePersonButton personName={row.name} contactId={row.contactID} />
</span>
</CampaignHoverTooltip>
) : null}
</div>
@ -412,7 +422,7 @@ const PeopleMobileCards = ({
const capacities: Array<{ label: string; ids: readonly number[] }> = [
{ label: 'Assessora', ids: row.assessoraMunicipalityIDs },
{ label: 'Lidera', ids: row.leadershipMunicipalityIDs },
{ label: 'Aliada em', ids: row.deputyMunicipalityIDs },
{ label: 'Dobra em', ids: row.deputyMunicipalityIDs },
]
return (
<li key={row.contactID} className="flex flex-col gap-2 py-4">
@ -543,6 +553,12 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
.filter((option): option is PeopleFilterOption => option !== null)
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
// C130 — party facet from the dobradinhas present in the actor's recorte.
const partyFilterOptions: PeopleFilterOption[] = listData.filterFacets.parties.map((party) => ({
value: party,
label: party,
}))
const advisorOptions = editability.canEditAssessorado
? (await loadEligibleAdvisorOptions(payload, user)).map((option) => ({
id: option.id,
@ -567,7 +583,8 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
state.capacities?.length ||
state.municipalities?.length ||
state.statuses?.length ||
state.ausencias?.length,
state.ausencias?.length ||
state.parties?.length,
)
return (
@ -577,6 +594,7 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
<PeopleFilters
state={state}
municipalityFilterOptions={municipalityFilterOptions}
partyFilterOptions={partyFilterOptions}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}

View file

@ -176,7 +176,7 @@ export const updatePersonContactRecord = async (
await assertPersonContactEditable(payload, currentActor, data.id, req)
const contactData: Partial<Pick<Contact, 'name' | 'email' | 'city'>> & {
const contactData: Partial<Pick<Contact, 'name' | 'email'>> & {
phones?: { value: string }[]
} = {}
if (data.field === 'name') {
@ -202,8 +202,6 @@ export const updatePersonContactRecord = async (
contactData.phones = reorderWithPrimaryPhone(current.phones, data.phone).map((value) => ({
value,
}))
} else if (data.field === 'city') {
contactData.city = data.city
}
// Bypass: the scope check above established the actor's right over the

View file

@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import { mapCreateCampaignInviteError } from '@/lib/campaignInviteClient'
import { cn } from '@/lib/utils'
type LeadershipInviteRowActionProps = {
leadershipID: number
@ -66,7 +67,10 @@ export const LeadershipInviteRowAction = ({
type="button"
variant="ghost"
size="icon"
className="size-10"
// C130 — `pointer-events-none` when disabled: Chromium excludes
// disabled form controls from pointer hit-testing, so the row's
// tooltip wrapper span would never receive the hover otherwise.
className={cn('size-10', !hasValidPhone && 'pointer-events-none')}
disabled={!hasValidPhone}
aria-label={inviteAriaLabel}
aria-expanded={open}

View file

@ -27,10 +27,12 @@ import {
export const PeopleFilters = ({
state,
municipalityFilterOptions,
partyFilterOptions,
trailing,
}: {
state: PeopleListState
municipalityFilterOptions: PeopleFilterOption[]
partyFilterOptions: PeopleFilterOption[]
trailing?: ReactNode
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
@ -57,8 +59,8 @@ export const PeopleFilters = ({
)
const suggestionSeeds = useMemo(
() => buildPeopleOmniboxSuggestionSeeds({ municipalityFilterOptions }),
[municipalityFilterOptions],
() => buildPeopleOmniboxSuggestionSeeds({ municipalityFilterOptions, partyFilterOptions }),
[municipalityFilterOptions, partyFilterOptions],
)
const suggestions = useMemo(

View file

@ -34,6 +34,8 @@ type PeopleMunicipalityCellProps = {
addableIds?: ReadonlySet<number>
/** Read-only chips (actor may see the relation but not edit it). */
readOnly?: boolean
/** C130 — closed-cell width floor so empty columns stay narrow. */
minWidthClassName?: string
commitAction: (
state: CampaignFormActionState,
formData: FormData,
@ -48,7 +50,7 @@ const mapManifestError = (error: unknown): string => {
}
/**
* C116/C128 — the people-list municipality columns (Assessora, Lidera, Aliada
* C116/C128 — the people-list municipality columns (Assessora, Lidera, Dobra
* em): the shared `MunicipalityPortfolioCell` in its `quiet` form (transparent,
* always-input paradigm) plus the batch-chip expansion state — clicking a
* territory / "Salvador (19)" chip expands it into its member municipalities
@ -71,6 +73,7 @@ export const PeopleMunicipalityCell = ({
municipalityIndex,
addableIds,
readOnly = false,
minWidthClassName,
commitAction,
drawerTitle,
updateErrorMessage,
@ -220,6 +223,7 @@ export const PeopleMunicipalityCell = ({
extraFormFields={extraFormFields}
commitWithNullOwner
commitGuard={exitMode ? commitGuard : undefined}
minWidthClassName={minWidthClassName}
/>
{exitRequest ? (
<PeopleCapacityExitDialog

View file

@ -26,7 +26,6 @@ export type CampaignInlineEditableField =
| 'phone'
| 'party'
| 'ballotName'
| 'city'
type CampaignInlineEditableCellProps = {
recordId: number
@ -91,11 +90,19 @@ export const CampaignInlineEditableCell = ({
const [isPending, setIsPending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [showSavedFeedback, setShowSavedFeedback] = useState(false)
const [focused, setFocused] = useState(false)
const lastSaved = useRef((value ?? '').trim())
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const savedFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestId = useRef(0)
const inputRef = useRef<HTMLInputElement>(null)
/**
* C130 — Escape must DISCARD, not save. `setDraft` cannot run before the
* synchronous `blur()` that follows it, so the blur's save would read the
* STALE draft from this render's closure and persist the discarded text —
* the flag makes the next blur a no-op revert instead.
*/
const pendingDiscardRef = useRef(false)
useEffect(() => {
if (inputRef.current && document.activeElement === inputRef.current) return
@ -182,10 +189,18 @@ export const CampaignInlineEditableCell = ({
}
const handleBlur = () => {
setFocused(false)
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
if (pendingDiscardRef.current) {
pendingDiscardRef.current = false
setDraft(displayDraftFor(field, value ?? ''))
setErrorMessage(null)
setShowSavedFeedback(false)
return
}
save(draft)
}
@ -209,12 +224,26 @@ export const CampaignInlineEditableCell = ({
* name-link cell renders the LINK above an invisible input (the draft lives
* in the link's text, the input value is never the display — locked
* mechanism); every other field is a borderless input that reads as text.
*
* C130 — while the name-link input is FOCUSED the roles swap: the link
* steps out of the way (`opacity-0 pointer-events-none`) and the input
* becomes the display with a real caret, so typing shows the caret exactly
* where the text is (same element, no overlay misalignment — a z-order swap
* would drift on names long enough to scroll the input internally).
*/
if (permanent) {
const isNameLink = href !== undefined && field === 'name'
const inputClassName = cn(
isNameLink
? 'absolute inset-0 z-0 h-full w-full cursor-text border-transparent bg-transparent px-1 text-transparent caret-transparent shadow-none'
? cn(
// `z-0` while unfocused keeps the link (z-10) the click target —
// the locked C116 mechanism; `z-20` while focused steps the input
// above the faded link so the caret shows and clicks edit.
'absolute inset-0 h-full w-full cursor-text border-transparent bg-transparent px-0 shadow-none',
focused
? 'z-20 font-medium text-primary caret-foreground'
: 'z-0 text-transparent caret-transparent',
)
: 'min-h-10 w-full border-transparent bg-transparent px-1 shadow-none',
permanentFocusClassName,
)
@ -225,8 +254,17 @@ export const CampaignInlineEditableCell = ({
href={href}
// `w-auto`: the link spans ONLY its text, so the rest of the cell
// stays the input's clickable area (click on the text navigates,
// click beside it edits — the locked C116 mechanism).
className="relative z-10 inline-flex min-h-10 max-w-full items-center font-medium text-primary underline-offset-4 hover:underline"
// click beside it edits — the locked C116 mechanism). C130:
// focused cells fade the link so clicks land in the input (caret
// placement) instead of navigating mid-edit.
className={cn(
'relative z-10 inline-flex min-h-10 max-w-full items-center font-medium text-primary underline-offset-4 hover:underline',
// C130 — while the input owns focus the link steps out of the
// way AND out of the tab order (a faded link must not be
// reachable via Shift+Tab).
focused && 'pointer-events-none opacity-0',
)}
tabIndex={focused ? -1 : undefined}
onKeyDown={(event) => event.stopPropagation()}
>
<span className="truncate">{draft || value || ''}</span>
@ -241,14 +279,25 @@ export const CampaignInlineEditableCell = ({
aria-busy={isPending}
className={inputClassName}
onChange={handleChange}
onFocus={(event) => {
setFocused(true)
// C130 — select only in the name-link cell: a click on the phone
// or email must be able to place the caret mid-value, and
// selecting there would make one stray keystroke replace the
// whole number.
if (isNameLink) event.currentTarget.select()
}}
onBlur={handleBlur}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
setDraft(displayDraftFor(field, value ?? ''))
setErrorMessage(null)
setShowSavedFeedback(false)
// Blur saves the reverted draft — a no-op the equality check catches.
// Discard: the next blur reverts without saving (see
// `pendingDiscardRef` — blur would otherwise persist the stale
// draft from this render's closure). Bumping the request id also
// mutes a debounce save that already left the client.
pendingDiscardRef.current = true
requestId.current += 1
setIsPending(false)
event.currentTarget.blur()
return
}
@ -269,7 +318,7 @@ export const CampaignInlineEditableCell = ({
<span
role="status"
aria-live="polite"
className="absolute top-1/2 right-2 z-20 -translate-y-1/2 bg-background/90 px-1 text-xs text-muted-foreground"
className="pointer-events-none absolute top-1/2 right-2 z-20 -translate-y-1/2 bg-background/90 px-1 text-xs text-muted-foreground"
>
Salvo.
</span>

View file

@ -108,6 +108,8 @@ type MunicipalityPortfolioCellProps = {
assigned: boolean
currentIds: number[]
}) => Promise<boolean | 'destructive'>
/** C130 — closed-cell width floor — see `RelationChipCell.minWidthClassName`. */
minWidthClassName?: string
}
/**
@ -137,6 +139,7 @@ export const MunicipalityPortfolioCell = ({
extraFormFields,
commitWithNullOwner = false,
commitGuard,
minWidthClassName,
}: MunicipalityPortfolioCellProps) => {
/**
* `addableIds` also hides: an advisor's `canUpdateMunicipality` is scoped to
@ -234,6 +237,7 @@ export const MunicipalityPortfolioCell = ({
readOnly={readOnly}
commitWithNullOwner={commitWithNullOwner}
commitGuard={commitGuard}
minWidthClassName={minWidthClassName}
/>
)
}

View file

@ -212,6 +212,13 @@ type RelationChipCellProps = {
assigned: boolean
currentIds: number[]
}) => Promise<boolean | 'destructive'>
/**
* C130 — the closed-cell width floor. Default `min-w-56` keeps the editing
* box stable on the other lists; a list whose municipality columns must
* shrink when empty (people table) passes a smaller floor so the width
* follows the chips instead of the floor.
*/
minWidthClassName?: string
}
/**
@ -254,6 +261,7 @@ export const RelationChipCell = ({
overflowToggleLabel,
commitWithNullOwner = false,
commitGuard,
minWidthClassName = 'min-w-56',
}: RelationChipCellProps) => {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
@ -1027,7 +1035,11 @@ export const RelationChipCell = ({
// mobile cards — same gesture contract as the advisors column (B27/B154).
if (isTriggerMode) {
return (
<div ref={rootRef} aria-busy={isPending || undefined} className="relative min-w-56">
<div
ref={rootRef}
aria-busy={isPending || undefined}
className={cn('relative', minWidthClassName)}
>
<CampaignCellEditOverlay
variant={editorVariant}
open={editorOpen}
@ -1067,7 +1079,8 @@ export const RelationChipCell = ({
aria-busy={isPending || undefined}
// Same box in both pointer modes so nothing reflows when the row is touched.
className={cn(
'relative min-w-56 rounded-md border border-transparent p-1 outline-none',
'relative rounded-md border border-transparent p-1 outline-none',
minWidthClassName,
// C116 quiet: no tint at all — the cell reads as plain text (the locked
// paradigm); keyboard focus keeps only the input's discreet ring.
!quiet && 'pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40',
@ -1136,6 +1149,13 @@ export const RelationChipCell = ({
onKeyDown={onSearchKeyDown}
placeholder={chips.length ? 'Adicionar…' : copy.searchPlaceholder}
aria-label={copy.searchLabel}
// `size={1}` (C130): an input's intrinsic width follows its
// `size` attribute (~20ch by default), and in table auto layout
// that intrinsic is what the empty column stretches to. With
// the wrapper's `minWidthClassName` lowered on the people
// table, size=1 lets the column follow the chips (min-w-32 on
// this input keeps the typing box wide enough).
size={1}
className={cn(
'hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block',
// C116 quiet: the ONLY focus indicator — a discreet ring on

View file

@ -81,11 +81,6 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
field: z.literal('phones'),
phones: contactPhonesSchema,
}),
z.object({
id: positiveRelationshipId,
field: z.literal('city'),
city: z.string().trim().min(2, 'Cidade inválida').max(100, 'Cidade muito longa'),
}),
])
export type ContactFieldUpdateInput = z.input<typeof contactFieldUpdateSchema>

View file

@ -76,23 +76,22 @@ export type PeopleRowViewModel = {
}
/**
* C129 — the Name-cell subline of `/campanha/pessoas`: the dobradinha's
* "nome de legenda" (ballot name), discreet under the real name, when present.
* A ballot name identical to the real name is redundant noise — it reads as a
* duplicated line, so it is skipped (a mesa registering the full name as urn
* name is plausible data entry).
*
* C130 extends this with the base fallback — the FINAL shared rule for the
* second line is `ballotName ?? city` (legenda overrides base), the two plans
* (`pessoas-nome-de-legenda-dobradinha.md` / `pessoas-tabela-desktop-ajustes.md`)
* pin it, and this function is the single seam: C130 changes one expression
* here (and its unit spec), never markup. Until C130 lands, this branch keeps
* the accept "sem nome de legenda, nada muda na linha" — null when absent.
* The Name-cell subline of `/campanha/pessoas`: the dobradinha's "nome de
* legenda" (ballot name), discreet under the real name, when present — else
* the contact's base city (C130), so the second line always has a value when
* there is one to show. A ballot name identical to the real name is redundant
* noise — it reads as a duplicated line, so it is skipped (a mesa registering
* the full name as urn name is plausible data entry). FINAL shared rule:
* `ballotName ?? city` (legenda overrides base), pinned by the two plans
* (`pessoas-nome-de-legenda-dobradinha.md` / `pessoas-tabela-desktop-ajustes.md`);
* this function is the single seam between the line and its sources.
*/
export const peopleNameSubline = (
person: Pick<MergedPerson, 'name' | 'ballotName'>,
): string | null =>
person.ballotName && person.ballotName !== person.name ? person.ballotName : null
person: Pick<MergedPerson, 'name' | 'ballotName' | 'city'>,
): string | null => {
if (person.ballotName && person.ballotName !== person.name) return person.ballotName
return person.city ?? null
}
/** The merge intermediate carries every field the list/detail row needs. */
export type MergedPerson = Omit<PeopleRowViewModel, 'assessoradoNames'> & {
@ -376,6 +375,9 @@ export const filterPeopleRows = (
if (state.statuses?.length) {
if (row.supportStatus === null || !state.statuses.includes(row.supportStatus)) return false
}
if (state.parties?.length) {
if (row.party === null || !state.parties.includes(row.party)) return false
}
if (state.ausencias?.length) {
const wanted = new Set(state.ausencias)
const hasAdvisor = row.leadershipAdvisorIDs.length > 0 || row.deputyAdvisorIDs.length > 0
@ -399,11 +401,13 @@ const mergedPersonAdvisorCount = (person: MergedPerson): number =>
/**
* 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.
* pagination. Keys are the visible-by-default columns plus the invisible
* C130 keys `base`/`party` (gate: both sortings stay useful without a
* column); 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[],
@ -422,6 +426,8 @@ export const sortPeopleRows = (
return person.phone
case 'base':
return person.city
case 'party':
return person.party
case 'assessora':
return person.assessoraMunicipalityIDs.length || null
case 'lidera':
@ -454,6 +460,8 @@ export type PeopleListFilterFacets = {
municipalityIDs: number[]
/** Leadership statuses present across the scoped rows (selected values unioned in). */
statuses: SupportStatus[]
/** Dobradinha parties present across the scoped rows (selected values unioned in). */
parties: string[]
}
export const peopleFilterFacetsFromRows = (
@ -462,20 +470,23 @@ export const peopleFilterFacetsFromRows = (
): PeopleListFilterFacets => {
const municipalities = new Set<number>(state.municipalities ?? [])
const statuses = new Set<SupportStatus>(state.statuses ?? [])
const parties = new Set<string>(state.parties ?? [])
for (const row of rows) {
for (const id of row.capacityMunicipalityIDs) municipalities.add(id)
if (row.supportStatus !== null) statuses.add(row.supportStatus)
if (row.party !== null) parties.add(row.party)
}
return {
municipalityIDs: [...municipalities].sort((left, right) => left - right),
statuses: leadershipStatusesInOrder(statuses),
parties: [...parties].sort((left, right) => left.localeCompare(right, 'pt-BR')),
}
}
const leadershipStatusesInOrder = (statuses: ReadonlySet<SupportStatus>): SupportStatus[] =>
leadershipSupportStatuses.filter((status) => statuses.has(status))
const EMPTY_FACETS: PeopleListFilterFacets = { municipalityIDs: [], statuses: [] }
const EMPTY_FACETS: PeopleListFilterFacets = { municipalityIDs: [], statuses: [], parties: [] }
type ContactSummary = {
id: number

View file

@ -21,7 +21,7 @@ export type PeopleFilterOption = {
label: string
}
type PeopleMultiFilterParam = 'capacity' | 'municipality' | 'status' | 'ausencia'
type PeopleMultiFilterParam = 'capacity' | 'municipality' | 'status' | 'ausencia' | 'party'
export const peopleCapacityFilterOptions: PeopleFilterOption[] = PEOPLE_CAPACITIES.map((value) => ({
value,
@ -58,6 +58,7 @@ const getPeopleMultiFilterValues = (
if (param === 'capacity') return state.capacities ?? []
if (param === 'status') return state.statuses ?? []
if (param === 'ausencia') return state.ausencias ?? []
if (param === 'party') return state.parties ?? []
return (state.municipalities ?? []).map(String)
}
@ -106,6 +107,17 @@ export const togglePeopleAbsenceFilter = (
return togglePeopleMultiFilterValue(state, 'ausencia', value)
}
/**
* C130 — party is FREE TEXT on `stateDeputy.party` (maxLength 32), so the
* toggle validates structurally (non-empty, ≤ 32 chars) and lets
* `parsePeopleListParams` dedupe — the municipalities facet precedent.
*/
export const togglePeoplePartyFilter = (state: PeopleListState, value: string): PeopleListState => {
const normalized = value.trim()
if (!normalized || normalized.length > 32) return state
return togglePeopleMultiFilterValue(state, 'party', normalized)
}
/** Drop every filter and the search; keep the ordering (municipios precedent). */
export const clearPeopleListFilters = (state: PeopleListState): PeopleListState => ({
page: 1,
@ -148,6 +160,9 @@ export const formatPeopleActiveFiltersSummary = (
if (state.ausencias?.length) {
parts.push(truncatedNamesLabel(state.ausencias.map((absence) => peopleAbsenceLabels[absence])))
}
if (state.parties?.length) {
parts.push(truncatedNamesLabel(state.parties))
}
if (state.q) parts.push(`Busca "${state.q}"`)
return parts.length ? parts.join(' · ') : null
}

View file

@ -36,6 +36,7 @@ export type PeopleListSortKey =
| 'aliada'
| 'assessorado'
| 'base'
| 'party'
export type PeopleListSortDirection = 'asc' | 'desc'
@ -70,6 +71,7 @@ export type PeopleListState = {
municipalities?: number[]
statuses?: SupportStatus[]
ausencias?: PeopleAbsence[]
parties?: string[]
sort?: PeopleListSortKey
dir?: PeopleListSortDirection
}
@ -82,6 +84,7 @@ const peopleListParamNames = [
'municipality',
'status',
'ausencia',
'party',
'sort',
'dir',
'page',
@ -92,8 +95,11 @@ 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
* Sort keys: the columns visible by default, PLUS `base` and `party` —
* invisible C130 keys kept because the gate decided both sortings stay
* useful without a column (city sorts under the name; party sorts the
* inline `(party)` suffix). `email` stays excluded as before (C117
* anti-goal: no ordering by a toggleable hidden column). Derived from the
* label record so a new key is one line here.
*/
export const peopleListSortLabels: Record<PeopleListSortKey, string> = {
@ -101,9 +107,10 @@ export const peopleListSortLabels: Record<PeopleListSortKey, string> = {
contact: 'Contato',
assessora: 'Assessora',
lidera: 'Lidera',
aliada: 'Aliada em',
aliada: 'Dobra em',
assessorado: 'Assessorado',
base: 'Base',
party: 'Partido',
}
const peopleListSortKeySet = new Set<string>(Object.keys(peopleListSortLabels))
@ -159,6 +166,11 @@ export const parsePeopleListParams = (params: PeopleListSearchParams): PeopleLis
.filter((id): id is number => typeof id === 'number' && id > 0)
const statuses = parseExhaustiveEnumParam<SupportStatus>(params.status, supportStatusSet)
const ausencias = parsePeopleAbsenceParam(params.ausencia)
// C130 — the party facet is DATA-DRIVEN (free text on `stateDeputy.party`,
// maxLength 32), so values are validated structurally, never against an
// enum — the municipalities facet precedent. `allParamValues` already
// trims, dedupes and drops empties.
const parties = allParamValues(params.party).filter((token) => token.length <= 32)
const rawSort = firstValue(params.sort)
const sort =
rawSort && peopleListSortKeySet.has(rawSort) ? (rawSort as PeopleListSortKey) : undefined
@ -175,6 +187,7 @@ export const parsePeopleListParams = (params: PeopleListSearchParams): PeopleLis
...(municipalities.length ? { municipalities } : {}),
...(statuses.length ? { statuses } : {}),
...(ausencias.length ? { ausencias } : {}),
...(parties.length ? { parties } : {}),
...(sort ? { sort } : {}),
...(dir ? { dir } : {}),
}
@ -190,6 +203,7 @@ export const peopleListStateToRawParams = (
municipality: state.municipalities?.map(String),
status: state.statuses,
ausencia: state.ausencias,
party: state.parties,
sort: state.sort,
dir: state.dir,
})
@ -206,6 +220,7 @@ export const serializeCanonicalPeopleListSearchParams = (
}
for (const status of canonicalState.statuses ?? []) params.append('status', status)
for (const ausencia of canonicalState.ausencias ?? []) params.append('ausencia', ausencia)
for (const party of canonicalState.parties ?? []) params.append('party', party)
if (!isDefaultPeopleListSort(canonicalState)) {
const { sort, dir } = resolvePeopleListSort(canonicalState)
params.set('sort', sort)
@ -231,7 +246,7 @@ export const buildPeopleSortHref = createSortToggleHref<PeopleListState, PeopleL
const peopleSortOptionLabel = (key: PeopleListSortKey, dir: PeopleListSortDirection): string => {
const label = peopleListSortLabels[key]
if (key === 'name' || key === 'contact' || key === 'base') {
if (key === 'name' || key === 'contact' || key === 'base' || key === 'party') {
return `${label} (${dir === 'asc' ? 'A–Z' : 'Z–A'})`
}
return `${label} (${dir === 'asc' ? 'menor → maior' : 'maior → menor'})`

View file

@ -17,6 +17,7 @@ import {
togglePeopleAbsenceFilter,
togglePeopleCapacityFilter,
togglePeopleMunicipalityFilter,
togglePeoplePartyFilter,
togglePeopleStatusFilter,
type PeopleFilterOption,
} from '@/utilities/people/peopleListFilters'
@ -74,6 +75,10 @@ export const buildPeopleOmniboxChips = ({
})
}
for (const party of state.parties ?? []) {
chips.push({ id: `party:${party}`, label: chipLabel('Partido', party) })
}
if (!isDefaultPeopleListSort(state)) {
const { sort, dir } = resolvePeopleListSort(state)
const option = peopleListSortOptions.find((entry) => entry.key === sort && entry.dir === dir)
@ -88,8 +93,10 @@ export const buildPeopleOmniboxChips = ({
export const buildPeopleOmniboxSuggestionSeeds = ({
municipalityFilterOptions,
partyFilterOptions,
}: {
municipalityFilterOptions: readonly PeopleFilterOption[]
partyFilterOptions: readonly PeopleFilterOption[]
}) => {
const seeds = []
@ -154,6 +161,20 @@ export const buildPeopleOmniboxSuggestionSeeds = ({
)
}
for (const option of partyFilterOptions) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `party:${option.value}`,
group: 'Partido',
label: option.label,
keywords: ['partido', 'sigla'],
},
{ emptyQueryVisible: true },
),
)
}
// C125 — primary-direction seeds only, visible on an empty query: on mobile
// the sortable headers do not exist (cards), so the omnibox group is the
// whole discoverability surface for the 7 keys (and 7 < the shared 8/group cap).
@ -207,6 +228,10 @@ export const applyPeopleOmniboxSuggestion = ({
return { kind: 'url', state: togglePeopleAbsenceFilter(state, suggestionId.slice(9)) }
}
if (suggestionId.startsWith('party:')) {
return { kind: 'url', state: togglePeoplePartyFilter(state, suggestionId.slice(6)) }
}
if (suggestionId.startsWith('sort:')) {
const raw = suggestionId.slice(5)
const [key, dir] = raw.split('|')
@ -247,6 +272,10 @@ export const removePeopleOmniboxChip = ({
return { kind: 'url', state: togglePeopleAbsenceFilter(state, chipId.slice(9)) }
}
if (chipId.startsWith('party:')) {
return { kind: 'url', state: togglePeoplePartyFilter(state, chipId.slice(6)) }
}
if (chipId === 'sort') {
return { kind: 'url', state: { ...state, page: 1, sort: undefined, dir: undefined } }
}

View file

@ -46,6 +46,41 @@ test.describe('Pessoas — lista unificada', () => {
await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible()
})
test('the desktop table reads C130: Dobra em, city under the name, party filter', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora C130'),
})
const municipality = await fixtures.claimMunicipality()
const { contactName } = await fixtures.createStaffLeadership({
namePrefix: 'C130 Liderança',
municipalities: [municipality],
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/pessoas')
// Renamed column, base column gone (the city now lives under the name).
await expect(page.getByRole('columnheader', { name: /Dobra em/ })).toBeVisible()
await expect(page.getByRole('columnheader', { name: /Base/ })).not.toBeVisible()
const nameCell = page
.getByRole('row', { name: new RegExp(contactName) })
.getByRole('cell')
.first()
await expect(nameCell).toContainText(municipality.name)
// The seeded dobradinha's party filters the recorte (omnibox facet is
// unit-tested; here the canonical URL drives the server-side filter and
// the chip renders from state).
await page.goto('/campanha/pessoas?party=PT')
await expect(page.getByRole('row', { name: /Seed Deputada Estadual/ })).toBeVisible()
await expect(page.getByRole('row', { name: new RegExp(contactName) })).not.toBeVisible()
await expect(page.getByText('Partido: PT').first()).toBeVisible()
})
test('the name link in the list opens the person detail (C118 entry point)', async ({
campaign,
page,

View file

@ -41,7 +41,7 @@ describe('C116 — cell edits of the people list', () => {
})
describe('updatePersonContactRecord', () => {
it('lets a coordinator edit name/email/city of a leadership-anchored person', async () => {
it('lets a coordinator edit name/phone of a leadership-anchored person', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
@ -53,11 +53,6 @@ describe('C116 — cell edits of the people list', () => {
field: 'name',
name: 'Maria Nova da Silva',
})
await updatePersonContactRecord(payload, actor, {
id: contact.id,
field: 'city',
city: 'Salvador',
})
await updatePersonContactRecord(payload, actor, {
id: contact.id,
field: 'phone',
@ -71,7 +66,6 @@ describe('C116 — cell edits of the people list', () => {
overrideAccess: true,
})
expect(updated.name).toBe('Maria Nova da Silva')
expect(updated.city).toBe('Salvador')
// C112 shape: the cell edit set the PRIMARY phone, the ficha list carries it first.
expect(primaryPhoneOf(updated.phones)).toBe('71999998888')
})

View file

@ -138,6 +138,26 @@ describe('C100 — lista unificada de pessoas (merge por Contact)', () => {
expect(names).not.toContain('João do Brejo')
})
it('filters by party and exposes the party facet from the recorte (C130)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const deputyContact = await fixtures.createContact({ name: 'Deputada do Partido' })
await fixtures.createStateDeputy({ contact: deputyContact, party: 'PSOL' })
const noDeputyContact = await fixtures.createContact({ name: 'Sem Dobradinha' })
await fixtures.createLeadership({ contact: noDeputyContact, municipalities: [municipality] })
const all = await loadPeopleListPageData(payload, coordinator, { page: 1 })
expect(all.filterFacets.parties).toContain('PSOL')
const filtered = await loadPeopleListPageData(payload, coordinator, {
page: 1,
parties: ['PSOL'],
})
expect(filtered.rows.map((row) => row.contactID)).toContain(relationId(deputyContact))
expect(filtered.rows.some((row) => row.contactID === relationId(noDeputyContact))).toBe(false)
})
it('returns empty data for a leader (lockdown)', async () => {
const fixtures = campaignFixtures()
const leader = await fixtures.createCampaignUser('leader')

View file

@ -155,4 +155,46 @@ describe('CampaignInlineEditableCell (B163)', () => {
expect(screen.queryByRole('textbox', { name: 'Partido' })).toBeNull()
expect(router.refresh).toHaveBeenCalled()
})
it('permanent name cell: focus swaps to the caret input and Escape discards without saving (C130)', () => {
const formAction = vi.fn(
async (
_state: CampaignFormActionState,
_formData: FormData,
): Promise<CampaignFormActionState> => ({
status: 'success',
message: 'Salvo.',
}),
)
render(
<CampaignInlineEditableCell
recordId={7}
recordIdField="contactId"
field="name"
value="Ana Bastos"
label="Nome"
href="/campanha/pessoas/7"
formAction={formAction}
permanent
/>,
)
const link = screen.getByRole('link', { name: 'Ana Bastos' })
const input = screen.getByRole('textbox', { name: 'Nome' }) as HTMLInputElement
// Focus steps the link out of the way so the input's real caret shows.
fireEvent.focus(input)
expect(link.className).toContain('opacity-0')
fireEvent.change(input, { target: { value: 'Ana Bastos Discard' } })
fireEvent.keyDown(input, { key: 'Escape' })
fireEvent.blur(input)
// Escape reverts the draft and never persists it (the blur after Escape
// used to save the stale draft from the keydown closure — C130 fix).
expect(input.value).toBe('Ana Bastos')
expect(formAction).not.toHaveBeenCalled()
expect(link.className).not.toContain('opacity-0')
})
})

View file

@ -61,6 +61,7 @@ describe('people list URL contract', () => {
})
expect(parsePeopleListParams({ sort: 'email', dir: 'up' })).toEqual({ page: 1 })
expect(parsePeopleListParams({ sort: 'name' })).toEqual({ page: 1, sort: 'name' })
expect(parsePeopleListParams({ sort: 'party' })).toEqual({ page: 1, sort: 'party' })
})
it('parses absence facets with OR values, dropping unknown and canonicalizing "todas"', () => {
@ -95,6 +96,19 @@ describe('people list URL contract', () => {
).toEqual({ page: 1, ausencias: ['qualquer_ausencia'] })
})
it('parses party facets as trimmed, deduped free text up to the field max length (C130)', () => {
expect(parsePeopleListParams({ party: 'PT' })).toEqual({ page: 1, parties: ['PT'] })
expect(parsePeopleListParams({ party: ['PT', 'pt', 'PT'] })).toEqual({
page: 1,
parties: ['PT', 'pt'],
})
expect(parsePeopleListParams({ party: [' PC do B ', '', 'x'.repeat(33), 'PSOL'] })).toEqual({
page: 1,
parties: ['PC do B', 'PSOL'],
})
expect(parsePeopleListParams({ party: ' ' })).toEqual({ page: 1 })
})
it('canonicalizes selecting every status member to the absent filter (C119)', () => {
expect(
parsePeopleListParams({
@ -110,9 +124,10 @@ describe('people list URL contract', () => {
capacities: ['lideranca'],
municipalities: [3, 12],
statuses: ['engajado'],
parties: ['PT', 'PC do B'],
})
expect(params.toString()).toBe(
'q=ana&capacity=lideranca&municipality=3&municipality=12&status=engajado',
'q=ana&capacity=lideranca&municipality=3&municipality=12&status=engajado&party=PT&party=PC+do+B',
)
})
@ -187,6 +202,7 @@ describe('people list URL contract', () => {
expect(buildPeopleSortHref({ page: 1, sort: 'lidera', dir: 'desc' }, 'base')).toBe(
'/campanha/pessoas?sort=base',
)
expect(buildPeopleSortHref({ page: 1 }, 'party')).toBe('/campanha/pessoas?sort=party')
expect(buildPeopleSortHref({ page: 3, sort: 'lidera', dir: 'desc' }, 'base')).toBe(
'/campanha/pessoas?sort=base',
)

View file

@ -223,6 +223,16 @@ describe('filterPeopleRows', () => {
expect(filterPeopleRows(rows, { page: 1, statuses: ['negativo'] })).toEqual([])
})
it('filters by party (dobradinha free text) with OR semantics, dropping rows without one (C130)', () => {
expect(
filterPeopleRows(rows, { page: 1, parties: ['PCdoB'] }).map((row) => row.contactID),
).toEqual([101])
expect(
filterPeopleRows(rows, { page: 1, parties: ['PCdoB', 'PT'] }).map((row) => row.contactID),
).toEqual([101])
expect(filterPeopleRows(rows, { page: 1, parties: ['PT'] })).toEqual([])
})
it('filters absence with OR semantics within the facet (C117)', () => {
expect(
filterPeopleRows(rows, { page: 1, ausencias: ['sem_contato'] })
@ -306,6 +316,11 @@ describe('sortPeopleRows (C117)', () => {
expect(ids(sortPeopleRows(rows, 'base', 'desc'))).toEqual([100, 101, 102, 103])
})
it('sorts by party with nulls (people without dobradinha) always last (C130)', () => {
expect(ids(sortPeopleRows(rows, 'party', 'asc'))).toEqual([101, 102, 103, 100])
expect(ids(sortPeopleRows(rows, 'party', 'desc'))).toEqual([101, 102, 103, 100])
})
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])
@ -325,30 +340,39 @@ describe('sortPeopleRows (C117)', () => {
})
})
describe('peopleNameSubline (C129)', () => {
it('returns the ballot name when the dobradinha has one', () => {
describe('peopleNameSubline (C129 + C130)', () => {
it('returns the ballot name when the dobradinha has one (legenda overrides base)', () => {
const ana = byContact(mergePeopleSources(source), 101)
expect(peopleNameSubline(ana)).toBe('Ana do Povo')
})
it('returns null when the dobradinha has no ballot name (nothing changes in the line)', () => {
it('falls back to the base city when the dobradinha has no ballot name (C130)', () => {
const withoutBallotName = mergePeopleSources({
...source,
deputies: [{ ...source.deputies[0]!, ballotName: null }],
})
const ana = byContact(withoutBallotName, 101)
expect(peopleNameSubline(ana)).toBeNull()
expect(peopleNameSubline(ana)).toBe('Camaçari')
const maria = byContact(mergePeopleSources(source), 100)
expect(peopleNameSubline(maria)).toBeNull()
expect(peopleNameSubline(maria)).toBe('Feira de Santana')
})
it('skips a ballot name identical to the real name (redundant duplicate line)', () => {
it('returns null when neither ballot name nor city is present', () => {
const withoutSubline = mergePeopleSources({
...source,
deputies: [{ ...source.deputies[0]!, ballotName: null, city: null }],
})
const ana = byContact(withoutSubline, 101)
expect(peopleNameSubline(ana)).toBeNull()
})
it('skips a ballot name identical to the real name (redundant duplicate line), falling back to base', () => {
const withIdenticalBallotName = mergePeopleSources({
...source,
deputies: [{ ...source.deputies[0]!, ballotName: 'Ana Lima' }],
})
const ana = byContact(withIdenticalBallotName, 101)
expect(peopleNameSubline(ana)).toBeNull()
expect(peopleNameSubline(ana)).toBe('Camaçari')
})
})
@ -360,6 +384,12 @@ describe('peopleFilterFacetsFromRows', () => {
expect(facets.statuses).toEqual(['engajado'])
})
it('unions the dobradinha parties with the selected values, sorted pt-BR (C130)', () => {
const rows = mergePeopleSources(source)
const facets = peopleFilterFacetsFromRows(rows, { page: 1, parties: ['XYZ'] })
expect(facets.parties).toEqual(['PCdoB', 'XYZ'])
})
it('keeps every leadership status present in the rows, in canonical selector order (C119)', () => {
const withAllStatuses: PeopleMergeSources = {
...source,