diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index 8679bb92..35108210 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -4,9 +4,16 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
+<<<<<<< HEAD
**Recently resolved (2026-08-11):** **C124** (Issue #668) — tarefa de atividade fica só com **texto, responsável e concluída**: o campo `due` (Prazo, datetime) sai do modelo e da UI. Remoção em cadeia num único commit simétrico: campo do array `tasks` da `activity` (o guardrail de liderança perde a comparação de `due` no mesmo item — continua fail-closed sobre título/responsável/contagem), `taskSchema` zod, `parseTasksFormData`/`ParsedActivityTask` (formData), `ActivityFormTaskViewModel`/`ActivityTaskViewModel`, input `datetime-local` do `ActivityTaskFields` (grid `2fr_1fr`) e linha "Prazo: …" do `ActivityTaskChecklist` (subtítulo condicional + `break-words` no título — P2 dos reviewers). `doneAt` ("concluída em") permanece como derivado invisível; `taskTotal`/`taskDoneCount` intocados. **Migration** `20260811_183016_drop_activity_task_due` (gerada; diff do gerador tinha no-op de `google_calendar_sync` por snapshot stale — limpa à mão para só o `DROP COLUMN "due"` de `activity_tasks`; dados descartados conforme decidido no gate). Grep pós: zero `due` fora das migrations (histórico frozen); nenhum consumidor fora da vertical (notificações, Google sync, quick actions). Gates: unit+int 736/736, e2e atividade 9/9 (1 flake ECONNRESET no run paralelo — rerun limpo), build OK. Intenção [`atividades-tarefas-sem-prazo.md`](plans/atividades-tarefas-sem-prazo.md); impl [`atividades-tarefas-sem-prazo-impl.md`](plans/atividades-tarefas-sem-prazo-impl.md).
+=======
+**Recently resolved (2026-08-11):** **C118** (Issue #657) — página de detalhe da pessoa `/campanha/pessoas/[id]` (staff), montada pelas **capacidades** da ficha: a mesma rota serve liderança, dobradinha, assessora, apoiador — e só mostra o que a pessoa tem. **Loader irmão** `loadPersonDetail` (`src/utilities/people/personDetail.ts`) reusa o merge da lista (`mergePeopleSources` + `scopePeopleRows`) com os mesmos selects por fonte — o **escopo do assessor é literalmente o da lista** (pessoa só aparece se alguma capacidade municipal estiver na carteira; `null` → `notFound()`), staff com bypass justificado (identity-gated, prova pelo merge); **seção Apoiador** (dado novo vs. view model da lista) lê `supporter` por contact com o access da própria collection (`overrideAccess: false`) — camada dupla, nunca alarga (int testado: apoiador de município fora da carteira é cortado). **`[id]` é `contactID`** (a chave de linha da C100 — e o link do Nome na lista, que a C116 nasceu apontando para `/campanha/liderancas/[id]`, agora aponta para cá). UI: `PersonDetailSections` em `components/campaign/people/` — Ficha (partido/telefone/e-mail/base + pills de capacidade), Liderança (status, acesso ao app, municípios, link "Abrir detalhe de liderança" — convivência v1 com `/campanha/liderancas/[id]`, votos continuam lá), Dobradinha, Assessora (por conta, `campaignRoleLabels`), Assessorado, Apoiador (fonte · município · intenção quando consentida · data), Ações reusando os botões da lista (WhatsApp, `LeadershipInviteRowAction` só com liderança, `DeletePersonButton` ganhou `deletedHref` opcional → volta à lista pós-delete). Sem migration/access novo/Consent; sem votos/KPI (intenção corta; assimetria intacta). Testes: int `personDetail` (matriz de escopo: unrestricted completo, assessor na carteira vê, fora → null, corte de supporter, só-staff só para unrestricted, conta leader não acende Assessora, contato solto/leader → null) e e2e novos em `campaignPeople` (coordinator vê seções por capacidade + link de liderança; dobradinha+apoiador sem bloco de liderança; leader → `/campanha/contatos`). Verificado ao vivo em dev (coordenador/assessor/leader, 404 de id inválido, mobile). Intenção [`pessoas-detalhe-por-capacidades.md`](plans/pessoas-detalhe-por-capacidades.md); impl [`pessoas-detalhe-por-capacidades-impl.md`](plans/pessoas-detalhe-por-capacidades-impl.md).
+>>>>>>> 778ebb56 (feat(C118): página de detalhe da pessoa — seções montadas pelas capacidades (Closes #657))
**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):** **C118** (Issue #657) — página de detalhe da pessoa `/campanha/pessoas/[id]` (staff), montada pelas **capacidades** da ficha: a mesma rota serve liderança, dobradinha, assessora, apoiador — e só mostra o que a pessoa tem. **Loader irmão** `loadPersonDetail` (`src/utilities/people/personDetail.ts`) reusa o merge da lista (`mergePeopleSources` + `scopePeopleRows`) com os mesmos selects por fonte — o **escopo do assessor é literalmente o da lista** (pessoa só aparece se alguma capacidade municipal estiver na carteira; `null` → `notFound()`), staff com bypass justificado (identity-gated, prova pelo merge); **seção Apoiador** (dado novo vs. view model da lista) lê `supporter` por contact com o access da própria collection (`overrideAccess: false`) — camada dupla, nunca alarga (int testado: apoiador de município fora da carteira é cortado). **`[id]` é `contactID`** (a chave de linha da C100 — e o link do Nome na lista, que a C116 nasceu apontando para `/campanha/liderancas/[id]`, agora aponta para cá). UI: `PersonDetailSections` em `components/campaign/people/` — Ficha (partido/telefone/e-mail/base + pills de capacidade), Liderança (status, acesso ao app, municípios, link "Abrir detalhe de liderança" — convivência v1 com `/campanha/liderancas/[id]`, votos continuam lá), Dobradinha, Assessora (por conta, `campaignRoleLabels`), Assessorado, Apoiador (fonte · município · intenção quando consentida · data), Ações reusando os botões da lista (WhatsApp, `LeadershipInviteRowAction` só com liderança, `DeletePersonButton` ganhou `deletedHref` opcional → volta à lista pós-delete). Sem migration/access novo/Consent; sem votos/KPI (intenção corta; assimetria intacta). Testes: int `personDetail` (matriz de escopo: unrestricted completo, assessor na carteira vê, fora → null, corte de supporter, só-staff só para unrestricted, conta leader não acende Assessora, contato solto/leader → null) e e2e novos em `campaignPeople` (coordinator vê seções por capacidade + link de liderança; dobradinha+apoiador sem bloco de liderança; leader → `/campanha/contatos`). Verificado ao vivo em dev (coordenador/assessor/leader, 404 de id inválido, mobile). Intenção [`pessoas-detalhe-por-capacidades.md`](plans/pessoas-detalhe-por-capacidades.md); impl [`pessoas-detalhe-por-capacidades-impl.md`](plans/pessoas-detalhe-por-capacidades-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-detalhe-por-capacidades-impl.md b/docs/plans/pessoas-detalhe-por-capacidades-impl.md
new file mode 100644
index 00000000..66025cf2
--- /dev/null
+++ b/docs/plans/pessoas-detalhe-por-capacidades-impl.md
@@ -0,0 +1,83 @@
+# Impl: Pessoas — página de detalhe da pessoa (seções montadas pelas capacidades)
+
+Status: aprovado
+Atualizado em: 2026-08-11
+Issue: #657
+Intenção: docs/plans/pessoas-detalhe-por-capacidades.md
+Appetite restante: ~2 dias eng (herdado; sem corte necessário — sem migration, sem escrita)
+
+## Leitura da intenção
+
+- **Outcome:** `/campanha/pessoas/` acessível a staff — uma ficha única por pessoa (qualquer capacidade), montada pelas capacidades dela: Ficha, Liderança, Dobradinha, Assessora, Assessorado, Apoiador, Ações. Seção ausente = não renderiza. O detalhe de liderança existente não regride (convivência v1: seção de liderança linka para ele).
+- **O que NÃO negociar:** gate staff (leader → `/campanha/contatos`); escopo do assessor espelhado do merge da lista (nunca alargado); sem migration; `Contact` continua a fonte da ficha; sem KPI/votos na página (assimetria votos × estimativas intacta — não há votos aqui, o detalhe de liderança os tem e fica linkado); seções só quando a capacidade existe.
+- **O que reavaliar:** a hipótese apontava "loader por `contactID` reusando o merge de `peopleData.ts` (ou um irmão `loadPersonDetail`)". Confirmada — o merge (`mergePeopleSources` + `scopePeopleRows`) é o mesmo contrato do escopo da lista e **deve** ser reusado para o escopo não divergir. O ponto novo: a seção Apoiador exige leitura de `supporter` por `contact`, que o view model da lista não carrega — é dado novo do loader, com o access da própria collection.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ A["pessoas/[id]/page.tsx (gate staff + metadata)"] --> B["loadPersonDetail utilities/people/personDetail.ts"]
+ B --> C["mergePeopleSources + scopePeopleRows (peopleData.ts — mesmo contrato da lista)"]
+ B --> D["supporters por contact (overrideAccess:false, user — access da collection)"]
+ B --> E["nomes municípios via portfolio index (cached)"]
+ B -->|null| F["notFound()"]
+ A --> G["components/campaign/people/PersonDetailSections.tsx ficha + seções por capacidade + ações (reuso dos botões da lista)"]
+```
+
+**Opções consideradas:** A | B | C
+**Recomendação:** A — rota nova `pessoas/[id]` com loader irmão no mesmo subdomínio e seções RSC declarativas por capacidade — porque mantém o escopo do assessor literalmente idêntico ao da lista (mesmo merge, mesmas fontes, mesmos selects) e a página fica fina (dado na rota, forma nas seções).
+**Rejeitadas:** B — estender `peopleData.ts` (474 linhas; o loader de detalhe adiciona supporters + nomes de assessorado com granularidade por pessoa — responsabilidade nova, módulo separado no mesmo `people/` é o padrão da casa); C — reusar `loadPeopleListPageData` e achar a linha (carrega a tabela inteira sem paginação só para pegar um id — trabalho ilimitado para uma ficha).
+
+### Decisões de engenharia
+
+- **D1 — Contrato do parâmetro de rota.** `[id]` **é `contactID`** (a chave de linha da lista C100, o que a C116 linkará). Validado por `/^[1-9]\d*$/`. _Rejeitadas:_ `leadershipID` (só cobre lideranças — quebra "qualquer pessoa"); slug (Contact não tem slug; criá-lo é migration + colisão).
+- **D2 — Loader.** `loadPersonDetail(payload, user, contactID)` em `src/utilities/people/personDetail.ts` (novo; importa `mergePeopleSources`/`scopePeopleRows` de `./peopleData`). Lê as três fontes com `where: { contact: { equals } }` e **os mesmos selects/shapes do `loadPeopleListPageData`** (leadership com `overrideAccess: false`; staff com `overrideAccess: true` e o MESMO comentário de justificativa — `contact` é identity-gated, o escopo é provado pelo merge). Depois: merge → scope (advisor → `getAdvisorMunicipalityIds`) → resolve nomes de assessorado (só para a pessoa) → carrega supporters → resolve nomes de municípios via `loadMunicipalityPortfolioIndex()` + `resolvedPortfolioEntriesById` (cached, admin bypass justificado — dados de referência). Retorna `null` quando a pessoa não existe em nenhuma capacidade ou está fora do escopo → `notFound()`.
+- **D3 — Escopo (contrato inegociável da intenção).** O view model do detalhe = `PeopleRowViewModel` estendido. O escopo do assessor é o do merge (`scopePeopleRows`), nunca o contrário. Leitura de supporters adicionalmente pelo access da collection (`overrideAccess: false, user` — advisor vê só municípios da carteira): camada dupla, nunca alarga.
+- **D4 — Seção Apoiador.** Query `supporter` por contact; resumo por registro: fonte (`supporterSourceLabels`), município (nome), intenção de voto (label `supporterVoteIntentionLabels`, só quando `voteIntentionConsentedAt` — convenção do detalhe de apoiador), criado em. Zero registros → seção não renderiza. Sem blocos de texto de Consent na página (o consent em tela continua nas superfícies de apoiador; aqui é resumo de leitura já autorizada pela collection).
+- **D5 — Composição de seções.** Um módulo `PersonDetailSections.tsx` em `components/campaign/people/`: shell compartilhado `PersonSectionCard` (ícone + título + badge trailing) + `PersonFichaSection` (nome/partido/contato/base + pills de capacidade "o que a pessoa é") + seções por capacidade + `PersonActionsSection`. Condicional na página: `leadershipID !== null`, `deputyID !== null`, `staff.length > 0`, `assessoradoNames.length > 0`, supporters > 0. _Rejeitada:_ um componente monolítico de detalhe (página deixa de ser fina); um "registro de capacidades" genérico (5 seções de leitura não são arquitetura de plugin).
+- **D6 — Ações.** Reuso direto do padrão da lista: WhatsApp (`whatsAppHrefForPhone` + `Button asChild`, disabled sem telefone), `LeadershipInviteRowAction` (só liderança), `DeletePersonButton` com **prop opcional `onDeleted`** — no detalhe navega para `/campanha/pessoas` após o delete (sem isso o usuário fica preso num 404). _Rejeitadas:_ envolver o delete num client component do detalhe (duplicação); deixar como está (tela morta pós-delete).
+- **D7 — Chrome/metadata.** `SetCampaignPageChrome { title: 'Pessoa', subtitle: name }` + `generateMetadata` = `campaignPageMetadata({ title: 'Pessoa', subtitle: name })` — padrão exato de `liderancas/[id]`.
+- **D8 — Chips de município do detalhe.** Componente read-only próprio (`PersonMunicipalityChips`, `Badge` outline, mesmo visual da célula da lista), via índice resolvido. _Decisão barata adiada:_ extrair `PeopleMunicipalityCell` da lista para compartilhar — a C116 a transforma em chips internos ao input; o detalhe é read-only; futuros divergentes, extração hoje criaria componente que a C116 deleta.
+
+### Componentes / mudanças
+
+- **`loadPersonDetail`** (`src/utilities/people/personDetail.ts`): o loader do detalhe; reusa merge/scope; novo: supporters por contact + nomes de assessorado por pessoa.
+- **`pessoas/[id]/page.tsx`** (rota nova): gate staff, metadata, loader, notFound, monta seções.
+- **`PersonDetailSections.tsx`** (`src/components/campaign/people/`): ficha + 5 seções + ações; shell `PersonSectionCard`.
+- **`DeletePersonButton`**: +1 prop opcional (`onDeleted`) — lista intocada.
+- **Migration:** sem migration (ficha = `Contact` + capacidades existentes; nada de schema novo).
+- **Access / Consent:** nenhum access novo — o loader herda o access de cada collection dona + o merge; sem chave de Consent nova (seção Apoiador lê só o que o access da collection já autoriza).
+- **UI:** Impeccable **C** (fluxo novo — rota de detalhe + composição por capacidade): shape → craft → critique → polish. Shells da casa (`CampaignPageShell`, `SetCampaignPageChrome`, `Badge`, `Button`); sem componente de lista novo.
+
+### Dados → forma (se aplicável)
+
+- Sem dados analíticos (a intenção corta KPI/votos). A única forma numérica é a contagem de municípios em chips quando excede o colapso "+X" — mesmo padrão da lista. Sem pergunta 3 de data-presentation.
+
+## Fases verificáveis
+
+1. **Tracer (server)** — `loadPersonDetail` + rota mínima (ficha + seções sem polish) + `notFound`/metadata. Verificação: int spec da matriz de escopo verde.
+2. **UI (shape → craft → critique → polish)** — seções por capacidade, pills, chips, ações reusadas, estados (sem telefone, sem capacidade, assessor).
+3. **Gates** — int (matriz de escopo + supporters) e e2e (`campaignPeople.e2e.spec.ts`: coordinator abre detalhe por URL e vê seções certas; pessoa só-dobradinha sem bloco de liderança; leader redirecionado). Depois: `tsc`, `lint`, `format`, `knip`, `cycles`, unit+int, `build`; entrada no CHANGELOG; `pnpm push` → PR.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Registro de capacidades / sistema de plugins de seção — 5 seções read-only não justificam abstração.
+- Edição na página (dono é a C116, in-place na lista); escala para "mini-admin".
+- Migrar `/campanha/liderancas/[id]` para a rota nova — v1 convive (decisão assumida da intenção); seção de liderança linka para o detalhe rico (votos/ações vivem lá).
+- Votos/estimativas na ficha — fora (a intenção corta); a assimetria atual fica onde está.
+- Mostrar texto de Consent na seção Apoiador — as superfícies de apoiador já o mostram; aqui é resumo autorizado pelo access.
+- Extrair `PeopleMunicipalityCell` da lista — C116 reescreve a célula; divergente do detalhe read-only.
+
+## Riscos e mitigação
+
+- **Divergência de escopo assessor entre lista e detalhe** — mitigado por construção: mesmo merge, mesmos selects, mesma função de scope; matriz pinada no int spec.
+- **Apoiador vazar município fora da carteira do assessor** — o access da collection corta na query (`overrideAccess: false`); int test pinando o corte.
+- **Convivência com C116** — contrato do link fixado: `/campanha/pessoas/` (D1). C116 linka para liderança enquanto C118 não existe; se C118 entrar primeiro, C116 aponta para cá. Sem overlap de arquivos (C116 mexe na célula da lista; C118 na rota nova). Se houver conflito de merge, é trivial (uma linha).
+- **`overrideAccess: true` na fonte staff** — o MESMO bypass já justificado na lista (identity-gated + escopo pelo merge); comentário replicado.
+- **Pessoa sem nenhuma capacidade digitada à mão na URL** → loader retorna `null` → `notFound()` — rota honesta, sem lookup de Contact solto (que vazaria fichas admin-gated).
+
+## Aceite de engenharia
+
+- [x] Aceite de produto da intenção ainda coberto (rota staff, seções por capacidade, ausentes não aparecem, escopo preservado, sem migration, liderança não regride)
+- [x] Invariantes AGENTS/engineering-standards (Local API `user` → `overrideAccess: false` exceto bypass justificado; leader lockdown; copy pt-BR / identificadores em inglês)
+- [ ] Testes de domínio previstos: `tests/int/personDetail.int.spec.ts` (matriz de escopo: unrestricted completo; advisor na carteira vê; advisor fora não vê → null; corte de supporter fora da carteira; pessoa só-staff visível só a unrestricted; contato sem capacidade → null) + e2e da rota
diff --git a/src/app/(campaign)/campanha/(app)/pessoas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/pessoas/[id]/page.tsx
new file mode 100644
index 00000000..e4ab7f81
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/pessoas/[id]/page.tsx
@@ -0,0 +1,58 @@
+import config from '@payload-config'
+import { notFound } from 'next/navigation'
+import { getPayload } from 'payload'
+
+import { PersonDetailSections } from '@/components/campaign/people/PersonDetailSections'
+import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
+import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
+import { campaignPageMetadata } from '@/lib/campaignPageChrome'
+import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
+import { resolvedPortfolioEntriesById } from '@/lib/municipalityPortfolio'
+import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
+import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
+import { loadPersonDetail } from '@/utilities/people/personDetail'
+
+export async function generateMetadata({ params }: PersonDetailPageProps) {
+ const { id } = await params
+ if (!/^[1-9]\d*$/.test(id)) return campaignPageMetadata({ title: 'Pessoa' })
+
+ const [user, payload] = await Promise.all([
+ requireCampaignPageActor({ gate: 'staff' }),
+ getPayload({ config }),
+ ])
+
+ const person = await loadPersonDetail(payload, user, Number(id))
+ if (!person) return campaignPageMetadata({ title: 'Pessoa' })
+
+ return campaignPageMetadata({ title: 'Pessoa', subtitle: person.name })
+}
+
+type PersonDetailPageProps = {
+ params: Promise<{ id: string }>
+}
+
+export default async function PersonDetailPage({ params }: PersonDetailPageProps) {
+ const { id } = await params
+ if (!/^[1-9]\d*$/.test(id)) notFound()
+
+ const [user, payload] = await Promise.all([
+ requireCampaignPageActor({ gate: 'staff' }),
+ getPayload({ config }),
+ ])
+
+ const person = await loadPersonDetail(payload, user, Number(id))
+ if (!person) notFound()
+
+ const municipalityIndex = resolvedPortfolioEntriesById(await loadMunicipalityPortfolioIndex())
+
+ return (
+
+
+
+
+ )
+}
diff --git a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx
index 6da90182..d6a0ef12 100644
--- a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx
@@ -179,9 +179,7 @@ const peopleColumns = ({
value={row.name}
label="Nome"
formAction={updatePersonContactFormAction}
- {...(row.leadershipID !== null
- ? { href: `/campanha/liderancas/${row.leadershipID}` }
- : {})}
+ href={`/campanha/pessoas/${row.contactID}`}
permanent
/>
{row.party ? ({row.party}) : null}
diff --git a/src/components/campaign/people/DeletePersonButton.tsx b/src/components/campaign/people/DeletePersonButton.tsx
index b5589d3c..92f9e109 100644
--- a/src/components/campaign/people/DeletePersonButton.tsx
+++ b/src/components/campaign/people/DeletePersonButton.tsx
@@ -31,6 +31,12 @@ import type { PersonDeleteManifest } from '@/utilities/people/personDelete'
type DeletePersonButtonProps = {
personName: string
contactId: number
+ /**
+ * Where to go after the destructive cascade succeeds. The people list keeps
+ * the default (`router.refresh()`); detail pages that show the person
+ * navigate away — the ficha no longer exists.
+ */
+ deletedHref?: string
}
type ManifestState =
@@ -59,7 +65,11 @@ const mapDeleteError = (error: unknown): string => {
* person protected; the ficha is deleted or anonymized (LGPD tombstone)
* depending on public joins.
*/
-export const DeletePersonButton = ({ personName, contactId }: DeletePersonButtonProps) => {
+export const DeletePersonButton = ({
+ personName,
+ contactId,
+ deletedHref,
+}: DeletePersonButtonProps) => {
const router = useRouter()
const [manifest, setManifest] = useState({ kind: 'idle' })
const [deleting, setDeleting] = useState(false)
@@ -98,7 +108,11 @@ export const DeletePersonButton = ({ personName, contactId }: DeletePersonButton
? 'Pessoa apagada. A ficha foi anonimizada porque ainda é referenciada por participações públicas.'
: 'Pessoa apagada.',
)
- router.refresh()
+ if (deletedHref) {
+ router.push(deletedHref)
+ } else {
+ router.refresh()
+ }
} catch (error) {
setDeleteError(mapDeleteError(error))
} finally {
diff --git a/src/components/campaign/people/PersonDetailSections.tsx b/src/components/campaign/people/PersonDetailSections.tsx
new file mode 100644
index 00000000..26f99b5d
--- /dev/null
+++ b/src/components/campaign/people/PersonDetailSections.tsx
@@ -0,0 +1,368 @@
+import {
+ ArrowRightIcon,
+ BriefcaseIcon,
+ FlagIcon,
+ HeartHandshakeIcon,
+ LandmarkIcon,
+ MessageCircleIcon,
+ UserIcon,
+ UsersIcon,
+ WrenchIcon,
+} from 'lucide-react'
+import Link from 'next/link'
+import type { ReactNode } from 'react'
+
+import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
+import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
+import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton'
+import { Badge } from '@/components/ui/Badge'
+import { Button } from '@/components/ui/button'
+import type { ResolvedPortfolioEntry } from '@/lib/municipalityPortfolio'
+import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
+import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
+import type { PersonDetailViewModel } from '@/utilities/people/personDetail'
+import {
+ supporterSourceLabels,
+ supporterVoteIntentionLabels,
+} from '@/utilities/supporter/supporterUi'
+
+/**
+ * C118 — the person detail, mounted by capacity: each section renders only
+ * when the ficha actually has that papel (liderança, dobradinha, assessora,
+ * assessorado, apoiador). The page stays thin — the composition rule lives
+ * here, next to the cards. Read-only by design: writing stays in the list
+ * (C116) and the surfaces that own each papel.
+ */
+
+const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' })
+
+const PersonSectionCard = ({
+ sectionId,
+ icon,
+ title,
+ trailing,
+ children,
+}: {
+ /** ASCII stable id (aria-labelledby + CSS): `person-section-`. */
+ sectionId: string
+ icon: ReactNode
+ title: string
+ trailing?: ReactNode
+ children: ReactNode
+}) => (
+
+