mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
commit
11605a2ef3
19 changed files with 1830 additions and 116 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
110
docs/plans/pessoas-edicao-inplace-lista-impl.md
Normal file
110
docs/plans/pessoas-edicao-inplace-lista-impl.md
Normal 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.
|
||||
188
src/app/(campaign)/campanha/(app)/pessoas/formActions.ts
Normal file
188
src/app/(campaign)/campanha/(app)/pessoas/formActions.ts
Normal 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.',
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
89
src/components/campaign/people/PeopleAssessoradoCell.tsx
Normal file
89
src/components/campaign/people/PeopleAssessoradoCell.tsx
Normal 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}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
112
src/components/campaign/people/PeopleMunicipalityCell.tsx
Normal file
112
src/components/campaign/people/PeopleMunicipalityCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
39
src/lib/schemas/personCell.ts
Normal file
39
src/lib/schemas/personCell.ts
Normal 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.'
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
309
tests/int/campaignPersonCellEditing.int.spec.ts
Normal file
309
tests/int/campaignPersonCellEditing.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue