diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index d8d1a464..ddc81eb8 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -5,6 +5,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-11):** **B197+** (Issue #642) — o menu do picker de colunas de assessores nasce da **mesma fonte** que os `
` do `AdvisorsTable` (fim do espelho): o módulo client declara `advisorTableColumns` (id/label/mandatory + `headerClassName` + `visuallyHiddenLabel` para o `sr-only` de Ações) e exporta `advisorPickerColumns = toCampaignColumnPickerColumns(...)` (precedente `supporterPickerColumns`); o header renderiza por `resolveVisibleColumns(advisorTableColumns, hiddenColumnIds)` — mesmo helper B17 das outras listas — e `emailHidden` deriva do mesmo `visibleColumns`. **Descoberta do /simplify incorporada:** o corpo só condiciona a coluna `email` (células à mão + exceção do draft), então `phone`/`municipalities`/`actions` são `mandatory: true` — esconder um deles (cookie velho do toggle inerte do B197) derrubaria o `
` sobre um corpo de 5 células; os checkboxes viram "sempre visível" (honestos). `assessores/page.tsx` consome a fonte exportada e perdeu o array espelho + imports órfãos. Comportamento da tabela idêntico (ids/labels/ordem, exceção do draft intacta); e2e `campaignColumnPicker` ganhou pin dos mandatory. Sem migration/access/Consent/UI/cache. Observações de ambiente (não da mudança): o e2e do picker falha no harness dev sob carga desta máquina (painel do popover não monta; falha idêntica na base e em lideranças — intocado; conjunto falho deriva com a carga; verificado funcional em Chromium real); warning React de key pré-existente no assessores (`CampaignListOmnibox` — base também). Intenção [`escala-dry-pos-b197.md`](plans/escala-dry-pos-b197.md); impl [`escala-dry-pos-b197-impl.md`](plans/escala-dry-pos-b197-impl.md).
+**Recently resolved (2026-08-11):** **C116** (Issue #655) — `/campanha/pessoas` desktop vira **edit-where-you-see**: toda célula (exceto Ações) é um input permanente sem destaque — salva no blur/Enter, Esc descarta, vazio mostra "—" apagado. **Nome** mantém o valor como link para a ficha (`/campanha/liderancas/[id]`) com o mecanismo travado no gate: input invisível (`text-transparent`, z-0) sobre o link (z-10, `pointer-events-auto`, `w-auto` — cobre só o texto; o resto da célula é a área clicável do input), draft renderizado **no texto do link** (o valor real do input nunca é o display), clique no link navega, teclado/área fora do texto edita. **Células de municípios** (Assessora, Lidera, Aliada em) e **Assessorado** usam chips internos com: grupo novo **"Salvador (19)"** (`buildMunicipalityPortfolioChips` ganha kind `city`, checado antes do território — senão as 19 ZEs colapsariam como "Metropolitano de Salvador"), **chip batch clicável expande in loco** (estado de apresentação do wrapper `PeopleMunicipalityCell` — clique expande, mouse-leave colapsa se todos os membros seguem atribuídos; X num membro remove e impede o recolapso), **"+X"** no toggle de overflow do B170, `quiet` (sem tint de hover/foco; indicador de foco discreto só no input), commit por **match exato** no Enter/espaço (digitar "salvador-ze-2"+Enter adiciona a ZE certa, não a pre-selecionada — regra da intenção). Escrita: `updatePersonContactRecord` (escopo "edita o que vê" — assessor só com entidade da pessoa na carteira; `contactFieldUpdateSchema` ganhou variante `city`, convivendo com `phones` do C112), `setPersonAssessoraMembershipRecord` (unrestricted-only, single-account — multi-conta nega com mensagem segura e célula read-only; toggle extraído para `toggleMunicipalityAdvisorMembership` em `municipality.ts`, reusado pelo record B27) e `setPersonAdvisorMembershipRecord` (union person-centric: delta de assessor nas DUAS entidades quando ambas existem). Matriz por papel na página: assessor edita Lidera/Aliada com `addableIds`=carteira e **read-only** em Assessora/Assessorado (regras das collections donas); tooltips de label (WhatsApp/Convidar/Apagar) via `CampaignHoverTooltip`; `CampaignListSheetProvider` na tabela (guard miss #52 — wrapper de composição registrado no `sharedSheetHostConventions`). Mobile intacto. Sem migration. Testes: unit (grupo Salvador + expand + match exato; guard do sheet host), int novo `campaignPersonCellEditing` (11 — escopo por papel, assessora single/multi/sem-conta, union nas duas entidades), e2e `campaignPeople` 4/4. Intenção [`pessoas-edicao-inplace-lista.md`](plans/pessoas-edicao-inplace-lista.md); impl [`pessoas-edicao-inplace-lista-impl.md`](plans/pessoas-edicao-inplace-lista-impl.md).
**Recently resolved (2026-08-11):** **B198** (Issue #660) — o drawer mobile do Sollinha fecha ao navegar por link interno: tocar num link `/campanha…` da resposta fecha o drawer **no toque** (antes de a página carregar) e a navegação segue no mesmo tab; desktop inalterado (painel fica aberto); link externo (nova aba) não fecha o drawer da aba de origem; reload não reabre (o close persiste `open: false`). **Engrenagem:** a decisão pura mora no owner `src/lib/ai/markdownLinks.ts` — regex `^\/campanha(?:\/|$)` promovido a `isCampaignInternalLink` (era `APP_INTERNAL_LINK` no renderer) + `shouldCloseDrawerOnLinkClick(href, isMobile, event)` (fecha ⇔ interno && mobile && clique simples — ignora ctrl/meta/shift/alt, middle-click e `defaultPrevented`, pois abrem nova aba e a origem mantém o chat); o `markdownComponents` estático do `CampaignAIChat` virou `useMemo` no corpo (acesso ao `ctx.isMobile`/`setOpen`; identidade estável, recomputa só no crossing de breakpoint). Sessão/restauração intactas (fechar com `requestOpen(false)` marca `openBy:'user'` com `open:false` — inerte, o restore só reabre com `open:true`). Testes: unit (12 — interno/externo, viewport, modifiers, middle, prevented) + e2e no `campaignSollinhaContext` (interno fecha+navega sem `load`+reload não reabre; externo mantém drawer e abre popup interceptado). Nota: dev-mode local flakeou no prewarm/compile cold — e2e rodado no modo CI (`E2E_PROD=1` com `NEXT_DIST_DIR=.next/e2e`, 7/7). **Débito registrado no gate (B199/#672, `depends` #660):** race pré-existente do persist effect (só grava com `status==='ready'`) — fechar o drawer mid-stream e recarregar antes do settle reabre o drawer "sozinho" (classe OPS22); o tap-no-link do B198 amplia a superfície. Intenção [`sollinha-drawer-fecha-link-mobile.md`](plans/sollinha-drawer-fecha-link-mobile.md); impl [`sollinha-drawer-fecha-link-mobile-impl.md`](plans/sollinha-drawer-fecha-link-mobile-impl.md).
diff --git a/docs/plans/pessoas-edicao-inplace-lista-impl.md b/docs/plans/pessoas-edicao-inplace-lista-impl.md
new file mode 100644
index 00000000..3bc5d95e
--- /dev/null
+++ b/docs/plans/pessoas-edicao-inplace-lista-impl.md
@@ -0,0 +1,110 @@
+# Impl: Pessoas — edição onde você vê (edit-where-you-see) na lista desktop
+
+Status: aprovado
+Atualizado em: 2026-08-11
+Issue: #655
+Intenção: docs/plans/pessoas-edicao-inplace-lista.md
+Appetite restante: ~1,5–2 dias eng; um outcome verificável — a linha da lista é editável sem sair da tabela
+
+## Leitura da intenção
+
+- **Outcome:** na tabela desktop de `/campanha/pessoas`, toda célula (exceto Ações) é um **input permanente sem destaque** — parece texto, se comporta como input; salva no blur/Enter (Esc descarta); Nome mantém o valor como link; colunas de municípios (Assessora, Lidera, Aliada em) e Assessorado usam **chips internos** com colapso por território ("Sertão do São Francisco (5)"), grupo "Salvador (19)", "+X" expande, X no hover remove; ações ganham tooltip.
+- **O que NÃO negociar:** sem migration (escreve no dono da ficha — `Contact` nome/telefone/e-mail/cidade; vínculos nas collections donas); leader fora da rota; escopo do assessor preservado (só a carteira dele); mobile intacto; nada criado fora do catálogo (texto sem match não vira chip); link do Nome navega; "—" vira placeholder apagado.
+- **O que reavaliar:** a hipótese de reusar `CampaignInlineEditableCell` (B163) — seu `editTrigger='cell'` tem estado de edição explícito; o paradigma travado exige input **sempre montado**, então a mudança é um **modo novo** no componente compartilhado, não um ajuste do modo existente. A hipótese de reusar `MunicipalityPortfolioCell`/`RelationChipCell` confirma-se: colapso por território já existe (chips batch); o trabalho novo é **expandir chip batch por clique/hover** (interação que nenhuma célula do sistema tem hoje), o grupo "Salvador (19)" (zona-cidade completa) e o modo `quiet` (sem tint de hover/foco).
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ L[lib/municipalityPortfolio: grupo Salvador + expand chips] --> M[MunicipalityPortfolioCell: opt-ins quiet/+X/expand]
+ M --> P[PeopleMunicipalityCell: estado de expansão + colapso no mouse-leave]
+ C[CampaignInlineEditableCell: modo permanent] --> T[Células de texto: Nome link-overlay, Contato, E-mail, Base]
+ R[RelationOptionCell: Assessorado readOnly + quiet] --> A[PeopleAssessoradoCell]
+ S[Actions: updatePersonContact, setPersonAssessora, setPersonAdvisor] --> F[pessoas/formActions.ts]
+ T --> PG[page.tsx: colunas com matriz de edição por papel]
+ A --> PG
+ P --> PG
+```
+
+**Opções consideradas:**
+
+- **A — estender os componentes compartilhados com props opt-in** (`CampaignInlineEditableCell` modo `permanent`; `RelationChipCell` com `quiet`/`onChipClick`/`overflowToggleLabel`; `MunicipalityPortfolioCell` repassa e expande chips; novo grupo no `buildMunicipalityPortfolioChips`): uma máquina de save e uma máquina de chips para todo o sistema; as outras listas ficam intactas (defaults inalterados).
+- **B — componentes novos `people/*` que duplicam a máquina de save/chips**: isolamento total, mas dois donos para blur/Enter/Esc/pending/erro e para o combobox ARIA — exatamente o twin que o repo proíbe.
+- **C — modo `permanent` virar o novo default do B163**: coerente com o paradigma, mas muda o comportamento visual das dobradinhas/lideranças sem ser pedido deste item.
+
+**Recomendação: A** — o trabalho de interação nova (expandir chip batch, +X, quiet) entra como opt-in nas máquinas existentes e a expansão fica como **estado de apresentação** do wrapper `PeopleMunicipalityCell` (puro, sem tocar o ciclo de commit do `RelationChipCell`). C é rejeitada porque altera superfícies fora do escopo; B porque cria twin.
+
+### Componentes / mudanças
+
+**Puro (lib, client-safe):**
+
+- **`src/lib/municipalityPortfolio.ts`**: (1) novo kind de chip `city` — zonas de uma zona-cidade **completas** viram UM chip `{ kind: 'city', label: 'Salvador', city: 'Salvador', municipalityIds: [19 ids] }`, checado **antes** do colapso por território (senão as 19 zonas colapsariam como "Metropolitano de Salvador (19)"); `MunicipalityPortfolioChip` ganha o variant; (2) helper puro `expandMunicipalityPortfolioChips(chips, expandedKeys)` — substitui chip batch (territory/city) cuja key está expandida pelos chips municipais membros; (3) testes unit.
+- **`src/lib/schemas/contact.ts`**: variante `city` no `contactFieldUpdateSchema` compartilhado (bounds da collection: 2–100; aditivo — as actions de dobradinhas/liderança não expõem `city` e continuam falhando em campo desconhecido).
+
+**Server (`person.ts` + formActions):**
+
+- **`src/app/(campaign)/campanha/actions/person.ts`**: três record-functions novas, todas `withPayloadTransaction` + `req`, Local API com `user`/`overrideAccess: false` e bypass justificado só depois da checagem:
+ - `updatePersonContactRecord(payload, actor, input)` — escopo: irrestrito sempre; assessor somente se **pelo menos uma** entidade da pessoa (leadership OU stateDeputy, lida com `overrideAccess: false`) é gerenciável por ele (regra "edita o que vê" da linha; staff-only → assessor nega). Depois, bypass do Contact (precedente B163/C99).
+ - `setPersonAssessoraMembershipRecord(payload, actor, { contactID, municipalityIds, assigned })` — `reloadUnrestrictedActor` (canAssignMunicipalityAdvisors é unrestricted-only); transação única: resolve as contas staff da pessoa por `contact`, **asserta exatamente uma** (multi-conta → célula read-only no client; o server nega com mensagem segura), e para cada município: lock advisory `municipality-advisors:` + toggle — **extrair** o toggle de `setMunicipalityAdvisorMembershipRecord` (`src/app/(campaign)/campanha/actions/municipality.ts`) para um helper compartilhado usado pelos dois (edit the owner, sem duplicar o bloco lock+toggle).
+ - `setPersonAdvisorMembershipRecord(payload, actor, { contactID, advisorId, assigned })` — `reloadUnrestrictedActor` (regra B156); transação única: aplica o delta de advisor em **cada entidade** da pessoa (leadership e/ou stateDeputy) via `nextAdvisorIdsAfterMembership`/helper existente — semântica person-centric: a coluna Assessorado é atributo da pessoa, adicionar/remover atinge os dois vínculos quando ambos existem.
+- **`src/app/(campaign)/campanha/(app)/pessoas/formActions.ts`** (novo): `updatePersonContactFormAction`, `setPersonAssessoraFormAction`, `setPersonAssessoradoFormAction`, `setPersonLeadershipMunicipalitiesFormAction` (envolve `setLeadershipMunicipalitiesMembership`), `setPersonStateDeputyMunicipalitiesFormAction` (envolve `setStateDeputyMunicipalitiesBatch`) — cascas `runCampaignFormAction` no padrão das dobradinhas, com safe-messages existentes.
+- **Revalidação:** `/campanha/pessoas` + caminhos de detalhe das entidades tocadas (`liderancas/[id]`, `dobradinhas/[id]`, municípios tocados no assessora) — espelhando `revalidateStateDeputyPaths`/`revalidateLeadershipMunicipalityPaths`.
+- **`src/utilities/people/peopleData.ts`**: `PeopleRowViewModel` ganha `assessoradoIDs: number[]` (união `leadershipAdvisorIDs ∪ deputyAdvisorIDs`, já resolvida hoje para os nomes) — ids que os chips do Assessorado precisam.
+
+**UI (Impeccable B — encaixe em tela existente):**
+
+- **`src/components/campaign/shared/CampaignInlineEditableCell.tsx`**: prop `permanent?: boolean` (default false — B163/dobradinhas/lideranças intactos). Em `permanent`:
+ - texto (Contato/E-mail/Base): input **sempre montado**, `bg-transparent border-transparent` (mesmo vocabulário de `campaignInlineInputClassName`), valor = draft, salva no blur/Enter, Esc reverte, vazio → `placeholder="—"` apagado (o "—" atual vira placeholder), telefone mantém formatação; máquina de save/pending/erro/saved-feedback/requestId reutilizada tal qual.
+ - Nome (`permanent` + `href`): **mecanismo travado no gate** — input sempre montado `absolute inset-0 z-0` com `value` **sempre vazio** (`bg-transparent`), link em `z-10` com `pointer-events-auto` cujo texto renderiza `draft || value`; cada tecla atualiza o draft **no display** (texto do link), o valor real do input permanece vazio; clique no link navega; foco por Tab/clique fora do texto → digitação; blur/Enter salva, Esc descarta. Sufixo `({party})` permanece fora do link como texto read-only.
+ - Indicador de foco **discreto**: `focus-visible` ring suave; nada em hover (paradigma sem fundo/borda).
+- **`src/components/campaign/shared/RelationChipCell.tsx`**: três opt-ins, defaults inalterados (B34/B36/B37/B156/B157/B159/B169 intactos):
+ - `quiet?: boolean` — remove `pointer-fine:hover:bg-muted/40` do container; mantém apenas indicador discreto de foco (guardrail de acessibilidade);
+ - `onChipClick?: (chip) => void` — chip **sem href** clicável (hoje só navega); com stopPropagation;
+ - `overflowToggleLabel?: (hiddenCount) => string` — o toggle de overflow do B170 renderiza como chip `+N` em vez de "Ver mais…".
+- **`src/components/campaign/shared/MunicipalityPortfolioCell.tsx`**: repassa os três opt-ins ao `RelationChipCell` e aplica `expandMunicipalityPortfolioChips` no `buildChips` quando `expandedKeys` é passado.
+- **`src/components/campaign/people/PeopleMunicipalityCell.tsx`** (novo, substitui o `PeopleMunicipalityCell` local do page.tsx): wrapper de `MunicipalityPortfolioCell` com estado local `expandedKeys` + `onMouseLeave` que **colapsa** chips expandidos cujos membros continuam todos atribuídos (lê `ids` da prop via ref); serve as colunas Assessora/Lidera/Aliada com `commitAction`/`buildFormData`/`minItems`/`readOnly` definidos pela página.
+- **`src/components/campaign/people/PeopleAssessoradoCell.tsx`** (novo): wrapper de `RelationOptionCell` — chips = assessorados com href `/campanha/assessores/[id]` (só para irrestrito; regra B156), `options` de `loadEligibleAdvisorOptions`, `readOnly` para assessor; copy do `ADVISOR_COPY` (sem o twin: extrair/reusar a copy de `LeadershipAdvisorRelationCell` se o diff permitir, senão constante local).
+- **`src/app/(campaign)/campanha/(app)/pessoas/page.tsx`**: colunas Contato/E-mail/Base com `CampaignInlineEditableCell permanent`; Nome com `permanent` + `href` (`/campanha/liderancas/[id]` quando `leadershipID`); Assessora/Lidera/Aliada com `PeopleMunicipalityCell`; Assessorado com `PeopleAssessoradoCell`; **matriz de edição por papel** (helper puro na page ou `lib`): irrestrito edita tudo; assessor edita células de texto e Lidera/Aliada (com `addableIds = administeredIds`, `minItems=1` em Lidera) apenas nas linhas cujo vínculo cruza a carteira; Assessora e Assessorado **read-only** para assessor (canAssignMunicipalityAdvisors / B156 são unrestricted-only). Ações: `CampaignHoverTooltip` com a label (WhatsApp, Convidar, Apagar) nos três botões. `PeopleMobileCards` intacto.
+- **Migration:** nenhuma — sem mudança de schema.
+
+### Dados → forma
+
+- **Forma escolhida:** inputs permanentes sem destaque + chips internos ao input — o paradigma travado no gate 2026-08-11, que É a forma deste item (não há dado analítico; o chip de território mostra contagem, não métrica). Rejeitadas: toggle global de edição, lápis obrigatório e células read→edit com estado visual — tudo divergente do aceite.
+
+## Fases verificáveis
+
+1. **Tracer / puro + UI machines** (~1/2 appetite): grupo `city` de Salvador + `expandMunicipalityPortfolioChips` + unit tests; opt-ins no `RelationChipCell`/`MunicipalityPortfolioCell`; modo `permanent` no `CampaignInlineEditableCell`; `PeopleMunicipalityCell` + `PeopleAssessoradoCell`. Validar que dobradinhas/lideranças não regridem (testes existentes + revisão visual).
+2. **Server / access** (~1/3 appetite): variante `city` no schema; as três record-functions + formActions; `assessoradoIDs` no VM; int tests — coordenador/candidato editam tudo, assessor só a carteira (célula de texto em linha em escopo ok; linha staff-only negada; Lidera/Aliada com addableIds; Assessora/Assessorado read-only), multi-conta do Assessora nega, union do Assessorado escreve nas duas entidades, telefone conflitante falha com mensagem segura.
+3. **Page + polish** (~1/3 appetite): colunas com a matriz por papel, tooltips nas ações, indicador de foco discreto, `+X`, colapso por mouse-leave; shape→craft→critique→polish.
+4. **Gates:** após cada slice `pnpm exec tsc --noEmit` + testes focados; ao final `pnpm lint` (0 warnings), `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm test:e2e` (se tocar jornada), `pnpm build` local.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Contenteditable real para chips internos — o comportamento continua o de input (chips como dados, teclado previsível); a mecânica é chips + input inline do `RelationChipCell`.
+- Estado de expansão global / animações entre linhas — expansão é local à célula, por hover/foco, sem estado persistido.
+- Tree-view de território com sub-chips persistentes — grupo colapsa/expande in loco; nada de hierarquia.
+- Edição em lote / spreadsheet mode / reorder de colunas / coluna nova.
+- C112 (telefones múltiplos) e B155+ (rota em vez de form action) continuam suaves — o editor de Contato segue o shape atual e o padrão de escrita vigente (form action).
+- Migration de schema — nenhuma.
+
+## Riscos e mitigação
+
+- **Regressão B163/dobradinhas/lideranças:** modo `permanent` default-off; testes existentes do controle compartilhado cobrem os modos antigos; revisão visual das duas listas na Fase 1.
+- **Regressão `RelationChipCell` (B34/B36/B37/B156/B157/B159/B169):** três props opt-in com defaults inalterados; testes unit/int existentes das células de relação rodam intactos na Fase 1.
+- **Mecanismo do Nome (input vazio + draft no link):** caret invisível é trade-off do mecanismo travado; a navegação e a digitação coexistem; teste de componente cobre clique-no-link (navega) vs clique-fora (edita), Enter/Esc/blur.
+- **Assessor com linha visível por capacidade fora da carteira:** matriz por coluna derruba a edição quando a entidade subjacente não é gerenciável (Lidera/Aliada read-only na linha); o server nega de qualquer forma (access da collection dona).
+- **Pessoa com múltiplas contas staff (Assessora):** célula read-only + server nega; edge raro e honesto.
+- **Chips batch (território/Salvador) removidos no X removem o grupo todo:** semântica já existente no sistema; o undo toast cobre a remoção em lote.
+- **Revalidação de paths novos** (`/campanha/pessoas` + detalhes das entidades): seguir os helpers de revalidação existentes por entidade; nunca `revalidatePath` global.
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção ainda coberto (inputs permanentes, Nome-link, chips com colapso por território/Salvador, +X, X no hover, tooltips, mobile intacto, sem lote)
+- [ ] Sem migration; escrita no dono da ficha (`Contact`) e nas collections donas dos vínculos
+- [ ] Local API com `user` + `overrideAccess: false`; bypass do Contact/entities justificado após checagem de escopo; transações com `req` em toda escrita multi-collection
+- [ ] Escopo do assessor preservado (matriz por coluna + access da collection dona); leader fora da rota; multi-conta do Assessora nega
+- [ ] `contactFieldUpdateSchema` ganha `city` de forma aditiva; mensagens seguras allowlisted
+- [ ] Testes previstos: unit (grupo Salvador, expand chips, +X) e int (escopo de célula por papel, assessora single-account, union do assessorado, telefone conflitante)
+- [ ] Nome em identificadores inglês; copy pt-BR
+
+**Self-score decision-quality: 5/5** — decisões caras (estender vs criar twin, mecanismo do Nome, semântica do Assessorado/Assessora) têm alternativas rejeitadas com justificativa; appetite bounded por fases; rabbit holes nomeados; reuso máximo das máquinas existentes; o aceite da intenção não foi alterado.
diff --git a/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts b/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts
new file mode 100644
index 00000000..79f666c1
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts
@@ -0,0 +1,188 @@
+'use server'
+
+import { setLeadershipMunicipalitiesMembership } from '@/app/(campaign)/campanha/actions/leadership'
+import {
+ setPersonAdvisorMembership,
+ setPersonAssessoraMembership,
+ updatePersonContact,
+} from '@/app/(campaign)/campanha/actions/person'
+import { setStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy'
+import {
+ nullableFormText,
+ repeatedRelationshipFormValues,
+ requiredFormBoolean,
+ requiredFormText,
+ requiredRelationshipFormValue,
+} from '@/lib/formData'
+import {
+ LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
+ LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
+ LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
+ LEADERSHIP_STAFF_MESSAGE,
+} from '@/lib/schemas/leadership'
+import {
+ PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
+ PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
+ PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
+ PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
+ PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
+ PERSON_CELL_STAFF_MESSAGE,
+ PERSON_CONTACT_INVALID_MESSAGE,
+} from '@/lib/schemas/personCell'
+import {
+ STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
+ STATE_DEPUTY_STAFF_MESSAGE,
+} from '@/lib/schemas/stateDeputy'
+import {
+ runCampaignFormAction,
+ type CampaignFormActionState,
+} from '@/utilities/campaignFormActionError'
+
+const personContactSafeMessages = [
+ PERSON_CELL_STAFF_MESSAGE,
+ PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
+ PERSON_CONTACT_INVALID_MESSAGE,
+] as const
+
+const personAssessoraSafeMessages = [
+ PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
+ PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
+ PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
+] as const
+
+const personAssessoradoSafeMessages = [
+ PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
+ PERSON_CONTACT_INVALID_MESSAGE,
+] as const
+
+const leadershipMunicipalitiesSafeMessages = [
+ LEADERSHIP_STAFF_MESSAGE,
+ LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
+ LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
+ LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
+] as const
+
+/** Per-field Contact edit for the people list cells (C116). */
+export const updatePersonContactFormAction = async (
+ _state: CampaignFormActionState,
+ formData: FormData,
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ const contactId = requiredRelationshipFormValue(formData, 'contactId')
+ const field = requiredFormText(formData, 'field')
+
+ if (field === 'name') {
+ await updatePersonContact({
+ id: contactId,
+ field: 'name',
+ name: requiredFormText(formData, 'name'),
+ })
+ } else if (field === 'email') {
+ await updatePersonContact({
+ id: contactId,
+ field: 'email',
+ email: nullableFormText(formData, 'email') ?? undefined,
+ })
+ } else if (field === 'phone') {
+ await updatePersonContact({
+ id: contactId,
+ field: 'phone',
+ phone: nullableFormText(formData, 'phone'),
+ })
+ } else if (field === 'city') {
+ await updatePersonContact({
+ id: contactId,
+ field: 'city',
+ city: requiredFormText(formData, 'city'),
+ })
+ } else {
+ throw new Error(PERSON_CELL_NOT_IN_SCOPE_MESSAGE)
+ }
+
+ return { message: 'Salvo.' }
+ },
+ safeMessages: personContactSafeMessages,
+ genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
+ })
+
+/**
+ * Assessora column (C116): one territory / "Salvador (19)" batch or single
+ * municipality toggling the person's staff account on `municipality.advisors`.
+ */
+export const setPersonAssessoraFormAction = async (
+ _state: CampaignFormActionState,
+ formData: FormData,
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ await setPersonAssessoraMembership({
+ contactId: requiredRelationshipFormValue(formData, 'contactId'),
+ municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
+ assigned: requiredFormBoolean(formData, 'assigned'),
+ })
+ return { message: 'Carteira atualizada.' }
+ },
+ safeMessages: personAssessoraSafeMessages,
+ genericMessage: 'Não foi possível atualizar a carteira. Tente novamente.',
+ })
+
+/**
+ * Assessorado column (C116): one advisor delta applied to every entity of the
+ * person (leadership and/or dobradinha).
+ */
+export const setPersonAssessoradoFormAction = async (
+ _state: CampaignFormActionState,
+ formData: FormData,
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ await setPersonAdvisorMembership({
+ contactId: requiredRelationshipFormValue(formData, 'contactId'),
+ advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
+ assigned: requiredFormBoolean(formData, 'assigned'),
+ })
+ return { message: 'Assessores atualizados.' }
+ },
+ safeMessages: personAssessoradoSafeMessages,
+ genericMessage: 'Não foi possível atualizar os assessores. Tente novamente.',
+ })
+
+/** Lidera column (C116) — reuses the leadership municipalities write. */
+export const setPersonLeadershipMunicipalitiesFormAction = 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: leadershipMunicipalitiesSafeMessages,
+ genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
+ })
+
+/** Aliada em column (C116) — reuses the dobradinha municipalities write. */
+export const setPersonStateDeputyMunicipalitiesFormAction = async (
+ _state: CampaignFormActionState,
+ formData: FormData,
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ await setStateDeputyMunicipalitiesBatch({
+ stateDeputyId: requiredRelationshipFormValue(formData, 'ownerId'),
+ municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
+ assigned: requiredFormBoolean(formData, 'assigned'),
+ })
+ return { message: 'Municípios atualizados.' }
+ },
+ safeMessages: [
+ ...STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
+ STATE_DEPUTY_STAFF_MESSAGE,
+ ] as const,
+ genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
+ })
diff --git a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx
index d94f6e65..250d6f46 100644
--- a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx
@@ -6,20 +6,26 @@ import type { ReactNode } from 'react'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton'
+import { PeopleAssessoradoCell } from '@/components/campaign/people/PeopleAssessoradoCell'
import { PeopleFilters } from '@/components/campaign/people/PeopleFilters'
import { PeopleListPageChrome } from '@/components/campaign/people/PeopleListPageChrome'
+import { PeopleMunicipalityCell } from '@/components/campaign/people/PeopleMunicipalityCell'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
+import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
+import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
+import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
+import type { RelationCellOption } from '@/components/campaign/shared/RelationOptionCell'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button, buttonVariants } from '@/components/ui/button'
@@ -34,14 +40,18 @@ import {
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
+import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import {
resolvedPortfolioEntriesById,
type ResolvedPortfolioEntry,
} from '@/lib/municipalityPortfolio'
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
import { cn } from '@/lib/utils'
+import type { CampaignUser } from '@/payload-types'
+import { getAdvisorMunicipalityIds } from '@/utilities/access/municipalities'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
+import { loadEligibleAdvisorOptions } from '@/utilities/campaignRelationOptions'
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
import { loadPeopleListPageData, type PeopleRowViewModel } from '@/utilities/people/peopleData'
import {
@@ -51,6 +61,14 @@ import {
} from '@/utilities/people/peopleListFilters'
import { buildPeopleListHref, resolvePeopleListUrl } from '@/utilities/people/peopleListUrl'
+import {
+ setPersonAssessoraFormAction,
+ setPersonAssessoradoFormAction,
+ setPersonLeadershipMunicipalitiesFormAction,
+ setPersonStateDeputyMunicipalitiesFormAction,
+ updatePersonContactFormAction,
+} from './formActions'
+
export const metadata = campaignPageMetadataFromCatalog('pessoas')
type PeoplePageProps = {
@@ -84,64 +102,91 @@ const PeopleListEmptyState = ({ hasFilters, scoped }: { hasFilters: boolean; sco
)
-const PeopleMunicipalityCell = ({
- ids,
- index,
-}: {
- ids: readonly number[]
- index: ReadonlyMap
-}) => {
- if (!ids.length) return —
- const names = ids
- .map((id) => index.get(id)?.name)
- .filter((name): name is string => name !== undefined)
- if (!names.length) return —
- const visible = names.slice(0, 2)
- const rest = names.length - visible.length
- return (
-