feat(C118): página de detalhe da pessoa — seções montadas pelas capacidades (Closes #657)

This commit is contained in:
Francisco Solla 2026-08-11 15:22:12 -03:00
parent e68e4f6eda
commit c0ffc22026
10 changed files with 1171 additions and 76 deletions

View file

@ -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 `<th>` 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 `<th>` 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).

View file

@ -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/<id>` 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<br/>(gate staff + metadata)"] --> B["loadPersonDetail<br/>utilities/people/personDetail.ts"]
B --> C["mergePeopleSources + scopePeopleRows<br/>(peopleData.ts — mesmo contrato da lista)"]
B --> D["supporters por contact<br/>(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<br/>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/<contactID>` (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

View file

@ -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 (
<CampaignPageShell>
<SetCampaignPageChrome chrome={{ title: 'Pessoa', subtitle: person.name }} />
<PersonDetailSections
person={person}
municipalityIndex={municipalityIndex}
canDelete={isUnrestrictedCampaignRole(user.role)}
/>
</CampaignPageShell>
)
}

View file

@ -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 ? <span className="shrink-0 text-muted-foreground">({row.party})</span> : null}

View file

@ -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<ManifestState>({ 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 {

View file

@ -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-<slug>`. */
sectionId: string
icon: ReactNode
title: string
trailing?: ReactNode
children: ReactNode
}) => (
<section
aria-labelledby={`person-section-${sectionId}`}
className="flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex flex-wrap items-center gap-2">
<span className="flex size-5 items-center justify-center text-muted-foreground">{icon}</span>
<h2 id={`person-section-${sectionId}`} className="text-base font-medium">
{title}
</h2>
{trailing}
</div>
{children}
</section>
)
const PersonMunicipalityChips = ({
ids,
index,
}: {
ids: readonly number[]
index: ReadonlyMap<number, ResolvedPortfolioEntry>
}) => {
if (!ids.length) return <span className="text-muted-foreground">—</span>
const names = ids
.map((id) => index.get(id)?.name)
.filter((name): name is string => name !== undefined)
if (!names.length) return <span className="text-muted-foreground">—</span>
const visible = names.slice(0, 2)
const rest = names.length - visible.length
return (
<div className="flex flex-wrap gap-1">
{visible.map((name) => (
<Badge key={name} variant="outline" className="max-w-full truncate font-normal">
{name}
</Badge>
))}
{rest > 0 ? (
<Badge variant="outline" className="font-normal text-muted-foreground">
+{rest}
</Badge>
) : null}
</div>
)
}
const FichaField = ({ label, value }: { label: string; value: string | null }) => (
<div className="flex min-w-0 flex-col gap-1">
<dt className="text-sm text-muted-foreground">{label}</dt>
<dd className="truncate font-medium" title={value ?? undefined}>
{value ?? <span className="font-normal text-muted-foreground">—</span>}
</dd>
</div>
)
const PersonFichaSection = ({ person }: { person: PersonDetailViewModel }) => {
const capacityPills: string[] = []
if (person.leadershipID !== null) capacityPills.push('Liderança')
if (person.deputyID !== null) capacityPills.push('Dobradinha')
if (person.staff.length > 0) capacityPills.push('Assessora')
if (person.supporters.length > 0) capacityPills.push('Apoiador')
return (
<PersonSectionCard
sectionId="ficha"
icon={<UserIcon className="size-4" aria-hidden="true" />}
title="Ficha"
>
<dl className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<FichaField label="Partido" value={person.party} />
<FichaField
label="Telefone"
value={person.phone ? formatBrazilianPhoneInput(person.phone) : null}
/>
<FichaField label="E-mail" value={person.email} />
<FichaField label="Base" value={person.city} />
</dl>
<div className="flex flex-wrap gap-1">
{capacityPills.map((label) => (
<Badge key={label} variant="secondary">
{label}
</Badge>
))}
</div>
</PersonSectionCard>
)
}
const PersonLeadershipSection = ({
person,
municipalityIndex,
}: {
person: PersonDetailViewModel
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
}) => (
<PersonSectionCard
sectionId="leadership"
icon={<FlagIcon className="size-4" aria-hidden="true" />}
title="Liderança"
trailing={
<Badge variant={person.hasAppAccess ? 'estimate-confirmed' : 'outline'}>
{person.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app'}
</Badge>
}
>
{person.supportStatus ? <SupportStatusBadge status={person.supportStatus} /> : null}
<div className="flex flex-col gap-1 text-sm">
<span className="text-muted-foreground">Municípios</span>
<PersonMunicipalityChips ids={person.leadershipMunicipalityIDs} index={municipalityIndex} />
</div>
{person.leadershipID !== null ? (
<Button asChild variant="ghost" className="w-fit gap-1 pl-0 text-muted-foreground">
<Link href={`/campanha/liderancas/${person.leadershipID}`}>
Abrir detalhe de liderança
<ArrowRightIcon className="size-4" aria-hidden="true" />
</Link>
</Button>
) : null}
</PersonSectionCard>
)
const PersonDeputySection = ({
person,
municipalityIndex,
}: {
person: PersonDetailViewModel
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
}) => (
<PersonSectionCard
sectionId="deputy"
icon={<LandmarkIcon className="size-4" aria-hidden="true" />}
title="Dobradinha"
trailing={person.party ? <Badge variant="outline">{person.party}</Badge> : null}
>
<div className="flex flex-col gap-1 text-sm">
<span className="text-muted-foreground">Municípios</span>
<PersonMunicipalityChips ids={person.deputyMunicipalityIDs} index={municipalityIndex} />
</div>
</PersonSectionCard>
)
const PersonAssessoraSection = ({
person,
municipalityIndex,
}: {
person: PersonDetailViewModel
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
}) => (
<PersonSectionCard
sectionId="assessora"
icon={<BriefcaseIcon className="size-4" aria-hidden="true" />}
title="Assessora"
trailing={<Badge variant="outline">{person.staff.length}</Badge>}
>
<div className="flex flex-col gap-3">
{person.staff.map((account) => (
<div key={account.id} className="flex flex-col gap-1 text-sm">
<p>
{account.name}{' '}
<span className="text-muted-foreground">({campaignRoleLabels[account.role]})</span>
</p>
{account.municipalityIDs.length > 0 ? (
<PersonMunicipalityChips ids={account.municipalityIDs} index={municipalityIndex} />
) : (
<span className="text-muted-foreground">Sem municípios na carteira</span>
)}
</div>
))}
</div>
</PersonSectionCard>
)
const PersonAssessoradoSection = ({ names }: { names: string[] }) => (
<PersonSectionCard
sectionId="assessorado"
icon={<UsersIcon className="size-4" aria-hidden="true" />}
title="Assessorado"
trailing={<Badge variant="outline">{names.length}</Badge>}
>
<p className="text-sm">{names.join(' · ')}</p>
</PersonSectionCard>
)
const PersonSupporterSection = ({
person,
municipalityIndex,
}: {
person: PersonDetailViewModel
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
}) => (
<PersonSectionCard
sectionId="supporter"
icon={<HeartHandshakeIcon className="size-4" aria-hidden="true" />}
title="Apoiador"
trailing={<Badge variant="outline">{person.supporters.length}</Badge>}
>
<ul className="flex flex-col gap-3">
{person.supporters.map((supporter) => {
const municipalityName =
supporter.municipalityID === null
? null
: (municipalityIndex.get(supporter.municipalityID)?.name ?? null)
const voteIntentionLabel = supporter.voteIntention
? supporterVoteIntentionLabels[supporter.voteIntention]
: null
return (
<li key={supporter.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span className="text-muted-foreground">
{supporterSourceLabels[supporter.source]}
{municipalityName ? ` · ${municipalityName}` : ''}
</span>
{voteIntentionLabel ? (
<>
<Badge variant="estimate-confirmed">{voteIntentionLabel}</Badge>
{!supporter.hasVoteIntentionConsent ? (
<span className="text-xs text-muted-foreground">
sem consentimento registrado
</span>
) : null}
</>
) : (
<Badge variant="outline">Sem intenção registrada</Badge>
)}
<span className="text-xs text-muted-foreground">
{dateFormatter.format(new Date(supporter.createdAt))}
</span>
</li>
)
})}
</ul>
</PersonSectionCard>
)
const PersonActionsSection = ({
person,
canDelete,
}: {
person: PersonDetailViewModel
canDelete: boolean
}) => {
const whatsAppHref = whatsAppHrefForPhone(person.phone)
const hasActions = person.leadershipID !== null || whatsAppHref !== null || canDelete
if (!hasActions) return null
return (
<PersonSectionCard
sectionId="actions"
icon={<WrenchIcon className="size-4" aria-hidden="true" />}
title="Ações"
>
<div className="flex flex-wrap items-center gap-2">
{person.leadershipID !== null ? (
<LeadershipInviteRowAction
leadershipID={person.leadershipID}
name={person.name}
hasValidPhone={whatsAppHref !== null}
/>
) : null}
{whatsAppHref ? (
<Button asChild variant="outline" className="min-h-10">
<a href={whatsAppHref} target="_blank" rel="noopener noreferrer" className="gap-1.5">
<MessageCircleIcon className="size-4" aria-hidden="true" />
Enviar WhatsApp
</a>
</Button>
) : (
<Button
type="button"
variant="outline"
disabled
className="min-h-10 gap-1.5"
aria-label={`WhatsApp indisponível — ${person.name} sem celular`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
Enviar WhatsApp
</Button>
)}
{canDelete ? (
<DeletePersonButton
personName={person.name}
contactId={person.contactID}
deletedHref="/campanha/pessoas"
/>
) : null}
</div>
</PersonSectionCard>
)
}
export const PersonDetailSections = ({
person,
municipalityIndex,
canDelete,
}: {
person: PersonDetailViewModel
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
canDelete: boolean
}) => (
<div className="flex flex-col gap-4">
<PersonFichaSection person={person} />
{person.leadershipID !== null ? (
<PersonLeadershipSection person={person} municipalityIndex={municipalityIndex} />
) : null}
{person.deputyID !== null ? (
<PersonDeputySection person={person} municipalityIndex={municipalityIndex} />
) : null}
{person.staff.length > 0 ? (
<PersonAssessoraSection person={person} municipalityIndex={municipalityIndex} />
) : null}
{person.assessoradoNames.length > 0 ? (
<PersonAssessoradoSection names={person.assessoradoNames} />
) : null}
{person.supporters.length > 0 ? (
<PersonSupporterSection person={person} municipalityIndex={municipalityIndex} />
) : null}
<PersonActionsSection person={person} canDelete={canDelete} />
</div>
)

View file

@ -79,7 +79,7 @@ export type MergedPerson = Omit<PeopleRowViewModel, 'assessoradoNames'> & {
deputyAdvisorIDs: number[]
}
type PeopleLeadershipSource = {
export type PeopleLeadershipSource = {
id: number
contactID: number
name: string
@ -92,7 +92,7 @@ type PeopleLeadershipSource = {
advisorIDs: number[]
}
type PeopleDeputySource = {
export type PeopleDeputySource = {
id: number
contactID: number
name: string
@ -104,7 +104,7 @@ type PeopleDeputySource = {
advisorIDs: number[]
}
type PeopleStaffSource = {
export type PeopleStaffSource = {
id: number
contactID: number
name: string
@ -124,6 +124,96 @@ export type PeopleMergeSources = {
const sortedDistinct = (values: Iterable<number>): number[] =>
[...new Set(values)].sort((left, right) => left - right)
/**
* Doc → source mappers shared by the list and the person detail (C118): the
* capacity definition must never diverge between the two surfaces, so the
* transforms (and the role filter of the staff source) live here once.
*/
export const toPeopleLeadershipSource = (doc: Leadership): PeopleLeadershipSource => {
const contact = contactSummary(doc.contact)
return {
id: doc.id,
contactID: contact.id,
name: contact.name,
phone: contact.phone,
email: contact.email,
city: contact.city,
municipalityIDs: uniqueRelationshipIds(doc.municipalities),
supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null,
hasAppAccess: relationshipId(doc.user) !== null,
advisorIDs: uniqueRelationshipIds(doc.advisors),
}
}
export const toPeopleDeputySource = (
doc: StateDeputy,
municipalityIdsByDeputy: ReadonlyMap<number, number[]>,
): PeopleDeputySource => {
const contact = contactSummary(doc.contact)
return {
id: doc.id,
contactID: contact.id,
name: contact.name,
phone: contact.phone,
email: contact.email,
city: contact.city,
party: doc.party ?? null,
municipalityIDs: municipalityIdsByDeputy.get(doc.id) ?? [],
advisorIDs: uniqueRelationshipIds(doc.advisors),
}
}
export const toPeopleStaffSource = (
doc: CampaignUser,
municipalityIdsByStaff: ReadonlyMap<number, number[]>,
): PeopleStaffSource => {
const contact = contactSummary(doc.contact)
return {
id: doc.id,
contactID: contact.id,
name: contact.name,
phone: contact.phone,
email: contact.email,
city: contact.city,
role: doc.role,
municipalityIDs: municipalityIdsByStaff.get(doc.id) ?? [],
}
}
/** Dedupe + pt-BR sort of the assessed-by advisors (the Assessorado capacity). */
export const resolveAssessorados = (
advisorIDs: readonly number[],
namesById: ReadonlyMap<number, string>,
): Array<{ id: number; name: string }> =>
[...new Set(advisorIDs)]
.map((id) => ({ id, name: namesById.get(id) }))
.filter((pair): pair is { id: number; name: string } => pair.name !== undefined)
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
/** Merged person → list/detail row: same fields, no accidental drift. */
export const toPeopleRowViewModel = (
person: MergedPerson,
assessorados: ReadonlyArray<{ id: number; name: string }>,
): PeopleRowViewModel => ({
contactID: person.contactID,
name: person.name,
phone: person.phone,
email: person.email,
city: person.city,
party: person.party,
leadershipID: person.leadershipID,
leadershipMunicipalityIDs: person.leadershipMunicipalityIDs,
supportStatus: person.supportStatus,
hasAppAccess: person.hasAppAccess,
deputyID: person.deputyID,
deputyMunicipalityIDs: person.deputyMunicipalityIDs,
staff: person.staff,
assessoraMunicipalityIDs: person.assessoraMunicipalityIDs,
capacityMunicipalityIDs: person.capacityMunicipalityIDs,
assessoradoNames: assessorados.map((pair) => pair.name),
assessorados: [...assessorados],
})
/**
* One row per `contactID`. The ficha fields come from the first source that
* carries them (leadership, then dobradinha, then staff — same ficha, so the
@ -449,51 +539,16 @@ export const loadPeopleListPageData = async (
])
const leaderships: PeopleLeadershipSource[] = (leadershipResult.docs as Leadership[]).map(
(doc) => {
const contact = contactSummary(doc.contact)
return {
id: doc.id,
contactID: contact.id,
name: contact.name,
phone: contact.phone,
email: contact.email,
city: contact.city,
municipalityIDs: uniqueRelationshipIds(doc.municipalities),
supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null,
hasAppAccess: relationshipId(doc.user) !== null,
advisorIDs: uniqueRelationshipIds(doc.advisors),
}
},
toPeopleLeadershipSource,
)
const deputies: PeopleDeputySource[] = (deputyResult.docs as StateDeputy[]).map((doc) => {
const contact = contactSummary(doc.contact)
return {
id: doc.id,
contactID: contact.id,
name: contact.name,
phone: contact.phone,
email: contact.email,
city: contact.city,
party: doc.party ?? null,
municipalityIDs: deputyMunicipalityIdsByDeputy.get(doc.id) ?? [],
advisorIDs: uniqueRelationshipIds(doc.advisors),
}
})
const deputies: PeopleDeputySource[] = (deputyResult.docs as StateDeputy[]).map((doc) =>
toPeopleDeputySource(doc, deputyMunicipalityIdsByDeputy),
)
const staff: PeopleStaffSource[] = (staffResult.docs as CampaignUser[]).map((doc) => {
const contact = contactSummary(doc.contact)
return {
id: doc.id,
contactID: contact.id,
name: contact.name,
phone: contact.phone,
email: contact.email,
city: contact.city,
role: doc.role,
municipalityIDs: staffMunicipalityIdsByStaff.get(doc.id) ?? [],
}
})
const staff: PeopleStaffSource[] = (staffResult.docs as CampaignUser[]).map((doc) =>
toPeopleStaffSource(doc, staffMunicipalityIdsByStaff),
)
const merged = mergePeopleSources({ leaderships, deputies, staff })
const accessibleMunicipalityIds =
@ -515,32 +570,15 @@ export const loadPeopleListPageData = async (
}
const advisorNames = await loadCampaignUserNamesByIds(payload, [...advisorIDs])
const rows: PeopleRowViewModel[] = pageRows.map((person) => {
const advisorIDs = [...new Set([...person.leadershipAdvisorIDs, ...person.deputyAdvisorIDs])]
const assessorados = advisorIDs
.map((id) => ({ id, name: advisorNames.get(id) }))
.filter((pair): pair is { id: number; name: string } => pair.name !== undefined)
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
return {
contactID: person.contactID,
name: person.name,
phone: person.phone,
email: person.email,
city: person.city,
party: person.party,
leadershipID: person.leadershipID,
leadershipMunicipalityIDs: person.leadershipMunicipalityIDs,
supportStatus: person.supportStatus,
hasAppAccess: person.hasAppAccess,
deputyID: person.deputyID,
deputyMunicipalityIDs: person.deputyMunicipalityIDs,
staff: person.staff,
assessoraMunicipalityIDs: person.assessoraMunicipalityIDs,
capacityMunicipalityIDs: person.capacityMunicipalityIDs,
assessoradoNames: assessorados.map((pair) => pair.name),
assessorados,
}
})
const rows: PeopleRowViewModel[] = pageRows.map((person) =>
toPeopleRowViewModel(
person,
resolveAssessorados(
[...person.leadershipAdvisorIDs, ...person.deputyAdvisorIDs],
advisorNames,
),
),
)
return { rows, totalDocs, totalPages, filterFacets }
}

View file

@ -0,0 +1,170 @@
import 'server-only'
import type { Payload } from 'payload'
import { relationshipId } from '@/lib/relationship'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import type { CampaignUser, Leadership, StateDeputy, Supporter } from '@/payload-types'
import { municipalityIdsByAdvisorIds } from '@/utilities/advisorData'
import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess'
import { loadCampaignUserNamesByIds } from '@/utilities/loadNamesByIds'
import {
mergePeopleSources,
resolveAssessorados,
scopePeopleRows,
toPeopleDeputySource,
toPeopleLeadershipSource,
toPeopleRowViewModel,
toPeopleStaffSource,
type PeopleRowViewModel,
} from '@/utilities/people/peopleData'
import { buildPeopleStaffSourceWhere } from '@/utilities/people/peopleListUrl'
import { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData'
/**
* The person detail (C118): the same merge-by-`Contact` as the unified list,
* narrowed to ONE ficha, plus the supporter records that the list row does not
* carry. The scope contract is the LIST'S, verbatim — `scopePeopleRows` with
* the advisor's carteira — so a detail can never show a person the list would
* not: an advisor opens only people with at least one capacity municipality in
* her carteira, unrestricted actors open anyone with any role.
*
* The staff source reads with the same justified `overrideAccess` as
* `loadPeopleListPageData` (the `contact` field is identity-gated and would be
* cut from the depth-1 populate otherwise); the merge is what proves the
* actor's scope. The supporter read goes through the collection's own access
* (`overrideAccess: false`) — a second, narrower layer, never a widening one:
* an advisor sees only supporter rows in her municipalities.
*
* Municipality names are NOT resolved here: the page renders every section
* through the shared portfolio index (same "ids only" contract as the list
* columns), so there is one source of truth for names.
*/
type PersonSupporterSummary = {
id: number
source: Supporter['source']
municipalityID: number | null
voteIntention: SupporterVoteIntention | null
hasVoteIntentionConsent: boolean
createdAt: string
}
export type PersonDetailViewModel = PeopleRowViewModel & {
supporters: PersonSupporterSummary[]
}
const supporterDetailSelect = {
source: true,
municipality: true,
voteIntention: true,
voteIntentionConsentedAt: true,
createdAt: true,
} as const
export const loadPersonDetail = async (
payload: Payload,
user: CampaignUser,
contactID: number,
): Promise<PersonDetailViewModel | null> => {
if (!isCampaignStaff(user)) return null
const [leadershipResult, deputyResult, staffResult, supporterResult] = await Promise.all([
payload.find({
collection: 'leadership',
where: { contact: { equals: contactID } },
depth: 1,
limit: 0,
pagination: false,
select: {
contact: true,
municipalities: true,
advisors: true,
supportStatus: true,
user: true,
},
user,
overrideAccess: false,
}),
payload.find({
collection: 'stateDeputy',
where: { contact: { equals: contactID } },
depth: 1,
limit: 0,
pagination: false,
select: { contact: true, slug: true, party: true, advisors: true },
user,
overrideAccess: false,
}),
payload.find({
collection: 'campaignUser',
// The list's own staff where (`role in advisor|coordinator|candidate` +
// `contact exists`) — a leader account linked to the ficha must never
// light the Assessora capacity here, exactly as it never does in the list.
where: {
and: [buildPeopleStaffSourceWhere({ page: 1 }), { contact: { equals: contactID } }],
},
depth: 1,
limit: 0,
pagination: false,
select: { name: true, role: true, contact: true },
// Same justified bypass as `loadPeopleListPageData`: `contact` is
// identity-gated and the merge below proves the actor's scope.
overrideAccess: true,
}),
payload.find({
collection: 'supporter',
where: { contact: { equals: contactID } },
depth: 1,
limit: 0,
pagination: false,
select: supporterDetailSelect,
user,
overrideAccess: false,
}),
])
const [deputyMunicipalityIdsByDeputy, staffMunicipalityIdsByStaff] = await Promise.all([
municipalityIdsByStateDeputyIds(
payload,
deputyResult.docs.map((doc) => doc.id),
),
municipalityIdsByAdvisorIds(
payload,
staffResult.docs.map((doc) => doc.id),
),
])
const leaderships = (leadershipResult.docs as Leadership[]).map(toPeopleLeadershipSource)
const deputies = (deputyResult.docs as StateDeputy[]).map((doc) =>
toPeopleDeputySource(doc, deputyMunicipalityIdsByDeputy),
)
const staff = (staffResult.docs as CampaignUser[]).map((doc) =>
toPeopleStaffSource(doc, staffMunicipalityIdsByStaff),
)
const merged = mergePeopleSources({ leaderships, deputies, staff })
const accessibleMunicipalityIds =
user.role === 'advisor' ? new Set(await getAdvisorMunicipalityIds(payload, user.id)) : null
const [person] = scopePeopleRows(merged, accessibleMunicipalityIds)
if (!person) return null
const advisorIDs = [...new Set([...person.leadershipAdvisorIDs, ...person.deputyAdvisorIDs])]
const advisorNames = await loadCampaignUserNamesByIds(payload, advisorIDs)
const assessorados = resolveAssessorados(advisorIDs, advisorNames)
const supporters: PersonSupporterSummary[] = (supporterResult.docs as Supporter[]).map((doc) => ({
id: doc.id,
source: doc.source,
municipalityID: relationshipId(doc.municipality),
voteIntention: doc.voteIntention ?? null,
hasVoteIntentionConsent: Boolean(doc.voteIntentionConsentedAt),
createdAt: doc.createdAt,
}))
return {
...toPeopleRowViewModel(person, assessorados),
supporters,
}
}

View file

@ -39,6 +39,27 @@ test.describe('Pessoas — lista unificada', () => {
await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible()
})
test('the name link in the list opens the person detail (C118 entry point)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
const { contactId, contactName } = await fixtures.createStaffLeadership({
namePrefix: 'Nome Link Detalhe',
municipalities: [municipality],
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/pessoas')
await page.getByRole('link', { name: contactName, exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/campanha/pessoas/${contactId}$`))
await expect(campaignPageChrome(page, 'Pessoa')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Liderança' })).toBeVisible()
})
test('leader cannot open the people page', async ({ campaign, page }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
@ -54,6 +75,125 @@ test.describe('Pessoas — lista unificada', () => {
await expect(page.getByRole('heading', { name: 'Contatos' })).toBeVisible()
})
test('leader cannot open the person detail route', async ({ campaign, page }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança Detalhe Sem Acesso'),
username: phone,
})
await campaign.login(page, phone, leader.password)
await page.goto('/campanha/pessoas/99999')
await expect(page).toHaveURL(/\/campanha\/contatos/)
await expect(page.getByRole('heading', { name: 'Contatos' })).toBeVisible()
})
test('coordinator opens the person detail and sees the sections of her capacities', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
const { contactId, contactName, leadershipId } = await fixtures.createStaffLeadership({
namePrefix: 'Liderança Detalhe',
municipalities: [municipality],
supportStatus: 'engajado',
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/pessoas/${contactId}`)
await expect(page).toHaveURL(new RegExp(`/campanha/pessoas/${contactId}$`))
await expect(campaignPageChrome(page, 'Pessoa')).toBeVisible()
await expect(
page
.locator('[data-slot="campaign-page-chrome"]')
.filter({ visible: true })
.getByText(contactName),
).toBeVisible()
// Sections mounted by capacity: a leadership-only person shows Ficha +
// Liderança, never an empty Dobradinha block.
await expect(page.getByRole('heading', { name: 'Ficha' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Liderança' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Dobradinha' })).not.toBeVisible()
await expect(page.getByRole('heading', { name: 'Assessorado' })).not.toBeVisible()
// The leadership section links to the rich existing detail (v1 coexistence).
await expect(page.getByRole('link', { name: 'Abrir detalhe de liderança' })).toHaveAttribute(
'href',
`/campanha/liderancas/${leadershipId}`,
)
await expect(
page.getByRole('region', { name: 'Liderança' }).getByText('Engajado'),
).toBeVisible()
// Actions: WhatsApp (phone exists) + delete (coordinator); invite for leaders.
await expect(page.getByRole('link', { name: 'Enviar WhatsApp' })).toBeVisible()
await expect(page.getByRole('button', { name: /Convidar/ })).toBeVisible()
await expect(page.getByRole('button', { name: `Apagar pessoa ${contactName}` })).toBeVisible()
})
test('coordinator sees dobradinha and apoiador sections only for a person who has them', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const deputyMunicipality = await fixtures.claimMunicipality()
const supporterMunicipality = await fixtures.claimMunicipality()
const contact = await fixtures.payload.create({
collection: 'contact',
data: {
name: fixtures.value('Dobradinha Detalhe'),
phones: [{ value: fixtures.phone() }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
})
const deputy = await fixtures.payload.create({
collection: 'stateDeputy',
data: { contact: contact.id, party: 'PCdoB', slug: fixtures.value('dobradinha-detalhe') },
depth: 0,
})
await fixtures.payload.update({
collection: 'municipality',
id: deputyMunicipality.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
overrideAccess: true,
})
fixtures.touchMunicipality(deputyMunicipality.id)
await fixtures.payload.create({
collection: 'supporter',
data: {
contact: contact.id,
municipality: supporterMunicipality.id,
source: 'evento',
voteIntention: 'certo',
},
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/pessoas/${contact.id}`)
await expect(page.getByRole('heading', { name: 'Ficha' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Dobradinha' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Apoiador' })).toBeVisible()
await expect(page.getByRole('region', { name: 'Dobradinha' }).getByText('PCdoB')).toBeVisible()
await expect(page.getByRole('region', { name: 'Apoiador' }).getByText('Certo')).toBeVisible()
// No leadership capacity → no leadership block, no invite.
await expect(page.getByRole('heading', { name: 'Liderança' })).not.toBeVisible()
await expect(page.getByRole('button', { name: /Convidar/ })).not.toBeVisible()
})
test('staff sorts by a column header and filters absence via the omnibox (C117)', async ({
campaign,
page,
@ -130,3 +270,4 @@ test.describe('Pessoas — lista unificada', () => {
await expect(page.getByRole('row', { name: new RegExp(twoCities.contactName) })).toBeHidden()
})
})
})

View file

@ -0,0 +1,218 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { loadPersonDetail } from '@/utilities/people/personDetail'
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('C118 — detalhe da pessoa (seções por capacidade, escopo do merge da lista)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('unrestricted vê a ficha inteira: liderança + dobradinha + staff + assessorado + apoiador', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const leadershipMunicipality = await fixtures.getMunicipality()
const supporterMunicipality = await fixtures.getMunicipality()
const mariaName = fixtures.personName('Maria de Jesus')
const contact = await fixtures.createContact({ name: mariaName })
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessor Maria'),
})
const leadership = await fixtures.createLeadership({
contact,
municipalities: [leadershipMunicipality],
supportStatus: 'engajado',
})
await payload.update({
collection: 'leadership',
id: leadership.id,
data: { advisors: [advisor.id] },
depth: 0,
overrideAccess: true,
})
const deputy = await fixtures.createStateDeputy({ contact, party: 'PCdoB' })
await payload.update({
collection: 'municipality',
id: leadershipMunicipality.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
overrideAccess: true,
})
// Phone reuse folds the account into the same ficha (staff capacity).
const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
const supporter = await fixtures.createSupporter({
contact,
municipality: supporterMunicipality,
source: 'manual',
voteIntention: 'certo',
})
const person = await loadPersonDetail(payload, coordinator, relationId(contact))
expect(person).not.toBeNull()
expect(person).toMatchObject({
name: mariaName,
party: 'PCdoB',
leadershipID: leadership.id,
supportStatus: 'engajado',
deputyID: deputy.id,
})
expect(person?.leadershipMunicipalityIDs).toContain(leadershipMunicipality.id)
expect(person?.deputyMunicipalityIDs).toEqual([leadershipMunicipality.id])
expect(person?.staff.map((entry) => entry.id)).toContain(account.id)
expect(person?.assessoradoNames).toContain(advisor.name)
expect(person?.supporters).toEqual([
expect.objectContaining({
id: supporter.id,
source: 'manual',
municipalityID: supporterMunicipality.id,
voteIntention: 'certo',
hasVoteIntentionConsent: false,
}),
])
})
it('advisor vê a pessoa com capacidade na carteira', async () => {
const fixtures = campaignFixtures()
const municipality = await fixtures.getMunicipality()
const advisor = await fixtures.createCampaignUser('advisor')
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const contact = await fixtures.createContact({ name: fixtures.personName('Ana Lima') })
const leadership = await fixtures.createLeadership({
contact,
municipalities: [municipality],
})
const person = await loadPersonDetail(payload, advisor, relationId(contact))
expect(person).not.toBeNull()
expect(person?.leadershipID).toBe(leadership.id)
})
it('advisor não vê a pessoa cuja única capacidade está fora da carteira', async () => {
const fixtures = campaignFixtures()
const outside = await fixtures.getMunicipality()
const carteira = await fixtures.getMunicipality()
const advisor = await fixtures.createCampaignUser('advisor')
await fixtures.assignMunicipalityAdvisors(carteira, [advisor])
const contact = await fixtures.createContact({ name: fixtures.personName('Fora da Carteira') })
await fixtures.createLeadership({ contact, municipalities: [outside] })
expect(await loadPersonDetail(payload, advisor, relationId(contact))).toBeNull()
})
it('corta o apoiador de município fora da carteira do assessor', async () => {
const fixtures = campaignFixtures()
const carteira = await fixtures.getMunicipality()
const outside = await fixtures.getMunicipality()
const advisor = await fixtures.createCampaignUser('advisor')
await fixtures.assignMunicipalityAdvisors(carteira, [advisor])
const contact = await fixtures.createContact({ name: fixtures.personName('Apoio Misto') })
// Dobradinha as the carteira capacity: the supporter hook forbids a
// leadership in the same municipality, so the in-scope supporter row
// needs a capacity that is not leadership.
const deputy = await fixtures.createStateDeputy({ contact, party: 'PT' })
await payload.update({
collection: 'municipality',
id: carteira.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
overrideAccess: true,
})
const inside = await fixtures.createSupporter({
contact,
municipality: carteira,
source: 'evento',
})
await fixtures.createSupporter({ contact, municipality: outside, source: 'convite' })
const person = await loadPersonDetail(payload, advisor, relationId(contact))
expect(person).not.toBeNull()
expect(person?.deputyID).toBe(deputy.id)
expect(person?.supporters.map((entry) => entry.id)).toEqual([inside.id])
})
it('pessoa só-staff (carteira vazia) é visível a unrestricted, nunca a assessor', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const advisor = await fixtures.createCampaignUser('advisor')
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const contact = await fixtures.createContact({ name: fixtures.personName('Só Assessora') })
const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
const asCoordinator = await loadPersonDetail(payload, coordinator, relationId(contact))
expect(asCoordinator).not.toBeNull()
expect(asCoordinator?.staff.map((entry) => entry.id)).toContain(account.id)
expect(asCoordinator?.assessoraMunicipalityIDs).toEqual([])
expect(asCoordinator?.supportStatus).toBeNull()
expect(await loadPersonDetail(payload, advisor, relationId(contact))).toBeNull()
})
it('contato sem nenhuma capacidade e leader não resolvem detalhe', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const leader = await fixtures.createCampaignUser('leader')
const bareContact = await fixtures.createContact({ name: fixtures.personName('Contato Solto') })
expect(await loadPersonDetail(payload, coordinator, relationId(bareContact))).toBeNull()
const contact = await fixtures.createContact({ name: fixtures.personName('Líder de Teste') })
await fixtures.createLeadership({ contact, municipalities: [await fixtures.getMunicipality()] })
expect(await loadPersonDetail(payload, leader, relationId(contact))).toBeNull()
})
it('conta leader na ficha não acende a capacidade Assessora (filtro de papel da lista)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact({ name: fixtures.personName('Liderança Leader') })
await fixtures.createLeadership({ contact, municipalities: [municipality] })
// Phone reuse folds the leader account into the same ficha — but the staff
// source contract (`role in advisor|coordinator|candidate`) must cut it.
await fixtures.createCampaignUser('leader', { phone: contact.phone })
const person = await loadPersonDetail(payload, coordinator, relationId(contact))
expect(person).not.toBeNull()
expect(person?.staff).toEqual([])
expect(person?.assessoraMunicipalityIDs).toEqual([])
})
it('apoiador sem município é visível só a unrestricted (coordinator-only por access)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const advisor = await fixtures.createCampaignUser('advisor')
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const contact = await fixtures.createContact({ name: fixtures.personName('Apoio Solto') })
await fixtures.createLeadership({ contact, municipalities: [municipality] })
const supporter = await fixtures.createSupporter({ contact, source: 'convite' })
const asCoordinator = await loadPersonDetail(payload, coordinator, relationId(contact))
expect(asCoordinator?.supporters.map((entry) => entry.id)).toEqual([supporter.id])
const asAdvisor = await loadPersonDetail(payload, advisor, relationId(contact))
expect(asAdvisor).not.toBeNull()
expect(asAdvisor?.supporters).toEqual([])
})
})