diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index b31635e9..bbdd9615 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **B42 entregue em código (2026-07-27):** polimento mobile de `/campanha/municipios`. **Escopo ampliado na sessão (decisão do usuário): Votos estimados entrou** junto com Tendência e Assessores, o que fez o item passar de 2 para **3 controles com a mesma casca** — critério de extração do repo — e **inverteu a questão em aberto do plano** (que recomendava branch local e extração só no `/simplify`): nasceu `src/components/campaign/shared/CampaignCellEditOverlay.tsx`, Popover em `md+` / Drawer abaixo. A casca carrega **só o container**: cada controle segue dono do seu auto-save, e como o `handleOpenChange` de cada um passa direto para `onOpenChange`, fechar o Drawer grava exatamente como dispensar o Popover (é o que preserva o flush da justificativa do **B24 ✓**). O **Sinal (B26 ✓) não migrou, e o motivo que registrei primeiro estava errado** — não é que "a casca não expressa isso sem virar genérica": o `
` não precisa envolver o `DrawerHeader` (título não é campo) e submit fora do form é HTML padrão via `form=""`. Falta só um **slot `footer`** (+ `formId` do chamador), débito com gatilho. Ele segue sendo o precedente visual (com "Fechar" no lugar de "Cancelar", porque aqui não há o que cancelar) e agora **consome** a classe de trigger da casca e os três atributos ARIA que lhe faltavam. **`useIsMobile` foi descartado por auditoria**: cards (`md:hidden`) e tabela (`hidden md:block`) são árvores irmãs, então `variant` por call site basta — media query trocaria o container depois da hidratação. **Navegação do card = link esticado, zero JS** (`` no `

` com `after:absolute after:inset-0` sobre `
`), então `MunicipalityList` continua Server Component; o botão "Abrir município" saiu — o plano previa testar "tap no título navega" quando o título **ainda não era link**, e a navegação nasceu junto. **Quem é `relative` é o trigger, não a célula:** a primeira versão posicionou os seis `
` do `
` e com eles os rótulos e o padding de cada célula, o que fez metade do card não editar nem navegar (P0 do critique, corrigido movendo `relative` para a classe compartilhada do trigger; o e2e passou a tocar num rótulo `dt` dentro da grade, exatamente a área que o bug engolia). **Dois props `layout` morreram** com a paridade pedida (`TerritorialClassReadout` = badge + `sr-only` do porquê; `MunicipalityListGoalCoverageCell` = sempre `compact`). `autoFocusScenario` suprimido no `sheet` para o teclado virtual não subir antes de o número ser lido; `municipalityName` é **exigido** onde há Drawer e **opcional** no Popover (união discriminada nos props de Votos estimados; o 3º call site, `MunicipalityGoalAccountCard`, é Popover sob o título do próprio município e não passa). A primeira versão o **proibia** no Popover (`?: never`) e criou assimetria na mesma linha da tabela — Tendência/Assessores nomeando o município no rótulo e Votos estimados não; resolvido para o lado de incluir porque a célula do nome é ``, **não** ``, então nada supre o contexto quando o foco entra num trigger (custo: 0,22 KiB gzip em 25 linhas). Sem migration/collection/action/`Consent`. **Critique + `/simplify` (mesma sessão), três defeitos de comportamento além do P0 acima:** (1) o Drawer focava o primeiro tabbable e subia o teclado virtual sobre a folha antes de ela ser lida — `initialFocus` no `DrawerTitle` (`tabIndex={-1}`), e o `preventContentAutoFocus` que devia mitigar isso era **inerte no branch `sheet`**, um prop que mentia → renomeado `preventPopoverAutoFocus`, comportamento do Drawer incondicional e pinado no spec; (2) `aria-label` **substitui** o conteúdo do trigger, então as quatro células anunciavam o verbo e engoliam o valor que o pill/avatar/número mostra → forma do Sinal ("Editar tendência em X — Favorável"), o que de passagem tirou `municipalityName` de payload RSC morto no desktop (27,9 KiB crus / 2,9 KiB gzip em 435 linhas); (3) fechar **commita** o rascunho e desmonta o `Alert`/`aria-live` que carregariam o erro, então falha na saída revertia o badge em silêncio → `toast.error` só quando já fechado, nos quatro controles. Mais: alvos de toque a 44px abaixo de `md` (inputs de votos, chip de assessor, linhas do `Command`), `CampaignHoverTooltip` na Classe do card (`sr-only` sozinho deixava o veredito inalcançável no toque — fecha o "Adiado com gatilho" do plano) e a frase da Cobertura em `sr-only` (`title` não é anunciado nem alcançado por toque). **Reuso: `LeadershipListSupportStatusControl` (B32 ✓) era o branch Popover da casca redigitado à mão** (classe de trigger byte-a-byte inclusive) → 4º call site, ~25 linhas a menos, nenhum prop novo; a string de trigger virou `campaignCellEditTriggerClassName`; `sheetBodyClassName` era o único prop de classe que substituía em vez de compor, o que obrigava o chamador de assessores a re-espalhar `px-4`. **Medido e não pago:** cada Drawer fechado ainda executa o corpo de `DrawerContent`, cujo `cn()` roda `tailwind-merge` sobre 4.068 caracteres (6,2 µs contra 0,8 µs do Popover que substituiu = +0,40 ms em 25 linhas, +7 ms no caminho de 435) — é troca, não adição; o fix é `{everOpened && …}` (não `{open && …}`, senão perde `data-starting-style`). Testes: 6 casos jsdom (3 controles × 2 variants; stubs de `ResizeObserver`/`scrollIntoView` que o `cmdk` exige) + e2e em viewport 390×844 (abrir Tendência não navega; toque num rótulo da grade abre o município). **Nada foi confirmado em device real:** a coleta de evidência visual falhou por falta de automação de browser na sessão, então alvos de toque estão checados por classe, não medidos. Gate tsc/lint/format/knip(P3)/cycles/**592 unit**/**420 int**/build (`/campanha/municipios` 13,4 kB / 320 kB); Aikido 0 findings. Falhas rotativas da suíte cheia reproduzidas **piores na árvore limpa** (4×3, mesmo teste de demanda na mesma linha) sob contenção de outra sessão Playwright — mesmo padrão de ambiente do B27 ✓/B32 ✓. **Cuidado operacional descoberto aqui: `git stash` é compartilhado entre worktrees** — stashar para medir baseline com sessões concorrentes traz o WIP alheio no `pop` (revertido com `git stash store `); prefira patch local. Plano: [`polimento-mobile-lista-municipios.md`](../../../docs/plans/polimento-mobile-lista-municipios.md). - **B41 entregue em código (2026-07-27):** scroll horizontal + coluna Município sticky em `/campanha/municipios` (`md+`). Espelha **B21 ✓** `TerritoryList`: `containerClassName="overflow-x-auto"`, coluna `name` com `sticky left-0 z-20`/`z-[5]` + `min-w-56 bg-background`; wrapper `overflow-visible` mantido. Cards mobile inalterados (**B42**). Sem migration/action/Consent. Critique: P1 `min-w`×`max-w` corrigido; edge fade Adiado. Gate tsc/lint/format/knip(P3)/cycles/540 unit/413 int/build; Aikido 0 findings. Plano: [`scroll-horizontal-lista-municipios.md`](../../../docs/plans/scroll-horizontal-lista-municipios.md). - **C8 F4b entregue em código (2026-07-27):** o guard `campaign formActions convention` em `codebaseConventions.unit.spec.ts` só via `…/formActions.ts`, então os irmãos (`lifecycleFormActions`, `resultFormActions`, `updateFormActions` ×2, `pledgeFormActions`, `municipalityStaffFormActions`, `taskActions`) ficavam sem verificação. Filtro ampliado para basename `/^\w*[Ff]ormActions\.ts$/` + `taskActions.ts`; 6 ladders migradas para `runCampaignFormAction`/`runCampaignRedirectFormAction`; `taskActions.ts` allowlisted (contrato posicional para `useOptimistic`). Docstring do wrapper precisada: exceção de atividades = só `atividades/formActions.ts`. Sem migration/UI. Gate: tsc/lint/format/knip (P3 pré-existente)/cycles/unit 540/build; int relacionado (activity + votePledge) verde — suíte int cheia flocou com `too many clients` no Postgres compartilhado. Aikido Opengrep exit 2 pré-existente, 0 findings nos arquivos editados. Plano: [`escala-dry-pos-c6.md`](../../../docs/plans/escala-dry-pos-c6.md). - **B8+ entregue em código (2026-07-27):** os quatro débitos do `/simplify` do B8 F2, e o achado central mudou de dono. **F1 — a alavanca não era a que o plano dizia.** O plano tratava "escala de chão" como algo implementado via dígitos de quantização; medindo o re-snap do artefato commitado para grades mais grossas, quantização sozinha quase não corta: ~12 m → 8.990 pontos (99% do original), ~25 m → 8.164, ~50 m → 6.219, e **mesmo na grade da malha estadual (~103 m) → 3.849 pontos, ainda 203/feature** — ~9× o irmão. Quem manda é o **limiar absoluto de Visvalingam**, que o plano citava entre parênteses. Então a política nasceu invertida: o peso absoluto **em m² de chão** é a política, os dígitos são derivados do bbox de cada topologia. `scripts/lib/topology.mjs` (**primeiro módulo de `scripts/lib/` — o B5 F2 pousa nele, não abre um segundo**) expõe `SIMPLIFY_TOLERANCE_M2` por malha, `GROUND_GRID_METRES = 102.82`, `simplifyToGroundScale`, `quantizeToGroundGrid` e `describeGroundScale`; `SIMPLIFY_QUANTILE`/`QUANTIZE_DIGITS` morreram nos dois scripts. Resultado medido: as zonas caíram de **9.120 → 574 pontos de arco (480 → 30,2/feature) e 72.310 → 7.687 bytes**; `bahia-municipalities` saiu **byte-idêntico** (a política foi semeada no valor histórico dele); territórios mudou **1 byte** (a grade passou de 102,730 para 102,822 m ao ser derivada do bbox em vez de um `1e4` fixo). **Três fatos para ninguém re-derivar:** (1) tolerância é **por malha de propósito** — a municipal (1,78 km²) chega a 17,9 pontos/feature nas zonas, mas come **5% da área de terra de Salvador (306 → 291 km²)**, que é justamente a grandeza com que o int prova que nenhum bairro foi descartado nem duplicado; as zonas ficaram em 0,06 km² e os territórios em 13,6 km² (dissolve da malha municipal, já vêm generalizados); (2) **a ordem importa** — quantizar antes de simplificar colapsa vértices em duplicatas e o `merge` posterior produz anéis quebrados (medido: Salvador dissolvendo para 24 km² em vez de 307); (3) a área das zonas pós-corte é **306,3 km²** (era ~324), dentro da janela de 280–360 km² que o int já tinha. `MAX_TOPO_BYTES` ficou e ganhou ao lado **`MAX_POINTS_PER_FEATURE = 60`** — bytes só pegam malha que ninguém simplificou (as zonas viviam a 10% do teto de bytes com 22× a densidade do irmão — 26× é a razão do passo de grade, não da densidade). `zoneNumber` saiu do artefato e do tipo. **F2:** `featureMapKey` passou a rodar sobre features **reais** das três malhas, cada uma no laço que já a varre inteira — 417 `codarea`, 27 `code`, 19 `slug` (a zona responde slug mesmo carregando o `ibgeCode` da cidade); a primeira escrita asseria a cidade dentro do laço das zonas, e o `/simplify` mostrou que isso era **uma** feature municipal asserida 19 vezes (as 19 ZE compartilham o código de Salvador) enquanto 416 municípios e 27 TIs nunca passavam por `featureMapKey`; o caso de precedência do unit usava `{ municipalitySlug, codarea }`, forma que **nenhuma malha produz** — caiu, e o comentário passou a declarar o invariante real: as três propriedades de chave são **mutuamente exclusivas**, e `ibgeCode` na malha de zonas deliberadamente não é chave. **F3:** `.catch()` na metade das zonas — e a precisão que o plano deixava otimista: como o bundle é keyed por slug, **Salvador não volta a pintar agregada**, volta como polígono sem dado e sem interação (não está em `scopedKeys`); a memoização da rejeição fica (é o estado de falha acionável do B14) e `status === 'error'` segue reservado à malha municipal. A ressalva de aproximação saiu do gate `!comparisonActive` e virou `

` condicionado a `bundle.hasZoneMunicipalities` — campo novo do contrato, derivado de `kind === 'zona'` no loader. O `/simplify` rejeitou as duas alternativas: um `isZoneMapKey` no cliente (segunda codificação da regra de `mapKeyForMunicipality`, em espaço negativo) e `zoneBreakdown.length > 0` (recria o acoplamento que o fill-in da lista de zonas vai deletar); com o campo próprio, o fill-in só apaga o `

    ` + `zoneBreakdown`. **F4:** `getTerritoryFeature`/`getMunicipalityZoneFeature` deletados; `MunicipalityMapNavigation` colapsado em `string | undefined` e `MunicipalitiesByMapKey` em `Record` (com o tipo morreram o helper, o `useMemo` que só existia porque ele alocava por chamada, o `| null` do `MapFeatureReadout` e **435 strings no payload RSC de toda visita ao Início**); `polygonRingsOf` exportado; helpers de geometria retipados em `PolygonalFeature` (o requisito real, em vez de `BahiaGeometryFeature` que arrasta `GeoJsonProperties`, ou seja `any`), mais `BahiaFeature

    ` e `BahiaMeshFeature`. **Um item do plano foi rejeitado por medição:** `city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` **ficam** — nenhum runtime os lê, mas os dois entram nas linhas hasheadas por `canonicalEvidenceRows` e são colunas do fixture de proveniência da RA 02/2017 (transcrição **independente** da resolução), então deletar enfraqueceria a verificação por ganho zero; só o `zoneNumber` do **artefato** saiu. Sem migration, collection, action ou `Consent`; e2e segue 436 paths / 435 interativos. Gate completo rodado bare contra o banco local (as falhas de e2e encontradas foram reproduzidas em árvore limpa — flakiness pré-existente já no ledger); Aikido **0 findings** nos arquivos editados (desta vez o scanner rodou; o exit code 2 do B8 F2 não se repetiu para o SAST). Plano as-built: [`escala-dry-pos-b8f2.md`](../../../docs/plans/escala-dry-pos-b8f2.md). @@ -15,7 +16,7 @@ alwaysApply: false - **B39 entregue em código (2026-07-27):** login de `/campanha` ganhou “Lembrar de mim neste dispositivo”, default desmarcado. Contrato: **8 h** padrão / **14 dias** opt-in, definidos em `campaignSessionTtl.ts`; `CampaignUser.auth.tokenExpiration` usa o teto longo porque Payload não aceita TTL por login e `useSessions` poda pelo `expiresAt` armazenado; `setCampaignAuthCookie` re-assina por `jwtSign` os claims do token para 8 h quando necessário e mantém JWT/`maxAge` em lockstep. Convite e reset continuam no default curto. “Sair” autentica o token e delega a revogação da sessão corrente à operação transacional pública `logoutOperation` do Payload antes de apagar cookie/PWA; falha de DB nunca impede a limpeza local. Sem migration (`migrate:create __probe`: “No schema changes detected”). Impeccable critique/polish: copy outcome-first, descrição ligada por `aria-describedby`, alvo medido **44×44 px**, sem overflow em 390/768/1440; detector 0. E2E mede `campaign-token.expires` em ~14 dias. - **B32 entregue em código (2026-07-26):** editar Status de apoio na célula da lista de lideranças — `LeadershipListSupportStatusControl` (Popover + `NativeSelect`, trigger = `SupportStatusBadge` atual) **sempre montado** na coluna "Status" de `/campanha/liderancas`, sem gate de "modo de edição" (anti-goal nomeado, mesmo já rejeitado em B19/B28/B31 para esta tabela). Auto-save por debounce ~150 ms via `POST /campanha/liderancas/support-status` (novo route, espelho de `political-trend/`/`advisors/`), reusando `updateLeadershipInternal({ id, supportStatus })` sem `revalidatePath`. **Auditoria do plano antes de codar:** `isSameOriginRequest` e o shell `campaignJsonMutationRoute.ts` já existiam (extraídos por B24/B27) — este item só importa, não extrai; a extração pendente era só `leadershipStaffEditSafeMessages` (espelho de `municipalityStaffEditMessages.ts`), consumida pelo route novo e por `[id]/formActions.ts`. Terceiro call site de `supportStatusLabels` (já usado em `LeadershipInternalForm`/`LeadershipForm`) motivou extrair `src/utilities/leadershipLabels.ts`, que passou a ser fonte única também para `SupportStatusBadge` depois do `/simplify`. Verificação por e2e (não unit do route, molde inexistente para `political-trend`/`advisors`/`expected-votes` também) — `tests/e2e/campaignLeaderships.e2e.spec.ts`, primeiro spec da rota, com `Promise.all([waitForResponse, selectOption])` para não confirmar persistência antes do POST assentar. **`/simplify`:** consolidou os dois cadastros de label pt-BR (badge + `leadershipLabels`), colapsou `displayStatus`/`draft` (sempre idênticos) num único estado, trocou o parse manual pelo guard `isSupportStatus` já existente, e completou a extração de `leadershipStaffEditSafeMessages` nos dois irmãos que ainda inlinavam a mesma string (`liderancas/formActions.ts`, `liderancas/nova/formActions.ts`) — o inline em `actions/leadership.ts` ficou (mesmo precedente de `actions/municipality.ts`), e a decisão de coagir `null`→`a_abordar` (já travada no plano) não mudou. Sem migration/collection/Consent. Gate: tsc/lint/format/knip (erro pré-existente em `payload.config.ts`)/check:cycles/unit/int/e2e (suíte cheia com 1 worker; 2 falhas de `campaignMunicipalities.e2e.spec.ts` não relacionadas — B24/B27, `strict mode violation`/timeout por contenção de outra sessão concorrente no mesmo Postgres local — reproduzidas verdes isoladamente). Plano: [`autosave-status-lista-liderancas.md`](../../../docs/plans/autosave-status-lista-liderancas.md). - **B8 F2 entregue em código (2026-07-26):** as 19 ZE de Salvador ganharam polígono próprio e o coroplético deixou de ser keyed por `codarea`. Antes, Salvador era **um** polígono carregando os votos da cidade inteira, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu" — a única visão por zona era a lista textual do `zoneBreakdown`. **Fase 0 (prep S1+S3):** `municipalityZoneNeighborhoods` passou a autorar só `{ municipalitySlug, source, neighborhoods }` e hidrata `city`/`zoneNumber` de `getMunicipalityCatalogEntry` no `entry()` (slug desconhecido ou não-`zona` falha no import); int novo de ordem de slugs vs catálogo. S2 (gerar TS do JSON) segue cortado — a curadoria ficou no script, não no catálogo. **Fase 1–2 (artefato):** `scripts/build-municipality-zone-geometries.mjs` (`pnpm build:municipality-zone-geometries`, `--report` só reconcilia) baixa/cacheia `BA_bairros_CD2022.zip` do geoftp do IBGE, parseia com **`shpjs`** (devDependency nova — reprojeta o SIRGAS 2000, o IBGE não publica GeoJSON de bairro), filtra `CD_MUN === '2927408'` (**170 polígonos**) e dissolve por zona pela RA 02/2017 do TRE-BA → `src/lib/geometries/bahia-municipality-zones.topo.json` (**72.310 bytes**, ~20 KB gzip, 19 features keyed por `municipalitySlug`), com submódulo `bahiaMunicipalityZoneGeometries.ts` + `loadMunicipalityZoneGeometryModule()` no padrão memoizado de B5 F1. **Reconciliação = 3 tabelas curadas no cabeçalho do script, não regex mais esperta:** `IBGE_TO_TRE_NEIGHBORHOOD` (7 aliases — compostos "Beiru/Tancredo Neves", CAB, ilhas), `TRE_NEIGHBORHOODS_MERGED_INTO` (Jardim Cruzeiro dentro de Vila Ruy Barbosa; o build **recusa** um merge que atravesse zonas) e `SPLIT_NEIGHBORHOOD_ZONES` (Periperi, que a resolução corta na Rua das Pedrinhas, vai **inteiro** para a ZE 17 — o bairro é o átomo geométrico). Atribuição em dois passes: nome da resolução, depois **maior fronteira compartilhada** para os 5 bairros que a RA nunca nomeia (Ilha Amarela, Dois de Julho, Chame-Chame, Horto Florestal, Mirantes de Periperi) — a regra foi **validada** contra os 4 que a resolução nomeia sob outro rótulo e reproduz a zona oficial nos 4. O build morre se sobrar polígono órfão ou bairro TRE sem polígono (hoje 170/170 → 19/19). **Fase 3 (re-keying):** map key = `kind === 'zona' ? slug : ibgeCode` (`mapKeyForMunicipality` + `buildMunicipalitiesByMapKey` em `municipalityMapNavigation.ts`); o bundle inteiro re-keyed (`territorialClassByMapKey`, `projectedValidVotesByMapKey`, `municipalitiesByMapKey`, `valuesByYear`, `values2026ByScenario`, `validVotesByYear`, `diffByYear`); os rollups pararam de acumular com `+=` porque cada map key agora é 1:1; `computeAggregateTerritorialClass` deu lugar a `computeMunicipalityTerritorialClass` (um slug por chave); `resolveMunicipalityMapNavigation` virou 1:1 e o ramo `kind: 'zones'` morreu junto com o scroll-anchor e a copy "toque de novo" do readout. **`competitiveRank` continua por cidade** (o artefato TSE só tem `federalRankByIbgeCode`): as 19 zonas dividem a posição da cidade, o que a legenda já afirmava — int novo pina que as 19 têm **uma** posição só. Per-ZE segue fora (gatilho: produto pedir posição dentro da zona). `buildMunicipalitiesByIbgeCode` sobrevive intacto — o href de lista do B14 ✓ é por cidade. **Fase 4 (mapa):** `BahiaMap` monta a camada de município a partir de **duas** malhas via `loadLayerFeatures`, e o polígono municipal de Salvador fica desenhado **por baixo** como base sem dado e sem pointer (`baseKeys`), para que um sliver de desacordo entre duas malhas simplificadas independentemente não abra buraco no contorno do estado; `keyPropertyForMode`/`featureKeyFromProperties` deram lugar a `featureMapKey` (`municipalitySlug ?? codarea ?? code` — as três nunca coexistem, então o argumento de modo sumiu); `fitMapToHighlights` passou a medir os **paths desenhados** (`pathByKeyRef`) em vez do módulo de geometria, que já não sabe sozinho de qual malha veio a chave, e `geometryModuleRef` foi apagado. Copy do painel: a linha morta "Zonas não têm polígono oficial — Salvador e **Camaçari** aparecem agregadas" (Camaçari saiu do modelo de zona na M1) virou o disclaimer de aproximação. **Dois fatos medidos, para ninguém re-derivar:** o polígono municipal de Salvador tem **~690 km²** e a soma das 19 zonas **~324 km²** — a diferença é a água da baía que os limites municipais encerram e a malha de bairros não desenha, então o int de área compara contra a área de **terra** (280–360 km²) e não contra o município; e centróide-dentro-de-outra-zona **não** é teste de sobreposição válido aqui, porque a ZE 16 é concava e envolve a ZE 14 (o centróide da 16 cai na 14) — a garantia de não-sobreposição é estrutural (cada bairro é atribuído a exatamente uma zona) mais a soma de áreas. Testes: int de contrato (19 features, slugs == catálogo, budget 600 KB, centróide de cada zona dentro do polígono municipal), unit de `featureMapKey`, e2e `campaignZoneMap.e2e.spec.ts` (436 paths / 435 interativos — o único não-interativo é a base de Salvador — readout "Salvador — ZE N" e clique abrindo `/campanha/municipios/salvador-ze-N`). **`/simplify`:** comentário de `NearbyMunicipalityResolution.zoneCity` que dizia "sem polígonos de zona (B8 F2)" corrigido (os polígonos passaram a existir nesta entrega; o card **deliberadamente** continua na lista filtrada — débito aberto, não efeito colateral); duas asserções do int de bairros que viraram tautologia com a hidratação da Fase 0 (comparavam `city`/`zoneNumber` com o catálogo de onde foram copiados) removidas, apontando para o teste de fixture que checa contra a transcrição independente; e o script parou de re-indexar geometrias simplificadas por **nome** de bairro (o irmão `build-bahia-geometries.mjs` usa `codarea`, único por construção) — hoje são 170 nomes distintos em 170 polígonos, mas um nome repetido em malha futura colapsaria dois polígonos silenciosamente num artefato commitado; passou a haver `id` próprio, e o rebuild saiu **byte-idêntico** (sha256 `1dd0d629…`). Sem migration, sem collection, sem action, sem Consent. **Gate:** tsc/lint/format/knip/`check:cycles`/unit+int/e2e/build rodados; **Aikido não fechou** — o scanner MCP responde Opengrep exit code 2 em **qualquer** entrada (inclusive um arquivo-probe trivial de uma linha), então os "4 findings" que ele reportou são artefato da execução quebrada e não achados atribuíveis; o feed do Aikido não tem nada deste repo. SCA do `shpjs` também pendente. **Relatórios do `/simplify` chegaram depois do merge** e o que sobrou virou o fill-in **B8+** ([`escala-dry-pos-b8f2.md`](../../../docs/plans/escala-dry-pos-b8f2.md)), aberto por uma medição que contradiz a intuição de "malha pequena, artefato pequeno": as 19 zonas têm **9.120** pontos de arco contra **9.139** da malha de **todos** os 417 municípios da Bahia (480 vs 22 pontos/feature; Salvador é um `Polygon` de **35** pontos na malha municipal). A causa não é a malha de bairros ser detalhada — é que `QUANTIZE_DIGITS = 1e4` e `SIMPLIFY_QUANTILE = 0.35` foram copiados verbatim do script irmão e **os dois são relativos à própria topologia**: a quantização é uma grade sobre o bbox do arquivo, e o de Salvador é ~26× mais estreito, o que dá **~3,9 m** de resolução contra ~103 m no estado, num mapa que para em `maxZoom: 10`. Consequência para quem for consertar: o que deve ficar compartilhado entre os dois scripts é a **escala de chão**, não a contagem de dígitos — extrair as constantes como estão (o que o revisor de reuse pediu, sem essa medição) cimentaria o defeito e lhe daria autoridade de política. Os outros três: nada roda `featureMapKey` sobre um feature **real** fora do e2e, então as três escritas da map key podem divergir e pintar cinza sem erro em lugar nenhum; o `Promise.all` das duas malhas somado à memoização da rejeição fez de um chunk de 19 polígonos um ponto único de falha do mapa inteiro (antes a malha municipal sozinha bastava); e a ressalva "é aproximado, não é o limite oficial do TSE" está pendurada na condição de render da **lista** (`!comparisonActive`), então desaparece no modo comparação enquanto a malha aproximada continua pintada. Plano: [`poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). -- **B39–B42 (lote 2026-07-26):** ~~**B39**~~ ✓ e ~~**B41**~~ ✓ entregues 2026-07-27; abertos: **B40** WebAuthn, **B42** polimento mobile. Planos: `lembrar-de-mim-sessao-campanha.md`, `login-biometrico-campanha.md`, `scroll-horizontal-lista-municipios.md`, `polimento-mobile-lista-municipios.md`. +- **B39–B42 (lote 2026-07-26):** ~~**B39**~~ ✓, ~~**B41**~~ ✓ e ~~**B42**~~ ✓ entregues 2026-07-27; aberto: **B40** WebAuthn. Planos: `lembrar-de-mim-sessao-campanha.md`, `login-biometrico-campanha.md`, `scroll-horizontal-lista-municipios.md`, `polimento-mobile-lista-municipios.md`. - **E12 entregue em código (2026-07-26):** camada TI — rollups MAUP + benchmark intra-TI. Estende `territoryOverview.ts` (`computeTerritoryE12Rollup`: cobertura agregada E8 cenário `central`, captura regional como razão dos agregados, mediana/amplitude, município crítico e farol) e `loadTerritoryOverview.ts` (passa `user`, `loadMunicipalityGoalCoverageBundle`, `computeAggregateTerritorialClass` por TI e sub-linhas do Metropolitano). `/campanha/territorios`: colunas Cobertura/Captura/Classe, sorts e descrições B22 em `territoryListUrl.ts` / `territoryListLabels.ts` / `TerritoryListColumns.tsx`. Detalhe: `territoryIntraCaptureBenchmark.ts` + linha T4 no `MunicipalityGoalAccountCard`. Conceitos E18 `captura-regional` e `benchmark-intra-ti`. Cortes v1: sem agrupamento na lista de municípios, sem mapa TI. `sanityCheckSuggestedGoalsByTerritory` segue sem UI. Gate: tsc/lint/format/check:cycles/533 unit/406 int/build (com `PAYLOAD_SECRET` local); knip P3 pré-existente em `payload.config.ts`; Aikido 0 findings. Plano: [`camada-territorios-identidade.md`](../../../docs/plans/camada-territorios-identidade.md). - **B36 entregue em código (2026-07-26):** coluna "Lideranças" em `/campanha/dobradinhas` — chips nomeados (link `/campanha/liderancas/[id]`) + Popover+Command + auto-save por delta, reusando **sem alteração** a mutação/schema/lock do **B31 ✓** (`setLeadershipStateDeputyMembership`, `leadership-state-deputies:{id}`, `nextStateDeputyIdsAfterMembership`). **Auditoria do plano** (escrito antes do B31 as-built) corrigiu duas defasagens: (1) sem `RelationOptionsProvider`/Context — B31 já tinha trocado Context por columns factory no `/simplify`; (2) casca `dobradinhas/formActions.ts` própria (não import cross-rota de `liderancas/formActions`). **Extração paga aqui:** `LeadershipStateDeputiesCell` (domínio) → `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction: 'fromLeadership' | 'fromStateDeputy'`, `measureOverflow` default true — clamp 3 linhas + "Ver mais…" no lado do deputado; `liderancas` passa `false`). `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Gate: tsc/lint/format/knip/cycles/unit 530 + int (flakes de timeout re-rodados verdes)/build; Aikido 0 findings. Plano: [`chips-liderancas-lista-dobradinhas.md`](../../../docs/plans/chips-liderancas-lista-dobradinhas.md). - **B30 entregue em código (2026-07-26):** convite de completar cadastro por WhatsApp na lista `/campanha/liderancas` — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` existente (B28 ✓), ao lado do `MessageCircleIcon` de contato; Popover two-step (`Gerar convite` → Alert com âncora `wa.me` + copiar link) reusa `createCampaignInvite`/`kind: 'autopreenchimento'`; disable alinhado ao `whatsAppHrefForPhone` da mesma célula (`hasValidPhone`); sem migration/action/collection nova. Plano revisado pós-B28 (encaixe em `actions`, não coluna `inviteAction`). Gate: tsc/lint/format/cycles; 532 unit (+2) / 406 int; build não rodou sem `PAYLOAD_SECRET`; knip P3 pré-existente; Aikido 0 findings. Plano: [`convite-whatsapp-lista-liderancas.md`](../../../docs/plans/convite-whatsapp-lista-liderancas.md). diff --git a/docs/plans/escala-dry-pos-b32.md b/docs/plans/escala-dry-pos-b32.md index 6ca0e186..939b6dd1 100644 --- a/docs/plans/escala-dry-pos-b32.md +++ b/docs/plans/escala-dry-pos-b32.md @@ -31,6 +31,8 @@ A comparação linha a linha dos três (feita nesta triage, `capture-review-debt - `handleOpenChange`: ao abrir, limpa erro/dirty; ao fechar com um save pendente, faz _flush_ (chama o save direto em vez de deixar o cleanup do unmount descartá-lo) — o **B32** só ganhou esse flush no `/simplify` da própria entrega, espelhando o `flushDraft` que o B24 já tinha. - `statusMessage` calculado igual nos três (`errorMessage` → "Salvando…" → "Alterações serão salvas automaticamente." → vazio), num `

    `. +**Atualização (2026-07-27, pós-B42 ✓):** o **container** dos três já saiu — `shared/CampaignCellEditOverlay.tsx` (Popover `md+` / Drawer mobile) absorveu Popover/Drawer/trigger/tooltip e `LeadershipListSupportStatusControl` foi migrado como 4º call site. Isso **não** consome o F1 (a casca não toca estado, refs, debounce nem `fetch` — foi o corte deliberado do B42, "só o container"), mas muda duas coisas para a auditoria: os três controles ficaram ~25–40 linhas menores, então a máquina de auto-save é agora a **maior** coisa duplicada neles; e o `/simplify` do B42 acrescentou à lista de peças idênticas um **13º item**, `reportFailure` — `setErrorMessage` + `toast.error` **só quando o overlay já fechou**, porque fechar desmonta o `Alert`/`aria-live` que carregariam o erro. Ele nasceu copiado nos quatro controles (os três do debounce + assessores) no mesmo passe, o que o torna a peça mais recente e mais óbvia do hook, se F1 concluir "extrair". + As diferenças reais, que é o que torna a extração não trivial (achado do `/simplify` reuse review do B32, 2026-07-26): o **shape do valor** diverge (`VoteEstimateScenarioViewModel` objeto com 3 cenários × `MunicipalityListSavedPoliticalTrend` `{status, note}` × `SupportStatus` escalar), **display vs. draft** existe em dois dos três (o B32 colapsou os dois porque só tem um campo — ver "Decisões travadas" do próprio plano B32) e o **B24** tem um guard extra (`inFlightTrendRef`) para não abortar+reenviar o mesmo payload quando blur e fechamento do popover disparam o mesmo flush. Um quarto controle, `MunicipalityListAdvisorsControl.tsx` (B27, chips + `Command`), **não** compartilha essa máquina — é delta multi-seleção com `requestSeqRef`/`latestConfirmedRef` (fora de escopo, mecânica genuinamente diferente) — mas compartilha com os quatro (incluindo ele) o **preâmbulo do `fetch`** (`credentials: 'same-origin'`, `Content-Type`/`Accept` JSON, `cast` da resposta) e, do lado do servidor, os quatro `route.ts` (`liderancas/support-status`, `municipios/political-trend`, `municipios/expected-votes`, `municipios/advisors`) repetem o mesmo preâmbulo de rota: `isSameOriginRequest(request)` → 403 → `parseCampaignJsonRequestBody` → `bodySchema.parse` → `try/catch` com `campaignJsonMutationErrorResponse`. @@ -97,6 +99,7 @@ Componentes (condicionais à decisão de cada fase): - `docs/roadmap.md` (Fill-ins abertos — **B32+**) - [autosave-status-lista-liderancas.md](autosave-status-lista-liderancas.md) — o pai do lote (B32 ✓), onde o gatilho "3º call site" foi fechado - [autosave-tendencia-lista-municipios.md](autosave-tendencia-lista-municipios.md) (B24) e [combobox-assessores-lista-municipios.md](combobox-assessores-lista-municipios.md) (B27) — os outros dois planos que registraram o mesmo "Adiado com gatilho" +- [polimento-mobile-lista-municipios.md](polimento-mobile-lista-municipios.md) (B42 ✓) — extraiu o **container** dos mesmos três controles (`CampaignCellEditOverlay`) e introduziu `reportFailure` nos quatro; leia o "As-built" antes de auditar o F1 - [escala-dry-pos-b33.md](escala-dry-pos-b33.md) — precedente direto de forma: hook via callbacks (não módulos de domínio), auditoria derrubando a premissa do plano antes de escrever código - `src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx`, `MunicipalityListTrendControl.tsx`, `src/components/campaign/leadership/LeadershipListSupportStatusControl.tsx` — os três call sites de F1 - `src/app/(campaign)/campanha/(app)/liderancas/support-status/route.ts`, `municipios/{political-trend,expected-votes,advisors}/route.ts` — os quatro call sites de F2 diff --git a/docs/plans/polimento-mobile-lista-municipios.md b/docs/plans/polimento-mobile-lista-municipios.md index 94b5d505..1f75e92e 100644 --- a/docs/plans/polimento-mobile-lista-municipios.md +++ b/docs/plans/polimento-mobile-lista-municipios.md @@ -1,7 +1,7 @@ # Polimento mobile da lista de municípios (drawers + card compacto) -Status: rascunho -Atualizado em: 2026-07-26 +Status: **entregue em código (2026-07-27)** — ver "As-built" no fim +Atualizado em: 2026-07-27 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B42) Impeccable: B — encaixe nos cards `md:hidden` + controles Tendência/Assessores; sem rota nova Appetite: ~1 dia eng; Drawer nos dois controles (padrão B26) + densificar card + tap-to-open @@ -110,15 +110,54 @@ Depth check: reusa `Drawer` shadcn + padrão B26; não cria design system de ove ## Adiado com gatilho -- **Tooltip/tap da Classe no card mobile** (mostrar o why sob demanda). Revisitar se a mesa sentir falta após a paridade com desktop. -- **Drawer para votos estimados** no card. Revisitar se o Popover de votos reclamar do teclado como tendência. +- ~~**Tooltip/tap da Classe no card mobile**~~ — **fechado no critique da própria sessão** (ver As-built): o `sr-only` sozinho deixava o veredito inalcançável no toque, o que contraria `DESIGN.md`. +- ~~**Drawer para votos estimados** no card~~ — **entrou nesta entrega** por decisão do usuário; foi o 3º call site que justificou extrair a casca. +- **Slot `footer` + `formId` na casca, para o Sinal (B26) migrar.** Gatilho: um 2º controle precisar de submit explícito num Drawer, ou o Sinal divergir visualmente dos outros três. +- **Montar o Drawer só no primeiro `open`** (`everOpened`, não `open`). Gatilho: a lista mobile aparecer num profile de render, ou o caminho `pagination: false` (435 linhas × 4 overlays) passar a ser rota quente. Números medidos no As-built. +- **`prefers-reduced-motion` no `Drawer`.** O `Dialog`/`Sheet` do kit também não respeita; a animação de entrada é do base-ui e vem do kit, então a correção é do componente `ui/`, não deste item. Gatilho: qualquer trabalho de acessibilidade de movimento no kit. +- **`DrawerVirtualKeyboardProvider`.** Sem ele, o teclado do Android pode cobrir os inputs de votos dentro da folha; hoje mitigado por não focar campo na abertura. Gatilho: relato de campo em Android. + +## As-built (2026-07-27) + +**Escopo ampliado por decisão do usuário na sessão:** **Votos estimados entrou** no mesmo tratamento (estava em "Adiado com gatilho" acima). Isso levou o item de 2 para **3 controles com a mesma casca** — que é o critério de extração do repo —, então a **questão em aberto foi resolvida ao contrário da recomendação**: `src/components/campaign/shared/CampaignCellEditOverlay.tsx` nasceu já nesta entrega, não no `/simplify`. Appetite ~1d → ~1,25d. + +- **`CampaignCellEditOverlay`** carrega só o container: `variant` (`popover` | `sheet`), `open`/`onOpenChange`, `title`/`description`, `trigger`/`triggerLabel`, `tooltipContent` (B23, só no popover — não há hover no toque) e `preventPopoverAutoFocus`. **Cada controle continua dono do seu auto-save** (debounce, abort, flush no fechamento): fechar o Drawer grava exatamente como dispensar o Popover, porque o `handleOpenChange` de cada um passa direto para `onOpenChange`. +- **O Sinal (B26) não migrou — mas o motivo registrado antes estava errado.** O `/simplify` derrubou a alegação de que "a casca não expressa isso sem virar genérica": o `` **não** precisa envolver o `DrawerHeader` (um título não é campo) e um submit fora do form é HTML padrão via `form=""`. O que falta é um **slot `footer`** (mais um `formId` do chamador) — adiado com gatilho abaixo, não impossível. O Sinal segue sendo o precedente visual e agora **consome** a classe de trigger da casca (`campaignCellEditTriggerClassName`) e os três atributos ARIA que lhe faltavam (`aria-expanded`/`aria-haspopup`/`aria-busy`). +- **`useIsMobile` descartado:** os cards (`md:hidden`) e a tabela (`hidden md:block`) são árvores irmãs, então o call site já sabe em que viewport está. Uma media query aqui trocaria o container depois da hidratação. +- **Navegação do card = link esticado, zero JS:** `` dentro do `

    ` com `after:absolute after:inset-0` sobre o `
    `. **Quem é `relative` é o controle, não a célula** — a primeira versão posicionou os seis `
    ` do `
    `, o que levantou também os rótulos e o padding de cada célula acima do overlay e transformou metade do card em toque que não editava nem navegava (P0 do critique). Hoje o `relative` mora na classe compartilhada do trigger; nada mais dentro do card pode ser posicionado. `MunicipalityList` continua Server Component e o foco/teclado seguem nativos. O botão "Abrir município" saiu. +- **Dois props `layout` morreram** com a paridade pedida: `TerritorialClassReadout` (badge + `sr-only` do porquê, como na tabela — E10 nunca entrega veredito nu) e `MunicipalityListGoalCoverageCell` (sempre `compact`). Com um único comportamento cada, o prop e o branch órfão foram deletados. +- **`autoFocusScenario` suprimido no `sheet`:** no desktop o Popover é transitório e digitar é o ponto; no toque, focar um `input` na abertura subiria o teclado virtual antes de o número ser lido. +- **`municipalityName` é obrigatório exatamente onde há Drawer:** os props de Votos estimados são uma união discriminada — `sheet` **exige** o nome (uma folha sempre nomeia seu assunto, e isso é erro de compilação, não nota de revisão), `popover` o deixa **opcional**, porque o 3º call site (`MunicipalityGoalAccountCard`, na ficha do município) já está sob o título do próprio município. A primeira versão **proibia** o nome no `popover` (`?: never`), o que criou uma assimetria na mesma linha da tabela: Tendência e Assessores anunciavam "… em Feira de Santana — …" e Votos estimados não. Resolvido para o lado de incluir depois de verificar que a célula do nome é um `` comum, **não** um `` — não existe associação de cabeçalho de linha para o leitor de tela suprir o contexto quando o foco entra num trigger. Custo medido do nome nos dois variants: **0,22 KiB gzip** na página de 25 linhas (2,9 KiB no caminho `pagination: false` de 435). + +**Testes:** `tests/unit/campaignCellEditOverlay.unit.spec.ts` (jsdom, 6 casos = 3 controles × 2 variants: `sheet` abre `role="dialog"` com título + nome do município e botão "Fechar"; `popover` mantém `popover-content` e não repete o nome) — precisou dos stubs de `ResizeObserver` e `Element.prototype.scrollIntoView` que o `cmdk` do combobox de assessores exige. E2e novo `test.describe` com `viewport: { width: 390, height: 844 }` em `campaignMunicipalities.e2e.spec.ts`: abrir Tendência no card **não** navega, e o toque fora dos controles abre `/campanha/municipios/`. + +**Gate:** tsc, `lint --max-warnings=0`, `format:check`, knip (só o erro pré-existente de `payload.config.ts`, P3), `check:cycles`, **592 unit / 420 int**, build (`/campanha/municipios` 13,4 kB / 320 kB First Load JS) e Aikido sem achados. O e2e mobile passou; as falhas rotativas da suíte cheia foram reproduzidas **piores na árvore limpa** (4 falhas contra 3, o mesmo teste de demanda falhando na mesma linha) sob contenção de outra sessão Playwright no mesmo host — mesmo padrão de ambiente já registrado no B27 ✓/B32 ✓, não regressão. + +### Critique + `/simplify` (mesma sessão, depois do gate) + +O critique rodou **sobre o código**, não sobre a tela: a coleta de evidência visual em viewport 390×844 falhou por falta de automação de browser nesta sessão, então nada aqui foi confirmado em device real — os alvos de toque são checados por classe (`min-h-11`), não medidos. Mesmo assim achou um **P0 de correção no gesto central**, e os revisores paralelos (qualidade, performance, reuso) convergiram no resto. O que entrou: + +- **P0 — zonas mortas do link esticado.** Descrito no bullet de navegação acima. O e2e passou a tocar num **rótulo `dt` dentro da grade de métricas** (não mais no canto do card), que é exatamente a área que o bug engolia. +- **O Drawer abre com foco no título, nunca num campo.** `initialFocus={titleRef}` num `DrawerTitle` com `tabIndex={-1}`: o default do base-ui (primeiro tabbable) subia o teclado virtual sobre a folha antes de ela ser lida — e o `preventContentAutoFocus` que devia mitigar isso era **inerte no branch `sheet`**, um prop que mentia. Renomeado para `preventPopoverAutoFocus` (é só do Popover) e o comportamento do Drawer virou incondicional. Pinado no unit spec, porque é o ponto mais frágil da entrega. +- **Rótulo acessível carrega o valor.** Um `aria-label` **substitui** o conteúdo do trigger: as quatro células de edição anunciavam o verbo e engoliam justamente o valor que todo mundo lê no pill/no avatar/no número. Agora seguem a forma que o Sinal (B26) já usava — "Editar tendência política em Feira de Santana — Favorável". Isso resolveu o achado de payload do revisor de performance **ao contrário** do que ele propôs: ele mediu `municipalityName` como payload RSC **morto** no desktop (27,9 KiB crus / 2,9 KiB gzip em 435 linhas) e sugeriu proibi-lo no `popover` nos três controles; com o nome dentro do rótulo ele deixou de ser morto, e a célula do nome ser um `` sem `scope="row"` é o que diz que ninguém mais fornece esse contexto (ver o bullet do `municipalityName` acima). +- **Falha silenciosa depois do fechamento.** Fechar é o que **commita** o rascunho e também o que desmonta o `Alert` e a região `aria-live` que carregariam o erro: uma gravação que falhava na saída revertia o badge sem avisar ninguém. Os três controles de auto-save por debounce (+ o de lideranças) ganharam `toast.error` **apenas quando já estão fechados**; abertos, o `Alert` inline continua sendo o canal mais próximo. +- **Alvos de toque dentro do Drawer.** Os três inputs compactos de votos passam a 44px abaixo de `md` e continuam 36px no `md+` (única call site do variant `compact`); o chip de remover assessor e cada linha do `Command` ganham `min-h-11` só no `sheet`. +- **A explicação voltou a ser alcançável no toque.** A Classe no card ganhou `CampaignHoverTooltip` (o canal tap-to-open que a tabela já tem via `cellTooltip`) — `DESIGN.md` exige que a classe nunca chegue como veredito nu, e no card ela só tinha `sr-only`; a Cobertura ganhou a frase completa em `sr-only`, porque `title` não é anunciado nem alcançado por toque. Isso **fecha** o "Adiado com gatilho: tooltip/tap da Classe no card mobile". +- **Reuso — o 4º call site migrou.** `LeadershipListSupportStatusControl` (B32) era o branch Popover da casca redigitado à mão, classe de trigger byte-a-byte inclusive: migrado com `variant="popover"`, ~25 linhas a menos e nenhum prop novo. `MunicipalityListSignalControl` passou a importar `CampaignCellEditOverlayVariant` em vez de re-escrever a união. A string de trigger, que estava em três arquivos, virou `campaignCellEditTriggerClassName` (precedente: `campaignHoverExplanationClassName`). +- **API da casca:** o ` - - event.preventDefault()} - > -
    - {isPending ? ( - - ) : null} - - - Status de apoio - - handleStatusChange(event.target.value)} - className="min-h-11 w-full" - > - {leadershipSupportStatuses.map((option) => ( - - {supportStatusLabels[option]} - - ))} - - - {errorMessage ? ( - - {errorMessage} - - ) : null} -

    - {statusMessage} -

    -
    -
    - + // The leaderships list is table-only, so this stays a Popover at every + // viewport — the shell is here for the trigger and content it already owns, + // not for a Drawer this surface has nowhere to put (B42). + } + > +
    + {isPending ? ( + + ) : null} + + + Status de apoio + + handleStatusChange(event.target.value)} + className="min-h-11 w-full" + > + {leadershipSupportStatuses.map((option) => ( + + {supportStatusLabels[option]} + + ))} + + + {errorMessage ? ( + + {errorMessage} + + ) : null} +

    + {statusMessage} +

    +
    +
    ) } diff --git a/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx b/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx index d47627c9..6c1f28e1 100644 --- a/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx +++ b/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx @@ -262,6 +262,7 @@ export const MunicipalityGoalAccountCard = ({ municipalityID={municipalityID} expectedVotes={expectedVotes} pledgeCoverage={pledgeCoverage} + variant="popover" />

    Meta usada na conta: {formatElectionNumber(goalCoverage.goal)} ( diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 90112697..bf66c64a 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -14,10 +14,11 @@ import { MunicipalityListSignalControl } from '@/components/campaign/municipalit import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl' import { MunicipalitySortableHead } from '@/components/campaign/municipality/MunicipalitySortableHead' import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink' +import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending' import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable' import { Badge } from '@/components/ui/Badge' -import { Button, buttonVariants } from '@/components/ui/button' +import { buttonVariants } from '@/components/ui/button' import { Empty, EmptyContent, @@ -117,32 +118,24 @@ const VotePositionReadout = ({ } /** - * E10 classe. In the table only the pill is visible — spelling the factors out - * under it still bloats the sticky/dense row even with horizontal scroll, - * so the "por quê" moves to the column's `cellTooltip` (hover, focus and tap) - * and stays as `sr-only` text; the card has the width to show it outright. - * Either way the class never reaches anyone as a bare verdict, which is the - * one thing it must never be. + * E10 classe. Only the pill is visible — spelling the factors out under it + * bloats the dense row on desktop and, on the mobile card, was the tallest + * block in a card the field asked us to shorten (B42). So the "por quê" is + * `sr-only` text here, and both surfaces add a tap/hover/focus channel around + * it: the column's `cellTooltip` in the table, an explicit `CampaignHoverTooltip` + * on the card. Neither may be dropped — the class must never reach anyone, + * sighted or not, as a bare verdict. */ -const TerritorialClassReadout = ({ - municipality, - layout, -}: { - municipality: MunicipalityListViewModel - layout: 'table' | 'card' -}) => { - const isTable = layout === 'table' +const TerritorialClassReadout = ({ municipality }: { municipality: MunicipalityListViewModel }) => { const why = formatTerritorialClassWhy(municipality.territorialClassFactors) if (municipality.territorialClass === 'sem_base') { // Same idiom as the "2022" column: absent data is a dash, not a pill. - return isTable ? ( + return ( {why} - ) : ( - {why} ) } @@ -151,7 +144,7 @@ const TerritorialClassReadout = ({ {territorialClassLabels[municipality.territorialClass]} - {why} + {why} ) } @@ -346,9 +339,7 @@ const municipalityListColumns = ({ Classe ), - cell: (municipality) => ( - - ), + cell: (municipality) => , // The factors behind the label — the cell keeps them as `sr-only` // text, so this stays a redundant affordance (see `cellTooltip`). cellTooltip: (municipality) => @@ -373,10 +364,12 @@ const municipalityListColumns = ({ isCoordinator ? ( ) : ( ( ), }, @@ -429,8 +424,10 @@ const municipalityListColumns = ({

    ), @@ -473,7 +470,6 @@ const municipalityListColumns = ({ cell: (municipality) => ( ), }, @@ -519,12 +515,33 @@ export const MunicipalityList = (props: MunicipalityListProps) => { const isPriority = municipality.priority === 'alta' const { region, zoneSuffix } = municipalityGeographyParts(municipality) return ( -
    +
    -

    {municipality.name}

    + {/* + * The name's link stretches over the whole card, so tapping + * anywhere that isn't a control opens the município — B42 + * removed the "Abrir município" button that used to be the + * only way in. Each control carries its own `relative`, + * which paints it above this overlay and keeps its own tap; + * nothing else in the card may be positioned, or it would + * become a hole in the card's tap target. + */} +

    + + {municipality.name} + +

    - + + + {zoneSuffix ? ` ${zoneSuffix}` : null}

    {position ? : null} @@ -535,19 +552,46 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
    {isStaffView ? (
    -
    +
    Classe
    - + {/* + * The card has no hover, so the "por quê" the class must + * never travel without gets the same tap-to-open channel + * the desktop column gets through `cellTooltip` — the + * `sr-only` copy inside the readout covers AT either way. + */} + + + + + +
    +
    +
    +
    Cobertura da meta
    +
    +
    Votos estimados
    -
    +
    @@ -556,16 +600,10 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
    -
    -
    -
    -
    Cobertura da meta
    -
    -
    @@ -590,10 +628,12 @@ export const MunicipalityList = (props: MunicipalityListProps) => { {isCoordinator ? ( ) : names.length ? ( names.join(', ') @@ -604,9 +644,6 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
    ) : null} -
    ) })} diff --git a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx index b801bd15..45417c1c 100644 --- a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx @@ -9,11 +9,13 @@ import { formatAdvisorNamesTooltip, MunicipalityAdvisorAvatarStack, } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack' -import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' +import { + CampaignCellEditOverlay, + type CampaignCellEditOverlayVariant, +} from '@/components/campaign/shared/CampaignCellEditOverlay' import { Alert, AlertDescription } from '@/components/ui/Alert' import { Badge } from '@/components/ui/Badge' import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' import { Spinner } from '@/components/ui/Spinner' import { sameIdSet } from '@/lib/sameIdSet' import { cn } from '@/lib/utils' @@ -28,19 +30,23 @@ const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar os assessores. Tente no type MunicipalityListAdvisorsControlProps = { municipalityID: number + municipalityName: string currentAdvisorIDs: number[] /** Raises the empty state to "Sem responsável" — see `MissingAdvisorBadge`. */ isPriority: boolean advisorNamesById: ReadonlyMap options: EligibleAdvisorOption[] + variant: CampaignCellEditOverlayVariant } export const MunicipalityListAdvisorsControl = ({ municipalityID, + municipalityName, currentAdvisorIDs, isPriority, advisorNamesById, options, + variant, }: MunicipalityListAdvisorsControlProps) => { const [open, setOpen] = useState(false) const [selectedIDs, setSelectedIDs] = useState(currentAdvisorIDs) @@ -95,6 +101,11 @@ export const MunicipalityListAdvisorsControl = ({ // chips: it's the no-open-popover read. `null` for an empty list — the // trigger already reads "Sem responsável"/"Sem assessor" by extenso. const tooltipContent = formatAdvisorNamesTooltip(chips) + const assignedLabel = chips.length + ? chips.map((chip) => chip.name).join(', ') + : isPriority + ? 'sem responsável' + : 'sem assessor' const filteredOptions = useMemo( () => options.filter((option) => matchesAtWordStart(option.name, query)), @@ -167,106 +178,109 @@ export const MunicipalityListAdvisorsControl = ({ } const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando assessores.' : '' + // The body pads its own sections (`px-4`, aligned with the Drawer header) so + // the `Command` can stay full-bleed in both containers — hence `px-0` on the + // sheet body and `p-0` on the popover content. + const isSheet = variant === 'sheet' return ( - - - - - - - -
    -

    Atribuir assessores

    -

    - O assessor vê e gerencia somente os municípios que administra. -

    - {isPending ? ( - - ) : null} -
    - {chips.length > 0 ? ( -
    - {chips.map((chip) => ( - - ))} -
    - ) : null} - - - - {filteredOptions.length === 0 ? ( -

    - Nenhum resultado. -

    - ) : ( - filteredOptions.map((option) => { - const assigned = selectedSet.has(option.id) - return ( - toggle(option.id, !assigned)} - > - - {option.name} - {option.isCurrent ? ' (você)' : ''} - - - ) - }) - )} -
    -
    - {errorMessage ? ( -
    - - {errorMessage} - -
    - ) : null} -

    - {statusMessage} + } + > +

    + {/* The Drawer's own header already titles it. */} + {isSheet ? null :

    Atribuir assessores

    } + {/* `pr-6` keeps this line clear of the sheet's spinner, which sits at + `top-0 right-4` because there is no title row above it to hold it. */} +

    + O assessor vê e gerencia somente os municípios que administra.

    - - + {isPending ? ( + + ) : null} +
    + {chips.length > 0 ? ( +
    + {chips.map((chip) => ( + + ))} +
    + ) : null} + + + + {filteredOptions.length === 0 ? ( +

    Nenhum resultado.

    + ) : ( + filteredOptions.map((option) => { + const assigned = selectedSet.has(option.id) + return ( + toggle(option.id, !assigned)} + > + + {option.name} + {option.isCurrent ? ' (você)' : ''} + + + ) + }) + )} +
    +
    + {errorMessage ? ( +
    + + {errorMessage} + +
    + ) : null} +

    + {statusMessage} +

    + ) } diff --git a/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx b/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx index a47d7f6d..7ab79c9f 100644 --- a/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx @@ -1,17 +1,19 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' import type { MunicipalityListExpectedVotesResponse } from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types' import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext' +import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay' import { StaffMunicipalityVotesDisplay } from '@/components/campaign/votePledge/StaffMunicipalityVotesDisplay' import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs' import { Alert, AlertDescription } from '@/components/ui/Alert' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' import { Spinner } from '@/components/ui/Spinner' -import { cn } from '@/lib/utils' +import { formatElectionNumber } from '@/lib/electionFormat' import { DEFAULT_VOTE_ESTIMATE_SCENARIO, + voteEstimateScenarioLabels, voteEstimatesEqual, type VoteEstimateScenarioViewModel, } from '@/lib/voteEstimate' @@ -21,16 +23,30 @@ const AUTOSAVE_MS = 600 const EXPECTED_VOTES_ENDPOINT = '/campanha/municipios/expected-votes' const SAVE_ERROR_MESSAGE = 'Não foi possível salvar os votos estimados. Tente novamente.' +/** + * `municipalityName` is the Drawer's subject line and part of the trigger's + * accessible name, so a Drawer must always name its subject — the union makes + * that a compile error instead of a review note. It stays optional for a Popover + * because the third call site (`MunicipalityGoalAccountCard` on the detail page) + * already sits under the município's own heading; inside the list, the Popover + * does pass it, since the name cell is a plain `` and not a ``, + * so nothing else announces which row a focused trigger belongs to. + */ type MunicipalityListExpectedVotesControlProps = { municipalityID: number expectedVotes: VoteEstimateScenarioViewModel pledgeCoverage: MunicipalityPledgeCoverageView | null -} +} & ( + | { variant: 'popover'; municipalityName?: string } + | { variant: 'sheet'; municipalityName: string } +) export const MunicipalityListExpectedVotesControl = ({ municipalityID, + municipalityName, expectedVotes, pledgeCoverage, + variant, }: MunicipalityListExpectedVotesControlProps) => { const scenarioContext = useMunicipalityEstimateScenarioOptional() const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO @@ -45,6 +61,18 @@ export const MunicipalityListExpectedVotesControl = ({ const committedVotesRef = useRef(expectedVotes) const lastPropsVotesRef = useRef(expectedVotes) const saveGenerationRef = useRef(0) + const openRef = useRef(false) + + /** + * Closing commits the draft and unmounts the Alert that would carry a + * failure, so a save that fails on the way out would silently revert the + * number. A toast outlives the overlay; while it is open the inline Alert is + * still the closer channel. + */ + const reportFailure = (message: string) => { + setErrorMessage(message) + if (!openRef.current) toast.error(message) + } // Adopt server props only when they change from outside (navigation / RSC refresh). useEffect(() => { @@ -91,7 +119,7 @@ export const MunicipalityListExpectedVotesControl = ({ if (!response.ok || payload.status !== 'success') { setDisplayVotes(committedVotesRef.current) setDraft(committedVotesRef.current) - setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE) + reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE) return } @@ -103,7 +131,7 @@ export const MunicipalityListExpectedVotesControl = ({ if (controller.signal.aborted || generation !== saveGenerationRef.current) return setDisplayVotes(committedVotesRef.current) setDraft(committedVotesRef.current) - setErrorMessage(SAVE_ERROR_MESSAGE) + reportFailure(SAVE_ERROR_MESSAGE) } finally { if (generation === saveGenerationRef.current) { setIsPending(false) @@ -126,6 +154,7 @@ export const MunicipalityListExpectedVotesControl = ({ } const handleOpenChange = (nextOpen: boolean) => { + openRef.current = nextOpen if (nextOpen) { setIsDirty(false) setErrorMessage(null) @@ -147,62 +176,69 @@ export const MunicipalityListExpectedVotesControl = ({ : isDirty ? 'Alterações serão salvas automaticamente.' : '' + // An `aria-label` replaces the trigger's content, and the content here is the + // number itself — so the label has to carry it, and name the scenario it + // belongs to (the picker can disagree with the column's sort, E9). + const activeValue = displayVotes[activeScenario] + const triggerLabel = [ + 'Editar votos estimados', + municipalityName ? ` em ${municipalityName}` : '', + ` — ${voteEstimateScenarioLabels[activeScenario]}: `, + activeValue == null ? 'sem estimativa' : formatElectionNumber(activeValue), + ].join('') return ( - - - - - event.preventDefault()} - > -
    - {isPending ? ( - - ) : null} - - {errorMessage ? ( - - {errorMessage} - - ) : null} -

    - {statusMessage} -

    -
    -
    -
    + ) : null} + + {errorMessage ? ( + + {errorMessage} + + ) : null} +

    + {statusMessage} +

    +

+ ) } diff --git a/src/components/campaign/municipality/MunicipalityListGoalCoverageCell.tsx b/src/components/campaign/municipality/MunicipalityListGoalCoverageCell.tsx index aa2f98bb..4cf0d679 100644 --- a/src/components/campaign/municipality/MunicipalityListGoalCoverageCell.tsx +++ b/src/components/campaign/municipality/MunicipalityListGoalCoverageCell.tsx @@ -18,18 +18,18 @@ import { * pattern as `MunicipalityListExpectedVotesControl`) and shows the * percent + deficit for that scenario, computed server-side for all three. * - * `compact` (desktop table, dense) shows a short signed deficit with the - * full sentence as a `title` tooltip — same "abbreviate + title" pattern as - * `StaffMunicipalityVotesDisplay`'s "Nas lideranças" subline — so the column - * doesn't force a wide `whitespace-nowrap` cell. `default` (mobile cards, - * more room) spells the sentence out. + * The signed deficit is abbreviated so neither the desktop column forces a wide + * `whitespace-nowrap` cell nor the mobile card spends two lines on it (B42). + * The full sentence rides along twice: as a `title` for the mouse (same pattern + * as `StaffMunicipalityVotesDisplay`'s "Nas lideranças" subline) and as + * `sr-only` text, because `title` alone reaches neither a screen reader nor a + * touch device. Sighted touch users read the abbreviation under its `dt` label; + * the sentence itself is on the detail page's "Conta da cadeira" card. */ export const MunicipalityListGoalCoverageCell = ({ coverageByScenario, - layout = 'default', }: { coverageByScenario: Record - layout?: 'default' | 'compact' }) => { const scenarioContext = useMunicipalityEstimateScenarioOptional() const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO @@ -45,18 +45,19 @@ export const MunicipalityListGoalCoverageCell = ({ {formatGoalCoverageRatioLabel(coverage)} - {layout === 'compact' ? ( - - {formatGoalCoverageDeficitShortLabel(coverage)} - - ) : ( - - {deficitLabel} · cenário {scenarioLabel} - - )} + + {/* + * `title` is a mouse-only channel — never announced reliably, never + * reachable by touch — so the abbreviation it explains is hidden from + * assistive tech and the sentence itself is what gets read. + */} + {`${deficitLabel} (cenário ${scenarioLabel})`} ) } diff --git a/src/components/campaign/municipality/MunicipalityListSignalControl.tsx b/src/components/campaign/municipality/MunicipalityListSignalControl.tsx index b7803a69..34c624cd 100644 --- a/src/components/campaign/municipality/MunicipalityListSignalControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListSignalControl.tsx @@ -4,6 +4,10 @@ import { useActionState, useEffect, useId, useState, type ReactNode } from 'reac import { toast } from 'sonner' import { MunicipalitySignalFields } from '@/components/campaign/municipality/MunicipalitySignalFields' +import { + campaignCellEditTriggerClassName, + type CampaignCellEditOverlayVariant, +} from '@/components/campaign/shared/CampaignCellEditOverlay' import { Alert, AlertDescription } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' import { @@ -17,6 +21,7 @@ import { } from '@/components/ui/Drawer' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' import { Spinner } from '@/components/ui/Spinner' +import { cn } from '@/lib/utils' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' import { formatMunicipalitySignalAgeLabel, @@ -33,7 +38,7 @@ type MunicipalityListSignalControlProps = { municipalitySlug: string municipalityName: string lastSignalAt: string | null - variant: 'popover' | 'sheet' + variant: CampaignCellEditOverlayVariant formAction: MunicipalityStaffFormAction children: ReactNode } @@ -97,8 +102,18 @@ export const MunicipalityListSignalControl = ({ setFormKey((key) => key + 1) }, [state.message, state.status]) - const triggerClassName = - 'min-h-11 rounded-md px-1 text-left hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' + // This control keeps its own container (its `` has to wrap header, body + // and submit), so it borrows the shell's trigger instead of re-spelling it — + // `relative` included, which is what keeps the tap on the mobile card. + const triggerClassName = cn(campaignCellEditTriggerClassName, 'text-left') + const triggerProps = { + type: 'button', + 'aria-expanded': open, + 'aria-haspopup': 'dialog', + 'aria-busy': isPending || undefined, + className: triggerClassName, + 'aria-label': triggerLabel, + } as const const fields = ( - @@ -149,9 +159,7 @@ export const MunicipalityListSignalControl = ({ return ( - + diff --git a/src/components/campaign/municipality/MunicipalityListTrendControl.tsx b/src/components/campaign/municipality/MunicipalityListTrendControl.tsx index 9c169f67..bf5e3896 100644 --- a/src/components/campaign/municipality/MunicipalityListTrendControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListTrendControl.tsx @@ -1,21 +1,23 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' import type { MunicipalityListPoliticalTrendResponse, MunicipalityListSavedPoliticalTrend, } from '@/app/(campaign)/campanha/(app)/municipios/political-trend/types' -import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' +import { + CampaignCellEditOverlay, + type CampaignCellEditOverlayVariant, +} from '@/components/campaign/shared/CampaignCellEditOverlay' import { Alert, AlertDescription } from '@/components/ui/Alert' import { Badge } from '@/components/ui/Badge' import { Field, FieldLabel } from '@/components/ui/field' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' import { Spinner } from '@/components/ui/Spinner' import { Textarea } from '@/components/ui/textarea' import { parsePoliticalTrendStatusFormValue } from '@/lib/schemas/municipality' -import { cn } from '@/lib/utils' import { politicalTrendBadgeVariant, politicalTrendLabels } from '@/utilities/municipalityLabels' const NOTE_AUTOSAVE_MS = 600 @@ -36,14 +38,18 @@ const trendsEqual = ( type MunicipalityListTrendControlProps = { municipalityID: number + municipalityName: string status: MunicipalityListSavedPoliticalTrend['status'] trendNote: string | null + variant: CampaignCellEditOverlayVariant } export const MunicipalityListTrendControl = ({ municipalityID, + municipalityName, status, trendNote, + variant, }: MunicipalityListTrendControlProps) => { const initialTrend: MunicipalityListSavedPoliticalTrend = { status, @@ -63,6 +69,18 @@ export const MunicipalityListTrendControl = ({ const saveGenerationRef = useRef(0) /** Skip abort+re-POST when blur and popover-close flush the same payload. */ const inFlightTrendRef = useRef(null) + const openRef = useRef(false) + + /** + * Closing is what commits the draft, and closing also unmounts the Alert and + * the live region that would carry a failure — so a save that fails on the + * way out reverts the badge with nobody told. A toast outlives the overlay; + * while it is still open the inline Alert remains the closer channel. + */ + const reportFailure = (message: string) => { + setErrorMessage(message) + if (!openRef.current) toast.error(message) + } const adoptTrend = (trend: MunicipalityListSavedPoliticalTrend) => { setDisplayTrend(trend) @@ -128,7 +146,7 @@ export const MunicipalityListTrendControl = ({ if (!response.ok || payload.status !== 'success') { adoptTrend(committedTrendRef.current) setIsDirty(false) - setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE) + reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE) return } @@ -139,7 +157,7 @@ export const MunicipalityListTrendControl = ({ if (controller.signal.aborted || generation !== saveGenerationRef.current) return adoptTrend(committedTrendRef.current) setIsDirty(false) - setErrorMessage(SAVE_ERROR_MESSAGE) + reportFailure(SAVE_ERROR_MESSAGE) } finally { if (generation === saveGenerationRef.current) { inFlightTrendRef.current = null @@ -196,6 +214,7 @@ export const MunicipalityListTrendControl = ({ } const handleOpenChange = (nextOpen: boolean) => { + openRef.current = nextOpen if (nextOpen) { setIsDirty(false) setErrorMessage(null) @@ -207,6 +226,9 @@ export const MunicipalityListTrendControl = ({ } const hasNote = Boolean(displayTrend.note) + const trendLabel = displayTrend.status + ? politicalTrendLabels[displayTrend.status] + : 'Não registrada' const statusMessage = errorMessage ? errorMessage : isPending @@ -216,91 +238,78 @@ export const MunicipalityListTrendControl = ({ : '' return ( - - {displayTrend.note}

: null} - align="start" - openOnTouch={false} - disabled={open} - > - - - -
- event.preventDefault()} - > -
- {isPending ? ( - - ) : null} - - Tendência - handleStatusChange(event.target.value)} - className="min-h-11 w-full" - > - Não registrada - {(Object.keys(politicalTrendLabels) as Array).map( - (trendStatus) => ( - - {politicalTrendLabels[trendStatus]} - - ), - )} - - - - - Justificativa - -