diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index a5ade5ae..d96fb0e5 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -7,6 +7,7 @@ alwaysApply: false
## Status
+- **B26 entregue em código (2026-07-25):** registrar sinal a partir da célula "Último sinal" de `/campanha/municipios` — Popover (desktop) / Drawer bottom sheet (mobile) cria `municipalityUpdate` `kind: 'sinal'` (C12) sem sair da fila; submit explícito "Registrar sinal" (exceção do princípio 4); `revalidateMunicipalityListPaths`; sem migration. Plano: [`registrar-sinal-lista-municipios.md`](../../../docs/plans/registrar-sinal-lista-municipios.md).
- **B20 entregue (2026-07-25):** Municípios prioritários no Início — `pickDashboardPriorityMunicipalities` (`src/utilities/dashboardPriorityMunicipalities.ts`) ordena `priority=alta` por déficit E9/`central`, amostra 8; `CampaignDashboard` exibe Badge com `highPriorityCount`, CTA “Ver todas” → `?priority=alta`, chips com `list-none`. Unit `dashboardPriorityMunicipalities.unit.spec.ts`. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.md).
- **B28 registrado (2026-07-25):** e-mail + celular na lista `/campanha/liderancas` — colunas com copy-on-click + WhatsApp (`wa.me`) no padrão de `/campanha/assessores` (B19 ✓); `email` no view model da lista; extrair primitivo compartilhado (2º call site); **sem** toggle Editar in-list (Contact update adiado). Appetite ~0,5–0,75d; Janela 1; cortável; Impeccable B. Plano: [`email-celular-lista-liderancas.md`](../../../docs/plans/email-celular-lista-liderancas.md).
- **B27 registrado (2026-07-25):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (pedido de produto). Hoje: lista de checkboxes de todas as contas elegíveis + botão "Salvar assessores" (`MunicipalityListAdvisorsControl`, B9) → replace do array + `revalidateMunicipalityListPaths`. Plano: chips removíveis acima de um `Command` com busca acento-insensível; gravação **por delta** em endpoint JSON espelho do `expected-votes/` (sem revalidar 435 linhas), reusando o lock `municipality-advisors:{id}` e o cálculo puro do B19 (`nextAdvisorIdsAfterMembership` sobe para `src/lib/`) — mas **não** `setAdvisorMunicipalityMembership`, cujo `assertTargetAdvisor` recusa coordenador/candidato e o próprio ator (legais nesta tela). Gatilho da célula intacto (contrato de tooltip do B23); UI segue coordenador-only (candidato = adiado com gatilho, servidor já permite desde B19 ✓). Appetite ~0,75d; Janela 1–2; cortável; Impeccable B. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
diff --git a/docs/plans/registrar-sinal-lista-municipios.md b/docs/plans/registrar-sinal-lista-municipios.md
index a667c45b..a9bb0aa4 100644
--- a/docs/plans/registrar-sinal-lista-municipios.md
+++ b/docs/plans/registrar-sinal-lista-municipios.md
@@ -1,12 +1,16 @@
# Registrar sinal a partir da célula "Último sinal" da lista de municípios
-Status: rascunho
+Status: entregue (2026-07-25)
Atualizado em: 2026-07-25
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B26)
Impeccable: B — encaixe na célula `lastSignal` de `/campanha/municipios` (tabela desktop + card mobile); sem rota nova de UI
Appetite: ~0,75 dia eng; 1 controle novo + 1 form action + fields compartilhados; sem migration, sem collection, sem endpoint JSON
Responsável: —
+## Revisão de entrega (2026-07-25)
+
+Entregue: `MunicipalitySignalFields` + `MunicipalityListSignalControl` (Popover desktop / Drawer mobile) na coluna "Último sinal"; `createMunicipalityListSignalFormAction` em `municipalityStaffFormActions.ts`; wiring em `MunicipalityList` + `municipios/page.tsx`; e2e dedicado. Correções da auditoria aplicadas: (1) form action no padrão try/catch das irmãs do arquivo — **não** `runCampaignFormAction` (esse arquivo ainda é hand-rolled); (2) sem novo teste int (access já coberto em `campaignMunicipalityUpdate.int.spec.ts`); (3) Drawer controlado sem `DrawerTrigger`. Gate: tsc/lint/format/knip/cycles/unit+int verdes; e2e do fluxo B26 verde; build verde. Aikido: 0 findings nos arquivos novos/editados.
+
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípio 2 **Clarity under pressure**, princípio 3 **Edit where you see**, princípio 4 **Auto-save, no Save button** — e a **exceção** nomeada nele para escrita atômica multi-campo, princípio 8 **Feel the action**) / `DESIGN.md` (register `product` — Field Desk) · tema `data-theme='campaign'` · regras `.cursor/rules/campanha-edit-where-you-see.mdc` (item 4: submit explícito continua válido para fluxo multi-campo) e `.cursor/rules/campanha-action-feedback.mdc` · precedentes vivos: [`MunicipalityListTrendControl.tsx`](../../src/components/campaign/municipality/MunicipalityListTrendControl.tsx) (Popover na célula) e [`MunicipalityUpdateForm.tsx`](../../src/components/campaign/municipality/MunicipalityUpdateForm.tsx) (campos do sinal tipado do C12).
@@ -82,7 +86,7 @@ Componentes:
- **`MunicipalitySignalFields`** (novo, `src/components/campaign/municipality/MunicipalitySignalFields.tsx`): só os campos do sinal (`body` textarea 2 linhas, `signalType` `NativeSelect` + descrição do tipo escolhido, `signalSource` `Input`, `triangulated` checkbox) mais os `fieldErrors` do estado da action. Reusa `municipalitySignalTypes` / `municipalitySignalTypeLabels` / `municipalitySignalTypeDescriptions` de `src/lib/schemas/municipalityUpdate.ts` e os shells `Field`/`FieldLabel`/`FieldError`. Consumido pelas duas variantes (Popover e Drawer) — e é a peça que o `MunicipalityUpdateForm` do detalhe pode passar a reusar depois (ver Adiado com gatilho).
- **`MunicipalityListSignalControl`** (novo, `src/components/campaign/municipality/MunicipalityListSignalControl.tsx`): client component com prop `variant: 'popover' | 'sheet'`, `useActionState` sobre a form action, `Spinner` + `disabled` no submit, `Alert` de erro, live region `sr-only`, `setOpen(false)` + toast só no sucesso. O gatilho recebe o `SignalAgeReadout` como children — a leitura E9 não é reimplementada.
- **`MunicipalityList`** (`src/components/campaign/municipality/MunicipalityList.tsx`): a célula `lastSignal` (coluna staff) passa a envolver `SignalAgeReadout` no controle com `variant="popover"`; o card mobile usa `variant="sheet"`. Nova prop `signalFormAction` no mesmo formato das existentes (`MunicipalityStaffFormAction`).
-- **`createMunicipalityListSignalFormAction`** (`src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts`): casca sobre `runCampaignFormAction` — lê `municipalityId`, `body`, `signalType`, `signalSource`, `triangulated` e o `municipalitySlug` do `FormData` (helpers `requiredRelationshipFormValue` / `nullableFormText` / `optionalMunicipalitySlugFromForm` já usados no arquivo), chama `createMunicipalityUpdate({ kind: 'sinal', … })` e `revalidateMunicipalityListPaths({ slug })`. **Depth check:** ao contrário do `createMunicipalityUpdateFormAction` do detalhe (try/catch à mão), esta nasce dentro de `runCampaignFormAction` — o padrão fechado no Pass 2 W4d; a do detalhe fica como está (troca é fill-in de higiene, não deste item).
+- **`createMunicipalityListSignalFormAction`** (`src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts`): casca no padrão try/catch + `mapCampaignFormActionError` das irmãs do arquivo — **não** `runCampaignFormAction` (esse arquivo ainda é hand-rolled; a troca do detalhe/`runCampaignFormAction` continua fill-in de higiene). Lê `municipalityId`, `body`, `signalType`, `signalSource`, `triangulated` e o `municipalitySlug` do `FormData` (helpers `requiredRelationshipFormValue` / `optionalFormText` / `checkboxFormValue` / `optionalMunicipalitySlugFromForm` + `parseMunicipalitySignalType`), chama `createMunicipalityUpdate({ kind: 'sinal', … })` e `revalidateMunicipalityListPaths({ slug })`.
- **`municipios/page.tsx`**: passa a nova form action para `MunicipalityList`, ao lado de `trendFormAction`/`advisorsFormAction`.
- **Migration:** nenhuma. `municipalityUpdate` e todos os campos do sinal existem desde o C12; `lastUpdateAt` já é derivado por hook.
- **Testes:** int cobrindo o access da nova form action (assessor fora da carteira recebe erro e nada é criado; assessor da carteira cria e `lastUpdateAt` muda) em `tests/int/campaignMunicipalityUpdate.int.spec.ts`; e2e em `tests/e2e/campaignMunicipalities.e2e.spec.ts` — abrir a célula na tabela, preencher, submeter, ver a célula virar "hoje".
@@ -113,7 +117,7 @@ Componentes:
- **Shell responsivo compartilhado (Popover ↔ Drawer) para controles de célula.** Revisitar quando: existir o **3º** controle de lista que precise das duas variantes.
- **Reuso de `MunicipalitySignalFields` dentro do `MunicipalityUpdateForm` do detalhe.** Revisitar quando: este item estiver em produção e os dois blocos de campos tiverem divergido em algum detalhe visível (hoje seriam dois consumidores no mesmo dia — extrair é barato, unificar copy/erros no mesmo passe é que estoura o appetite).
-- **Contexto do último sinal dentro do popover.** Revisitar quando: alguém pedir em uso real "registrei duplicado porque não vi o que já tinha".
+- **Lazy-mount de `Popover`/`Drawer` até o primeiro open** (custo idle do ramo CSS-oculto). Revisitar quando: perfilar a lista com vários controles dual-branch montados e o idle cost aparecer no trace — aplicar em lote nos controles da linha, não só neste.
## Referências
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 576b0220..f9d0c155 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-25 (**C13** renomear "Plano de Ação" → **"Atividade"** em toda a vertical — pedido de produto (2026-07-25): a palavra que a mesa usa é "atividade"/"agenda" e "plano de ação" sempre precisa ser explicado (o próprio `PRODUCT.md` já chama a superfície de "agenda"); rename **completo** — entidade e labels, `slug: 'activity'`, tabelas/enums/colunas/índices, 61 arquivos (13 componentes, 7 utilities, schemas, actions, access), campo `campaignDemand.actionPlan` → `activity`, rota `/campanha/planos` → `/campanha/atividades` e copy fora da vertical (organizações, demandas, sidebar) — com **migration data-preserving escrita à mão** (`ALTER … RENAME` de tabela/tipo/índice/constraint, idempotente e logada; o diff do Payload geraria DROP+CREATE e apagaria a agenda já cadastrada — a janela de "sem dados reais" que justificou o drop na remodelagem fechou: atividade não tem trava jurídica e a mesa registra assim que o smoke passar); guard estende o describe "banned campaign terminology" de `tests/unit/codebaseConventions.unit.spec.ts` para tabela de termos varrendo `src`+`tests`+`scripts` (compostos + copy + rota; `plan`/`plano` isolado **não** entra — colidiria com os "planos" da Fase 2 white-label); fazer **antes** do onboarding e de **E13**, e obrigatoriamente antes do congelamento (~20/09) por carregar migration; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); **B27** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — a lista de checkboxes com botão "Salvar assessores" vira chips removíveis sobre um combobox com busca, gravando **por delta** (endpoint JSON espelho do `expected-votes/`, lock do B19 ✓), [plano](plans/combobox-assessores-lista-municipios.md); **B26** registrar sinal a partir da célula "Último sinal" de `/campanha/municipios` — Popover no desktop e bottom sheet (`Drawer`) no mobile criando `municipalityUpdate` de `kind: 'sinal'` (C12 ✓) sem sair da fila; submit explícito por ser criação atômica multi-campo (exceção nomeada no princípio 4), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); **B23** tooltip de conteúdo nas células das listas — `cellTooltip` em `CampaignTableColumn`, consumidores as colunas "Assessores" e "Tendência" (justificativa do E4R no hover; sem nota, sem tooltip) de `/campanha/municipios`, [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); **B22** explicação por coluna no header das listas — tooltip de hover/foco/tap promovido para o sistema de listas compartilhado, [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
+Atualizado em: 2026-07-25 (**B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13** renomear "Plano de Ação" → **"Atividade"** em toda a vertical — pedido de produto (2026-07-25): a palavra que a mesa usa é "atividade"/"agenda" e "plano de ação" sempre precisa ser explicado (o próprio `PRODUCT.md` já chama a superfície de "agenda"); rename **completo** — entidade e labels, `slug: 'activity'`, tabelas/enums/colunas/índices, 61 arquivos (13 componentes, 7 utilities, schemas, actions, access), campo `campaignDemand.actionPlan` → `activity`, rota `/campanha/planos` → `/campanha/atividades` e copy fora da vertical (organizações, demandas, sidebar) — com **migration data-preserving escrita à mão** (`ALTER … RENAME` de tabela/tipo/índice/constraint, idempotente e logada; o diff do Payload geraria DROP+CREATE e apagaria a agenda já cadastrada — a janela de "sem dados reais" que justificou o drop na remodelagem fechou: atividade não tem trava jurídica e a mesa registra assim que o smoke passar); guard estende o describe "banned campaign terminology" de `tests/unit/codebaseConventions.unit.spec.ts` para tabela de termos varrendo `src`+`tests`+`scripts` (compostos + copy + rota; `plan`/`plano` isolado **não** entra — colidiria com os "planos" da Fase 2 white-label); fazer **antes** do onboarding e de **E13**, e obrigatoriamente antes do congelamento (~20/09) por carregar migration; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); **B27** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — a lista de checkboxes com botão "Salvar assessores" vira chips removíveis sobre um combobox com busca, gravando **por delta** (endpoint JSON espelho do `expected-votes/`, lock do B19 ✓), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); **B23** tooltip de conteúdo nas células das listas — `cellTooltip` em `CampaignTableColumn`, consumidores as colunas "Assessores" e "Tendência" (justificativa do E4R no hover; sem nota, sem tooltip) de `/campanha/municipios`, [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); **B22** explicação por coluna no header das listas — tooltip de hover/foco/tap promovido para o sistema de listas compartilhado, [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
@@ -52,6 +52,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
- **B19 (2026-07-24)** — gerenciar assessores em `/campanha/assessores` (lista + novo + detalhe): criar/editar contas `advisor`, carteira de municípios (auto-save por delta), reenviar link de senha; só CG/candidato (`isCampaignUnrestricted`); access de update/phone e atribuição `municipality.advisors` alinhados ao `candidate`; leitura privilegiada de e-mail no loader (`reloadUnrestrictedActor`); sem migration. Plano: [gerenciar-assessores.md](plans/gerenciar-assessores.md).
- **E9 (2026-07-24)** — fila de alocação na própria lista de municípios (sem rota nova, sem coluna nova, sem migration). Corrigiu antes a meta sintética do E8, que repartia os 150 mil proporcionalmente **só ao teto do campo** e por isso dava meta 2.911 a Vitória da Conquista (5.005 votos em 2022) e 813 a Campo Formoso (47) — ordenar por déficit sobre ela colocaria desertos no topo. `deriveSuggestedGoalsByScenario` (substitui `decomposeStateGoal`) ancora a meta na votação própria de 2022 por cenário: pessimista = base×(1−`margin`), central = base, otimista = base×(`stateGoal` ÷ Σ base), com clamp; `goalCoverage` passou a receber `SuggestedGoalByScenario`. Na fila: `lastPledgeAt` no agregado de pledges, `lastSignalAt` = máx(`lastUpdateAt`, pledge) no view model, sort keys `deficit` (**novo default do staff**) e `frescor` (cenário `central` fixo), frescor dobrado na coluna "Última atualização" (frio ≥ 21 dias), badge "sem responsável" em prioridade alta sem assessor, "coluna da vergonha" no detalhe da métrica de assessoria (`?priority=alta&coverage=sem_assessor`) e copy Praças→Municípios. Cortes: votos em jogo → **B13**, LQ/captura → **E10**, coluna dedicada de déficit → desnecessária. Plano: [fila-de-alocacao.md](plans/fila-de-alocacao.md). Desbloqueia **E11**.
- **E16 (2026-07-25)** — dossiê do município (pré-agenda, pedido O6): aba `?tab=dossie` staff-only no detalhe compondo os loaders existentes (`municipalityDossierData.ts` + `MunicipalityDossier.tsx`) — capa com data de geração, série TSE/rank (A11), conta da cadeira (E8), rede de lideranças com frescor, conjuntura/dobradinhas/encaminhamentos, agenda (`actionPlan`), sinais recentes (C12), perfil IBGE (A8 — reentrada do artefato, caveat para zonas); caps 8/5/3+2 com "ver tudo"; campo staff-only `municipality.budgetNotes` ("Emendas aportadas", G11 manual-first, migration `20260725_022155`); **visão print** via CSS (chrome oculto, shells destravados, tipografia A4 — 1–2 páginas). Plano: [dossie-municipio.md](plans/dossie-municipio.md).
+- **B26 (2026-07-25)** — registrar sinal na célula "Último sinal" de `/campanha/municipios`: Popover (desktop) / Drawer (mobile) cria `municipalityUpdate` `kind: 'sinal'` sem sair da fila; submit explícito; `revalidateMunicipalityListPaths`; sem migration. Plano: [registrar-sinal-lista-municipios.md](plans/registrar-sinal-lista-municipios.md).
- **B16 (2026-07-25)** — filtros no header da lista de municípios: `MunicipalityHeaderFilter` (funil + Popover ao lado do sort do B15) com multi-seleção OR em município, território, assessores e tendência, busca acento-insensível e "Limpar"; mobile mantém os selects empilhados; header sticky com a página como scroller e empty state que troca só as linhas, preservando visão geral e filtros; coluna "Assessoria" removida (ordenação foi para "Assessores", com/sem assessor virou opção do popover e o alerta "sem responsável" do E9 passou a morar na célula de assessores). Plano: [filtros-no-header-lista-municipios.md](plans/filtros-no-header-lista-municipios.md).
- **B20 (2026-07-25)** — Municípios prioritários no Início staff: seção deixa de truncar 6 prioritárias sem critério; amostra de 8 ordenada por déficit de cobertura (E9/`central`) via `pickDashboardPriorityMunicipalities`; `highPriorityCount` no Badge; CTA “Ver todas” → `/campanha/municipios?priority=alta`; markup `list-none`. Plano: [municipios-prioritarios-inicio.md](plans/municipios-prioritarios-inicio.md).
@@ -97,7 +98,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **B23** tooltip de conteúdo nas células das listas — dois pedidos de produto (2026-07-25): (a) hover na célula de "Assessores" em `/campanha/municipios` mostra os nomes dos assessores do município (hoje só há até 3 avatares de **iniciais** + `sr-only`; do 4º em diante nem avatar); (b) hover na célula de "Tendência" mostra a **justificativa** registrada (`politicalTrend.note`, semeada em massa pelo **E4R ✓** — hoje já chega na célula, mas só como ``, e só é legível abrindo o detalhe), **sem tooltip quando não há nota**. Ambos **implementados como capacidade genérica da célula** para reuso em outros campos/páginas: `cellTooltip?: (row) => ReactNode` em `CampaignTableColumn`, envolvido pela `CampaignTable` no primitivo compartilhado (`CampaignHoverTooltip` — promovido no **B22** ou aqui, o que vier primeiro); sem novos tab stops (tooltip é redundante; texto acessível segue na célula) e, nas células que já são gatilho de Popover de edição rápida (assessores do coordenador, tendência), `openOnTouch={false}` + `disabled={open}` para não roubar o tap nem sobreviver atrás do Popover (B9/B19 ✓) — em touch a informação aparece dentro do próprio Popover (a nota da tendência entra ali como leitura, acima do select); sem migration/action/query nova · ~0,75d · Janela 1–2 · Impeccable B · soft: B22 (primitivo compartilhado), E4R ✓ (as notas) · cortável · [plano](plans/tooltip-celulas-listas.md)
- **B24** auto-save + justificativa no popover de Tendência da lista de municípios — pedido de produto (2026-07-25): o popover da coluna "Tendência" perde o botão **"Salvar"** (grava sozinho: status na hora, justificativa com debounce que **cancela o timer anterior**, flush no blur/fechamento) e ganha o campo para **editar a justificativa** (`politicalTrend.note`, hoje hidden input na lista e só editável em `/[slug]/editar`). Fecha duas dívidas datadas: o anti-goal literal do princípio 4 do `PRODUCT.md` ("Save buttons on Popovers and quick-edit cells") — a célula vizinha da mesma linha (votos estimados) já grava sozinha — e o "nota de tendência no popover, gatilho: feedback de uso" adiado no **B9 ✓**. Persistência por endpoint JSON próprio (`POST /campanha/municipios/political-trend`, espelho do `expected-votes/`), **sem revalidar** a lista de 435 linhas por gravação; `setMunicipalityPoliticalTrend` e o access ficam intactos; sem migration/collection/Consent · ~0,5d · Janela 1–2 · Impeccable B · soft: B23 (se vier antes, a nota read-only do Popover vira o textarea) · cortável · [plano](plans/autosave-tendencia-lista-municipios.md)
- **B25** link do território na lista de municípios — pedido de produto (2026-07-25): na coluna "Território" de `/campanha/municipios` o nome do TI vira link para **a página daquele território**. Hoje a célula é texto morto (`cell: (m) => m.region`) e a navegação é assimétrica: o painel do Início (**E17 ✓**) já linka TI → `/campanha/municipios?region=`, mas do município não se chega ao território. Destino = a página do **B21** ancorada na linha do TI (`/campanha/territorios#ti-` + `scroll-mt` e `target:` no precedente de `/campanha/conceitos`), **não** rota de detalhe `/campanha/territorios/` (Não escopo do B21, sem evidência nova: seria a linha do rollup + a lista filtrada, que já existem) nem `?region=` (tabela de uma linha destrói a comparação que é o job da tela). Âncora derivada por helper puro (`territoryAnchorId` sobre `slugify`), consumida pelas duas pontas e pinada por teste — TI não ganha slug persistido; sem migration/collection/action/query nova · ~0,25d · Janela 1–2 · Impeccable B · **dep dura: B21** · soft: B17 (coluna ocultável) · E12 é gatilho de revisitação da decisão "âncora vs rota de detalhe", não dependência · cortável · [plano](plans/link-territorio-lista-municipios.md)
-- **B26** registrar sinal a partir da célula "Último sinal" da lista de municípios — pedido de produto (2026-07-25): clicar na célula de frescor abre um **Popover** (desktop) / **bottom sheet `Drawer`** (mobile) que cria um `municipalityUpdate` de `kind: 'sinal'` (**C12 ✓**: texto + tipo + fonte + `triangulated`) sem sair da lista. Fecha o laço da fila: o **E9 ✓** diz onde está frio ("há 34 dias", âmbar a partir de 21) e hoje a única saída é abrir `/[slug]?tab=updates` e perder o lugar na varredura — é o último readout morto da faixa staff da linha (assessores/tendência/votos já editam in loco desde B9/B19 ✓). **O submit continua explícito** ("Registrar sinal"): é criação **atômica multi-campo** de registro **imutável** para o staff (`canMutateMunicipalityUpdate` = só admin), exatamente a exceção nomeada no princípio 4 do `PRODUCT.md` — auto-save aqui criaria registros inválidos ou lixo permanente no feed, ao contrário do **B24** (campo único na mesma linha). Grava por server action + `revalidateMunicipalityListPaths` (o rótulo é **derivado**, `max(lastUpdateAt, lastPledgeAt)` — otimismo local seria o anti-goal do princípio 3, e o detalhe/dossiê **E16 ✓** precisam ver a linha nova); variante mobile escolhida pela divisão de breakpoint que a lista já tem (cards `md:hidden` × tabela `hidden md:block`), **não** por `useIsMobile`; sem migration/collection/Consent/endpoint JSON · ~0,75d · Janela 1–2 · Impeccable B · soft: B24 (contrato vizinho deliberadamente diferente), B23 (se o `cellTooltip` chegar nesta coluna, mesmo `openOnTouch={false}`/`disabled={open}`) · cortável · [plano](plans/registrar-sinal-lista-municipios.md)
+- ~~**B26** registrar sinal a partir da célula "Último sinal"~~ — **entregue 2026-07-25** (Popover desktop / Drawer mobile; `municipalityUpdate` `kind: 'sinal'`; submit explícito; `revalidateMunicipalityListPaths`; sem migration) · [plano](plans/registrar-sinal-lista-municipios.md)
- **B27** combobox + chips + auto-save no popover de Assessores da lista de municípios — pedido de produto (2026-07-25): na coluna "Assessores" de `/campanha/municipios`, o popover de edição rápida (**B9 ✓**) troca a **lista de checkboxes + botão "Salvar assessores"** por (a) **chips removíveis** dos assessores já atribuídos acima de (b) um **combobox** com busca acento-insensível e teclado, (c) gravando **na hora** a cada atribuição/remoção, com UI otimista. Três dívidas datadas num item só: o botão é o anti-goal literal do princípio 4 do `PRODUCT.md` (a célula vizinha da mesma linha já grava sozinha); a lista de checkboxes não escala com as contas semeadas pelo **E4R ✓**; e a seleção atual só se lê como iniciais no gatilho — o mesmo par entidade↔carteira já é chips + busca em `/campanha/assessores` (**B19 ✓**). Gravação **por delta** (`municipalityId`+`advisorId`+`assigned`) via endpoint JSON próprio espelhando `expected-votes/` — **sem revalidar** 435 linhas por clique e sem o replace do array (que sob auto-save apaga a edição concorrente); reusa o lock `municipality-advisors:{id}` e o cálculo puro do B19 (`nextAdvisorIdsAfterMembership` sobe para `src/lib/`), mas **não** o record daquela tela (`assertTargetAdvisor` recusa coordenador/candidato e o próprio ator — legais aqui). Combobox = `Command` já em uso, não primitivo novo; gatilho da célula intacto (contrato de tooltip do B23); UI segue coordenador-only (candidato = adiado com gatilho); sem migration/collection/Consent/query nova · ~0,75d · Janela 1–2 · Impeccable B · soft: B24 (mesmo padrão de endpoint; quem chegar primeiro extrai `isSameOriginRequest`), B23 (tooltip no mesmo gatilho — os chips melhoram o fallback de toque que aquele plano presumia), B26 (contrato deliberadamente diferente na mesma linha), B19 ✓/B16 ✓/B9 ✓/E4R ✓/E9 ✓ · cortável · [plano](plans/combobox-assessores-lista-municipios.md)
- **B28** e-mail + celular na lista de lideranças — pedido de produto (2026-07-25): em `/campanha/liderancas` adicionar colunas **E-mail** e **Celular** com o **mesmo funcionamento de leitura** da tabela de assessores (**B19 ✓**): clique copia (toast), “—” se vazio, celular com máscara BR, ícone WhatsApp (`wa.me`) por linha quando o número normaliza. Hoje o loader já traz `phone` no view model e o e-mail só no detalhe — a lista mostra Nome · Status · Municípios · Organizações · Acesso e obriga abrir a ficha para contactar. Extrair o primitivo de copy (+ helper WhatsApp de linha) para `shared/` no mesmo PR (2º call site do B19); `CampaignTable` permanece (não clonar `AdvisorsTable`). **Fora:** toggle “Editar” / debounce in-list (Contact update + locks = Adiado no plano); sem migration/collection/Consent/action de escrita · ~0,5–0,75d · Janela 1 · Impeccable B · soft: B19 ✓, B30 (mesma linha — ícone distinto) · cortável · [plano](plans/email-celular-lista-liderancas.md)
- **B30** convidar para completar cadastro por WhatsApp na lista de lideranças — pedido de produto (2026-07-25): ação ao fim de cada linha de `/campanha/liderancas` disparando o **mesmo** fluxo que já existe no detalhe (`/campanha/liderancas/[id]` → `LeadershipInviteButtons`, `kind: 'autopreenchimento'`): gera `campaignInvite` (token único, 7 dias) via `createCampaignInvite` e mostra a âncora `wa.me` com a mensagem pronta, num `Popover` compacto (two-step reveal — sem `window.open` automático, que quebra com popup blocker após o `await`). Botão **disabled** quando `contact.phone` não passa em `normalizeBrazilianPhone` (mesmo critério de `AdvisorsTable`/B19 ✓), com `aria-label` explicando o motivo. Distinto do WhatsApp de contato do **B28** (que é atalho de chat sem token) — ícone diferente para não ambiguar as duas ações na mesma linha; nenhum dos dois bloqueia o outro. Reusa 100% da action/collection/access existentes; sem migration, sem endpoint, sem mudança de access · ~0,25–0,5d · Janela 1 · Impeccable B · soft: B28 (coordenar ícone na mesma linha) · cortável · [plano](plans/convite-whatsapp-lista-liderancas.md)
@@ -160,7 +161,7 @@ flowchart TD
B23n["B23 Tooltip nas células das listas"]
B24n["B24 Auto-save + justificativa no popover de Tendência"]
B25n["B25 Link do território na lista de municípios"]
- B26n["B26 Registrar sinal na célula Último sinal"]
+ B26n["B26 ✓ Registrar sinal na célula Último sinal"]
B27n["B27 Combobox + chips no popover de Assessores"]
B28n["B28 E-mail + celular na lista de lideranças"]
B30n["B30 Convite WhatsApp na lista de lideranças"]
diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts
index e2179cc2..4abadc9d 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts
+++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts
@@ -11,9 +11,8 @@ import {
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
- municipalitySignalTypes,
municipalityUpdateKinds,
- type MunicipalitySignalType,
+ parseMunicipalitySignalType,
type MunicipalityUpdateKind,
} from '@/lib/schemas/municipalityUpdate'
import {
@@ -32,11 +31,6 @@ export const createMunicipalityUpdateFormAction = async (
? (rawKind as MunicipalityUpdateKind)
: 'semanal'
- const rawSignalType = optionalFormText(formData, 'signalType')
- const signalType = municipalitySignalTypes.includes(rawSignalType as MunicipalitySignalType)
- ? (rawSignalType as MunicipalitySignalType)
- : undefined
-
await createMunicipalityUpdate({
municipality,
kind,
@@ -46,7 +40,7 @@ export const createMunicipalityUpdateFormAction = async (
body: optionalFormText(formData, 'body'),
activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
newSupports: optionalIntegerFormValue(formData, 'newSupports'),
- signalType,
+ signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
signalSource: optionalFormText(formData, 'signalSource'),
triangulated: checkboxFormValue(formData, 'triangulated'),
})
diff --git a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts
index 4cfb7668..0d95ecde 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts
+++ b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts
@@ -5,7 +5,9 @@ import {
setMunicipalityExpectedVotes,
setMunicipalityPoliticalTrend,
} from '@/app/(campaign)/campanha/actions/municipality'
+import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
import {
+ checkboxFormValue,
nullableFormText,
optionalFormText,
optionalMunicipalitySlugFromForm,
@@ -14,6 +16,7 @@ import {
voteEstimateScenarioFromForm,
} from '@/lib/formData'
import { parsePoliticalTrendStatusFormValue } from '@/lib/schemas/municipality'
+import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
@@ -88,3 +91,28 @@ export const assignMunicipalityAdvisorsFormAction = async (
})
}
}
+
+export const createMunicipalityListSignalFormAction = async (
+ _state: CampaignFormActionState,
+ formData: FormData,
+): Promise => {
+ try {
+ const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
+
+ await createMunicipalityUpdate({
+ municipality,
+ kind: 'sinal',
+ body: optionalFormText(formData, 'body'),
+ signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
+ signalSource: optionalFormText(formData, 'signalSource'),
+ triangulated: checkboxFormValue(formData, 'triangulated'),
+ })
+ revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
+ return { status: 'success', message: 'Sinal registrado.' }
+ } catch (error) {
+ return mapCampaignFormActionError({
+ error,
+ genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
+ })
+ }
+}
diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx
index 72a0524d..1c63db94 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx
@@ -34,6 +34,7 @@ import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData
import { getEligibleAdvisorOptions, loadAdvisorSummaries } from '@/utilities/municipalityViewModels'
import {
assignMunicipalityAdvisorsFormAction,
+ createMunicipalityListSignalFormAction,
setMunicipalityPoliticalTrendFormAction,
} from './municipalityStaffFormActions'
@@ -131,6 +132,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
columnFilterOptions={columnFilterOptions}
trendFormAction={setMunicipalityPoliticalTrendFormAction}
advisorsFormAction={assignMunicipalityAdvisorsFormAction}
+ signalFormAction={createMunicipalityListSignalFormAction}
state={state}
/>
),
cell: (municipality) => (
-
+
+
+
),
},
{
@@ -476,7 +488,16 @@ export const MunicipalityList = (props: MunicipalityListProps) => {