diff --git a/docs/changelog/2026-08-24-b155-plus.md b/docs/changelog/2026-08-24-b155-plus.md
new file mode 100644
index 00000000..1064e9f1
--- /dev/null
+++ b/docs/changelog/2026-08-24-b155-plus.md
@@ -0,0 +1 @@
+**Recently resolved (2026-08-24):** **B155+** (Issue #368) — padrão de escrita de célula: migra os quick-edits restantes de form action para rota JSON. **F1 (sinal de municípios) morreu antes do lote** — C87 (2026-08-07) trocou o quick-edit por form multi-campo (`MunicipalityListUpdateControl`), que per a regra B155 corretamente permanece em server action; registrado como "já resolvido" no plano de intenção. **F2+F3 entregues:** `POST /campanha/liderancas/state-deputies` e `/campanha/liderancas/municipalities` via `campaignJsonMutationRoute` (schemas reusados `leadershipStateDeputyMembershipSchema`/`leadershipMunicipalitiesMembershipSchema`; allowlists floor/cap/scope/cap-dobradinhas; revalidate herdado das actions record — contrato B34/B31 preservado, a lista onde o chip está não é revalidada). Call sites trocados por wrappers `'use client'` de mesma assinatura `(state, formData) → postCampaignJson` (`LeadershipStateDeputiesColumnCell`, `LeadershipMunicipalitiesColumnCell` em `src/components/campaign/leadership/`), shared cells intocadas (multi-consumidor: dobradinhas/assessores/pessoas). **B36 incluído de graça** (coluna "Lideranças" de `/campanha/dobradinhas` usa a mesma action record da rota F2 — 3 form actions removidas no total). **Deferido com gatilho:** chips B37/B156 de `/campanha/dobradinhas` (domínio stateDeputy) e inline-edits por campo B153/B163/C129 (contrato de célula próprio) — rotas F2/F3 já nascem prontas para os dois lados. Guards atualizados: whitelist do `sharedSheetHostConventions` (+2 wrappers, padrão `PeopleMunicipalityCell`), prewarm e2e (+2 rotas), e2e B34 aponta para `/campanha/liderancas/municipalities`. Sem migration/access/Consent/UI/schema (Impeccable A).
diff --git a/docs/plans/escala-dry-pos-b155-padrao-rota-impl.md b/docs/plans/escala-dry-pos-b155-padrao-rota-impl.md
new file mode 100644
index 00000000..e3d82d62
--- /dev/null
+++ b/docs/plans/escala-dry-pos-b155-padrao-rota-impl.md
@@ -0,0 +1,163 @@
+# Impl: Escala/DRY pós-B155 — padrão de escrita de célula: quick-edits via form action → rota JSON
+
+Status: rascunho
+Atualizado em: 2026-08-24
+Issue: #368
+Intenção: docs/plans/escala-dry-pos-b155-padrao-rota.md
+Appetite restante: herdado (~0,5–1 dia eng; sem migration)
+
+## Leitura da intenção
+
+- **Outcome:** As anomalias de quick-edit de célula ainda via form action migram para o padrão `campaignJsonMutationRoute` + `postCampaignJson` (mesmo transporte das 8+ rotas irmãs), reusando as actions record sem mudança de domínio, transporte apenas (Impeccable A), sem migration.
+- **O que NÃO negociar:** `campaignJsonMutationRoute` é obrigatório (o sweep de `codebaseConventions.unit.spec.ts` recusa POST nu); as actions record existentes são reusadas sem mudança de acesso/revalidação; o contrato de revalidação B34/B31 é preservado (a lista `/campanha/liderancas` onde o chip está NÃO é revalidada — `leadership.ts:630-639`); copy pt-BR, identificadores em inglês.
+- **O que reavaliar** (estado do main em 2026-08-24, 798 commits depois da intenção):
+ - **F1 está MORTA — fora do lote, registrar como "já resolvido no simplify/fora".** O commit C87 (`212a1f24`, 2026-08-07, "atualização unificada (texto + polaridade + urgente)") removeu `MunicipalityListSignalControl` e `createMunicipalityListSignalFormAction` (grep em `src/`: zero ocorrências de ambos) e os substituiu pelo form multi-campo unificado `MunicipalityListUpdateControl` (`src/components/campaign/municipality/MunicipalityListUpdateControl.tsx` — `useActionState` + `startTransition` explícito em `:60`/`:90`, **sem** `useCampaignCellAutosave`) com `createMunicipalityListUpdateFormAction` (`municipalityStaffFormActions.ts:103`, wired em `municipios/page.tsx:193` como `signalFormAction`). Per a regra travada B155 (form multi-campo → server action), essa superfície CORRETAMENTE permanece em server action. Nada a migrar. `municipio/[slug]/v2/formActions.ts:3-9` já é shim de `createMunicipalityListUpdateFormAction` — nenhum símbolo morto restante. Atualizar o plano de intenção: as fases 1 e 3 (F1 + menção ao sinal em `politica de rotas`) saem; restam F2 + F3.
+ - **F2 e F3 continuam vivas exatamente como escopadas** (ver "Abordagem recomendada"). A página de lideranças agora é lista virtualizada (B161, `CampaignListResults`) — as células passam por `leadershipColumns` em `liderancas/page.tsx`; os call sites dos chips continuam `:232-243` (Municípios) e `:257-270` (Dobradinhas).
+ - **`useCampaignCellFailureChannel` não é necessário neste lote:** o contrato confirmado (`src/components/campaign/shared/useCampaignCellFailureChannel.ts:19` — `{ errorMessage, setErrorMessage, reportFailure, noteOpenChange }`) serve controles de lista auto-save (F1 morto). Nos F2/F3 o feedback já vive na shared cell (`RelationChipCell`/`RelationOptionCell` — Alert inline + toast de undo C128), que consome o retorno `CampaignFormActionState` do `commitAction`/`membershipAction`. O shim só traduz o transporte.
+ - **Schemas já exportados:** `leadershipStateDeputyMembershipSchema` (`src/lib/schemas/leadership.ts:148`) e `leadershipMunicipalitiesMembershipSchema` (`:164`, com `.min(1).max(MAX_LEADERSHIP_MUNICIPALITIES)` + dedupe) são exatamente os bodies JSON das rotas F2/F3 — reusar, não colar.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ subgraph F2["F2 — Dobradinhas (coluna de /campanha/liderancas)"]
+ C2[LeadershipStateDeputyRelationCell] -->|membershipAction = shim route-backed| S2[shim 'use client' LeadershipStateDeputiesColumnCell]
+ S2 -->|postCampaignJson| R2[POST /campanha/liderancas/state-deputies]
+ R2 -->|campaignJsonMutationRoute + leadershipStateDeputyMembershipSchema| A2[setLeadershipStateDeputyMembership]
+ end
+ subgraph F3["F3 — Municípios (coluna de /campanha/liderancas)"]
+ C3[MunicipalityPortfolioCell] -->|commitAction = shim route-backed| S3[shim 'use client' LeadershipMunicipalitiesColumnCell]
+ S3 -->|postCampaignJson| R3[POST /campanha/liderancas/municipalities]
+ R3 -->|campaignJsonMutationRoute + leadershipMunicipalitiesMembershipSchema| A3[setLeadershipMunicipalitiesMembership]
+ end
+ A2 -->|revalidateLeadershipStateDeputyPaths
lista NÃO revalidada| RV2
+ A3 -->|revalidateLeadershipMunicipalityPaths
lista NÃO revalidada \(B34/B31\)| RV3
+```
+
+**Opções consideradas:** A (refatorar as shared cells para transporte rota interno) | B (wrappers de coluna `'use client'` no call site da página, com shim route-backed de mesma assinatura) | C (prop `transport: 'form' | 'route'` nas shared cells) | D (shim solto sem wrapper, passado direto da página)
+
+**Recomendação:** **B** — porque a página de lideranças é server component (não pode carregar `postCampaignJson`/hooks clientes), as shared cells têm múltiplos consumidores (dobradinhas B36/B37, assessores B156, pessoas C128) que não entram no lote, e `RelationChipCell` chama `commitAction({}, formData)` como função async regular dentro de `startTransition` (`RelationChipCell.tsx:555-558`) — qualquer função com a assinatura `(state: CampaignFormActionState, formData: FormData) => Promise` funciona, sem `useActionState`. O wrapper `'use client'` encapsula o shim e repassa as props da página para a shared cell intocada. É exatamente a arquitetura da tentativa anterior (branch `origin/agent/368-padrao-rota-celula`, `e494fdca` — wrappers `LeadershipMunicipalitiesColumnCell`/`LeadershipStateDeputiesColumnCell` em `src/components/campaign/leadership/`), validada e reutilizada, menos o F1.
+
+**Rejeitadas:**
+
+- **A:** refatorar `RelationChipCell`/`RelationOptionCell` para `postCampaignJson` interno → quebra ou exige refatorar os consumidores fora do lote (dobradinhas, assessores, pessoas) e viola "transporte apenas".
+- **C:** prop de transporte nas shared cells → os dois modos coexistem para 2 call sites; o destino é toda célula em rota — não faz sentido manter o modo form.
+- **D:** passar o shim como import direto na página → página é server component; funções clientes não podem ser definidas/carregadas lá. O wrapper é o envelope mínimo que torna o shim legal.
+
+### Componentes / mudanças
+
+- **`POST /campanha/liderancas/state-deputies/route.ts`** (novo, `src/app/(campaign)/campanha/(app)/liderancas/state-deputies/route.ts`): `export const POST = campaignJsonMutationRoute(` (literal exigido pelo sweep) com `bodySchema = leadershipStateDeputyMembershipSchema` (reuso direto de `src/lib/schemas/leadership.ts:148`), `safeMessages: [...leadershipStaffEditSafeMessages, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE]`, `genericMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.'` (cópia da form action). Handler: `await setLeadershipStateDeputyMembership(body)` → `NextResponse.json({ status: 'success', message: 'Dobradinhas atualizadas.' })`. `types.ts` colado: `LeadershipStateDeputiesToggleResponse = { status: 'success'; message: string } | { status: 'error'; message: string }` (padrão `liderancas/support-status/types.ts`). `export const dynamic = 'force-dynamic'` (padrão das irmãs).
+- **`POST /campanha/liderancas/municipalities/route.ts`** (novo, `.../liderancas/municipalities/route.ts`): `bodySchema = leadershipMunicipalitiesMembershipSchema` (o cap já é o ceiling do schema; o floor de 1 é a action record contra o array resultante), `safeMessages: [...leadershipStaffEditSafeMessages, LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE]`, `genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.'`. Handler: `await setLeadershipMunicipalitiesMembership(body)` → `{ status: 'success', message: 'Municípios atualizados.' }`. `types.ts` + `force-dynamic` iguais. **Nota de body:** a shared cell envia `ownerId` no FormData; o shim converte para `leadershipId` no JSON (a action record exige `leadershipId`).
+- **`LeadershipStateDeputiesColumnCell`** (novo, `src/components/campaign/leadership/LeadershipStateDeputiesColumnCell.tsx`, `'use client'`): wrapper que recebe as props da página, monta com `useCallback` o shim `(state, formData) => Promise` — lê `leadershipId`/`stateDeputyId`/`assigned` do FormData (parse idêntico ao de `liderancas/formActions.ts:92-95`, hoje `requiredRelationshipFormValue`/`requiredFormBoolean`) e chama `postCampaignJson('/campanha/liderancas/state-deputies', { leadershipId, stateDeputyId, assigned })`; `ok` → `{ status: 'success', message: payload.message }`; senão → `{ message: payload.message }` (sem `status` — o caminho de erro da shared cell reverter o otimismo). Renderiza `LeadershipStateDeputyRelationCell` com `membershipAction={shim}` e o restante das props repassadas.
+- **`LeadershipMunicipalitiesColumnCell`** (novo, `src/components/campaign/leadership/LeadershipMunicipalitiesColumnCell.tsx`, `'use client'`): idem para `MunicipalityPortfolioCell` com `commitAction={shim}`; o shim lê `ownerId` (campo que a cell envia — `MunicipalityPortfolioCell.tsx:190`), `municipalityIds` repetidos e `assigned`, e chama `postCampaignJson('/campanha/liderancas/municipalities', { leadershipId: ownerId, municipalityIds, assigned })`.
+- **`liderancas/page.tsx`** (editado): troca `MunicipalityPortfolioCell` (`:232-243`) por `LeadershipMunicipalitiesColumnCell` (remove a prop `commitAction`) e `LeadershipStateDeputyRelationCell` (`:257-270`) por `LeadershipStateDeputiesColumnCell` (remove `membershipAction`); remove os imports de `./formActions` (`:83-84`). O restante (filter head, drawerTitle, min/max, options) permanece.
+- **`liderancas/formActions.ts`** (editado): remove `setLeadershipStateDeputyMembershipFormAction` (`:86-101`) e `setLeadershipMunicipalitiesFormAction` (`:109-124`). `updateLeadershipContactFormAction` (B153, `:42-83`) PERMANECE — fora do lote (ver decisão de espelhos). Limpar imports órfãos (`requiredFormBoolean`, `repeatedRelationshipFormValues`, `LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`, floor/cap/scope) e reenxugar o `safeMessages` local para o de B153.
+- **`tests/e2e/setup.e2e.spec.ts`** (editado): adicionar `'/campanha/liderancas/state-deputies'` e `'/campanha/liderancas/municipalities'` ao prewarm de POSTs (`:73-93`).
+- **`tests/e2e/campaignLeaderships.e2e.spec.ts`** (editado): `:102` — `expectPostResponse(page, '/campanha/liderancas')` → `expectPostResponse(page, '/campanha/liderancas/municipalities')`; atualizar o comentário acima (não há mais server action POSTando na URL da página para chips — o POST agora é a rota JSON).
+- **`tests/e2e/campaignMunicipalities.e2e.spec.ts`**: **sem mudança** — verificado: `:319` (B157) e `:470-522` (B176) cobrem a coluna "Dobradinhas" da página de MUNICÍPIOS (`setMunicipalityStateDeputiesFormAction`/`createMunicipalityStateDeputyFormAction`, wired em `municipios/page.tsx:190-191`) — superfície fora do lote, segue em form action.
+- **`tests/unit/campaignCellEditOverlay.unit.spec.ts`**: **sem mudança obrigatória** — importa `MunicipalityPortfolioCell` (shared, intocada); as células de liderança não estão nos `overlayCases`. Conferir na fase 2 se vale adicionar os wrappers (opcional, sem pressão de teste).
+- **`tests/unit/codebaseConventions.unit.spec.ts`**: sem mudança — sweep automático (`:227-244`); as rotas novas nascem com o literal `export const POST = campaignJsonMutationRoute(`.
+- **Migration:** nenhuma. **Access / Consent:** sem mudança — `getCampaignActionContext` (`src/utilities/campaignActionContext.ts:88`) lê a sessão via cookie e lança `CAMPAIGN_AUTH_REQUIRED_MESSAGE`/`CAMPAIGN_SESSION_EXPIRED_MESSAGE`, que o wrapper mapeia para 401 (`campaignJsonMutationRoute.ts:55-73`); as actions record já usam `overrideAccess: false`. A rota não duplica lógica de access além do que o wrapper exige (same-origin 403 + safe-messages). **UI:** Impeccable A — transporte apenas; nenhuma shape/craft/critique.
+
+### Dados → forma
+
+Não se aplica (refactor de transporte; a apresentação dos chips/undo/toast é das shared cells e não muda).
+
+## Decisões de engenharia
+
+### F1: registrar como já resolvido
+
+**Situação:** A intenção cataloga 3 anomalias; o main só tem 2. C87 (`212a1f24`) trocou o sinal por form multi-campo unificado, e a regra B155 manda form multi-campo para server action — a superfície atual está CORRETA.
+
+**Decisão:** F1 entra na seção "Já resolvido no simplify (não reabrir)" do plano de intenção — zero trabalho neste lote. A seção de fases da intenção ("F1 primeiro — ROI mais alto") é reescrita para F2+F3.
+
+**Rejeitadas:** migrar `createMunicipalityListUpdateFormAction` para rota mesmo assim (violaria a regra travada B155 — form multi-campo, submit explícito, campos interdependentes); reabrir o quick-edit de sinal como célula auto-save (C87 foi deliberação de produto fechada).
+
+### Shim route-backed vs refatoração interna da shared cell
+
+**Opções:** A (wrappers de coluna `'use client'` com shim `(state, formData) => Promise` chamando `postCampaignJson`) | B (refatorar `RelationChipCell`/`RelationOptionCell` para rota interno) | C (prop `transport`)
+
+**Recomendação:** A — o contrato de chamada das shared cells já é uma função async regular (`RelationChipCell.tsx:555-558`), o wrapper é o envelope mínimo que legaliza um shim client-side numa página server, e as shared cells (multi-consumidor) ficam intocadas — o mesmo desenho da tentativa anterior, reaproveitado integralmente para F2/F3.
+
+**Rejeitadas:** B porque exige tocar consumidores fora do lote (dobradinhas, assessores, pessoas) e muda transporte onde o lote não migra; C porque mantém dois transportes vivos nas cells para 2 call sites — o destino é rota para tudo.
+
+### Rotas reusam os schemas exportados vs colar schema local
+
+**Opções:** A (reusar `leadershipStateDeputyMembershipSchema`/`leadershipMunicipalitiesMembershipSchema` de `src/lib/schemas/leadership.ts`) | B (colar `z.strictObject({...})` com `positiveRelationshipId` no route.ts, padrão das rotas irmãs mais antigas)
+
+**Recomendação:** A — os dois schemas são exatamente os bodies JSON (o de municípios já tem `.min(1).max(30)` + dedupe, e o cap como ceiling do próprio schema, comentado em `schemas/leadership.ts:166-171`); o precedente próximo é `political-trend/route.ts:16-18`, que reusa `municipalityPoliticalTrendSchema`. Colar duplicaria a validação em dois lugares.
+
+**Rejeitadas:** B porque cria dois schemas com a mesma forma em módulos vizinhos — DRY violado sem volatilidade que justifique.
+
+### Destino das quick-edits espelho (fora deste lote — decidir e registrar)
+
+**Situação:** `setLeadershipStateDeputyMembershipFormAction` em `dobradinhas/formActions.ts:129-144` (B36, coluna "Lideranças" de `/campanha/dobradinhas`, wired em `dobradinhas/page.tsx:266` — mesma action record do F2), `setStateDeputyMunicipalitiesFormAction` (B37, `dobradinhas/formActions.ts:170-174`), `setStateDeputyAdvisorMembershipFormAction` (B156, `dobradinhas/formActions.ts:147-162`), `updateStateDeputyPartyFormAction` (B163), `updateStateDeputyBallotNameFormAction` (C129, `dobradinhas/formActions.ts:115-126`), `updateLeadershipContactFormAction` (B153, `liderancas/formActions.ts:42-83`).
+
+**Decisão:**
+
+- **B36 / B37 / B156 → defer com gatilho** (não entram no lote para respeitar o appetite ~0,5–1 dia): gatilho = próximo lote de células em `/campanha/dobradinhas` — as rotas F2/F3 nascem prontas para os dois lados (mesma action record, mesmo body) e B156 tem a allowlist pronta (`LEADERSHIP_ADVISORS_CAP_MESSAGE`/`LEADERSHIP_ADVISORS_UNRESTRICTED_MESSAGE`, `schemas/leadership.ts:41-44`).
+- **B153 / B163 / C129 → defer com gatilho** de um lote dedicado a **inline-edits por campo** (`CampaignInlineEditableCell`, `liderancas/page.tsx:197`): não são chips/toggles de relação — o popover de edição de campo único tem outro contrato de chamada e precisa de tratamento próprio (rota por campo + cell editando via `postCampaignJson`). Fora deste lote, NÃO descartado permanentemente.
+- **Rejeitada:** "fora de escopo permanente" para qualquer um deles — a regra B155 (célula → rota) não distingue chip de inline-edit; descartar criaria um padrão duplo sem evidência de produto. O que fica permanente fora é o ladder de forms (`runCampaignFormAction` para forms multi-campo — B155), não essas células.
+
+### Revalidação após mutation
+
+**Situação:** `setLeadershipStateDeputyMembership` (`leadership.ts:615-627`) revalida `revalidateLeadershipStateDeputyPaths` (`:508`); `setLeadershipMunicipalitiesMembership` (`:759-773`) revalida `revalidateLeadershipMunicipalityPaths` (`:640-648`), com a lista `/campanha/liderancas` deliberadamente excluída (`:630-639` — contrato B34/B31).
+
+**Decisão:** as rotas herdam isso automaticamente ao chamar as actions record — zero linhas de revalidação nas rotas, zero mudança de contrato. Adicionar a lista ao revalidate seria mudança de comportamento, não de transporte (mesma posição da tentativa anterior).
+
+## Fases verificáveis
+
+### Fase 1 — Tracer: as 2 rotas + specs
+
+1. Criar `liderancas/state-deputies/route.ts` + `types.ts` e `liderancas/municipalities/route.ts` + `types.ts` (schema reusado, safe-messages, `force-dynamic`).
+2. Adicionar as 2 rotas ao prewarm POST de `tests/e2e/setup.e2e.spec.ts:73-93`.
+3. `campaignLeaderships.e2e.spec.ts:102` → `expectPostResponse(page, '/campanha/liderancas/municipalities')`.
+4. Verificar a rota no navegador (`pnpm dev` + sessão de campanha): POST `{ leadershipId, stateDeputyId, assigned: false }` e `{ leadershipId, municipalityIds: [..], assigned: true }` respondem `{ status: 'success', ... }`; body malformado → 400 com `genericMessage`; sem sessão → 401.
+5. Rodar o spec e2e de lideranças.
+
+### Fase 2 — Shims e remoção das form actions
+
+1. Criar `LeadershipStateDeputiesColumnCell` e `LeadershipMunicipalitiesColumnCell` em `src/components/campaign/leadership/` (shims route-backed com a mesma assinatura; parse do FormData copiado de `liderancas/formActions.ts`).
+2. Trocar os call sites em `liderancas/page.tsx:232-243` e `:257-270`; remover imports de `./formActions`.
+3. Remover as 2 form actions de `liderancas/formActions.ts`; limpar imports órfãos.
+4. `pnpm exec knip` (exports órfãos), `tsc --noEmit`, conferir `tests/unit/campaignCellEditOverlay.unit.spec.ts` (sem mudança esperada).
+
+### Fase 3 — Gates
+
+1. `pnpm gate:fast` (cascade: lint/format/typecheck/knip/cycles/unit/int).
+2. e2e afetados: `campaignLeaderships` (B32/B34) e `campaignMunicipalities` (regressão B157/B176 — devem passar intocados).
+3. `pnpm check:cycles`; `pnpm push` + PR + auto-merge.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- **Generalizar shared cells / fundir espelhos** durante a migração — os 2+2 controles seguem espelhos; refactor de transporte apenas.
+- **Migrar B36/B37/B156/B163/C129/B153** — defer com gatilho registrado acima; não entram nas fases.
+- **`MunicipalityListUpdateControl` / `createMunicipalityListUpdateFormAction` (ex-F1)** — form multi-campo; fica em server action por regra B155.
+- **Coluna "Dobradinhas" da página de municípios** (`setMunicipalityStateDeputiesFormAction`, `municipios/page.tsx:190-191`) — espelho de relação, mas outra superfície; não tocada.
+- **Mudar o contrato de revalidação B34/B31** (adicionar `/campanha/liderancas` ao revalidate) — comportamento, não transporte.
+- **`useCampaignCellFailureChannel` nos shims** — o feedback já é da shared cell; canal duplo seria camada sem volatilidade.
+- **Fundir com UX / copy** — Impeccable A; nenhum achado de critique pendente nesta superfície.
+
+## Riscos e mitigação
+
+- **Shim diverge do contrato do FormData da shared cell:** `MunicipalityPortfolioCell` envia `ownerId` + `municipalityIds` repetidos + `assigned` (`MunicipalityPortfolioCell.tsx:187-199`); `LeadershipStateDeputyRelationCell` envia `leadershipId` + `stateDeputyId` + `assigned` (`:98-113`). **Mitigação:** o shim copia o parse das form actions atuais (`liderancas/formActions.ts:92-95` e `:115-118` — a fonte da verdade do formato), com `Number()` nos ids e `assigned` string→boolean; o e2e B34 (rota apontada) cobre o fluxo feliz e o undo C128 re-chama o shim pelo mesmo caminho.
+- **Erro fora de 200 (auth 401 / malformed 400 / safe-message 400):** `postCampaignJson` retorna `{ ok, payload }` (`campaignJsonRequest.ts:12-26`) — o shim propaga `payload.message`; `ok` separado do status porque há rotas que respondem 409 com body real. **Mitigação:** o shim só olha `ok` + `payload.message`, nunca o status.
+- **Página server component:** shim não pode viver em `liderancas/page.tsx`. **Mitigação:** wrappers `'use client'` (opção B) — o motivo estrutural da escolha.
+- **Sweep `codebaseConventions` recusa POST nu:** as rotas devem exportar o literal `export const POST = campaignJsonMutationRoute(` (`codebaseConventions.unit.spec.ts:227-244`). **Mitigação:** nascer no padrão, como as irmãs.
+- **Knip/imports órfãos após remover as form actions:** `requiredFormBoolean`, `repeatedRelationshipFormValues` e as constantes floor/cap/scope perdem uso em `liderancas/formActions.ts`. **Mitigação:** limpar no mesmo commit; `pnpm exec knip` na fase 2.
+- **`safeMessages` errados nas rotas:** mensagens de erro das actions record são match por EXACT string (`campaignJsonMutationRoute.ts:51-74`) — allowlist faltando colapsa em `genericMessage`. **Mitigação:** espelhar a lista da form action atual (que já era a allowlist do `runCampaignFormAction`), sem `LEADERSHIP_INVALID_CONTACT_MESSAGE`/`INVALID_FIELD_MESSAGE` (só pertencem ao B153).
+- **e2e flakiness da migração:** o B34 hoje espera POST na URL da página (`campaignLeaderships.e2e.spec.ts:102`) — se não migrar junto com o código, o teste fica pendurado até timeout. **Mitigação:** spec migrado na mesma fase do código (fase 1 antecipa a rota, fase 2 o call site — rodar o spec nas duas).
+- **B161 (lista virtualizada):** wrappers são componentes clientes comuns — compatíveis com `CampaignListResults`; nada de portal/scroll novo.
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção ainda coberto — F2+F3 migrados para rota; F1 registrado como já resolvido no simplify/fora (C87)
+- [ ] Invariantes AGENTS/engineering-standards — `campaignJsonMutationRoute` obrigatório; actions record reusadas com `overrideAccess: false` e transação; contrato de revalidação B34/B31 preservado; copy pt-BR, identificadores em inglês
+- [ ] Testes de domínio previstos — e2e B34 apontando para `/campanha/liderancas/municipalities`; prewarm com as 2 rotas novas; regressão B157/B176 intocada
+- [ ] `codebaseConventions` — zero offenders no sweep automático
+- [ ] `knip` — zero exports órfãos; `pnpm check:cycles` — zero ciclos
+- [ ] Sem migration, sem mudança de acesso/consent, sem mudança de UI (Impeccable A)
+
+## Self-score decision-quality
+
+**5/5** — (1) decisões caras têm rejeitadas (shim vs refatorar cells vs prop transport; rotas reusam schema vs colam; destino dos espelhos decidido com alternativa rejeitada); (2) cabe no appetite herdado (~0,5–1 dia: 2 rotas + 2 wrappers + 2 remoções + 2 specs — a tentativa anterior provou o tamanho); (3) rabbit holes nomeados (espelhos, ex-F1, coluna de municípios, revalidação, canal de falha duplo); (4) depth check: reusa `campaignJsonMutationRoute`, `postCampaignJson`, actions record, schemas exportados, `leadershipStaffEditSafeMessages`, shared cells, prewarm existente — nenhum módulo novo além dos wrappers mínimos; (5) intenção satisfeita: transporte apenas, sem reescrever o outcome — com a divergência F1 explicitamente reconciliada no plano de intenção.
diff --git a/docs/plans/escala-dry-pos-b155-padrao-rota.md b/docs/plans/escala-dry-pos-b155-padrao-rota.md
index 0c725306..d5a2fbf5 100644
--- a/docs/plans/escala-dry-pos-b155-padrao-rota.md
+++ b/docs/plans/escala-dry-pos-b155-padrao-rota.md
@@ -1,7 +1,7 @@
# Escala/DRY pós-B155 — padrão de escrita de célula: quick-edits via form action → rota JSON
Status: rascunho
-Atualizado em: 2026-08-04
+Atualizado em: 2026-08-24 (escopo revisado — F1 resolvida por C87; B36 entra)
Issue: #368
Prioridade: P2
Item do roadmap: débito da entrega B155 (#359) — regra travada com o humano em 2026-08-04: **célula → rota, form → server action**
@@ -18,36 +18,45 @@ multi-campo** usam **server action** (`runCampaignFormAction`). O padrão rota
`codebaseConventions.unit.spec.ts` recusa `POST` nu — com 8+ endpoints sob `(app)/municipios` +
`liderancas/support-status`.
-Restam **3 quick-edits de célula** que ainda passam por form action (as anomalias):
+Restam quick-edits de célula que ainda passam por form action (as anomalias):
-1. **Sinal na lista de municípios** — `MunicipalityListSignalControl` recebe
- `signalFormAction` = `createMunicipalityListSignalFormAction` (debounce/abort via
- `useCampaignCellAutosave`), mesma superfície que já tem advisors/leaderships/level/trend
- por rota.
-2. **Coluna "Dobradinhas" de `/campanha/liderancas` (B31)** — `setLeadershipStateDeputyMembershipFormAction`.
+1. ~~**Sinal na lista de municípios** — ~~ **RESOLVIDO por C87 (2026-08-07):** o quick-edit de sinal
+ (`MunicipalityListSignalControl`/`createMunicipalityListSignalFormAction`) foi substituído pelo
+ form multi-campo unificado `MunicipalityListUpdateControl` (body/polaridade/urgente) — per a
+ regra B155, form multi-campo permanece em server action. Nada a migrar.
+2. **Coluna "Dobradinhas" de `/campanha/liderancas` (B31)** — `setLeadershipStateDeputyMembershipFormAction`
+ (mesma action record também na coluna "Lideranças" de `/campanha/dobradinhas`, B36 — a rota F2
+ serve os dois lados).
3. **Coluna "Municípios" de `/campanha/liderancas` (B34)** — `setLeadershipMunicipalitiesMembershipFormAction`
(delta por chip e batch território/ZE).
+**Deferido (fora deste lote):** colunas de `/campanha/dobradinhas` B37 (Municípios,
+`setStateDeputyMunicipalitiesBatch`) e B156 (Assessores), inline-edits por campo B153 (contatos de
+liderança), B163 (partido) e C129 (nome de legenda) — mesmas células-espelho em outros domínios;
+gatilho registrado no `*-impl.md`.
+
## Escopo
-Migrar os 3 para o padrão rota, sem tocar comportamento:
+Migrar as anomalias vivas para o padrão rota, sem tocar comportamento:
-- **F1 — sinal de municípios:** nova rota `POST /campanha/municipios/signal` (ou reusar o
- contrato do `political-trend`) via `campaignJsonMutationRoute`; o controle passa a chamar
- `postCampaignJson` mantendo debounce/abort; `createMunicipalityListSignalFormAction` vira
- wrapper interno ou é eliminada.
+- **F1 — ~~sinal de municípios~~** **não existe mais** — C87 substituiu o quick-edit por form
+ multi-campo (fica em server action por regra B155). Registrado como "já resolvido", não reabrir.
- **F2 — dobradinhas de liderança:** rota `POST /campanha/liderancas/state-deputies` (delta
- `{ leadershipId, stateDeputyId, assigned }`, allowlist do cap `LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`).
+ `{ leadershipId, stateDeputyId, assigned }`, allowlist do cap `LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`),
+ servindo os chips de `/campanha/liderancas` (B31) e `/campanha/dobradinhas` (B36).
- **F3 — municípios de liderança:** rota `POST /campanha/liderancas/municipalities` (delta
`{ leadershipId, municipalityIds[], assigned }` — inclui o batch território/ZE; allowlist de
floor/cap/scope).
Cada fase reusa as **actions record já existentes** (sem mudança de domínio): o trabalho é
-transport (rota + allowlist) + o controle trocando `useActionState`/form action por
-`postCampaignJson` com o mesmo estado otimista/debounce que já tem.
+transport (rota + allowlist) + o call site trocando a form action por um shim cliente
+`postCampaignJson` com o mesmo estado otimista que as shared cells já têm.
## Já resolvido no simplify (não reabrir)
+- **F1 (sinal na lista de municípios)** — resolvido por C87 (2026-08-07): o quick-edit virou form
+ multi-campo `MunicipalityListUpdateControl`; per a regra B155 essa superfície permanece em
+ server action (`createMunicipalityListUpdateFormAction`). Nada a migrar, não reabrir.
- Reconcile scoped das wrappers B155 (sem bypass novo), toggle revalidando `/campanha/liderancas`,
nome real do contato no chip otimista, validação cliente espelhando o schema, ids `useId`,
refoco pós-form — todos aplicados na sessão B155.
@@ -65,23 +74,24 @@ transport (rota + allowlist) + o controle trocando `useActionState`/form action
## Fases verificáveis
-1. **F1** — sinal de municípios (mesma superfície já no padrão; ROI mais alto).
-2. **F2 + F3** — colunas de `/campanha/liderancas`.
-3. **Gates** — `pnpm gate:fast`; e2e afetado (campaignMunicipalities, campaignLeaderships);
+1. **F2 + F3** — colunas de `/campanha/liderancas` (+ B36 em `/campanha/dobradinhas`, mesma rota).
+2. **Gates** — `pnpm gate:fast`; e2e afetado (campaignLeaderships, campaignMunicipalities);
`pnpm push`.
## Rabbit holes
- **Generalizar** os controles/providers de chips durante a migração — refactor de transporte
- apenas; os 2+2 controles seguem espelhos.
-- **Mudar o contrato do sinal** (hoje debounce com flush no close) — preservar
- `useCampaignCellAutosave` behavior no novo transporte.
+ apenas; os controles espelho (dobradinhas B37/B156, inline-edits B153/B163/C129) seguem
+ em form action até o lote próprio.
+- **Mudar o contrato do sinal** — não se aplica mais (F1 morta por C87); sem debounce/autosave
+ novo no lote.
- **Fundir com UX** — nenhum achado de critique nesta superfície; manter o lote só-engenharia.
## Riscos e mitigação
-- Controles de lideranças hoje acionam form actions com revalidate via server action — a rota
- precisa replicar o `revalidatePath` equivalente (as actions record reusadas já revalidam
- paths de detalhe; a lista onde o chip está NÃO é revalidada — contrato B34/B31).
+- A rota reusa as actions record (que já revalidam paths de detalhe; a lista onde o chip está
+ NÃO é revalidada — contrato B34/B31) — o revalidate vem das actions, zero linhas na rota;
+ adicionar a lista seria mudança de comportamento, não de transporte.
- `codebaseConventions` (rota obrigatória via `campaignJsonMutationRoute`) e o prewarm do e2e
- (`setup.e2e.spec.ts`) ganham as rotas novas no mesmo PR.
+ (`setup.e2e.spec.ts`) ganham as rotas novas no mesmo PR; o e2e B34 troca o filtro de POST da
+ URL da página para a rota nova.
diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts
index aeafd5ac..b61b1129 100644
--- a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts
+++ b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts
@@ -1,6 +1,5 @@
'use server'
-import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
import {
setStateDeputyAdvisorMembership,
setStateDeputyMunicipalitiesBatch,
@@ -16,10 +15,6 @@ import {
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
-import {
- LEADERSHIP_STAFF_MESSAGE,
- LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
-} from '@/lib/schemas/leadership'
import {
STATE_DEPUTY_ADVISOR_SAFE_MESSAGES,
STATE_DEPUTY_CONFLICT_MESSAGE,
@@ -33,8 +28,6 @@ import {
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
-const safeMessages = [LEADERSHIP_STAFF_MESSAGE, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE] as const
-
const stateDeputyContactSafeMessages = [
STATE_DEPUTY_STAFF_MESSAGE,
STATE_DEPUTY_CONFLICT_MESSAGE,
@@ -125,24 +118,6 @@ export const updateStateDeputyBallotNameFormAction = async (
'Não foi possível salvar o nome de legenda. Verifique os dados e tente novamente.',
})
-/** One chip toggle in the "Lideranças" column of `/campanha/dobradinhas` (B36). */
-export const setLeadershipStateDeputyMembershipFormAction = async (
- _state: CampaignFormActionState,
- formData: FormData,
-): Promise =>
- runCampaignFormAction({
- execute: async () => {
- await setLeadershipStateDeputyMembership({
- leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'),
- stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
- assigned: requiredFormBoolean(formData, 'assigned'),
- })
- return { message: 'Lideranças atualizadas.' }
- },
- safeMessages,
- genericMessage: 'Não foi possível atualizar as lideranças. Tente novamente.',
- })
-
/** One chip toggle in the "Assessores" column/section of a dobradinha (B156). */
export const setStateDeputyAdvisorMembershipFormAction = async (
_state: CampaignFormActionState,
diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx
index 609d10ea..44210b2c 100644
--- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx
@@ -4,6 +4,7 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
+import { LeadershipStateDeputiesColumnCell } from '@/components/campaign/leadership/LeadershipStateDeputiesColumnCell'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
@@ -19,10 +20,7 @@ import {
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
-import {
- LeadershipStateDeputyRelationCell,
- type RelationCellOption,
-} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
+import type { RelationCellOption } from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { StateDeputyAdvisorRelationCell } from '@/components/campaign/stateDeputy/StateDeputyAdvisorRelationCell'
@@ -69,7 +67,6 @@ import {
} from '@/utilities/stateDeputyListUrl'
import {
- setLeadershipStateDeputyMembershipFormAction,
setStateDeputyAdvisorMembershipFormAction,
setStateDeputyMunicipalitiesFormAction,
updateStateDeputyBallotNameFormAction,
@@ -253,7 +250,7 @@ const stateDeputyColumns = (
label: 'Lideranças',
cellClassName: 'max-w-72 whitespace-normal',
cell: (row) => (
-
),
},
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts
index 8d9d409b..77877ae7 100644
--- a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts
+++ b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts
@@ -1,26 +1,14 @@
'use server'
import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
-import {
- setLeadershipMunicipalitiesMembership,
- setLeadershipStateDeputyMembership,
- updateLeadershipContact,
-} from '@/app/(campaign)/campanha/actions/leadership'
+import { updateLeadershipContact } from '@/app/(campaign)/campanha/actions/leadership'
import {
nullableFormText,
repeatedPhoneFormValues,
- repeatedRelationshipFormValues,
- requiredFormBoolean,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
-import {
- LEADERSHIP_INVALID_CONTACT_MESSAGE,
- LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
- LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
- LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
- LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
-} from '@/lib/schemas/leadership'
+import { LEADERSHIP_INVALID_CONTACT_MESSAGE } from '@/lib/schemas/leadership'
import {
runCampaignFormAction,
type CampaignFormActionState,
@@ -31,10 +19,6 @@ const INVALID_FIELD_MESSAGE = 'Campo inválido.'
const safeMessages = [
...leadershipStaffEditSafeMessages,
LEADERSHIP_INVALID_CONTACT_MESSAGE,
- LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
- LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
- LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
- LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
INVALID_FIELD_MESSAGE,
] as const
@@ -81,44 +65,3 @@ export const updateLeadershipContactFormAction = async (
safeMessages,
genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
})
-
-/** One chip toggle in the "Dobradinhas" column of `/campanha/liderancas` (B31). */
-export const setLeadershipStateDeputyMembershipFormAction = async (
- _state: CampaignFormActionState,
- formData: FormData,
-): Promise =>
- runCampaignFormAction({
- execute: async () => {
- await setLeadershipStateDeputyMembership({
- leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'),
- stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
- assigned: requiredFormBoolean(formData, 'assigned'),
- })
- return { message: 'Dobradinhas atualizadas.' }
- },
- safeMessages,
- genericMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
- })
-
-/**
- * Add or remove municipalities in the "Municípios" column of
- * `/campanha/liderancas` (B34) — one chip or a whole território/ZE. `ownerId` is
- * the field name `MunicipalityPortfolioCell` sends, so the same cell serves
- * lideranças and assessores.
- */
-export const setLeadershipMunicipalitiesFormAction = async (
- _state: CampaignFormActionState,
- formData: FormData,
-): Promise =>
- runCampaignFormAction({
- execute: async () => {
- await setLeadershipMunicipalitiesMembership({
- leadershipId: requiredRelationshipFormValue(formData, 'ownerId'),
- municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
- assigned: requiredFormBoolean(formData, 'assigned'),
- })
- return { message: 'Municípios atualizados.' }
- },
- safeMessages,
- genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
- })
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/municipalities/route.ts b/src/app/(campaign)/campanha/(app)/liderancas/municipalities/route.ts
new file mode 100644
index 00000000..619c72bb
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/liderancas/municipalities/route.ts
@@ -0,0 +1,38 @@
+import { NextResponse } from 'next/server'
+
+import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
+import { setLeadershipMunicipalitiesMembership } from '@/app/(campaign)/campanha/actions/leadership'
+import {
+ LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
+ LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
+ LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
+ leadershipMunicipalitiesMembershipSchema,
+} from '@/lib/schemas/leadership'
+import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
+
+import type { LeadershipListMunicipalitiesResponse } from './types'
+
+export type { LeadershipListMunicipalitiesResponse } from './types'
+
+export const dynamic = 'force-dynamic'
+
+export const POST = campaignJsonMutationRoute(
+ {
+ bodySchema: leadershipMunicipalitiesMembershipSchema,
+ safeMessages: [
+ ...leadershipStaffEditSafeMessages,
+ LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
+ LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
+ LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
+ ],
+ genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
+ },
+ async (body) => {
+ await setLeadershipMunicipalitiesMembership(body)
+
+ return NextResponse.json({
+ status: 'success',
+ message: 'Municípios atualizados.',
+ })
+ },
+)
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/municipalities/types.ts b/src/app/(campaign)/campanha/(app)/liderancas/municipalities/types.ts
new file mode 100644
index 00000000..5481b90d
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/liderancas/municipalities/types.ts
@@ -0,0 +1,9 @@
+export type LeadershipListMunicipalitiesResponse =
+ | {
+ status: 'success'
+ message: string
+ }
+ | {
+ status: 'error'
+ message: string
+ }
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx
index 7fec4f86..c8e02d69 100644
--- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx
@@ -7,10 +7,12 @@ import { getPayload } from 'payload'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
import { LeadershipFilters } from '@/components/campaign/leadership/LeadershipFilters'
import { LeadershipListSupportStatusControl } from '@/components/campaign/leadership/LeadershipListSupportStatusControl'
+import { LeadershipMunicipalitiesColumnCell } from '@/components/campaign/leadership/LeadershipMunicipalitiesColumnCell'
import {
LeadershipFilterHead,
LeadershipSortableHead,
} from '@/components/campaign/leadership/LeadershipSortableHead'
+import { LeadershipStateDeputiesColumnCell } from '@/components/campaign/leadership/LeadershipStateDeputiesColumnCell'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
@@ -25,11 +27,7 @@ import {
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
-import {
- LeadershipStateDeputyRelationCell,
- type RelationCellOption,
-} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
-import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
+import type { RelationCellOption } from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button, buttonVariants } from '@/components/ui/button'
@@ -79,11 +77,7 @@ import { loadOrganizationNamesByIds } from '@/utilities/loadNamesByIds'
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
import { loadStateDeputySummaries } from '@/utilities/stateDeputyData'
-import {
- setLeadershipMunicipalitiesFormAction,
- setLeadershipStateDeputyMembershipFormAction,
- updateLeadershipContactFormAction,
-} from './formActions'
+import { updateLeadershipContactFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('liderancas')
@@ -229,7 +223,7 @@ const leadershipColumns = ({
),
cellClassName: 'max-w-64 whitespace-normal',
cell: (row) => (
-
@@ -254,7 +247,7 @@ const leadershipColumns = ({
label: 'Dobradinhas',
cellClassName: 'max-w-56 whitespace-normal',
cell: (row) => (
-
),
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/state-deputies/route.ts b/src/app/(campaign)/campanha/(app)/liderancas/state-deputies/route.ts
new file mode 100644
index 00000000..7d96668a
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/liderancas/state-deputies/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from 'next/server'
+
+import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
+import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
+import {
+ LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
+ leadershipStateDeputyMembershipSchema,
+} from '@/lib/schemas/leadership'
+import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
+
+import type { LeadershipListStateDeputiesResponse } from './types'
+
+export type { LeadershipListStateDeputiesResponse } from './types'
+
+export const dynamic = 'force-dynamic'
+
+export const POST = campaignJsonMutationRoute(
+ {
+ bodySchema: leadershipStateDeputyMembershipSchema,
+ safeMessages: [...leadershipStaffEditSafeMessages, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE],
+ genericMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
+ },
+ async (body) => {
+ await setLeadershipStateDeputyMembership(body)
+
+ return NextResponse.json({
+ status: 'success',
+ message: 'Dobradinhas atualizadas.',
+ })
+ },
+)
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/state-deputies/types.ts b/src/app/(campaign)/campanha/(app)/liderancas/state-deputies/types.ts
new file mode 100644
index 00000000..4521146e
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/liderancas/state-deputies/types.ts
@@ -0,0 +1,9 @@
+export type LeadershipListStateDeputiesResponse =
+ | {
+ status: 'success'
+ message: string
+ }
+ | {
+ status: 'error'
+ message: string
+ }
diff --git a/src/components/campaign/leadership/LeadershipMunicipalitiesColumnCell.tsx b/src/components/campaign/leadership/LeadershipMunicipalitiesColumnCell.tsx
new file mode 100644
index 00000000..118c5acf
--- /dev/null
+++ b/src/components/campaign/leadership/LeadershipMunicipalitiesColumnCell.tsx
@@ -0,0 +1,51 @@
+'use client'
+
+import { useMemo, type ComponentProps } from 'react'
+
+import type { LeadershipListMunicipalitiesResponse } from '@/app/(campaign)/campanha/(app)/liderancas/municipalities/types'
+import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
+import { postCampaignJson } from '@/lib/campaignJsonRequest'
+import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
+
+const ENDPOINT = '/campanha/liderancas/municipalities'
+const GENERIC_ERROR = 'Não foi possível atualizar os municípios. Tente novamente.'
+
+type Props = Omit, 'commitAction'>
+
+/**
+ * Thin adapter (B155+): wraps `MunicipalityPortfolioCell` with a route-based
+ * `commitAction` that posts JSON to `POST /campanha/liderancas/municipalities`
+ * — the cell sends `ownerId`, which is the leadership id on this call site.
+ */
+export const LeadershipMunicipalitiesColumnCell = (props: Props) => {
+ const commitAction = useMemo(
+ () =>
+ async (
+ _state: CampaignFormActionState,
+ formData: FormData,
+ ): Promise => {
+ const leadershipId = Number(formData.get('ownerId'))
+ const assigned = formData.get('assigned') === 'true'
+ const municipalityIds = formData.getAll('municipalityIds').map(Number)
+
+ try {
+ const { ok, payload } = await postCampaignJson(
+ ENDPOINT,
+ { leadershipId, municipalityIds, assigned },
+ )
+
+ if (!ok || payload.status !== 'success') {
+ return {
+ message: payload.status === 'error' ? payload.message : GENERIC_ERROR,
+ }
+ }
+ return { status: 'success' as const, message: payload.message }
+ } catch {
+ return { message: GENERIC_ERROR }
+ }
+ },
+ [],
+ )
+
+ return
+}
diff --git a/src/components/campaign/leadership/LeadershipStateDeputiesColumnCell.tsx b/src/components/campaign/leadership/LeadershipStateDeputiesColumnCell.tsx
new file mode 100644
index 00000000..7efce9da
--- /dev/null
+++ b/src/components/campaign/leadership/LeadershipStateDeputiesColumnCell.tsx
@@ -0,0 +1,52 @@
+'use client'
+
+import { useMemo, type ComponentProps } from 'react'
+
+import type { LeadershipListStateDeputiesResponse } from '@/app/(campaign)/campanha/(app)/liderancas/state-deputies/types'
+import { LeadershipStateDeputyRelationCell } from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
+import { postCampaignJson } from '@/lib/campaignJsonRequest'
+import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
+
+const ENDPOINT = '/campanha/liderancas/state-deputies'
+const GENERIC_ERROR = 'Não foi possível atualizar as dobradinhas. Tente novamente.'
+
+type Props = Omit, 'membershipAction'>
+
+/**
+ * Thin adapter (B155+): wraps `LeadershipStateDeputyRelationCell` with a
+ * route-based `membershipAction` that posts JSON to
+ * `POST /campanha/liderancas/state-deputies` — same `(state, formData)`
+ * contract the cell already calls, transport swapped from server action.
+ */
+export const LeadershipStateDeputiesColumnCell = (props: Props) => {
+ const membershipAction = useMemo(
+ () =>
+ async (
+ _state: CampaignFormActionState,
+ formData: FormData,
+ ): Promise => {
+ const leadershipId = Number(formData.get('leadershipId'))
+ const stateDeputyId = Number(formData.get('stateDeputyId'))
+ const assigned = formData.get('assigned') === 'true'
+
+ try {
+ const { ok, payload } = await postCampaignJson(
+ ENDPOINT,
+ { leadershipId, stateDeputyId, assigned },
+ )
+
+ if (!ok || payload.status !== 'success') {
+ return {
+ message: payload.status === 'error' ? payload.message : GENERIC_ERROR,
+ }
+ }
+ return { status: 'success' as const, message: payload.message }
+ } catch {
+ return { message: GENERIC_ERROR }
+ }
+ },
+ [],
+ )
+
+ return
+}
diff --git a/tests/e2e/campaignLeaderships.e2e.spec.ts b/tests/e2e/campaignLeaderships.e2e.spec.ts
index d52b5d1c..060ac838 100644
--- a/tests/e2e/campaignLeaderships.e2e.spec.ts
+++ b/tests/e2e/campaignLeaderships.e2e.spec.ts
@@ -97,9 +97,9 @@ test.describe('campaign leaderships list', () => {
page.getByRole('button', { name: new RegExp(`não é possível remover ${linked.name}$`) }),
).toBeDisabled()
- // Server actions POST to the page URL, so the response filter is the route
- // itself — the optimistic chip renders before the write lands.
- const persisted = () => expectPostResponse(page, '/campanha/liderancas')
+ // The chip cell posts JSON to its mutation route — the optimistic chip
+ // renders before the write lands.
+ const persisted = () => expectPostResponse(page, '/campanha/liderancas/municipalities')
const search = page.getByRole('combobox', {
name: 'Buscar município, território de identidade ou zona eleitoral',
diff --git a/tests/e2e/setup.e2e.spec.ts b/tests/e2e/setup.e2e.spec.ts
index 94991fef..bb50980c 100644
--- a/tests/e2e/setup.e2e.spec.ts
+++ b/tests/e2e/setup.e2e.spec.ts
@@ -80,6 +80,8 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/municipios/political-trend',
'/campanha/municipios/next-steps',
'/campanha/liderancas/support-status',
+ '/campanha/liderancas/state-deputies',
+ '/campanha/liderancas/municipalities',
'/campanha/home-search',
// The Sollinha chat mounts on every authenticated page: its transport and
// the PWA registration hit these on first load/session-restore — cold
diff --git a/tests/unit/sharedSheetHostConventions.unit.spec.ts b/tests/unit/sharedSheetHostConventions.unit.spec.ts
index 101cf27e..5fd29656 100644
--- a/tests/unit/sharedSheetHostConventions.unit.spec.ts
+++ b/tests/unit/sharedSheetHostConventions.unit.spec.ts
@@ -20,7 +20,14 @@ describe('shared sheet host for chip-relation cells (miss #52)', () => {
* table (their owning page holds the provider — checked for the C116 people
* list like the dobradinhas one).
*/
- const cellWrapperFiles = new Set(['src/components/campaign/people/PeopleMunicipalityCell.tsx'])
+ const cellWrapperFiles = new Set([
+ // C128 people list; C116 batch-chip expansion state — the owning page
+ // holds the provider.
+ 'src/components/campaign/people/PeopleMunicipalityCell.tsx',
+ // B155+ route-backed column adapters — the owning pages hold the provider.
+ 'src/components/campaign/leadership/LeadershipMunicipalitiesColumnCell.tsx',
+ 'src/components/campaign/leadership/LeadershipStateDeputiesColumnCell.tsx',
+ ])
it('wraps every chip-relation table in CampaignListSheetProvider', () => {
const offenders: string[] = []