# Roadmap — Teqo Atualizado em: 2026-07-28 ((**B18 ✓** filtros salvos na lista de municípios — "Salvar filtro" ao lado de "Limpar" nomeia o recorte atual da URL e o atalho vira submenu de 2º nível sob Municípios no sidebar, com chevron, estado persistido, ativo e apagar com desfazer. Cinco premissas do plano de 2026-07-24 morreram: `SidebarMenuSub*` **não existiam** (podados pelo Pass 2 W4b) e re-adicioná-los expôs um bug latente do kit — `data-active={false}` chega ao DOM como `data-active="false"` e o seletor `data-active:` casa, então **todo item não-ativo estava estilizado como ativo**; `municipalityUi.ts` não existe mais; a URL tem 14 params; B17 persiste em **cookie**; e o hover morreu com B38 ✓. O risco central era bundle — a sidebar está no layout de `(app)`, então importar o serializador de URL custaria 21 kB × toda rota `/campanha`; com o comparador puro `lib/listQueryMatch.ts` no lugar, `/campanha` ficou **inalterado em 277 kB**, medido contra árvore limpa. O e2e pegou dois bugs reais: o auto-open reabria o grupo em toda montagem (o store responde vazio até o cliente ler o `localStorage`, então um match vindo da hidratação parecia navegação) e apagar derrubava o foco para `
` · [plano](plans/filtros-salvos-municipios.md)) **fill-in ✓** dimir resultado no compositor de giro — `CampaignListPendingBoundary`/`CampaignListResults` em `/campanha/atividades/giros`, `TourRegionPicker` → `useCampaignListTransition`, unit pin · [plano](plans/feedback-pendente-compositor-giro.md); **fill-in ✓** selecionados no topo nos filtros do header — `orderFilterOptionsSelectedFirst` + `CampaignHeaderFilterPopover`/`CampaignMobileMultiFilterField` (4 listas); snapshot congelado; hairline oculto na busca · [plano](plans/selecionados-no-topo-filtros-header.md); **fill-in ✓** ícone de prioridade na lista de municípios — Flag + tooltip "Município prioritário" na tabela e no card mobile, Impeccable B · [plano](plans/icone-prioridade-lista-municipios.md); ~~**fill-in** remover lista de zonas do Início~~ ✓ (entregue 2026-07-28 — UI + `zoneBreakdown` removidos; ressalva via `hasZoneMunicipalities`) · [plano](plans/remover-lista-zonas-mapa-inicio.md); **E11 ✓** motor de sugestões v1 — os 8 padrões de maior confiança do §6.1 (P1/P2/P3/P5/P6/P7/K-A/K-B) como catálogo curado em código (`suggestionCatalog.ts`, client-safe: predicados puros sobre números derivados + a copy exata dos cards, revisados no mesmo diff — contrato do E18), avaliador stateless em leitura (`municipalityTriggers.ts`, molde do planner E13: leitura própria + 6 queries em `Promise.all`; nada de cron nem tabela de sugestões) e decisão humana gravando `allocationDecision` com o snapshot **recomputado no servidor** no momento da decisão — padrão fora da fila recusa como stale, lock `municipality-suggestion:{id}:{patternId}` antes de reler a última decisão; **a auditoria matou o "sem migration" do plano:** o enum real era `aceita|descarta|movimento` (os valores `descartada|adiada` do plano nunca existiram), então "Adiar" custou a migration `20260728_041547` adicionando `adiada` (precedente do `movimento` do E14) com `suppressUntil` no snapshot em vez de coluna; supressão por janelas (aceita 14d / descarta 30d / adiada até o snooze) com a regra "nível 1 fura a fila" pura e unit-testada; diferencial estrutural codificado (P2 cede a P3/K-B, P7 cede a K-A); P3 dispara só pelo artefato porque `runningAgain2026` está 100% `desconhecido` sem read path — "concorre de novo?" virou o 1º item do menu; pauta do silêncio = alta/N2+ sem gatilho e sem sinal ≥30d (inclui `priority: alta` porque E14 nasceu sem backfill); UI: painel Suspense no Início (top-5 + silêncio) e card na visão geral do município, aceite com radios do menu (1º pré-selecionado — a checagem barata) + adiamento de um toque (7d/14d pelo nível) + descarte com quick-picks dos "descartes" do padrão e leitura alternativa obrigatória, submit explícito (exceção prevista, mesmo precedente do E14), foco gerenciado na troca trigger↔form, só o nível 1 usa vermelho; int tests alocam municípios pelo alocador até um qualificar (taxas medidas 109/435 quartil superior, 205/435 quietos — slug explícito colide com a sequence com wrap); First Load JS de `/campanha` 268 → 277 kB; adjacente registrado no plano: ligar o override do "não vá" do compositor de giros ao `resolveSuggestion` · [plano](plans/motor-de-sugestoes.md); **B40 ✓** login biométrico WebAuthn em `/campanha` — enrollment opt-in (toast no shell + seção no perfil), botão de digital/Face ID na tela de login emitindo o mesmo `campaign-token` de 14 dias, revogação por dispositivo, para **todos os papéis** e **sem `Consent`** (só a chave pública sai do aparelho, nunca a biometria). O Payload não tem login password-less: a sessão é forjada com `db.findOne` cru + `checkLoginPermission` + `addSessionToUser` + `jwtSign` sob lock e transação, porque carregar o usuário pelo caminho normal apagaria `sessions` e derrubaria as outras sessões — int spec pinando o fato. Duas armadilhas fora do plano: o RP resolvia com sucesso num preview da Vercel e o browser estourava `SecurityError` (agora o host do request tem de bater com o host derivado, senão o botão não aparece), e o challenge do cookie tem de ser o mesmo que o `@simplewebauthn/server` gerou (a primeira versão gerava o seu e todo `register` dava 400); `campaignUser` ganhou `beforeDelete` em cascata porque a FK do Payload é `ON DELETE SET NULL` sobre coluna `NOT NULL`. `@simplewebauthn/browser` entra por `import()` dinâmico dentro do handler (a metade sempre carregada — probe + tipo de erro — vive em `campaignWebAuthnSupport.ts`), então `/campanha/login` fica em **126 kB** e `/campanha`, onde o toast monta em toda navegação, segue nos 268 kB de antes do item · [plano](plans/login-biometrico-campanha.md); **B37 ✓** chips editáveis de municípios na lista de dobradinhas — a coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`row.municipalityCount`) pela mesma peça do **B34 ✓** (`shared/MunicipalityPortfolioCell`, sem cópia, sem provider novo), escrevendo do lado invertido (`municipality.stateDeputies`) por uma única action de lote (`setStateDeputyMunicipalitiesBatch`, transacional, lock por município, **tudo-ou-nada** — sem "aplica o que pode"), sem piso mínimo e sem `assertMunicipalitiesWithinScope` (`canUpdateMunicipality` já escopa o assessor); o cap de `stateDeputiesArraySchema` subiu de 20 para 435 para caber um lote de território inteiro do lado do município. **O critique pós-implementação achou dois P1 e a resposta foi generalizar na hora, não adiar:** `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓/B36 ✓) tinham a mesma máquina de interação (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) implementada duas vezes com contratos incompatíveis — em vez de esperar um "3º call site" hipotético, este item **era** o 3º call site, então `src/components/campaign/shared/RelationChipCell.tsx` nasceu como o motor único e as duas células viraram wrappers finos (chips/busca/`FormData`/copy por domínio); isso fecha adiantado o gatilho `useChipRowOverflow` que o **B34+** havia registrado. O segundo P1 — um assessor via e tentava remover chips de município fora do escopo, falhando em silêncio no nível do documento — fechou escondendo o chip em vez de só explicar: `MunicipalityPortfolioCell` agora filtra por `addableIds` os municípios que renderiza, não só os que oferece para adicionar. `LeadershipStateDeputyRelationCell` ganhou `ownerName` e sua primeira suíte de testes própria (zero cobertura antes); gate completo (tsc/lint/format/cycles limpos, int 452/452, Aikido 0 findings). **O `/simplify` do fechamento achou um bug latente, não uma dedução:** o helper de delta que este item criou era a **terceira** cópia byte-a-byte do mesmo cálculo de um id, e o pareamento importa porque duas das mensagens de teto eram literais crus repetidos em três lugares de `src` cada — `mapCampaignFormActionError` casa a mensagem lançada contra `safeMessages` por **string exata**, então reescrever uma delas colapsaria silenciosamente uma recusa real em "tente novamente"; o algoritmo virou `nextIdsAfterMembership` (os três nomes de domínio são wrappers de uma linha), as mensagens viraram constantes ao lado dos seus caps, e os três specs clonados viraram um `describe.each` com **mais** cobertura que antes (unit 651 → 715). Também: `scopedPortfolioIndex` fecha o **F9** do B34+ (o `.filter()` do escopo do assessor devolvia um array novo por linha, derrotando o WeakMap que existe para compartilhar derivações entre linhas) e a busca da célula de dobradinhas parou de renormalizar o catálogo a cada tecla; `maxItems` "por paridade" foi **rejeitado** (dobradinha não tem teto de produto, e um "30 generoso" recusaria uma dobradinha estadual legítima); os dois débitos que sobraram — o laço município-a-município das duas actions de lote e a gemelaridade delas (2 call sites) — foram **absorvidos no B34+ como F10**, sem abrir ID novo · [plano](plans/chips-municipios-lista-dobradinhas.md); **B17+** novo fill-in de engenharia registrado no fechamento do B17 — esconder uma coluna não desliga a query que existe só para ela, e o botão "Colunas" some com a tabela no estado vazio de três rotas; **B17 ✓** seletor de colunas — entregue como capacidade do `CampaignTable`, nas **sete** superfícies do sistema de listas em vez de só em municípios; persistência em **cookie** (`campaign_columns`) com filtragem no servidor, porque `CampaignTableColumn.cell` é função e o array de colunas não atravessa a fronteira server/client — a coluna oculta some também do payload RSC; `label: string` virou obrigatório e o seam `defaultVisible` **morreu** (era `true` em 10 colunas e `false` em nenhuma: guardar só o conjunto de ocultas é o que faz coluna nova nascer visível, contrato que E9 ✓/E14 ✓ assumiram); uma coluna `mandatory` por superfície, refiltrada no servidor; o bug real pego na revisão foi o payload de um toggle chegando dentro da janela de debounce do seguinte e commitando o conjunto sem ele; a **segunda rodada** de revisão mudou a cadência por medição (**fechar o menu é o commit**, timer de 3 s como rede que grava **só o cookie, sem refresh** — os 400 ms agrupavam rajada, não ritmo de leitura, e qualquer timer que repintasse recriaria o mesmo custo mais devagar, re-pagando os ~229 ms / 371 statements de `/campanha/liderancas`, P1 no `TECH-DEBT`; sessão que termina onde começou não repinta, e desmontar com o menu aberto grava pelo cleanup) e apagou duplicação com dono: `contentKey` virou o 4º chamador de `sameIdSet` (generalizado p/ `string | number`), e os `label` de municípios/dobradinhas passaram a citar os módulos de rótulo que o header já usa, o que fez o `id` das colunas de municípios ser **tipado** contra `MunicipalityListColumnId` (id, descrição do B22 ✓ e rótulo do picker viraram a mesma chave; membro `nivel` → `level`, alinhado ao id e ao `?level=`) · [plano](plans/seletor-colunas-lista-municipios.md)); **B32+ ✓** escala e DRY pós-B32 — a máquina de auto-save dos controles de célula virou `useCampaignCellAutosave` (um valor só: a auditoria mostrou que `display`/`draft` eram o mesmo valor com dois nomes) e as cinco rotas JSON de `/campanha` passaram a nascer de `campaignJsonMutationRoute`, com guard de convenção — a verificação de mesma origem deixou de depender de disciplina de cópia; a região `aria-live` saiu de dentro do overlay (fechar é o que dispara a gravação, então ela desmontava junto) e o `variant="sheet"` da casca ganhou slot `footer`, o que fez o controle de Sinal (B26 ✓) largar o Popover/Drawer próprios · [plano](plans/escala-dry-pos-b32.md); 2026-07-27 (**E14 ✓** níveis de envolvimento N0–N4 — `municipality.engagementLevel` staff-only (sem default: nulo é "sem nível", e filtrar por ele é a fila de triagem), motivo e sinais de reversão obrigatórios, e o **primeiro escritor de produção do `allocationDecision`** (C12 ✓), gravado na mesma transação sob lock porque o "nível anterior" do snapshot precisa ser verdade; enum de `outcome` estendido com `movimento`; histerese do §6.8 (salto duplo só com choque triangulado, 3 semanas de proteção, um movimento por mês) num módulo puro que roda no cliente **e** no servidor, com a rota respondendo `blocked` (409) com as violações tipadas e o override registrado com as regras que contrariou; submit explícito em vez de auto-save (exceção prevista pela regra, e o controle não vira o 4º call site do B32+); coluna + filtro nativo (`?level=`) e sort em memória na lista (o Postgres põe NULL primeiro no DESC, e sem backfill isso entregaria "N4 primeiro" cheio de "Sem nível") + verbete no E18 · [plano](plans/niveis-de-envolvimento.md); a triage do fechamento **não abriu ID novo** — os três débitos caíram no **B32+**: o 5º controle/rota JSON tirou **F2/F3** da condicionalidade (a política de mesma origem que `isSameOriginRequest` diz possuir está copiada em cinco rotas, e a que esquecer a linha falha **aberta**) e o slot `footer` que o **B42 ✓** havia adiado virou **F5**, porque na folha "Fechar" está pinado e "Registrar movimento" fica abaixo da dobra; ~~**fill-in** dimir o resultado no compositor de giro~~ ✓ (entregue 2026-07-28) · [plano](plans/feedback-pendente-compositor-giro.md); **E13 ✓** planejador de presença/giros — elegibilidade de visita em **5 condições ✓/—** (volume, headroom, rede, janela, encaixe) no detalhe do município, fase do calendário (`construcao|consolidacao|ativacao`, âncoras ilustrativas em `src/lib/visitPlannerAnchors.ts`) e compositor em `/campanha/atividades/giros` gerando `activity` em rascunho com `deputyPresent` numa transação; **duas premissas do plano caíram na auditoria** — `kind: 'visita'` nunca entrou na migration do C12 (e não entra: `deputyPresent` já É o marcador de presença) e `allocationDecision` não tem write path em `src/` (o "não vá" é aviso textual; gravar o override vira gatilho do **E11**); "encaixe em giro" = **mesmo TI**, porque não existe grafo de contiguidade em runtime; o teto-do-campo-não-capturado e as medianas do catálogo saíram do classificador do E10 para `municipalityPotential.ts` para que card e coluna "Classe" não pudessem divergir; a action relê os ids das paradas com `overrideAccess: false` **dentro** da transação (o `create` de `activity` é booleano de staff — sem isso um assessor semeava o estado inteiro), buraco achado por int test; sem migration, sem collection, sem `Consent`, sem entidade `tour` · [plano](plans/planejador-de-giros.md); **B42 ✓** polimento mobile da lista de municípios — Drawer em Tendência/Assessores/Votos estimados pela casca `CampaignCellEditOverlay`, card compacto e clicável por link esticado; a triage do fechamento não abriu ID novo: **F4** entrou no **B32+** (região `aria-live` que desmonta com o overlay) e o débito de bundle do catálogo no **B33+** deixou de ser condicional (gatilho disparado por B41 ✓ e B42 ✓) · [plano](plans/polimento-mobile-lista-municipios.md); **B34+** fill-in de engenharia pós-B34 registrado — a entrega custou **+58 kB** de First Load JS em `/campanha/liderancas` e **+42 kB** em `/campanha/assessores` (medido contra `main` limpo; a maior parte é a célula, não o catálogo), o gatilho de extração do `useChipRowOverflow` **disparou** (~105 linhas idênticas entre as duas células de chips da mesma tabela) e os três módulos de delta de membership colapsam num só; o passe localizado que fechou o B34 já derrubou o custo de CPU medido (chips 4,2 → 0,043 ms quente; busca 4,59 → 0,077 ms por tecla), e a **segunda rodada** somou três fases que mudaram o maior lever — `revalidatePath` por toggle (374 statements / 229 ms por clique), índice de municípios sem cache no degrau 2 da escada, e o `filter` por linha que derrota o próprio WeakMap — mais o N+1 de access pré-existente promovido a **P1** no `TECH-DEBT` (367 dos 371 statements da rota), que é quem precisa cair primeiro · [plano](plans/escala-dry-pos-b34.md); **B34 ✓** chips editáveis de municípios na lista de lideranças — célula sempre editável em `pointer-fine` + Drawer em `pointer-coarse`, `AdvisorMunicipalityCell` promovido a `shared/MunicipalityPortfolioCell` com Assessores migrado no mesmo PR, action única de lote (a de um id só, inclusive a de assessor, foi deletada), anti-goal de input sempre montado **emendado** para relações por chips; corrigiu de passagem o seam `description` de `CampaignTableHead` (B22 ✓), que quebrava qualquer chamador RSC · [plano](plans/chips-municipios-lista-liderancas.md); **B41 ✓** scroll horizontal + sticky Município · [plano](plans/scroll-horizontal-lista-municipios.md); **B32+** novo fill-in de engenharia registrado no fechamento do B32 — `MunicipalityListExpectedVotesControl`/`MunicipalityListTrendControl`/`LeadershipListSupportStatusControl` são o 3º call site da mesma máquina de auto-save por debounce (estado/refs/save/schedule/flush quase idênticos), gatilho que os três planos B24/B27/B32 já haviam registrado; auditoria primeiro, extração condicional (precedente B33+) — [plano](plans/escala-dry-pos-b32.md); **B32 ✓** editar Status de apoio na célula da lista de lideranças — `LeadershipListSupportStatusControl` (Popover + `NativeSelect`, sempre montado, sem toggle "Editar" de tabela inteira) grava por `POST /campanha/liderancas/support-status` (auto-save ~150 ms, sem `revalidatePath`), reusando `isSameOriginRequest`/`campaignJsonMutationRoute.ts` já extraídos pelo B24 ✓/B27 ✓; fecha o "Adiado com gatilho" do B29 — [plano](plans/autosave-status-lista-liderancas.md); **B33+ ✓** `useCampaignListFilterNavigation` — o scaffold de navegação/debounce sai dos três filtros de lista para um hook em `shared/` (281 linhas removidas contra 75 em `src`); a auditoria derrubou a premissa do plano de que os três domínios teriam assinaturas de canonicalização diferentes — `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente**, então o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante e um único callback `toHref` cobre os três; a entrega achou **dois** bugs de comportamento, não de estrutura: tocar um filtro no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (unificado na semântica de munícipios por `navigateWithSearch`), e o `setTimeout` do debounce capturava o `state` do render que o agendou, então um sort clicado dentro da janela de 1s era **revertido** um segundo depois — munícipios escapava por acidente, pelo `key=` da página que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix); o `/simplify` ainda achou o fallback `useCampaignListPending() ?? useTransition()` copiado em 5 lugares, com o hook novo como 6º → `useCampaignListTransition`, e um `aria-live` que fazia o resumo de filtros de munícipios ser a **terceira** região viva da mesma página; `shouldUpdateMunicipalitySearchUrl` orfanou e foi deletado; `tests/unit/campaignListFilterNavigation.unit.spec.ts` (jsdom + router mockado + fake timers, 8 casos × 3 listas) é a primeira cobertura desses componentes — [plano](plans/escala-dry-pos-b33.md); **C8 F4b ✓** guard de `formActions` ampliado para irmãos `*FormActions.ts` + `taskActions.ts`; 6 ladders migradas; `taskActions.ts` allowlisted · [plano](plans/escala-dry-pos-c6.md); **Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** ~~remover lista "Municípios por zona eleitoral" do Início~~ ✓ entregue 2026-07-28 — pós-B8 F2 ✓ os polígonos tornaram a lista textual redundante; UI + `zoneBreakdown` removidos; ressalva via `hasZoneMunicipalities` · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/