Merge pull request #676 from fsolla/C116-pessoas-edicao-onde-voce-ve-na-lista-desktop-celulas-se

C116 — Pessoas — edição onde você vê na lista desktop
This commit is contained in:
Francisco Solla 2026-08-11 18:17:53 +00:00 committed by GitHub
commit 11605a2ef3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1830 additions and 116 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**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).
**Recently resolved (2026-08-11):** **C114** (Issue #635) — Agenda da Campanha espelhada no Google Calendar com push Teqo→Google (API oficial, service account). **Motor:** reconciliação completa por passada (`runCampaignCalendarSync`) com **id determinístico de evento** (`teqo` + base32hex do id — charset do Google) → upsert idempotente sem estado por evento; janela 90d passado/365d futuro (precedente do feed iCal), espelho cheio do escopo do staff (sem intersecção de advisor — agenda oficial), cancelado/deletado no Teqo **apaga o evento no Google** (sem estado cancelado visível a quem segue); conteúdo comparado por instante absoluto (imune a fuso do calendário) → passadas repetidas não tocam o Google; eventos estrangeiros do calendário nunca são tocados (decode completo — um `teqo3` manual não é nosso). **Triggers (sem cron — Hobby):** hook `afterChange`/`afterDelete` na `activity` (só campos espelhados, com normalização de shapes populados vs depth-0; tarefas não disparam; **nunca lança** — Teqo independe do Google; timeout 15s por request), auto-retry na view da agenda quando `paused` (uma vez por carregamento), botão "Sincronizar agora", e **auto-resync por config** (D7): trocar `calendarId`/reativar na collection `googleCalendarSync` re-conciliar sozinho (guard por comparação de campo quebra o loop de re-entrada); scan de delete com lookback de 730d (cancelado antigo não vira fantasma; atividade viva fora da janela mantém o evento). **Estado:** collection `googleCalendarSync` (uma linha; `calendarId` admin-only, `disabledAt` staff, timestamps system-write; **status derivado** `not-configured | disabled | synced | paused` em lib pura) + **chave da service account só em env** (`GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`, base64 do JSON do GCP — sem credencial no DB); sem env → `not-configured` com runbook de ops (criar calendário na conta da campanha, público no link, SA editor, calendarId no Painel). **Transporte:** `jose` (novo dep) + fetch → Calendar API v3 REST (token JWT assertion, cache em módulo, 401 re-minta uma vez, 409 de insert idempotente; `end` obrigatório → atividade sem `endAt` vira 1h). **UI (Impeccable B, padrão C94):** pill no header da agenda (sincronizado/pausado/não configurado/desativado) + FAB mobile + diálogo com os 4 estados do canvas (link público de adição `calendar/r?cid=webcal…` + instruções + sync agora + desativar; falha de action visível). **Sem participantes no v1** (decisão de produto do gate: aviso segue as configs de quem segue; service account exigiria domain-wide delegation — segue para o C115/Google→Teqo). DRY com o feed: janela/where/nomes de município/description compartilhados (`calendarFeed.ts` como dono da janela). Migration hand-written (o gerador re-emitia no-ops pré-existentes de `campaign_user.contact_id`). Testes: unit (mapping/derivação/guards com normalização/client com fetch fake/hook wiring via `vi.mock`), int (engine com stub in-memory: cria/converge/atualiza só drift/cancela deleta/delete limpa fantasma/falha→pausado→recupera/troca de calendarId), e2e (pill não-configurado + runbook, sem dependência externa). Débito de cobertura (estados disabled/paused/synced + ação) registrado como #666 (depends #635). Intenção [`c114-google-calendar-push-agenda-oficial.md`](plans/c114-google-calendar-push-agenda-oficial.md); impl [`c114-google-calendar-push-agenda-oficial-impl.md`](plans/c114-google-calendar-push-agenda-oficial-impl.md).

View file

@ -0,0 +1,110 @@
# Impl: Pessoas — edição onde você vê (edit-where-you-see) na lista desktop
Status: aprovado
Atualizado em: 2026-08-11
Issue: #655
Intenção: docs/plans/pessoas-edicao-inplace-lista.md
Appetite restante: ~1,5–2 dias eng; um outcome verificável — a linha da lista é editável sem sair da tabela
## Leitura da intenção
- **Outcome:** na tabela desktop de `/campanha/pessoas`, toda célula (exceto Ações) é um **input permanente sem destaque** — parece texto, se comporta como input; salva no blur/Enter (Esc descarta); Nome mantém o valor como link; colunas de municípios (Assessora, Lidera, Aliada em) e Assessorado usam **chips internos** com colapso por território ("Sertão do São Francisco (5)"), grupo "Salvador (19)", "+X" expande, X no hover remove; ações ganham tooltip.
- **O que NÃO negociar:** sem migration (escreve no dono da ficha — `Contact` nome/telefone/e-mail/cidade; vínculos nas collections donas); leader fora da rota; escopo do assessor preservado (só a carteira dele); mobile intacto; nada criado fora do catálogo (texto sem match não vira chip); link do Nome navega; "—" vira placeholder apagado.
- **O que reavaliar:** a hipótese de reusar `CampaignInlineEditableCell` (B163) — seu `editTrigger='cell'` tem estado de edição explícito; o paradigma travado exige input **sempre montado**, então a mudança é um **modo novo** no componente compartilhado, não um ajuste do modo existente. A hipótese de reusar `MunicipalityPortfolioCell`/`RelationChipCell` confirma-se: colapso por território já existe (chips batch); o trabalho novo é **expandir chip batch por clique/hover** (interação que nenhuma célula do sistema tem hoje), o grupo "Salvador (19)" (zona-cidade completa) e o modo `quiet` (sem tint de hover/foco).
## Abordagem recomendada
```mermaid
flowchart LR
L[lib/municipalityPortfolio: grupo Salvador + expand chips] --> M[MunicipalityPortfolioCell: opt-ins quiet/+X/expand]
M --> P[PeopleMunicipalityCell: estado de expansão + colapso no mouse-leave]
C[CampaignInlineEditableCell: modo permanent] --> T[Células de texto: Nome link-overlay, Contato, E-mail, Base]
R[RelationOptionCell: Assessorado readOnly + quiet] --> A[PeopleAssessoradoCell]
S[Actions: updatePersonContact, setPersonAssessora, setPersonAdvisor] --> F[pessoas/formActions.ts]
T --> PG[page.tsx: colunas com matriz de edição por papel]
A --> PG
P --> PG
```
**Opções consideradas:**
- **A — estender os componentes compartilhados com props opt-in** (`CampaignInlineEditableCell` modo `permanent`; `RelationChipCell` com `quiet`/`onChipClick`/`overflowToggleLabel`; `MunicipalityPortfolioCell` repassa e expande chips; novo grupo no `buildMunicipalityPortfolioChips`): uma máquina de save e uma máquina de chips para todo o sistema; as outras listas ficam intactas (defaults inalterados).
- **B — componentes novos `people/*` que duplicam a máquina de save/chips**: isolamento total, mas dois donos para blur/Enter/Esc/pending/erro e para o combobox ARIA — exatamente o twin que o repo proíbe.
- **C — modo `permanent` virar o novo default do B163**: coerente com o paradigma, mas muda o comportamento visual das dobradinhas/lideranças sem ser pedido deste item.
**Recomendação: A** — o trabalho de interação nova (expandir chip batch, +X, quiet) entra como opt-in nas máquinas existentes e a expansão fica como **estado de apresentação** do wrapper `PeopleMunicipalityCell` (puro, sem tocar o ciclo de commit do `RelationChipCell`). C é rejeitada porque altera superfícies fora do escopo; B porque cria twin.
### Componentes / mudanças
**Puro (lib, client-safe):**
- **`src/lib/municipalityPortfolio.ts`**: (1) novo kind de chip `city` — zonas de uma zona-cidade **completas** viram UM chip `{ kind: 'city', label: 'Salvador', city: 'Salvador', municipalityIds: [19 ids] }`, checado **antes** do colapso por território (senão as 19 zonas colapsariam como "Metropolitano de Salvador (19)"); `MunicipalityPortfolioChip` ganha o variant; (2) helper puro `expandMunicipalityPortfolioChips(chips, expandedKeys)` — substitui chip batch (territory/city) cuja key está expandida pelos chips municipais membros; (3) testes unit.
- **`src/lib/schemas/contact.ts`**: variante `city` no `contactFieldUpdateSchema` compartilhado (bounds da collection: 2–100; aditivo — as actions de dobradinhas/liderança não expõem `city` e continuam falhando em campo desconhecido).
**Server (`person.ts` + formActions):**
- **`src/app/(campaign)/campanha/actions/person.ts`**: três record-functions novas, todas `withPayloadTransaction` + `req`, Local API com `user`/`overrideAccess: false` e bypass justificado só depois da checagem:
- `updatePersonContactRecord(payload, actor, input)` — escopo: irrestrito sempre; assessor somente se **pelo menos uma** entidade da pessoa (leadership OU stateDeputy, lida com `overrideAccess: false`) é gerenciável por ele (regra "edita o que vê" da linha; staff-only → assessor nega). Depois, bypass do Contact (precedente B163/C99).
- `setPersonAssessoraMembershipRecord(payload, actor, { contactID, municipalityIds, assigned })` — `reloadUnrestrictedActor` (canAssignMunicipalityAdvisors é unrestricted-only); transação única: resolve as contas staff da pessoa por `contact`, **asserta exatamente uma** (multi-conta → célula read-only no client; o server nega com mensagem segura), e para cada município: lock advisory `municipality-advisors:<id>` + toggle — **extrair** o toggle de `setMunicipalityAdvisorMembershipRecord` (`src/app/(campaign)/campanha/actions/municipality.ts`) para um helper compartilhado usado pelos dois (edit the owner, sem duplicar o bloco lock+toggle).
- `setPersonAdvisorMembershipRecord(payload, actor, { contactID, advisorId, assigned })` — `reloadUnrestrictedActor` (regra B156); transação única: aplica o delta de advisor em **cada entidade** da pessoa (leadership e/ou stateDeputy) via `nextAdvisorIdsAfterMembership`/helper existente — semântica person-centric: a coluna Assessorado é atributo da pessoa, adicionar/remover atinge os dois vínculos quando ambos existem.
- **`src/app/(campaign)/campanha/(app)/pessoas/formActions.ts`** (novo): `updatePersonContactFormAction`, `setPersonAssessoraFormAction`, `setPersonAssessoradoFormAction`, `setPersonLeadershipMunicipalitiesFormAction` (envolve `setLeadershipMunicipalitiesMembership`), `setPersonStateDeputyMunicipalitiesFormAction` (envolve `setStateDeputyMunicipalitiesBatch`) — cascas `runCampaignFormAction` no padrão das dobradinhas, com safe-messages existentes.
- **Revalidação:** `/campanha/pessoas` + caminhos de detalhe das entidades tocadas (`liderancas/[id]`, `dobradinhas/[id]`, municípios tocados no assessora) — espelhando `revalidateStateDeputyPaths`/`revalidateLeadershipMunicipalityPaths`.
- **`src/utilities/people/peopleData.ts`**: `PeopleRowViewModel` ganha `assessoradoIDs: number[]` (união `leadershipAdvisorIDs ∪ deputyAdvisorIDs`, já resolvida hoje para os nomes) — ids que os chips do Assessorado precisam.
**UI (Impeccable B — encaixe em tela existente):**
- **`src/components/campaign/shared/CampaignInlineEditableCell.tsx`**: prop `permanent?: boolean` (default false — B163/dobradinhas/lideranças intactos). Em `permanent`:
- texto (Contato/E-mail/Base): input **sempre montado**, `bg-transparent border-transparent` (mesmo vocabulário de `campaignInlineInputClassName`), valor = draft, salva no blur/Enter, Esc reverte, vazio → `placeholder="—"` apagado (o "—" atual vira placeholder), telefone mantém formatação; máquina de save/pending/erro/saved-feedback/requestId reutilizada tal qual.
- Nome (`permanent` + `href`): **mecanismo travado no gate** — input sempre montado `absolute inset-0 z-0` com `value` **sempre vazio** (`bg-transparent`), link em `z-10` com `pointer-events-auto` cujo texto renderiza `draft || value`; cada tecla atualiza o draft **no display** (texto do link), o valor real do input permanece vazio; clique no link navega; foco por Tab/clique fora do texto → digitação; blur/Enter salva, Esc descarta. Sufixo `({party})` permanece fora do link como texto read-only.
- Indicador de foco **discreto**: `focus-visible` ring suave; nada em hover (paradigma sem fundo/borda).
- **`src/components/campaign/shared/RelationChipCell.tsx`**: três opt-ins, defaults inalterados (B34/B36/B37/B156/B157/B159/B169 intactos):
- `quiet?: boolean` — remove `pointer-fine:hover:bg-muted/40` do container; mantém apenas indicador discreto de foco (guardrail de acessibilidade);
- `onChipClick?: (chip) => void` — chip **sem href** clicável (hoje só navega); com stopPropagation;
- `overflowToggleLabel?: (hiddenCount) => string` — o toggle de overflow do B170 renderiza como chip `+N` em vez de "Ver mais…".
- **`src/components/campaign/shared/MunicipalityPortfolioCell.tsx`**: repassa os três opt-ins ao `RelationChipCell` e aplica `expandMunicipalityPortfolioChips` no `buildChips` quando `expandedKeys` é passado.
- **`src/components/campaign/people/PeopleMunicipalityCell.tsx`** (novo, substitui o `PeopleMunicipalityCell` local do page.tsx): wrapper de `MunicipalityPortfolioCell` com estado local `expandedKeys` + `onMouseLeave` que **colapsa** chips expandidos cujos membros continuam todos atribuídos (lê `ids` da prop via ref); serve as colunas Assessora/Lidera/Aliada com `commitAction`/`buildFormData`/`minItems`/`readOnly` definidos pela página.
- **`src/components/campaign/people/PeopleAssessoradoCell.tsx`** (novo): wrapper de `RelationOptionCell` — chips = assessorados com href `/campanha/assessores/[id]` (só para irrestrito; regra B156), `options` de `loadEligibleAdvisorOptions`, `readOnly` para assessor; copy do `ADVISOR_COPY` (sem o twin: extrair/reusar a copy de `LeadershipAdvisorRelationCell` se o diff permitir, senão constante local).
- **`src/app/(campaign)/campanha/(app)/pessoas/page.tsx`**: colunas Contato/E-mail/Base com `CampaignInlineEditableCell permanent`; Nome com `permanent` + `href` (`/campanha/liderancas/[id]` quando `leadershipID`); Assessora/Lidera/Aliada com `PeopleMunicipalityCell`; Assessorado com `PeopleAssessoradoCell`; **matriz de edição por papel** (helper puro na page ou `lib`): irrestrito edita tudo; assessor edita células de texto e Lidera/Aliada (com `addableIds = administeredIds`, `minItems=1` em Lidera) apenas nas linhas cujo vínculo cruza a carteira; Assessora e Assessorado **read-only** para assessor (canAssignMunicipalityAdvisors / B156 são unrestricted-only). Ações: `CampaignHoverTooltip` com a label (WhatsApp, Convidar, Apagar) nos três botões. `PeopleMobileCards` intacto.
- **Migration:** nenhuma — sem mudança de schema.
### Dados → forma
- **Forma escolhida:** inputs permanentes sem destaque + chips internos ao input — o paradigma travado no gate 2026-08-11, que É a forma deste item (não há dado analítico; o chip de território mostra contagem, não métrica). Rejeitadas: toggle global de edição, lápis obrigatório e células read→edit com estado visual — tudo divergente do aceite.
## Fases verificáveis
1. **Tracer / puro + UI machines** (~1/2 appetite): grupo `city` de Salvador + `expandMunicipalityPortfolioChips` + unit tests; opt-ins no `RelationChipCell`/`MunicipalityPortfolioCell`; modo `permanent` no `CampaignInlineEditableCell`; `PeopleMunicipalityCell` + `PeopleAssessoradoCell`. Validar que dobradinhas/lideranças não regridem (testes existentes + revisão visual).
2. **Server / access** (~1/3 appetite): variante `city` no schema; as três record-functions + formActions; `assessoradoIDs` no VM; int tests — coordenador/candidato editam tudo, assessor só a carteira (célula de texto em linha em escopo ok; linha staff-only negada; Lidera/Aliada com addableIds; Assessora/Assessorado read-only), multi-conta do Assessora nega, union do Assessorado escreve nas duas entidades, telefone conflitante falha com mensagem segura.
3. **Page + polish** (~1/3 appetite): colunas com a matriz por papel, tooltips nas ações, indicador de foco discreto, `+X`, colapso por mouse-leave; shape→craft→critique→polish.
4. **Gates:** após cada slice `pnpm exec tsc --noEmit` + testes focados; ao final `pnpm lint` (0 warnings), `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm test:e2e` (se tocar jornada), `pnpm build` local.
## Rabbit holes / Não escopo (engenharia)
- Contenteditable real para chips internos — o comportamento continua o de input (chips como dados, teclado previsível); a mecânica é chips + input inline do `RelationChipCell`.
- Estado de expansão global / animações entre linhas — expansão é local à célula, por hover/foco, sem estado persistido.
- Tree-view de território com sub-chips persistentes — grupo colapsa/expande in loco; nada de hierarquia.
- Edição em lote / spreadsheet mode / reorder de colunas / coluna nova.
- C112 (telefones múltiplos) e B155+ (rota em vez de form action) continuam suaves — o editor de Contato segue o shape atual e o padrão de escrita vigente (form action).
- Migration de schema — nenhuma.
## Riscos e mitigação
- **Regressão B163/dobradinhas/lideranças:** modo `permanent` default-off; testes existentes do controle compartilhado cobrem os modos antigos; revisão visual das duas listas na Fase 1.
- **Regressão `RelationChipCell` (B34/B36/B37/B156/B157/B159/B169):** três props opt-in com defaults inalterados; testes unit/int existentes das células de relação rodam intactos na Fase 1.
- **Mecanismo do Nome (input vazio + draft no link):** caret invisível é trade-off do mecanismo travado; a navegação e a digitação coexistem; teste de componente cobre clique-no-link (navega) vs clique-fora (edita), Enter/Esc/blur.
- **Assessor com linha visível por capacidade fora da carteira:** matriz por coluna derruba a edição quando a entidade subjacente não é gerenciável (Lidera/Aliada read-only na linha); o server nega de qualquer forma (access da collection dona).
- **Pessoa com múltiplas contas staff (Assessora):** célula read-only + server nega; edge raro e honesto.
- **Chips batch (território/Salvador) removidos no X removem o grupo todo:** semântica já existente no sistema; o undo toast cobre a remoção em lote.
- **Revalidação de paths novos** (`/campanha/pessoas` + detalhes das entidades): seguir os helpers de revalidação existentes por entidade; nunca `revalidatePath` global.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (inputs permanentes, Nome-link, chips com colapso por território/Salvador, +X, X no hover, tooltips, mobile intacto, sem lote)
- [ ] Sem migration; escrita no dono da ficha (`Contact`) e nas collections donas dos vínculos
- [ ] Local API com `user` + `overrideAccess: false`; bypass do Contact/entities justificado após checagem de escopo; transações com `req` em toda escrita multi-collection
- [ ] Escopo do assessor preservado (matriz por coluna + access da collection dona); leader fora da rota; multi-conta do Assessora nega
- [ ] `contactFieldUpdateSchema` ganha `city` de forma aditiva; mensagens seguras allowlisted
- [ ] Testes previstos: unit (grupo Salvador, expand chips, +X) e int (escopo de célula por papel, assessora single-account, union do assessorado, telefone conflitante)
- [ ] Nome em identificadores inglês; copy pt-BR
**Self-score decision-quality: 5/5** — decisões caras (estender vs criar twin, mecanismo do Nome, semântica do Assessorado/Assessora) têm alternativas rejeitadas com justificativa; appetite bounded por fases; rabbit holes nomeados; reuso máximo das máquinas existentes; o aceite da intenção não foi alterado.

View file

@ -0,0 +1,188 @@
'use server'
import { setLeadershipMunicipalitiesMembership } from '@/app/(campaign)/campanha/actions/leadership'
import {
setPersonAdvisorMembership,
setPersonAssessoraMembership,
updatePersonContact,
} from '@/app/(campaign)/campanha/actions/person'
import { setStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy'
import {
nullableFormText,
repeatedRelationshipFormValues,
requiredFormBoolean,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
LEADERSHIP_STAFF_MESSAGE,
} from '@/lib/schemas/leadership'
import {
PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
PERSON_CELL_STAFF_MESSAGE,
PERSON_CONTACT_INVALID_MESSAGE,
} from '@/lib/schemas/personCell'
import {
STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
STATE_DEPUTY_STAFF_MESSAGE,
} from '@/lib/schemas/stateDeputy'
import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
const personContactSafeMessages = [
PERSON_CELL_STAFF_MESSAGE,
PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
PERSON_CONTACT_INVALID_MESSAGE,
] as const
const personAssessoraSafeMessages = [
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
] as const
const personAssessoradoSafeMessages = [
PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
PERSON_CONTACT_INVALID_MESSAGE,
] as const
const leadershipMunicipalitiesSafeMessages = [
LEADERSHIP_STAFF_MESSAGE,
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
] as const
/** Per-field Contact edit for the people list cells (C116). */
export const updatePersonContactFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const contactId = requiredRelationshipFormValue(formData, 'contactId')
const field = requiredFormText(formData, 'field')
if (field === 'name') {
await updatePersonContact({
id: contactId,
field: 'name',
name: requiredFormText(formData, 'name'),
})
} else if (field === 'email') {
await updatePersonContact({
id: contactId,
field: 'email',
email: nullableFormText(formData, 'email') ?? undefined,
})
} else if (field === 'phone') {
await updatePersonContact({
id: contactId,
field: 'phone',
phone: nullableFormText(formData, 'phone'),
})
} else if (field === 'city') {
await updatePersonContact({
id: contactId,
field: 'city',
city: requiredFormText(formData, 'city'),
})
} else {
throw new Error(PERSON_CELL_NOT_IN_SCOPE_MESSAGE)
}
return { message: 'Salvo.' }
},
safeMessages: personContactSafeMessages,
genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
})
/**
* Assessora column (C116): one territory / "Salvador (19)" batch or single
* municipality toggling the person's staff account on `municipality.advisors`.
*/
export const setPersonAssessoraFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setPersonAssessoraMembership({
contactId: requiredRelationshipFormValue(formData, 'contactId'),
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Carteira atualizada.' }
},
safeMessages: personAssessoraSafeMessages,
genericMessage: 'Não foi possível atualizar a carteira. Tente novamente.',
})
/**
* Assessorado column (C116): one advisor delta applied to every entity of the
* person (leadership and/or dobradinha).
*/
export const setPersonAssessoradoFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setPersonAdvisorMembership({
contactId: requiredRelationshipFormValue(formData, 'contactId'),
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Assessores atualizados.' }
},
safeMessages: personAssessoradoSafeMessages,
genericMessage: 'Não foi possível atualizar os assessores. Tente novamente.',
})
/** Lidera column (C116) — reuses the leadership municipalities write. */
export const setPersonLeadershipMunicipalitiesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setLeadershipMunicipalitiesMembership({
leadershipId: requiredRelationshipFormValue(formData, 'ownerId'),
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Municípios atualizados.' }
},
safeMessages: leadershipMunicipalitiesSafeMessages,
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
})
/** Aliada em column (C116) — reuses the dobradinha municipalities write. */
export const setPersonStateDeputyMunicipalitiesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setStateDeputyMunicipalitiesBatch({
stateDeputyId: requiredRelationshipFormValue(formData, 'ownerId'),
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Municípios atualizados.' }
},
safeMessages: [
...STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
STATE_DEPUTY_STAFF_MESSAGE,
] as const,
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
})

View file

@ -6,20 +6,26 @@ import type { ReactNode } from 'react'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton'
import { PeopleAssessoradoCell } from '@/components/campaign/people/PeopleAssessoradoCell'
import { PeopleFilters } from '@/components/campaign/people/PeopleFilters'
import { PeopleListPageChrome } from '@/components/campaign/people/PeopleListPageChrome'
import { PeopleMunicipalityCell } from '@/components/campaign/people/PeopleMunicipalityCell'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import type { RelationCellOption } from '@/components/campaign/shared/RelationOptionCell'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button, buttonVariants } from '@/components/ui/button'
@ -34,14 +40,18 @@ import {
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import {
resolvedPortfolioEntriesById,
type ResolvedPortfolioEntry,
} from '@/lib/municipalityPortfolio'
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
import { cn } from '@/lib/utils'
import type { CampaignUser } from '@/payload-types'
import { getAdvisorMunicipalityIds } from '@/utilities/access/municipalities'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadEligibleAdvisorOptions } from '@/utilities/campaignRelationOptions'
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
import { loadPeopleListPageData, type PeopleRowViewModel } from '@/utilities/people/peopleData'
import {
@ -51,6 +61,14 @@ import {
} from '@/utilities/people/peopleListFilters'
import { buildPeopleListHref, resolvePeopleListUrl } from '@/utilities/people/peopleListUrl'
import {
setPersonAssessoraFormAction,
setPersonAssessoradoFormAction,
setPersonLeadershipMunicipalitiesFormAction,
setPersonStateDeputyMunicipalitiesFormAction,
updatePersonContactFormAction,
} from './formActions'
export const metadata = campaignPageMetadataFromCatalog('pessoas')
type PeoplePageProps = {
@ -84,64 +102,91 @@ const PeopleListEmptyState = ({ hasFilters, scoped }: { hasFilters: boolean; sco
</Empty>
)
const PeopleMunicipalityCell = ({
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>
)
/**
* C116 editability matrix by actor. The advisor rule mirrors each owner
* collection's access: Lidera/Aliada em follow
* `canManageLeadership`/`canReadStateDeputy` (the row's own municipalities
* must cross the carteira — the row may be visible via another capacity);
* Assessora and Assessorado are unrestricted-only
* (`canAssignMunicipalityAdvisors` / B156), and Assessora needs exactly ONE
* staff account for the batch write to have a target. The ficha text cells
* stay always-editable and the server enforces the scope ("you edit what you
* see") with a per-cell error.
*/
type PeopleEditability = {
administered: ReadonlySet<number> | null
canEditLidera: (row: PeopleRowViewModel) => boolean
canEditAliada: (row: PeopleRowViewModel) => boolean
canEditAssessora: (row: PeopleRowViewModel) => boolean
canEditAssessorado: boolean
}
const buildPeopleEditability = (
userRole: CampaignUser['role'],
administered: ReadonlySet<number> | null,
): PeopleEditability => {
const unrestricted = isUnrestrictedCampaignRole(userRole)
const inCarteira = (ids: readonly number[]): boolean =>
administered !== null && ids.some((id) => administered.has(id))
return {
administered,
canEditLidera: (row) =>
row.leadershipID !== null && (unrestricted || inCarteira(row.leadershipMunicipalityIDs)),
canEditAliada: (row) =>
row.deputyID !== null && (unrestricted || inCarteira(row.deputyMunicipalityIDs)),
canEditAssessora: (row) => unrestricted && row.staff.length === 1,
canEditAssessorado: unrestricted,
}
}
const peopleColumns = ({
municipalityIndex,
canDelete,
editability,
advisorOptions,
}: {
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
municipalityIndex: readonly MunicipalityPortfolioIndexEntry[]
canDelete: boolean
editability: PeopleEditability
advisorOptions: RelationCellOption[]
}): Array<CampaignTableColumn<PeopleRowViewModel>> => [
{
id: 'name',
label: 'Nome',
mandatory: true,
cell: (row) => (
<span className="font-medium">
{row.name}
{row.party ? <span className="text-muted-foreground"> ({row.party})</span> : null}
</span>
<div className="flex min-w-0 items-baseline gap-1">
<CampaignInlineEditableCell
recordId={row.contactID}
recordIdField="contactId"
field="name"
value={row.name}
label="Nome"
formAction={updatePersonContactFormAction}
{...(row.leadershipID !== null
? { href: `/campanha/liderancas/${row.leadershipID}` }
: {})}
permanent
/>
{row.party ? <span className="shrink-0 text-muted-foreground">({row.party})</span> : null}
</div>
),
},
{
id: 'contact',
label: 'Contato',
cellClassName: 'whitespace-normal',
cell: (row) =>
row.phone ? (
<span className="font-medium tabular-nums">{formatBrazilianPhoneInput(row.phone)}</span>
) : (
<span className="text-muted-foreground">—</span>
),
cell: (row) => (
<CampaignInlineEditableCell
recordId={row.contactID}
recordIdField="contactId"
field="phone"
value={row.phone}
label="Contato"
formAction={updatePersonContactFormAction}
permanent
/>
),
},
{
// B197 — the email left the "Contato" cell (which now carries only the
@ -150,14 +195,33 @@ const peopleColumns = ({
id: 'email',
label: 'E-mail',
cellClassName: 'max-w-56 whitespace-normal',
cell: (row) => (row.email ? row.email : <span className="text-muted-foreground">—</span>),
cell: (row) => (
<CampaignInlineEditableCell
recordId={row.contactID}
recordIdField="contactId"
field="email"
value={row.email}
label="E-mail"
formAction={updatePersonContactFormAction}
permanent
/>
),
},
{
id: 'assessora',
label: 'Assessora',
cellClassName: 'max-w-56 whitespace-normal',
cell: (row) => (
<PeopleMunicipalityCell ids={row.assessoraMunicipalityIDs} index={municipalityIndex} />
<PeopleMunicipalityCell
ownerId={editability.canEditAssessora(row) ? (row.staff[0]?.id ?? null) : null}
ownerName={row.name}
municipalityIds={row.assessoraMunicipalityIDs}
municipalityIndex={municipalityIndex}
commitAction={setPersonAssessoraFormAction}
drawerTitle="Municípios assessorados"
updateErrorMessage="Não foi possível atualizar a carteira. Tente novamente."
readOnly={!editability.canEditAssessora(row)}
/>
),
},
{
@ -165,7 +229,18 @@ const peopleColumns = ({
label: 'Lidera',
cellClassName: 'max-w-56 whitespace-normal',
cell: (row) => (
<PeopleMunicipalityCell ids={row.leadershipMunicipalityIDs} index={municipalityIndex} />
<PeopleMunicipalityCell
ownerId={row.leadershipID}
ownerName={row.name}
municipalityIds={row.leadershipMunicipalityIDs}
municipalityIndex={municipalityIndex}
{...(editability.administered ? { addableIds: editability.administered } : {})}
minItems={1}
commitAction={setPersonLeadershipMunicipalitiesFormAction}
drawerTitle="Municípios liderados"
updateErrorMessage="Não foi possível atualizar os municípios. Tente novamente."
readOnly={!editability.canEditLidera(row)}
/>
),
},
{
@ -173,19 +248,48 @@ const peopleColumns = ({
label: 'Aliada em',
cellClassName: 'max-w-56 whitespace-normal',
cell: (row) => (
<PeopleMunicipalityCell ids={row.deputyMunicipalityIDs} index={municipalityIndex} />
<PeopleMunicipalityCell
ownerId={row.deputyID}
ownerName={row.name}
municipalityIds={row.deputyMunicipalityIDs}
municipalityIndex={municipalityIndex}
{...(editability.administered ? { addableIds: editability.administered } : {})}
commitAction={setPersonStateDeputyMunicipalitiesFormAction}
drawerTitle="Municípios da dobradinha"
updateErrorMessage="Não foi possível atualizar os municípios. Tente novamente."
readOnly={!editability.canEditAliada(row)}
/>
),
},
{
id: 'assessorado',
label: 'Assessorado',
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
cell: (row) => row.assessoradoNames.join(', ') || '—',
cell: (row) => (
<PeopleAssessoradoCell
ownerId={row.contactID}
ownerName={row.name}
assessorados={row.assessorados}
options={editability.canEditAssessorado ? advisorOptions : []}
commitAction={setPersonAssessoradoFormAction}
readOnly={!editability.canEditAssessorado}
/>
),
},
{
id: 'base',
label: 'Base',
cell: (row) => row.city ?? '—',
cell: (row) => (
<CampaignInlineEditableCell
recordId={row.contactID}
recordIdField="contactId"
field="city"
value={row.city}
label="Base"
formAction={updatePersonContactFormAction}
permanent
/>
),
},
{
id: 'actions',
@ -201,23 +305,27 @@ const peopleColumns = ({
return (
<div className="inline-flex items-center justify-end gap-1">
{row.leadershipID !== null ? (
<LeadershipInviteRowAction
leadershipID={row.leadershipID}
name={row.name}
hasValidPhone={whatsAppHref !== null}
/>
<CampaignHoverTooltip content="Convidar">
<LeadershipInviteRowAction
leadershipID={row.leadershipID}
name={row.name}
hasValidPhone={whatsAppHref !== null}
/>
</CampaignHoverTooltip>
) : null}
{whatsAppHref ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Enviar WhatsApp para ${row.name}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
<CampaignHoverTooltip content="WhatsApp">
<Button asChild variant="ghost" size="icon" className="size-10">
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Enviar WhatsApp para ${row.name}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
</CampaignHoverTooltip>
) : (
<Button
type="button"
@ -231,7 +339,9 @@ const peopleColumns = ({
</Button>
)}
{canDelete ? (
<DeletePersonButton personName={row.name} contactId={row.contactID} />
<CampaignHoverTooltip content="Apagar">
<DeletePersonButton personName={row.name} contactId={row.contactID} />
</CampaignHoverTooltip>
) : null}
</div>
)
@ -308,7 +418,7 @@ const PeopleMobileCards = ({
capacity.ids.length > 0 ? (
<div key={capacity.label} className="flex flex-wrap items-center gap-1 text-xs">
<span className="font-medium text-muted-foreground">{capacity.label}:</span>
<PeopleMunicipalityCell ids={capacity.ids} index={municipalityIndex} />
<PeopleMunicipalityCellMobile ids={capacity.ids} index={municipalityIndex} />
</div>
) : null,
)}
@ -327,6 +437,36 @@ const PeopleMobileCards = ({
</ul>
)
const PeopleMunicipalityCellMobile = ({
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>
)
}
export default async function PeoplePage({ searchParams }: PeoplePageProps) {
const rawSearchParams = await searchParams
const canonicalUrl = resolvePeopleListUrl(rawSearchParams)
@ -337,11 +477,16 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
getPayload({ config }),
])
const [columnVisibility, listData, municipalityIndex] = await Promise.all([
const [columnVisibility, listData, municipalityIndex, administeredIds] = await Promise.all([
readCampaignColumnVisibility('pessoas'),
loadPeopleListPageData(payload, user, canonicalUrl.state),
loadMunicipalityPortfolioIndex(),
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
])
const editability = buildPeopleEditability(
user.role,
administeredIds ? new Set(administeredIds) : null,
)
const resolvedUrl = resolvePeopleListUrl(rawSearchParams, listData.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
@ -355,8 +500,24 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
.filter((option): option is PeopleFilterOption => option !== null)
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
const advisorOptions = editability.canEditAssessorado
? (await loadEligibleAdvisorOptions(payload, user)).map((option) => ({
id: option.id,
searchLabel: option.name,
item: {
id: option.id,
label: option.name,
href: `/campanha/assessores/${option.id}`,
},
}))
: []
const canDelete = isUnrestrictedCampaignRole(user.role)
const columns = peopleColumns({ municipalityIndex: resolvedIndex, canDelete })
const columns = peopleColumns({
municipalityIndex,
canDelete,
editability,
advisorOptions,
})
const hasFilters = Boolean(
state.q || state.capacities?.length || state.municipalities?.length || state.statuses?.length,
)
@ -385,17 +546,21 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
<PeopleListEmptyState hasFilters={hasFilters} scoped={user.role === 'advisor'} />
}
/>
<CampaignTable
className="hidden md:block"
caption="Uma linha por pessoa — cada pessoa pode assessorar, liderar e ser dobradinha ao mesmo tempo."
columns={columns}
columnVisibility={columnVisibility}
rows={listData.rows}
rowKey={(row) => row.contactID}
empty={
<PeopleListEmptyState hasFilters={hasFilters} scoped={user.role === 'advisor'} />
}
/>
{/* One shared Drawer for every chip-cell sheet on coarse pointers
(miss #52 — never a Drawer root per opened cell). */}
<CampaignListSheetProvider>
<CampaignTable
className="hidden md:block"
caption="Uma linha por pessoa — cada pessoa pode assessorar, liderar e ser dobradinha ao mesmo tempo. Edite direto na célula; o nome leva à ficha."
columns={columns}
columnVisibility={columnVisibility}
rows={listData.rows}
rowKey={(row) => row.contactID}
empty={
<PeopleListEmptyState hasFilters={hasFilters} scoped={user.role === 'advisor'} />
}
/>
</CampaignListSheetProvider>
{listData.rows.length ? (
<CampaignListFooter
totalDocs={listData.totalDocs}

View file

@ -32,7 +32,7 @@ import {
type MunicipalityStrategyUpdateInput,
} from '@/lib/schemas/municipality'
import { normalizeVoteEstimateOnSave, toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import type { CampaignUser } from '@/payload-types'
import type { CampaignUser, Municipality } from '@/payload-types'
import {
getCampaignActionContext,
reloadStaffActor,
@ -290,6 +290,52 @@ export const assignMunicipalityAdvisors = async (input: MunicipalityAdvisorsAssi
* revalidate — the list's facet/sort by advisor reconciles on next navigation,
* same choice as the votos estimados popover.
*/
/**
* One municipality↔advisor toggle under the caller's transaction: advisory
* lock, read-modify-write. Returns the updated document, or `null` for a no-op
* (the delta does not change the list). The unrestricted-role gate is the
* CALLER's job — `setMunicipalityAdvisorMembershipRecord` asserts it for a
* single toggle, and the C116 person-cell action asserts it once for a whole
* batch. Reads/writes run under a justified bypass: the `advisors` field is
* admin-only by field access, and the `validateMunicipalityAdvisors`
* beforeValidate hook still runs under the bypass and re-checks eligibility.
*/
export const toggleMunicipalityAdvisorMembership = async (
payload: Payload,
req: PayloadTransactionRequest,
municipalityId: number,
advisorId: number,
assigned: boolean,
): Promise<Municipality | null> => {
await acquireTextAdvisoryLocks(payload, req, [`municipality-advisors:${municipalityId}`])
// Intentional bypass: the unrestricted-role gate is the caller's job (see
// the doc above); the `advisors` field is admin-only by field access, and
// the `validateMunicipalityAdvisors` beforeValidate hook still runs.
const current = await payload.findByID({
collection: 'municipality',
id: municipalityId,
depth: 0,
select: { advisors: true },
overrideAccess: true,
req,
})
const currentAdvisorIDs = uniqueRelationshipIds(current.advisors)
const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisorId, assigned)
if (nextAdvisorIDs === null) return null
// Same intentional bypass as the read above: the gate ran in the caller.
return payload.update({
collection: 'municipality',
id: municipalityId,
data: { advisors: nextAdvisorIDs },
depth: 0,
overrideAccess: true,
req,
})
}
export const setMunicipalityAdvisorMembershipRecord = async (
payload: Payload,
actor: CampaignUser,
@ -307,9 +353,17 @@ export const setMunicipalityAdvisorMembershipRecord = async (
req,
)
await acquireTextAdvisoryLocks(payload, req, [`municipality-advisors:${municipality}`])
const toggled = await toggleMunicipalityAdvisorMembership(
payload,
req,
municipality,
advisor,
assigned,
)
if (toggled !== null) return toggled
const current = await payload.findByID({
// No-op: nothing to write — the caller still expects the current doc.
return payload.findByID({
collection: 'municipality',
id: municipality,
depth: 0,
@ -317,22 +371,6 @@ export const setMunicipalityAdvisorMembershipRecord = async (
overrideAccess: true,
req,
})
const currentAdvisorIDs = uniqueRelationshipIds(current.advisors)
const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisor, assigned)
if (nextAdvisorIDs === null) return current
// Intentional admin bypass: unrestricted role was freshly verified above;
// the advisors field is admin-only by field access, and eligibility is
// reconfirmed by the `validateMunicipalityAdvisors` beforeValidate hook.
return payload.update({
collection: 'municipality',
id: municipality,
data: { advisors: nextAdvisorIDs },
depth: 0,
overrideAccess: true,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização de assessores.' },
)

View file

@ -1,14 +1,42 @@
'use server'
import { revalidatePath } from 'next/cache'
import { toggleMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/actions/municipality'
import { nextLeadershipAdvisorIdsAfterMembership } from '@/lib/leadershipAdvisorMembership'
import { reorderWithPrimaryPhone } from '@/lib/phone'
import { uniqueRelationshipIds } from '@/lib/relationship'
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
import {
PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
PERSON_CELL_STAFF_MESSAGE,
PERSON_CONTACT_INVALID_MESSAGE,
personAdvisorMembershipSchema,
personAssessoraMembershipSchema,
type PersonAdvisorMembershipInput,
type PersonAssessoraMembershipInput,
} from '@/lib/schemas/personCell'
import {
PERSON_DELETE_FORBIDDEN_MESSAGE,
personDeleteInputSchema,
} from '@/lib/schemas/personDelete'
import { nextStateDeputyAdvisorIdsAfterMembership } from '@/lib/stateDeputyAdvisorMembership'
import type { CampaignUser, Contact } from '@/payload-types'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import {
getCampaignActionContext,
reloadStaffActor,
reloadUnrestrictedActor,
} from '@/utilities/campaignActionContext'
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { deletePersonRecord, loadPersonDeleteManifest } from '@/utilities/people/personDelete'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import type { Payload } from 'payload'
/**
* C100 "Apagar pessoa" server actions. Both are coordinator/candidate-only
@ -34,3 +62,273 @@ export const deletePersonAction = async (input: unknown) => {
)
return deletePersonRecord(payload, currentActor, data.contactId)
}
// ---------------------------------------------------------------------------
// C116 — "edit where you see": per-cell writes of the people list table.
// ---------------------------------------------------------------------------
/**
* C116 — the cell-edit scope rule for a person's ficha fields: unrestricted
* actors always pass; an advisor passes only when at least ONE entity of the
* person (leadership or dobradinha) is readable with his own access — the "you
* edit what you see" rule, mirroring the row's visibility (a staff-only person
* is not entity-manageable and therefore not editable by an advisor).
*/
const assertPersonContactEditable = async (
payload: Payload,
actor: CampaignUser,
contactID: number,
req: { transactionID: number | string },
): Promise<void> => {
if (isCampaignUnrestricted(actor)) return
const [leaderships, deputies] = await Promise.all([
payload.find({
collection: 'leadership',
where: { contact: { equals: contactID } },
depth: 0,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
req,
}),
payload.find({
collection: 'stateDeputy',
where: { contact: { equals: contactID } },
depth: 0,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
req,
}),
])
if (leaderships.docs.length === 0 && deputies.docs.length === 0) {
throw new Error(PERSON_CELL_NOT_IN_SCOPE_MESSAGE)
}
}
export const updatePersonContactRecord = async (
payload: Payload,
actor: CampaignUser,
input: ContactFieldUpdateInput,
) => {
const data = contactFieldUpdateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, PERSON_CELL_STAFF_MESSAGE, req)
await assertPersonContactEditable(payload, currentActor, data.id, req)
const contactData: Partial<Pick<Contact, 'name' | 'email' | 'city'>> & {
phones?: { value: string }[]
} = {}
if (data.field === 'name') {
contactData.name = data.name
} else if (data.field === 'email') {
contactData.email = data.email ?? null
} else if (data.field === 'phone') {
// Inline cell edit = set the PRIMARY phone, keeping the rest of the
// list untouched (C112 shape — same contract as the leadership and
// dobradinha siblings): the new number goes first, clearing removes
// the primary and the rest shifts up.
const current = await payload.findByID({
collection: 'contact',
id: data.id,
depth: 0,
select: { phones: true },
// Intentional bypass: the scope check above established the actor's
// right over the person's ficha; this read only resolves the current
// primary-phone list to reorder.
overrideAccess: true,
req,
})
contactData.phones = reorderWithPrimaryPhone(current.phones, data.phone).map((value) => ({
value,
}))
} else if (data.field === 'city') {
contactData.city = data.city
}
// Bypass: the scope check above established the actor's right over the
// person's ficha (same precedent as the leadership/dobradinha cell edits).
await payload.update({
collection: 'contact',
id: data.id,
data: contactData,
depth: 0,
overrideAccess: true,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a edição da pessoa.' },
)
}
export const updatePersonContact = async (input: ContactFieldUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
await updatePersonContactRecord(payload, actor, input)
revalidatePath('/campanha/pessoas', 'page')
revalidatePath('/campanha/liderancas', 'page')
revalidatePath('/campanha/dobradinhas', 'page')
}
/**
* C116 — the Assessora column of a person: toggles the person's staff account
* on `municipality.advisors` for a batch of municipalities (one territory / ZE
* chip or a "Salvador (19)" group), in ONE transaction. Unrestricted-only
* (`canAssignMunicipalityAdvisors`), and the person must have EXACTLY ONE
* staff account — with more, "which account" is ambiguous, so the server
* refuses with a safe message (the cell renders read-only for those rows).
*/
export const setPersonAssessoraMembershipRecord = async (
payload: Payload,
actor: CampaignUser,
input: PersonAssessoraMembershipInput,
) => {
const { contactId, municipalityIds, assigned } = personAssessoraMembershipSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
await reloadUnrestrictedActor(payload, actor, PERSON_ASSESSORA_UNRESTRICTED_MESSAGE, req)
const accounts = await payload.find({
collection: 'campaignUser',
where: { contact: { equals: contactId } },
depth: 0,
limit: 2,
pagination: false,
// Intentional bypass: the unrestricted gate above is the authorization;
// `contact` is identity-gated and the accounts are only counted.
overrideAccess: true,
req,
})
if (accounts.docs.length === 0) throw new Error(PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE)
if (accounts.docs.length > 1) throw new Error(PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE)
const accountId = accounts.docs[0].id
for (const municipalityId of municipalityIds) {
await toggleMunicipalityAdvisorMembership(payload, req, municipalityId, accountId, assigned)
}
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização da carteira.' },
)
}
export const setPersonAssessoraMembership = async (input: PersonAssessoraMembershipInput) => {
const { payload, actor } = await getCampaignActionContext()
await setPersonAssessoraMembershipRecord(payload, actor, input)
revalidatePath('/campanha/pessoas', 'page')
revalidatePath('/campanha/assessores', 'page')
revalidateMunicipalityListPaths({ scope: 'list' })
}
/**
* C116 — the Assessorado column: the advisor delta is applied to EVERY entity
* of the person (leadership and/or dobradinha) in one transaction — the column
* is a person attribute, so adding/removing an advisor touches both vínculos
* when both exist. Unrestricted-only (B156 rule: advisor assignment is
* coordinator/candidate).
*/
export const setPersonAdvisorMembershipRecord = async (
payload: Payload,
actor: CampaignUser,
input: PersonAdvisorMembershipInput,
) => {
const { contactId, advisorId, assigned } = personAdvisorMembershipSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
await reloadUnrestrictedActor(payload, actor, PERSON_ADVISORS_UNRESTRICTED_MESSAGE, req)
const [leaderships, deputies] = await Promise.all([
payload.find({
collection: 'leadership',
where: { contact: { equals: contactId } },
depth: 0,
pagination: false,
select: { id: true, advisors: true },
// Intentional bypass: the unrestricted gate above is the
// authorization; the `advisors` field is admin-only by field access
// (same contract as the C99/B156 per-entity cells).
overrideAccess: true,
req,
}),
payload.find({
collection: 'stateDeputy',
where: { contact: { equals: contactId } },
depth: 0,
pagination: false,
select: { id: true, advisors: true },
// Intentional bypass: same gate as the leadership read above.
overrideAccess: true,
req,
}),
])
const touched: number[] = []
for (const leadership of leaderships.docs) {
await acquireTextAdvisoryLocks(payload, req, [`leadership-advisors:${leadership.id}`])
const currentAdvisorIDs = uniqueRelationshipIds(leadership.advisors)
const nextAdvisorIDs = nextLeadershipAdvisorIdsAfterMembership(
currentAdvisorIDs,
advisorId,
assigned,
)
if (nextAdvisorIDs === null) continue
// Intentional bypass: the unrestricted gate above is the authorization;
// the `advisors` field is admin-only by field access (C99 contract).
await payload.update({
collection: 'leadership',
id: leadership.id,
data: { advisors: nextAdvisorIDs },
depth: 0,
overrideAccess: true,
req,
})
touched.push(leadership.id)
}
for (const deputy of deputies.docs) {
await acquireTextAdvisoryLocks(payload, req, [`state-deputy-advisors:${deputy.id}`])
const currentAdvisorIDs = uniqueRelationshipIds(deputy.advisors)
const nextAdvisorIDs = nextStateDeputyAdvisorIdsAfterMembership(
currentAdvisorIDs,
advisorId,
assigned,
)
if (nextAdvisorIDs === null) continue
// Intentional bypass: same gate as the leadership write above.
await payload.update({
collection: 'stateDeputy',
id: deputy.id,
data: { advisors: nextAdvisorIDs },
depth: 0,
overrideAccess: true,
req,
})
touched.push(deputy.id)
}
if (touched.length === 0) {
throw new Error(PERSON_CONTACT_INVALID_MESSAGE)
}
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização dos assessores.' },
)
}
export const setPersonAdvisorMembership = async (input: PersonAdvisorMembershipInput) => {
const { payload, actor } = await getCampaignActionContext()
await setPersonAdvisorMembershipRecord(payload, actor, input)
revalidatePath('/campanha/pessoas', 'page')
revalidatePath('/campanha/liderancas', 'page')
revalidatePath('/campanha/dobradinhas', 'page')
}

View file

@ -0,0 +1,89 @@
'use client'
import { useCallback } from 'react'
import type { RelationChipCellCopy } from '@/components/campaign/shared/RelationChipCell'
import {
RelationOptionCell,
type RelationCellOption,
} from '@/components/campaign/shared/RelationOptionCell'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
const ASSESSORADO_COPY: RelationChipCellCopy = {
searchPlaceholder: 'Buscar assessor…',
searchLabel: 'Buscar assessor',
suggestionsLabel: 'Sugestões de assessores',
emptyDrawerMessage: 'Nenhum assessor vinculado.',
savingMessage: 'Salvando assessores…',
savedMessage: 'Assessores salvos.',
removedMessage: (count: number) =>
count === 1 ? 'Assessor removido.' : `${count} assessores removidos.`,
}
type PeopleAssessoradoCellProps = {
/** The person (`contactID`) — the write is person-centric, not per entity. */
ownerId: number | null
ownerName: string
/** Assigned advisors, names resolved server-side (union of the person's entities). */
assessorados: Array<{ id: number; name: string }>
/** The addable staff catalog (`loadEligibleAdvisorOptions`). Empty when read-only. */
options: RelationCellOption[]
commitAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
/**
* Read-only for staff who may see but not assign (B156 rule): linked chips
* lose their href — `/campanha/assessores/[id]` is unrestricted-only.
*/
readOnly?: boolean
}
/**
* C116 — the "Assessorado" column of `/campanha/pessoas`: the union of the
* person's advisor links (leadership + dobradinha), edited person-centrically —
* the commit writes the advisor delta on EVERY entity the person has, in one
* transaction. Same `RelationOptionCell` machine as the C99/B156 advisor cells,
* in the `quiet` transparent form.
*/
export const PeopleAssessoradoCell = ({
ownerId,
ownerName,
assessorados,
options,
commitAction,
readOnly = false,
}: PeopleAssessoradoCellProps) => {
const buildFormData = useCallback(
(changedIds: number[], assigned: boolean) => {
const formData = new FormData()
formData.set('contactId', String(ownerId))
formData.set('advisorId', String(changedIds[0]))
formData.set('assigned', assigned ? 'true' : 'false')
return formData
},
[ownerId],
)
return (
<RelationOptionCell
ownerId={ownerId}
ownerName={ownerName}
items={assessorados.map((advisor) => ({
id: advisor.id,
label: advisor.name,
...(readOnly ? {} : { href: `/campanha/assessores/${advisor.id}` }),
}))}
options={options}
buildFormData={buildFormData}
commitAction={commitAction}
copy={ASSESSORADO_COPY}
drawerTitle="Assessores da pessoa"
triggerLabel={`Editar assessores de ${ownerName}`}
updateErrorMessage="Não foi possível atualizar os assessores. Tente novamente."
readOnly={readOnly}
quiet
overflowToggleLabel={(count) => `+${count}`}
/>
)
}

View file

@ -0,0 +1,112 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import {
buildMunicipalityPortfolioChips,
type MunicipalityPortfolioIndexEntry,
} from '@/lib/municipalityPortfolio'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
type PeopleMunicipalityCellProps = {
/** The entity the write targets (`leadership.id`, `stateDeputy.id` or the staff account). */
ownerId: number | null
/** Whose relation this is — spoken in the aria-labels and the Drawer. */
ownerName: string
municipalityIds: number[]
municipalityIndex: readonly MunicipalityPortfolioIndexEntry[]
/** Ids the actor may ADD (administered carteira for an advisor). */
addableIds?: ReadonlySet<number>
/** Floor the relation enforces server-side — 1 for `leadership.municipalities`. */
minItems?: number
/** Read-only chips (actor may see the relation but not edit it). */
readOnly?: boolean
commitAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
drawerTitle: string
updateErrorMessage: string
}
/**
* C116 — the people-list municipality columns (Assessora, Lidera, Aliada em):
* the shared `MunicipalityPortfolioCell` in its `quiet` form (transparent,
* always-input paradigm) plus the batch-chip expansion state — clicking a
* territory / "Salvador (19)" chip expands it into its member municipalities
* IN PLACE, and leaving the cell with the mouse collapses complete batches
* again. Expansion is local presentation: it never touches the commit cycle.
*/
export const PeopleMunicipalityCell = ({
ownerId,
ownerName,
municipalityIds,
municipalityIndex,
addableIds,
minItems,
readOnly = false,
commitAction,
drawerTitle,
updateErrorMessage,
}: PeopleMunicipalityCellProps) => {
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(() => new Set())
const idsRef = useRef(municipalityIds)
idsRef.current = municipalityIds
const toggleExpand = useCallback((chipKey: string) => {
setExpandedKeys((current) => {
const next = new Set(current)
if (next.has(chipKey)) next.delete(chipKey)
else next.add(chipKey)
return next
})
}, [])
/**
* Leave-with-mouse collapses the expanded batches whose members are ALL
* still assigned ("se a pessoa ainda tiver todas as cidades do território,
* colapsa de novo"). A batch whose member was removed via its chip's X no
* longer exists as a complete group — the chip builder drops it, so the key
* falls out here and the members keep rendering individually.
*/
const handleMouseLeave = useCallback(() => {
setExpandedKeys((current) => {
if (current.size === 0) return current
const chips = buildMunicipalityPortfolioChips(idsRef.current, municipalityIndex)
const assigned = new Set(idsRef.current)
let changed = false
const next = new Set(current)
for (const key of current) {
const chip = chips.find((candidate) => candidate.key === key)
if (!chip || (chip.kind !== 'territory' && chip.kind !== 'city')) continue
if (chip.municipalityIds.every((id) => assigned.has(id))) {
next.delete(key)
changed = true
}
}
return changed ? next : current
})
}, [municipalityIndex])
return (
<div onMouseLeave={handleMouseLeave}>
<MunicipalityPortfolioCell
ownerId={ownerId}
ownerName={ownerName}
municipalityIds={municipalityIds}
municipalityIndex={municipalityIndex}
addableIds={addableIds}
minItems={minItems}
commitAction={commitAction}
drawerTitle={drawerTitle}
updateErrorMessage={updateErrorMessage}
quiet
expandedKeys={expandedKeys}
onChipClick={toggleExpand}
overflowToggleLabel={(count) => `+${count}`}
readOnly={readOnly}
/>
</div>
)
}

View file

@ -20,7 +20,7 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro
const SAVE_DEBOUNCE_MS = 500
export type CampaignInlineEditableField = 'name' | 'email' | 'phone' | 'party'
export type CampaignInlineEditableField = 'name' | 'email' | 'phone' | 'party' | 'city'
type CampaignInlineEditableCellProps = {
recordId: number
@ -36,6 +36,15 @@ type CampaignInlineEditableCellProps = {
editTrigger?: 'pencil' | 'cell'
readBehavior?: 'text' | 'copy'
saveOnChange?: boolean
/**
* C116 — "edit where you see": the input is ALWAYS mounted, borderless and
* tintless (no edit state, no label/border/background change). Saves on
* blur/Enter, Escape discards, empty shows a "—" placeholder. With `href` +
* `field === 'name'` the value renders as a LINK above the invisible input —
* the draft lives in the link's text, so clicking the link navigates and
* typing edits in the same place (mechanism locked at the C116 gate).
*/
permanent?: boolean
className?: string
}
@ -45,6 +54,17 @@ const inputTypeForField = (field: CampaignInlineEditableField): 'text' | 'email'
return 'text'
}
/** The discreet keyboard-focus indicator of the permanent mode (a11y guardrail). */
const permanentFocusClassName =
'rounded-sm focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:outline-none'
/**
* Permanent cells display the phone already formatted — the raw 11 digits stay
* on the server, so the draft and the save baseline share the display.
*/
const displayDraftFor = (field: CampaignInlineEditableField, raw: string): string =>
field === 'phone' && raw ? formatBrazilianPhoneInput(raw) : raw
export const CampaignInlineEditableCell = ({
recordId,
recordIdField,
@ -56,6 +76,7 @@ export const CampaignInlineEditableCell = ({
editTrigger = 'pencil',
readBehavior = 'text',
saveOnChange = true,
permanent = false,
className,
}: CampaignInlineEditableCellProps) => {
const router = useRouter()
@ -73,8 +94,9 @@ export const CampaignInlineEditableCell = ({
useEffect(() => {
if (inputRef.current && document.activeElement === inputRef.current) return
const next = value ?? ''
setDraft(next)
lastSaved.current = next.trim()
const display = displayDraftFor(field, next)
setDraft(display)
lastSaved.current = display.trim()
}, [field, recordId, value])
useEffect(() => {
@ -176,6 +198,85 @@ export const CampaignInlineEditableCell = ({
startEditing()
}
/**
* C116 — "edit where you see": input always mounted, no edit state. The
* name-link cell renders the LINK above an invisible input (the draft lives
* in the link's text, the input value is never the display — locked
* mechanism); every other field is a borderless input that reads as text.
*/
if (permanent) {
const isNameLink = href !== undefined && field === 'name'
const inputClassName = cn(
isNameLink
? 'absolute inset-0 z-0 h-full w-full cursor-text border-transparent bg-transparent px-1 text-transparent caret-transparent shadow-none'
: 'min-h-10 w-full border-transparent bg-transparent px-1 shadow-none',
permanentFocusClassName,
)
return (
<div className={cn('relative min-w-0 w-full', isNameLink && 'min-h-10', className)}>
{isNameLink ? (
<Link
href={href}
// `w-auto`: the link spans ONLY its text, so the rest of the cell
// stays the input's clickable area (click on the text navigates,
// click beside it edits — the locked C116 mechanism).
className="relative z-10 inline-flex min-h-10 max-w-full items-center font-medium text-primary underline-offset-4 hover:underline"
onKeyDown={(event) => event.stopPropagation()}
>
<span className="truncate">{draft || value || ''}</span>
</Link>
) : null}
<Input
ref={inputRef}
type={inputTypeForField(field)}
value={draft}
placeholder={isNameLink ? undefined : '—'}
aria-label={label}
aria-busy={isPending}
className={inputClassName}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
setDraft(displayDraftFor(field, value ?? ''))
setErrorMessage(null)
setShowSavedFeedback(false)
// Blur saves the reverted draft — a no-op the equality check catches.
event.currentTarget.blur()
return
}
if (event.key === 'Enter') {
event.preventDefault()
event.currentTarget.blur()
}
}}
{...(field === 'phone' ? { inputMode: 'numeric' as const } : {})}
/>
{isPending ? (
<Spinner
className="pointer-events-none absolute top-1/2 right-2 z-20 size-3.5 -translate-y-1/2"
aria-label={`Salvando ${label.toLowerCase()}`}
/>
) : null}
{showSavedFeedback ? (
<span
role="status"
aria-live="polite"
className="absolute top-1/2 right-2 z-20 -translate-y-1/2 bg-background/90 px-1 text-xs text-muted-foreground"
>
Salvo.
</span>
) : null}
{errorMessage ? (
<p role="alert" className="mt-1 text-xs text-destructive">
{errorMessage}
</p>
) : null}
</div>
)
}
if (isEditing) {
return (
<div className={cn('relative min-w-40', className)}>

View file

@ -11,6 +11,7 @@ import {
} from '@/components/campaign/shared/RelationChipCell'
import {
buildMunicipalityPortfolioChips,
expandMunicipalityPortfolioChips,
scopedPortfolioIndex,
searchMunicipalityPortfolio,
type MunicipalityPortfolioChip,
@ -26,7 +27,7 @@ const hitDescription = (hit: MunicipalityPortfolioSearchHit): string => {
}
const toRelationChip = (chip: MunicipalityPortfolioChip): RelationChip =>
chip.kind === 'territory'
chip.kind === 'territory' || chip.kind === 'city'
? {
key: chip.key,
label: chip.label,
@ -76,6 +77,16 @@ type MunicipalityPortfolioCellProps = {
) => Promise<CampaignFormActionState>
drawerTitle: string
updateErrorMessage: string
/** C116 quiet cell — see `RelationChipCell.quiet`. */
quiet?: boolean
/** C116 expanded batch keys (territory / zone-city) — see `expandMunicipalityPortfolioChips`. */
expandedKeys?: ReadonlySet<string>
/** C116 — click on a collapsed batch chip (key like `territory:Irecê`), for the wrapper to expand it. */
onChipClick?: (chipKey: string) => void
/** C116 — "+N" overflow label — see `RelationChipCell.overflowToggleLabel`. */
overflowToggleLabel?: (hiddenCount: number) => string
/** C116 — read-only chips (actor may see the relation but not edit it). */
readOnly?: boolean
}
/**
@ -97,6 +108,11 @@ export const MunicipalityPortfolioCell = ({
commitAction,
drawerTitle,
updateErrorMessage,
quiet = false,
expandedKeys,
onChipClick,
overflowToggleLabel,
readOnly = false,
}: MunicipalityPortfolioCellProps) => {
/**
* `addableIds` also hides: an advisor's `canUpdateMunicipality` is scoped to
@ -124,8 +140,15 @@ export const MunicipalityPortfolioCell = ({
)
const buildChips = useCallback(
(ids: number[]) => buildMunicipalityPortfolioChips(ids, municipalityIndex).map(toRelationChip),
[municipalityIndex],
(ids: number[]) => {
const base = buildMunicipalityPortfolioChips(ids, municipalityIndex)
return (
expandedKeys
? expandMunicipalityPortfolioChips(base, expandedKeys, municipalityIndex)
: base
).map(toRelationChip)
},
[municipalityIndex, expandedKeys],
)
const searchHits = useCallback(
@ -178,6 +201,10 @@ export const MunicipalityPortfolioCell = ({
triggerLabel={`Editar municípios de ${ownerName}`}
updateErrorMessage={updateErrorMessage}
copy={copy}
quiet={quiet}
overflowToggleLabel={overflowToggleLabel}
onChipClick={onChipClick ? (chip) => onChipClick(chip.key) : undefined}
readOnly={readOnly}
/>
)
}

View file

@ -166,6 +166,27 @@ type RelationChipCellProps = {
* see the relation but not edit it (staff viewing `StateDeputy.advisors`).
*/
readOnly?: boolean
// C116 quiet/expand surface ─────────────────────────────────────────────────
/**
* Transparent cell (C116 "edit where you see"): no hover/focus background
* tint — the cell looks like plain text. Keyboard focus keeps a discreet
* indicator on the search input only (accessibility guardrail), never a box
* or tint around the cell.
*/
quiet?: boolean
/**
* C116 — click on a link-less (batch) chip fires this instead of nothing,
* letting a wrapper expand the batch into its member chips in place. Chips
* WITH a link keep navigating. Only the inline row editor is wired: the
* coarse Drawer keeps its list with the permanent remove buttons.
*/
onChipClick?: (chip: RelationChip) => void
/**
* C116 — replaces the overflow toggle's collapsed label ("Ver mais…") with a
* "+N" chip label (e.g. "+3"), keeping the B170 measure/collapse contract.
*/
overflowToggleLabel?: (hiddenCount: number) => string
}
/**
@ -203,6 +224,9 @@ export const RelationChipCell = ({
createLabel,
createErrorMessage = updateErrorMessage,
readOnly = false,
quiet = false,
onChipClick,
overflowToggleLabel,
}: RelationChipCellProps) => {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
@ -352,6 +376,18 @@ export const RelationChipCell = ({
: null
const listOptions = createHit ? [...hits, createHit] : hits
/**
* C116 — the "typed text IS the match" rule: a suggestion whose normalized
* label exactly equals the query (e.g. "Feira de Santana"). Space or Enter
* then commits THAT suggestion instead of the ARIA pre-selected first hit —
* without it, "Salvador" + Enter would silently add "Salvador — ZE 1" while
* every zone matches at word start. The create option can never be the exact
* match (it only exists when there are no hits).
*/
const exactMatchOption = listOptions.find(
(option) => normalizeSearchPhrase(option.label) === normalizeSearchPhrase(trimmedQuery),
)
const optionsKey = listOptions.map((option) => option.key).join('|')
useEffect(() => {
setActiveIndex(0)
@ -626,10 +662,10 @@ export const RelationChipCell = ({
setActiveIndex(event.key === 'Home' ? 0 : listOptions.length - 1)
return
}
if (event.key === 'Enter') {
if (event.key === 'Enter' || (event.key === ' ' && exactMatchOption)) {
if (listOptions.length === 0) return
event.preventDefault()
pickOption(activeIndex)
pickOption(exactMatchOption ? listOptions.indexOf(exactMatchOption) : activeIndex)
return
}
if (event.key === 'Escape') {
@ -676,6 +712,16 @@ export const RelationChipCell = ({
? copy.savedMessage
: ''
/**
* C116 — the collapsed overflow toggle may render as a "+N" chip instead of
* the "Ver mais…" link. `visibleChipCount === null` means the first
* measurement is still in flight, when the toggle is invisible anyway.
*/
const collapsedOverflowLabel =
!showAllChips && overflowToggleLabel && visibleChipCount !== null
? overflowToggleLabel(chips.length - visibleChipCount)
: null
/**
* Mounted while measuring so its width is reserved on the last line, but kept
* out of the tab order and the accessibility tree until the overflow it
@ -691,7 +737,10 @@ export const RelationChipCell = ({
aria-hidden={measuring || undefined}
className={cn(
// `min-h-6`: 24px is the SC 2.5.8 target floor for a text button.
'inline-flex min-h-6 items-center px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
'inline-flex min-h-6 items-center',
collapsedOverflowLabel
? 'rounded-md border border-input px-1.5 text-xs font-normal text-muted-foreground tabular-nums hover:bg-muted/60'
: 'px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
measuring && 'invisible pointer-events-none',
)}
onClick={(event) => {
@ -705,7 +754,7 @@ export const RelationChipCell = ({
}
}}
>
{showAllChips ? 'Ver menos' : 'Ver mais…'}
{showAllChips ? 'Ver menos' : (collapsedOverflowLabel ?? 'Ver mais…')}
</button>
) : null
@ -744,6 +793,22 @@ export const RelationChipCell = ({
{chipBody(chip)}
</Link>
</Badge>
) : onChipClick ? (
/* C116 — a link-less batch chip becomes a button: the wrapper
expands/collapses it in place. `stopPropagation` keeps the cell
click (focus + suggestions) out of the way. */
<Badge variant="secondary" className="max-w-full font-normal" asChild>
<button
type="button"
className="cursor-pointer truncate underline-offset-4 focus-visible:ring-2 focus-visible:ring-ring"
onClick={(event) => {
event.stopPropagation()
onChipClick(chip)
}}
>
{chipBody(chip)}
</button>
</Badge>
) : (
<Badge variant="secondary" className="max-w-full gap-1 font-normal">
{chipBody(chip)}
@ -953,7 +1018,12 @@ export const RelationChipCell = ({
// "nothing happened" until the next navigation proves otherwise.
aria-busy={isPending || undefined}
// Same box in both pointer modes so nothing reflows when the row is touched.
className="relative min-w-56 rounded-md border border-transparent p-1 outline-none pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40"
className={cn(
'relative min-w-56 rounded-md border border-transparent p-1 outline-none',
// C116 quiet: no tint at all — the cell reads as plain text (the locked
// paradigm); keyboard focus keeps only the input's discreet ring.
!quiet && 'pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40',
)}
// Reached only on a fine pointer: the coarse overlay below covers the cell
// and stops the click, so the pointer policy stays in the media queries.
onClick={(event) => {
@ -1018,7 +1088,12 @@ export const RelationChipCell = ({
onKeyDown={onSearchKeyDown}
placeholder={chips.length ? 'Adicionar…' : copy.searchPlaceholder}
aria-label={copy.searchLabel}
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
className={cn(
'hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block',
// C116 quiet: the ONLY focus indicator — a discreet ring on
// the input itself, never a box or tint around the cell.
quiet && 'rounded-sm focus-visible:ring-1 focus-visible:ring-ring/50',
)}
/>
)}
</div>

View file

@ -44,6 +44,10 @@ type RelationOptionCellProps = {
measureOverflow?: boolean
/** Read-only rendering (B156): linked chips, no search/remove/Drawer affordances. */
readOnly?: boolean
/** C116 quiet cell — see `RelationChipCell.quiet`. */
quiet?: boolean
/** C116 — "+N" overflow label — see `RelationChipCell.overflowToggleLabel`. */
overflowToggleLabel?: (hiddenCount: number) => string
}
const itemLabel = (item: RelationCellItem): string =>
@ -91,6 +95,8 @@ export const RelationOptionCell = ({
updateErrorMessage,
measureOverflow = true,
readOnly = false,
quiet = false,
overflowToggleLabel,
}: RelationOptionCellProps) => {
const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items])
// Additions resolve through `options` (the addable catalog), same asymmetry
@ -149,6 +155,8 @@ export const RelationOptionCell = ({
copy={copy}
measureOverflow={measureOverflow}
readOnly={readOnly}
quiet={quiet}
overflowToggleLabel={overflowToggleLabel}
/>
)
}

View file

@ -4,6 +4,7 @@ import {
getMunicipalityCatalogEntry,
municipalityCatalog,
municipalityCatalogEntriesForCity,
ZONE_MUNICIPALITY_CITIES,
type MunicipalityCatalogEntry,
} from '@/lib/municipalityCatalog'
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
@ -31,6 +32,7 @@ export type ResolvedPortfolioEntry = {
id: number
slug: string
name: string
city: string
region: BahiaIdentityTerritory
}
@ -42,6 +44,14 @@ export type MunicipalityPortfolioChip =
territory: BahiaIdentityTerritory
municipalityIds: number[]
}
| {
/** A zone-city (Salvador) with ALL of its zones assigned — "Salvador (19)". */
kind: 'city'
key: string
label: string
city: string
municipalityIds: number[]
}
| {
kind: 'municipality'
key: string
@ -97,6 +107,7 @@ type PortfolioIndexDerivations = {
byId: Map<number, ResolvedPortfolioEntry>
idBySlug: Map<string, number>
idsByTerritory: Map<BahiaIdentityTerritory, number[]>
idsByCity: Map<string, number[]>
normalizedNames: Map<number, string>
}
@ -123,6 +134,7 @@ const portfolioIndexDerivations = (
byId: new Map(),
idBySlug: new Map(),
idsByTerritory: new Map(),
idsByCity: new Map(),
normalizedNames: new Map(),
}
for (const entry of index) {
@ -137,6 +149,7 @@ const portfolioIndexDerivations = (
id: entry.id,
slug: entry.slug,
name: catalogEntry.name,
city: catalogEntry.city,
region: catalogEntry.region,
}
derived.byId.set(resolved.id, resolved)
@ -145,6 +158,9 @@ const portfolioIndexDerivations = (
const territoryIds = derived.idsByTerritory.get(resolved.region)
if (territoryIds) territoryIds.push(resolved.id)
else derived.idsByTerritory.set(resolved.region, [resolved.id])
const cityIds = derived.idsByCity.get(resolved.city)
if (cityIds) cityIds.push(resolved.id)
else derived.idsByCity.set(resolved.city, [resolved.id])
}
derivationsByIndex.set(index, derived)
@ -208,7 +224,11 @@ const municipalityIdsForTseZone = (zoneNumber: number, idBySlug: Map<string, num
}
/**
* Collapse complete territory memberships into a single territory chip.
* Collapse complete territory memberships into a single territory chip, and a
* complete zone-city (all of Salvador's 19 zones) into one "Salvador (N)" chip.
* The city group is checked FIRST: the zones share the city's territory, so
* without it the complete set would collapse into the territory chip
* ("Metropolitano de Salvador (19)") instead of the intended "Salvador (19)".
*
* Labels and slugs come from `index` — the complete catalog by construction — so
* an id it does not know cannot be rendered (no name, no link) and is dropped
@ -218,10 +238,27 @@ export const buildMunicipalityPortfolioChips = (
assignedMunicipalityIds: readonly number[],
index: readonly MunicipalityPortfolioIndexEntry[],
): MunicipalityPortfolioChip[] => {
const { byId, idsByTerritory } = portfolioIndexDerivations(index)
const { byId, idsByTerritory, idsByCity } = portfolioIndexDerivations(index)
const remaining = new Set(assignedMunicipalityIds)
const chips: MunicipalityPortfolioChip[] = []
for (const city of ZONE_MUNICIPALITY_CITIES) {
const cityIds = idsByCity.get(city)
if (!cityIds || cityIds.length === 0) continue
// A zone-city group never fits in fewer ids than it has (19 for Salvador).
if (cityIds.length > remaining.size) continue
if (!cityIds.every((id) => remaining.has(id))) continue
chips.push({
kind: 'city',
key: `city:${city}`,
label: city,
city,
municipalityIds: [...cityIds],
})
for (const id of cityIds) remaining.delete(id)
}
for (const territory of bahiaIdentityTerritories) {
const territoryIds = idsByTerritory.get(territory)
if (!territoryIds || territoryIds.length === 0) continue
@ -365,3 +402,35 @@ export const searchMunicipalityPortfolio = (
return hits
}
/**
* Replace expanded batch chips (territory / zone-city) with their member
* municipality chips, in place, keeping every other chip untouched. Pure
* presentation: nothing here writes — the members are real municipalities, so
* they carry the slug for the detail link and their own `ids` for removal.
*/
export const expandMunicipalityPortfolioChips = (
chips: readonly MunicipalityPortfolioChip[],
expandedKeys: ReadonlySet<string>,
index: readonly MunicipalityPortfolioIndexEntry[],
): MunicipalityPortfolioChip[] => {
if (expandedKeys.size === 0) return [...chips]
const { byId } = portfolioIndexDerivations(index)
return chips.flatMap((chip) => {
if (!expandedKeys.has(chip.key)) return [chip]
if (chip.kind !== 'territory' && chip.kind !== 'city') return [chip]
const members: MunicipalityPortfolioChip[] = []
for (const id of chip.municipalityIds) {
const entry = byId.get(id)
if (!entry) continue
members.push({
kind: 'municipality',
key: `municipality:${id}`,
label: entry.name,
municipalityId: id,
slug: entry.slug,
})
}
return members
})
}

View file

@ -81,6 +81,11 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
field: z.literal('phones'),
phones: contactPhonesSchema,
}),
z.object({
id: positiveRelationshipId,
field: z.literal('city'),
city: z.string().trim().min(2, 'Cidade inválida').max(100, 'Cidade muito longa'),
}),
])
export type ContactFieldUpdateInput = z.input<typeof contactFieldUpdateSchema>

View file

@ -0,0 +1,39 @@
import { z } from 'zod'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
/**
* C116 — cell-edit contracts of `/campanha/pessoas`: per-field Contact writes
* (via the shared `contactFieldUpdateSchema`), the Assessora carteira batch
* (unrestricted-only, person with exactly ONE staff account) and the
* person-centric Assessorado advisor delta.
*/
export const personAssessoraMembershipSchema = z.object({
contactId: positiveRelationshipId,
municipalityIds: z.array(positiveRelationshipId).min(1),
assigned: z.boolean(),
})
export const personAdvisorMembershipSchema = z.object({
contactId: positiveRelationshipId,
advisorId: positiveRelationshipId,
assigned: z.boolean(),
})
export type PersonAssessoraMembershipInput = z.input<typeof personAssessoraMembershipSchema>
export type PersonAdvisorMembershipInput = z.input<typeof personAdvisorMembershipSchema>
/** Refusal messages shared verbatim between the server action and the cell. */
export const PERSON_CELL_STAFF_MESSAGE =
'Somente a coordenação e o candidato podem editar essa pessoa.'
export const PERSON_CELL_NOT_IN_SCOPE_MESSAGE = 'Esta pessoa não está no seu escopo de edição.'
export const PERSON_CONTACT_INVALID_MESSAGE = 'Esta pessoa não tem ficha de contato.'
export const PERSON_ASSESSORA_UNRESTRICTED_MESSAGE =
'Somente a coordenação e o candidato podem editar a carteira de assessores.'
export const PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE =
'Esta pessoa não tem perfil de assessor para vincular municípios.'
export const PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE =
'Esta pessoa tem mais de um perfil de assessor; edite os municípios por perfil no admin.'
export const PERSON_ADVISORS_UNRESTRICTED_MESSAGE =
'Somente a coordenação e o candidato podem editar os assessores.'

View file

@ -62,6 +62,8 @@ export type PeopleRowViewModel = {
capacityMunicipalityIDs: number[]
/** Union of leadership + dobradinha advisors, names resolved (Assessorado). */
assessoradoNames: string[]
/** Union of leadership + dobradinha advisors as id+name pairs (Assessorado chips, C116). */
assessorados: Array<{ id: number; name: string }>
}
/** Merge intermediate: the ids the loader resolves into `assessoradoNames`. */
@ -146,6 +148,7 @@ export const mergePeopleSources = (sources: PeopleMergeSources): MergedPerson[]
staff: [],
assessoraMunicipalityIDs: [],
capacityMunicipalityIDs: [],
assessorados: [],
}
byContact.set(contactID, person)
return person
@ -444,13 +447,11 @@ export const loadPeopleListPageData = async (
const advisorNames = await loadCampaignUserNamesByIds(payload, [...advisorIDs])
const rows: PeopleRowViewModel[] = pageRows.map((person) => {
const assessoradoNames = [
...new Set(
[...person.leadershipAdvisorIDs, ...person.deputyAdvisorIDs]
.map((id) => advisorNames.get(id))
.filter((name): name is string => name !== undefined),
),
].sort((left, right) => left.localeCompare(right, 'pt-BR'))
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,
@ -467,7 +468,8 @@ export const loadPeopleListPageData = async (
staff: person.staff,
assessoraMunicipalityIDs: person.assessoraMunicipalityIDs,
capacityMunicipalityIDs: person.capacityMunicipalityIDs,
assessoradoNames,
assessoradoNames: assessorados.map((pair) => pair.name),
assessorados,
}
})

View file

@ -0,0 +1,309 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import {
setPersonAdvisorMembershipRecord,
setPersonAssessoraMembershipRecord,
updatePersonContactRecord,
} from '@/app/(campaign)/campanha/actions/person'
import { primaryPhoneOf } from '@/lib/phone'
import { uniqueRelationshipIds } from '@/lib/relationship'
import {
PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
PERSON_CELL_STAFF_MESSAGE,
} from '@/lib/schemas/personCell'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('C116 — cell edits of the people list', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
describe('updatePersonContactRecord', () => {
it('lets a coordinator edit name/email/city of a leadership-anchored person', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact({ city: 'Feira de Santana' })
await fixtures.createLeadership({ contact, municipalities: [municipality] })
await updatePersonContactRecord(payload, actor, {
id: contact.id,
field: 'name',
name: 'Maria Nova da Silva',
})
await updatePersonContactRecord(payload, actor, {
id: contact.id,
field: 'city',
city: 'Salvador',
})
await updatePersonContactRecord(payload, actor, {
id: contact.id,
field: 'phone',
phone: '71999998888',
})
const updated = await payload.findByID({
collection: 'contact',
id: contact.id,
depth: 0,
overrideAccess: true,
})
expect(updated.name).toBe('Maria Nova da Silva')
expect(updated.city).toBe('Salvador')
// C112 shape: the cell edit set the PRIMARY phone, the ficha list carries it first.
expect(primaryPhoneOf(updated.phones)).toBe('71999998888')
})
it('lets an advisor edit a person anchored in his carteira', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const contact = await fixtures.createContact()
await fixtures.createLeadership({ contact, municipalities: [municipality] })
await updatePersonContactRecord(payload, advisor, {
id: contact.id,
field: 'name',
name: 'Assessorada na Carteira',
})
const updated = await payload.findByID({
collection: 'contact',
id: contact.id,
depth: 0,
overrideAccess: true,
})
expect(updated.name).toBe('Assessorada na Carteira')
})
it('denies an advisor a person whose entities are outside his carteira', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const outside = await fixtures.getMunicipality()
const contact = await fixtures.createContact()
await fixtures.createLeadership({ contact, municipalities: [outside] })
await expect(
updatePersonContactRecord(payload, advisor, {
id: contact.id,
field: 'name',
name: 'Fora da Carteira',
}),
).rejects.toThrow(PERSON_CELL_NOT_IN_SCOPE_MESSAGE)
})
it('denies an advisor a staff-only person (no leadership/dobradinha anchor)', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const contact = await fixtures.createContact()
// The identity hook links the account to the existing ficha by phone.
await fixtures.createCampaignUser('advisor', { phone: contact.phone })
await expect(
updatePersonContactRecord(payload, advisor, {
id: contact.id,
field: 'email',
email: 'staff.only@example.com',
}),
).rejects.toThrow(PERSON_CELL_NOT_IN_SCOPE_MESSAGE)
})
it('denies a leader actor', async () => {
const fixtures = campaignFixtures()
const leader = await fixtures.createCampaignUser('leader')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact()
await fixtures.createLeadership({ contact, municipalities: [municipality] })
await expect(
updatePersonContactRecord(payload, leader, {
id: contact.id,
field: 'name',
name: 'Líder não edita',
}),
).rejects.toThrow(PERSON_CELL_STAFF_MESSAGE)
})
})
describe('setPersonAssessoraMembershipRecord', () => {
it('lets a coordinator toggle the single account of a person on municipalities', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
// The identity hook links the account to the existing ficha by phone.
const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
const first = await fixtures.getMunicipality()
const second = await fixtures.getMunicipality()
await setPersonAssessoraMembershipRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [first.id, second.id],
assigned: true,
})
for (const municipality of [first, second]) {
const updated = await payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
select: { advisors: true },
overrideAccess: true,
})
expect(uniqueRelationshipIds(updated.advisors)).toContain(account.id)
}
await setPersonAssessoraMembershipRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [first.id],
assigned: false,
})
const updatedFirst = await payload.findByID({
collection: 'municipality',
id: first.id,
depth: 0,
select: { advisors: true },
overrideAccess: true,
})
expect(uniqueRelationshipIds(updatedFirst.advisors)).not.toContain(account.id)
})
it('refuses a person with more than one staff account', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
await fixtures.createCampaignUser('advisor', { phone: contact.phone })
await fixtures.createCampaignUser('advisor', { phone: contact.phone })
const municipality = await fixtures.getMunicipality()
await expect(
setPersonAssessoraMembershipRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: true,
}),
).rejects.toThrow(PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE)
})
it('refuses a person with no staff account', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
await expect(
setPersonAssessoraMembershipRecord(payload, actor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: true,
}),
).rejects.toThrow(PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE)
})
it('denies an advisor actor', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
await expect(
setPersonAssessoraMembershipRecord(payload, advisor, {
contactId: contact.id,
municipalityIds: [municipality.id],
assigned: true,
}),
).rejects.toThrow(PERSON_ASSESSORA_UNRESTRICTED_MESSAGE)
})
})
describe('setPersonAdvisorMembershipRecord', () => {
it('writes the advisor delta on EVERY entity of the person (leadership + dobradinha)', async () => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser('coordinator')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
await fixtures.createLeadership({ contact, municipalities: [municipality] })
await fixtures.createStateDeputy({ contact })
const advisor = await fixtures.createCampaignUser('advisor')
await setPersonAdvisorMembershipRecord(payload, actor, {
contactId: contact.id,
advisorId: advisor.id,
assigned: true,
})
const leadership = await payload.find({
collection: 'leadership',
where: { contact: { equals: contact.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
const deputy = await payload.find({
collection: 'stateDeputy',
where: { contact: { equals: contact.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(uniqueRelationshipIds(leadership.docs[0].advisors)).toContain(advisor.id)
expect(uniqueRelationshipIds(deputy.docs[0].advisors)).toContain(advisor.id)
await setPersonAdvisorMembershipRecord(payload, actor, {
contactId: contact.id,
advisorId: advisor.id,
assigned: false,
})
const leadershipAfter = await payload.find({
collection: 'leadership',
where: { contact: { equals: contact.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
const deputyAfter = await payload.find({
collection: 'stateDeputy',
where: { contact: { equals: contact.id } },
depth: 0,
pagination: false,
overrideAccess: true,
})
expect(uniqueRelationshipIds(leadershipAfter.docs[0].advisors)).not.toContain(advisor.id)
expect(uniqueRelationshipIds(deputyAfter.docs[0].advisors)).not.toContain(advisor.id)
})
it('denies an advisor actor', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const contact = await fixtures.createContact()
const municipality = await fixtures.getMunicipality()
await fixtures.createLeadership({ contact, municipalities: [municipality] })
await expect(
setPersonAdvisorMembershipRecord(payload, advisor, {
contactId: contact.id,
advisorId: advisor.id,
assigned: true,
}),
).rejects.toThrow(PERSON_ADVISORS_UNRESTRICTED_MESSAGE)
})
})
})

View file

@ -5,6 +5,7 @@ import { municipalityCatalog } from '@/lib/municipalityCatalog'
import {
buildMunicipalityPortfolioChips,
catalogEntriesForTseZone,
expandMunicipalityPortfolioChips,
searchMunicipalityPortfolio,
type MunicipalityPortfolioIndexEntry,
} from '@/lib/municipalityPortfolio'
@ -47,6 +48,73 @@ describe('municipalityPortfolio', () => {
])
})
it('collapses the complete Salvador zone set into one "Salvador (N)" chip', () => {
const index = buildIndex()
const salvadorZones = slugsForCities(['Salvador'])
const assignedIds = index
.filter((entry) => salvadorZones.has(entry.slug))
.map((entry) => entry.id)
const chips = buildMunicipalityPortfolioChips(assignedIds, index)
expect(chips).toHaveLength(1)
expect(chips[0]).toMatchObject({ kind: 'city', label: 'Salvador' })
expect(chips[0]?.kind === 'city' ? chips[0].municipalityIds.length : 0).toBe(19)
})
it('keeps a partial Salvador zone set as municipality chips (never the city chip)', () => {
const index = buildIndex()
const salvadorZones = slugsForCities(['Salvador'])
const one = index.find((entry) => salvadorZones.has(entry.slug))
expect(one).toBeTruthy()
const chips = buildMunicipalityPortfolioChips([one!.id], index)
expect(chips).toHaveLength(1)
expect(chips[0]).toMatchObject({ kind: 'municipality', municipalityId: one!.id })
})
it('expands a collapsed territory chip into its member municipality chips', () => {
const index = buildIndex()
const irece = slugsForCities(citiesForTerritory('Irecê'))
const assignedIds = index.filter((entry) => irece.has(entry.slug)).map((entry) => entry.id)
const collapsed = buildMunicipalityPortfolioChips(assignedIds, index)
expect(collapsed).toHaveLength(1)
expect(collapsed[0]?.kind).toBe('territory')
const expanded = expandMunicipalityPortfolioChips(
collapsed,
new Set(['territory:Irecê']),
index,
)
expect(expanded).toHaveLength(assignedIds.length)
const municipalityChips = expanded.filter((chip) => chip.kind === 'municipality')
expect(municipalityChips).toHaveLength(expanded.length)
expect(new Set(municipalityChips.map((chip) => chip.municipalityId))).toEqual(
new Set(assignedIds),
)
})
it('expands the Salvador city chip into its 19 zone municipality chips', () => {
const index = buildIndex()
const salvadorZones = slugsForCities(['Salvador'])
const assignedIds = index
.filter((entry) => salvadorZones.has(entry.slug))
.map((entry) => entry.id)
const collapsed = buildMunicipalityPortfolioChips(assignedIds, index)
const expanded = expandMunicipalityPortfolioChips(collapsed, new Set(['city:Salvador']), index)
expect(expanded).toHaveLength(19)
expect(expanded.every((chip) => chip.kind === 'municipality')).toBe(true)
})
it('leaves non-expanded chips untouched', () => {
const index = buildIndex()
const irece = slugsForCities(citiesForTerritory('Irecê'))
const assignedIds = index.filter((entry) => irece.has(entry.slug)).map((entry) => entry.id)
const collapsed = buildMunicipalityPortfolioChips(assignedIds, index)
const unchanged = expandMunicipalityPortfolioChips(collapsed, new Set(['city:Salvador']), index)
expect(unchanged).toEqual(collapsed)
})
it('drops an id the index does not know (no name, no link to render)', () => {
const index = buildIndex()
expect(buildMunicipalityPortfolioChips([999_999], index)).toEqual([])

View file

@ -13,6 +13,14 @@ import { describe, expect, it } from 'vitest'
describe('shared sheet host for chip-relation cells (miss #52)', () => {
const componentRoots = ['src/components', 'src/app'] as const
const chipCellUsage = /<MunicipalityPortfolioCell|<LeadershipStateDeputyRelationCell/
/**
* Composition wrappers that render a chip cell but are not list surfaces
* themselves: the guard's contract is "the TABLE that mounts chip cells is
* wrapped in the provider", and these wrappers are mounted inside such a
* table (their owning page holds the provider — checked for the C116 people
* list like the dobradinhas one).
*/
const cellWrapperFiles = new Set(['src/components/campaign/people/PeopleMunicipalityCell.tsx'])
it('wraps every chip-relation table in CampaignListSheetProvider', () => {
const offenders: string[] = []
@ -29,6 +37,7 @@ describe('shared sheet host for chip-relation cells (miss #52)', () => {
const source = readFileSync(file, 'utf8')
if (!chipCellUsage.test(source)) continue
if (source.includes('CampaignListSheetProvider')) continue
if (cellWrapperFiles.has(relative(process.cwd(), file))) continue
offenders.push(relative(process.cwd(), file))
}
}