diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index f4656697..57418e93 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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 `` + `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). diff --git a/docs/plans/escala-dry-pos-c130.md b/docs/plans/escala-dry-pos-c130.md new file mode 100644 index 00000000..75c297ca --- /dev/null +++ b/docs/plans/escala-dry-pos-c130.md @@ -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). diff --git a/docs/plans/pessoas-tabela-desktop-ajustes-impl.md b/docs/plans/pessoas-tabela-desktop-ajustes-impl.md new file mode 100644 index 00000000..34764473 --- /dev/null +++ b/docs/plans/pessoas-tabela-desktop-ajustes-impl.md @@ -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 `` 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: `, 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 ``; 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) diff --git a/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts b/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts index 80d2fa32..8fe9b0fc 100644 --- a/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts @@ -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, diff --git a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx index d9f88b85..376b28d2 100644 --- a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx @@ -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: , cell: (row) => { const subline = peopleNameSubline(row) + const sublineLabel = + row.ballotName && row.ballotName !== row.name ? 'Nome de legenda' : 'Base' return (
@@ -199,7 +202,7 @@ const peopleColumns = ({ ({row.party}) ) : null}
- +
) }, @@ -207,7 +210,7 @@ const peopleColumns = ({ { id: 'contact', label: 'Contato', - cellClassName: 'whitespace-normal', + cellClassName: 'min-w-40', head: , cell: (row) => ( ), }, @@ -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: , 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: , - cell: (row) => ( - - ), - }, { id: 'actions', label: 'Ações', @@ -348,12 +338,18 @@ const peopleColumns = ({ return (
{row.leadershipID !== null ? ( - - + // C130 — the span wrapper keeps the hover tooltip reachable when + // the button is disabled (disabled buttons swallow pointer events). + + + + ) : null} {whatsAppHref ? ( @@ -370,20 +366,34 @@ const peopleColumns = ({ ) : ( - + + {/* 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. */} + + + + )} {canDelete ? ( - + {/* 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). */} + + + ) : null}
@@ -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 (
  • @@ -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) { > & { + const contactData: Partial> & { 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 diff --git a/src/components/campaign/invite/LeadershipInviteRowAction.tsx b/src/components/campaign/invite/LeadershipInviteRowAction.tsx index cd7909f9..8293c20b 100644 --- a/src/components/campaign/invite/LeadershipInviteRowAction.tsx +++ b/src/components/campaign/invite/LeadershipInviteRowAction.tsx @@ -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} diff --git a/src/components/campaign/people/PeopleFilters.tsx b/src/components/campaign/people/PeopleFilters.tsx index f689d450..ce25df42 100644 --- a/src/components/campaign/people/PeopleFilters.tsx +++ b/src/components/campaign/people/PeopleFilters.tsx @@ -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( diff --git a/src/components/campaign/people/PeopleMunicipalityCell.tsx b/src/components/campaign/people/PeopleMunicipalityCell.tsx index 1f9279d1..0c0eefba 100644 --- a/src/components/campaign/people/PeopleMunicipalityCell.tsx +++ b/src/components/campaign/people/PeopleMunicipalityCell.tsx @@ -34,6 +34,8 @@ type PeopleMunicipalityCellProps = { addableIds?: ReadonlySet /** 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 ? ( (null) const [showSavedFeedback, setShowSavedFeedback] = useState(false) + const [focused, setFocused] = useState(false) const lastSaved = useRef((value ?? '').trim()) const timerRef = useRef | null>(null) const savedFeedbackTimerRef = useRef | null>(null) const requestId = useRef(0) const inputRef = useRef(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()} > {draft || value || ''} @@ -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 = ({ Salvo. diff --git a/src/components/campaign/shared/MunicipalityPortfolioCell.tsx b/src/components/campaign/shared/MunicipalityPortfolioCell.tsx index 4e0a66e3..35a5512b 100644 --- a/src/components/campaign/shared/MunicipalityPortfolioCell.tsx +++ b/src/components/campaign/shared/MunicipalityPortfolioCell.tsx @@ -108,6 +108,8 @@ type MunicipalityPortfolioCellProps = { assigned: boolean currentIds: number[] }) => Promise + /** 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} /> ) } diff --git a/src/components/campaign/shared/RelationChipCell.tsx b/src/components/campaign/shared/RelationChipCell.tsx index 3578b09b..f3d49980 100644 --- a/src/components/campaign/shared/RelationChipCell.tsx +++ b/src/components/campaign/shared/RelationChipCell.tsx @@ -212,6 +212,13 @@ type RelationChipCellProps = { assigned: boolean currentIds: number[] }) => Promise + /** + * 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 ( -
    +
    diff --git a/src/utilities/people/peopleData.ts b/src/utilities/people/peopleData.ts index a401a77b..fefcb4b7 100644 --- a/src/utilities/people/peopleData.ts +++ b/src/utilities/people/peopleData.ts @@ -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, -): string | null => - person.ballotName && person.ballotName !== person.name ? person.ballotName : null + person: Pick, +): 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 & { @@ -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(state.municipalities ?? []) const statuses = new Set(state.statuses ?? []) + const parties = new Set(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[] => leadershipSupportStatuses.filter((status) => statuses.has(status)) -const EMPTY_FACETS: PeopleListFilterFacets = { municipalityIDs: [], statuses: [] } +const EMPTY_FACETS: PeopleListFilterFacets = { municipalityIDs: [], statuses: [], parties: [] } type ContactSummary = { id: number diff --git a/src/utilities/people/peopleListFilters.ts b/src/utilities/people/peopleListFilters.ts index cdfb0cf0..dd56bdbc 100644 --- a/src/utilities/people/peopleListFilters.ts +++ b/src/utilities/people/peopleListFilters.ts @@ -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 } diff --git a/src/utilities/people/peopleListUrl.ts b/src/utilities/people/peopleListUrl.ts index 9885b517..83b5c57b 100644 --- a/src/utilities/people/peopleListUrl.ts +++ b/src/utilities/people/peopleListUrl.ts @@ -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(PEOPLE_CAPACITIES) const supportStatusSet = new Set(leadershipSupportStatuses) /** - * Sort keys are exactly the columns visible by default (C117 anti-goal: no - * ordering by hidden columns — `email` is hidden since B197). Derived from the + * 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 = { @@ -101,9 +107,10 @@ export const peopleListSortLabels: Record = { contact: 'Contato', assessora: 'Assessora', lidera: 'Lidera', - aliada: 'Aliada em', + aliada: 'Dobra em', assessorado: 'Assessorado', base: 'Base', + party: 'Partido', } const peopleListSortKeySet = new Set(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(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 { 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'})` diff --git a/src/utilities/people/peopleOmnibox.ts b/src/utilities/people/peopleOmnibox.ts index f3d0edf5..6ae8c4f4 100644 --- a/src/utilities/people/peopleOmnibox.ts +++ b/src/utilities/people/peopleOmnibox.ts @@ -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 } } } diff --git a/tests/e2e/campaignPeople.e2e.spec.ts b/tests/e2e/campaignPeople.e2e.spec.ts index 5b4985fe..3ba46d9e 100644 --- a/tests/e2e/campaignPeople.e2e.spec.ts +++ b/tests/e2e/campaignPeople.e2e.spec.ts @@ -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, diff --git a/tests/int/campaignPersonCellEditing.int.spec.ts b/tests/int/campaignPersonCellEditing.int.spec.ts index 2305665e..435247e8 100644 --- a/tests/int/campaignPersonCellEditing.int.spec.ts +++ b/tests/int/campaignPersonCellEditing.int.spec.ts @@ -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') }) diff --git a/tests/int/peopleList.int.spec.ts b/tests/int/peopleList.int.spec.ts index 592d21c9..91fbae47 100644 --- a/tests/int/peopleList.int.spec.ts +++ b/tests/int/peopleList.int.spec.ts @@ -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') diff --git a/tests/unit/campaignInlineEditableCell.unit.spec.tsx b/tests/unit/campaignInlineEditableCell.unit.spec.tsx index e43d8124..950e7908 100644 --- a/tests/unit/campaignInlineEditableCell.unit.spec.tsx +++ b/tests/unit/campaignInlineEditableCell.unit.spec.tsx @@ -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 => ({ + status: 'success', + message: 'Salvo.', + }), + ) + + render( + , + ) + + 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') + }) }) diff --git a/tests/unit/peopleListUrl.unit.spec.ts b/tests/unit/peopleListUrl.unit.spec.ts index 57ac85ff..2d2df6ed 100644 --- a/tests/unit/peopleListUrl.unit.spec.ts +++ b/tests/unit/peopleListUrl.unit.spec.ts @@ -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', ) diff --git a/tests/unit/peopleMerge.unit.spec.ts b/tests/unit/peopleMerge.unit.spec.ts index f3c6b684..04e22bbe 100644 --- a/tests/unit/peopleMerge.unit.spec.ts +++ b/tests/unit/peopleMerge.unit.spec.ts @@ -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,