mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C118): página de detalhe da pessoa — seções montadas pelas capacidades (Closes #657)
This commit is contained in:
parent
e68e4f6eda
commit
c0ffc22026
10 changed files with 1171 additions and 76 deletions
|
|
@ -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).
|
||||
|
|
|
|||
83
docs/plans/pessoas-detalhe-por-capacidades-impl.md
Normal file
83
docs/plans/pessoas-detalhe-por-capacidades-impl.md
Normal 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
|
||||
58
src/app/(campaign)/campanha/(app)/pessoas/[id]/page.tsx
Normal file
58
src/app/(campaign)/campanha/(app)/pessoas/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
368
src/components/campaign/people/PersonDetailSections.tsx
Normal file
368
src/components/campaign/people/PersonDetailSections.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
170
src/utilities/people/personDetail.ts
Normal file
170
src/utilities/people/personDetail.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
218
tests/int/personDetail.int.spec.ts
Normal file
218
tests/int/personDetail.int.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue