Merge pull request #541 from fsolla/C100-pessoas-lista-unificada-da-campanha-rota-campanha-pesso
feat(C100): lista unificada de pessoas em /campanha/pessoas
This commit is contained in:
commit
9311c1623c
35 changed files with 3936 additions and 132 deletions
|
|
@ -38,6 +38,9 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
**Recently resolved (2026-08-10):** **B185** (Issue #524) — o Sollinha ganhou a tool **`getPendingLeaderships`** (`src/utilities/ai/tools/getPendingLeaderships.ts`): "quais lideranças ainda precisam ser abordadas" por **território de identidade** (grafia tolerante a acento — "Vale do Jiquiriça" resolve para a região canônica), **cidade** ("Salvador" = as 19 ZE juntas) ou **município** (aliases canônicos), com o **critério de pendência sempre declarado na resposta** (`'A abordar'/'Em disputa', ou 'Engajado' sem compromisso de votos no escopo consultado` — eixo do compromisso = presença de `votePledge` liderança×município no escopo; `negativo` é excluído na query, fail-closed), lista com nome/municípios/status/última atualização/**assessores responsáveis** (nomes resolvidos em query própria — nunca `depth` populando `campaignUser`), top N (default 20, max 50) com `total` e `truncado`, escopo omitido = escopo de acesso do usuário. Filtro **C3** `sem_assessor` (`advisors: { exists: false }`, precedente B176) e modo **C4** `municipios_sem_lideranca` (municípios do escopo sem nenhuma liderança). **Gate staff inline** no topo do `execute` (leader → `{ error: 'Leitura de dados de lideranças negada.' }` antes de qualquer query, mesmo shape da B180; 1 call site — sem helper); leituras todas `overrideAccess: false, user: ctx.user` (RBAC atual). Links via `buildCampaignLinks` existente (nada mudou lá); seção nova no system prompt manda declarar o critério. Unit tests novos (`pendingLeadershipsTool.unit.spec.ts`, 14) — gate fail-closed com payload intocado, critério por status+pledge, pledge fora do escopo não satisfaz (where `municipality in`), escopos com acento/Salvador/município/erro, `sem_assessor` no where, C4, truncamento, nomes de assessores; allowlist na convenção P3-D justificada (filtro de domínio sobre set resolvido ≠ escopo de actor). Sem migration, access change ou Consent. Intenção [`sollinha-liderancas-pendentes-abordagem.md`](plans/sollinha-liderancas-pendentes-abordagem.md); impl [`sollinha-liderancas-pendentes-abordagem-impl.md`](plans/sollinha-liderancas-pendentes-abordagem-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **C104** (Issue #505) — compromissos **"Todo o dia"** na agenda: o sheet de criação rápida (C91) e o formulário completo ganharam o toggle **Todo o dia** (checkbox; no sheet entre o título e Início/Término, no form no topo do card "Data e horário") — ligado, Início e Término viram **só datas** (término pode ser outro dia = faixa de vários dias inteiros; dia único = `startAt === endAt`). Representação: campo novo `allDay` (migration `20260810_010844_add_activity_all_day`) + convenção de instantes **fim inclusivo** (startAt = 1º dia 00:00 America/Bahia, endAt = último dia 00:00; Bahia sem DST); conversões exclusivo↔inclusivo (FullCalendar/iCal usam fim exclusivo) concentradas no módulo puro `lib/activityAllDay.ts` (unit-pinado: dia único, multi-dia, virada de mês/ano, rótulo "10/08/2026 a 12/08/2026"). Calendário: `allDaySlot` ligado (faixa no topo de semana/dia), `toEventInput` com `allDay`, reschedule converte os `startStr`/`endStr` exclusivos e envia `allDay`; **guard de drop**: arrastar entre a lane all-day e a grade de horário é rejeitado (revert + toast — converter por drag é feature fora do escopo). "Mais detalhes" preserva a escolha (`allDay=1` + datas civis nos params; form nasce com toggle ligado). Rótulos all-day em card, overview, dossiê do município, cabeçalho de detalhe e home search; feed iCal exporta `DTSTART;VALUE=DATE`/`DTEND;VALUE=DATE` com fim exclusivo. Liderança não consegue flipar `allDay` (snapshot staff). Sem access/Consent. Intenção [`agenda-toggle-todo-o-dia.md`](plans/agenda-toggle-todo-o-dia.md); impl [`agenda-toggle-todo-o-dia-impl.md`](plans/agenda-toggle-todo-o-dia-impl.md).
|
||||
**Recently resolved (2026-08-10):** **C100** (Issue #495) — nova rota staff **`/campanha/pessoas`**: lista unificada de pessoas da campanha (lideranças, dobradinhas e staff) com **merge por `Contact`** — uma linha por pessoa física, capacidades como colunas (Nome com partido da dobradinha, Contato, Assessora, Lidera, Aliada em, Assessorado, Base, Ações). Merge em memória de 3 fontes com o where/access existente de cada domínio (`src/utilities/people/peopleData.ts` — fontes leadership/dobradinha/staff; staff source com `overrideAccess` justificado: campo `contact` identity-gated; escopo do assessor = linha com ≥1 município de capacidade na carteira; **todo staff com ficha entra, mesmo sem carteira** — gate humano 2026-08-09). Filtros na omnibox (capacidade multi OR, município, apoio, q) com URL canônica própria (`peopleListUrl.ts`, sem sort v1); **filtros salvos = 2º call site do B18** via factory compartilhada `createSavedFilterStore` (`municipalitySavedFilters` refatorado como delegado, API + storage key + eventos idênticos, suite unit existente guarda) + submenu no sidebar e no drawer "Mais". **Apagar pessoa** (`src/utilities/people/personDelete.ts` + `actions/person.ts`): manifest read-only → diálogo lista tudo (liderança c/ municípios, dobradinha c/ partido, votos, convites, apoiadores, atualizações de município, feeds de agenda, contas); cascata transacional com advisory lock `person-delete:<contact>` — ordem por FKs NOT NULL reais (pledges → invites → supporters → leadership → dobradinha → contas (hooks de passkeys/notificações) → ficha deletada ou anonimizada se signature/subscription a referenciarem); contas coordenação/candidato bloqueiam a pessoa inteira; advisor joins cascadeiam no delete da conta. UI: omnibox `edgeToEdge` (novo variant no chassis B127 — label sr-only, sem bordas, Limpar = círculo-X interno), cards mobile sem bordas com divisórias, "Salvar filtro" como icon button no header via `SetCampaignHeaderAction` (slot C94), colunas com column picker B17 (novo `CampaignListId 'pessoas'`), FABs globais intactos. Mobile: convite reusa `LeadershipInviteRowAction`; WhatsApp `whatsAppHrefForPhone`. Sem migration. Testes: unit (URL contract, merge/escopo/facets, storage factory), int (escopo assessor/unrestricted/leader, cascata completa + anonimização + conta protegida), e2e novo `campaignPeople` (smoke staff + leader redirect; pegou e corrigiu um `div` dentro de `p` no card mobile — erro de hidratação). Intenção [`pessoas-lista-unificada.md`](plans/pessoas-lista-unificada.md); impl [`pessoas-lista-unificada-impl.md`](plans/pessoas-lista-unificada-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **B183** (Issue #501) — o auto-zoom do Safari iOS em inputs do PWA `/campanha` (font < 16px ao focar, escala que não volta no standalone) foi eliminado pela **causa**: uma regra CSS única em `styles.css` — `@media (pointer: coarse) and (hover: none)` + `[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) { font-size: 1rem }` — cobrindo omnibox B127, Command search, native-select e textareas em qualquer orientação do iPhone (landscape chega a 932px de largura, o que descartou escopo por `max-width`). Unlayered vence as utilities `text-sm` sem `!important`; desktop (pointer fine) fica intocado; `Input`/`Textarea` do ui já eram `text-base md:text-sm`. Sem migration, access, Consent ou mudança de componente. E2E novo `tests/e2e/campaignIosInputZoom.e2e.spec.ts` pina o contrato: emulação `devices['iPhone 13']` → omnibox de `/campanha/municipios` computa **16px**; control desktop → permanece **14px**. Intenção [`pwa-ios-autozoom-input-foco.md`](plans/pwa-ios-autozoom-input-foco.md); impl [`pwa-ios-autozoom-input-foco-impl.md`](plans/pwa-ios-autozoom-input-foco-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **C102** (Issue #498) — no mobile (<768px), nenhuma tela `/campanha` de staff mostra mais hamburger nem sheet de sidebar: a navegação staff mora na barra inferior + drawer "Mais". `CampaignMobileTopBar` esconde o `SidebarTrigger` via `useCampaignPageChromeRole` + `isStaffCampaignRole` (leader mantém — lockdown sem barra inferior); `CampaignSidebar` retorna `null` quando `isMobile && staff`, desmontando o Sheet; **B18 (filtros salvos de Municípios) migrou para o drawer "Mais"** — `MunicipalityNavSavedFilters` ganhou `variant: 'overflow'` (mesma store `useMunicipalitySavedFilters`, mesma lógica apagar/desfazer/sucessão de foco; no drawer o fallback de foco vira o link Perfil do footer) e `CampaignBottomNav` passou a renderizá-la entre destinos e footer (container com `overflow-y-auto`). **Bug pré-existente corrigido no caminho (D4):** o `<Toaster>` vivia dentro do wrapper `[contain:layout_paint]` do painel da AI — `contain: paint` vira containing block para `position: fixed` e cria stacking context próprio, prendendo o toast (z 999999999 do sonner) ABAIXO dos portais z-50 de drawer/sheet: qualquer toast com ação (ex. "Desfazer") era inalcançável com modal aberto; o Toaster foi movido para o root layout `(campaign)/layout.tsx` (irmão do `{children}`, direto no `<body>`). Testes: 2 unit novos na top bar (staff sem trigger / leader com trigger), 3 e2e novos (staff mobile sem trigger e sem dialog Sidebar; leader abre o sheet com "Meus contatos"; fluxo completo salvar→navegar→apagar→desfazer no drawer). Sem migration, access ou Consent. Intenção [`c102-mobile-sem-botao-sidebar.md`](plans/c102-mobile-sem-botao-sidebar.md); impl [`c102-mobile-sem-botao-sidebar-impl.md`](plans/c102-mobile-sem-botao-sidebar-impl.md).
|
||||
|
||||
|
|
|
|||
137
docs/plans/pessoas-lista-unificada-impl.md
Normal file
137
docs/plans/pessoas-lista-unificada-impl.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Impl: Pessoas — lista unificada da campanha (rota /campanha/pessoas)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: #495
|
||||
Intenção: docs/plans/pessoas-lista-unificada.md
|
||||
Appetite restante: ~3–4 dias eng (herdado — sem corte)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `/campanha/pessoas` lista e filtra todas as pessoas da campanha (lideranças, dobradinhas e staff) num lugar só; a mesma pessoa física em 2+ papéis = 1 linha (merge por `Contact`); 8 colunas (Nome, Contato, Assessora, Lidera, Aliada em, Assessorado, Base, Ações); omnibox com capacidade + município + filtros de domínio; filtros salvos no padrão B18; Ações = WhatsApp + convite (liderança) + Apagar (cascata destrutiva com confirmação explícita); sem apoiadores na v1; escopo de acesso preservado (assessor = carteira dele; leader lockdown).
|
||||
- **O que NÃO negociar:** merge por `Contact` (nunca segundo cadastro de pessoa); leader lockdown (`gate: 'staff'`); apoiadores fora da v1; convite reusa `createCampaignInvite`/`LeadershipInviteRowAction` (consent fail-closed, 1:1, Res. TSE 23.610 art. 33); apagar precedido de confirmação que lista tudo; contas coordenação/candidato nunca apagáveis; nenhuma rota existente muda; escopo de acesso nunca alargado (cada query com o where do domínio).
|
||||
- **O que reavaliar:** a hipótese da intenção citava B91 (busca global multi-entidade) como precedente de agregação — rejeitado para esta superfície (ver D0); "utilizadores por domínio existentes" existem para leitura individual, mas **nenhum loader existente faz union por Contact** — precisa loader novo de merge (D1); "especializar filtros salvos (2º call site)" — confirmado, com extração mínima do storage (D5).
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
### D0 — Superfície de busca: omnibox de lista, não B91
|
||||
|
||||
Opções: A) reusar a máquina de busca global (`CampaignHomeSearch`/`home-search`) | B) omnibox de lista (`CampaignListOmnibox` + adaptador de domínio, padrão Municípios/Lideranças).
|
||||
Recomendação: **B** — a rota é uma lista com URL canônica; o omnibox de lista é o precedente do sistema de listas (Pass 2 W1) e dá filtros salvos de graça. B91 é fortemente acoplado ao Início (context providers, exclude-current-entity, debounce 250ms, scope merge) e resolveria só a busca, não os facets.
|
||||
Rejeitada: A porque acopla a rota a contextos do Início e não participa do URL state.
|
||||
|
||||
### D1 — Merge por `Contact`: fontes por domínio + união em memória no servidor
|
||||
|
||||
Opções:
|
||||
|
||||
- A) query em `contact` (com o escopo de `canReadContacts`) e resolver joins por pessoa;
|
||||
- B) **três fontes por domínio** (leadership, stateDeputy, campaignUser-com-carteira), cada uma com o where/access existente do domínio, e **união em memória por `contact.id`** com escopo por capacidade (recomendada);
|
||||
- C) UNION SQL via drizzle.
|
||||
|
||||
Recomendação: **B** — cada fonte já tem loader + where + access pronto (`buildLeadershipListWhere`, `loadStateDeputyListPageData`-shape, `municipalityIdsByAdvisorIds`); a união em memória é uma função pura testável; volume atual (centenas de lideranças, dezenas de dobradinhas/staff) cabe sem paginação no nível de fonte.
|
||||
Rejeitadas: A porque `canReadContacts` inclui fichas de apoiadores (v1 exclui) e exige pós-filtro que quebra a semântica de paginação/facets; C porque contorna a camada de access do Payload e duplica o where de cada domínio.
|
||||
|
||||
**Merge (puro):** `mergePeopleSources({ leadershipRows, deputyRows, staffAccounts, municipalityIdsBy<Fonte> })` → `PeopleRow[]` — uma linha por `contactID` com capacidades preenchidas: `leadership?` (1, contato UNIQUE), `deputy?` (1, contato UNIQUE), `staff[]` (N contas por ficha — dedupe multpapel). Entram pessoas com **qualquer papel**: liderança, dobradinha ou conta staff com ficha (mesmo sem carteira — gate do humano em 2026-08-09, ver D2). Nome/telefone/e-mail/cidade vêm da **ficha** (depth-1 populate de `contact`), partido de `stateDeputy.party` (única fonte, no máximo uma dobradinha por ficha).
|
||||
|
||||
### D2 — Access: escopo por capacidade, nunca alargado
|
||||
|
||||
- **Leadership:** `payload.find` com `user` + `overrideAccess: false` → `canReadLeadership` aplica o where de carteira do assessor; filtros `q` (contact.name contains), `supportStatus`, `municipalities in` no where.
|
||||
- **Dobradinha:** `user` + `overrideAccess: false` → acesso staff-wide existente; municípios da dobradinha via `municipalityIdsByStateDeputyIds` (já existe).
|
||||
- **Staff (fonte de Assessora) — regra de inclusão (gate do humano 2026-08-09):** **toda conta staff** (`advisor` | `coordinator` | `candidate`) com ficha (`contact` ≠ null) entra na lista **mesmo sem carteira** — ser staff é papel; a carteira só preenche a coluna Assessora (vazia = "—"). `overrideAccess: true` **justificado e documentado** — precedente `loadAdvisorListPageData` (`/assessores`, mesmo padrão) e `canReadCampaignUserPhone` (regra same-municipality advisor); o campo `contact` de `campaignUser` tem field access `canReadCampaignUserIdentity` (self/admin) e o depth-1 populate seria cortado para assessor. O escopo de leitura é imposto **pelo merge** (D3), não pela collection access. Contas legadas sem ficha não têm identidade para o merge → fora da v1 (documentado).
|
||||
- **Contato:** lido via depth-1 das fontes (nunca via `contact` collection access — `canReadContacts` não alcança ficha puramente staff; a ficha do staff entra porque a pessoa aparece por papel staff, mesma regra de `canReadCampaignUserPhone`).
|
||||
|
||||
### D3 — Escopo do assessor (quem vê): carteira só
|
||||
|
||||
Opções: A) união literal dos wheres (assessor veria TODAS as dobradinhas — staff-wide hoje) | B) **restringir a linha a pessoas com ≥1 município de capacidade na carteira do assessor** (recomendada).
|
||||
Recomendação: **B** — o aceite trava "assessor vê só a carteira dele"; a dobradinha fora da carteira não é operação dele. Aplicado em memória após o merge (linha incluída sse `(lidera ∪ aliada ∪ carteira) ∩ portfolio ≠ ∅`). Consequência consistente com a regra de inclusão: staff **sem carteira** não tem município de capacidade → visível apenas a unrestricted (coordenação/candidato); `/dobradinhas` permanece staff-wide intacta (escopo é por página, não por coleção).
|
||||
Rejeitada: A porque contradiz o aceite ("só a carteira dele").
|
||||
|
||||
### D4 — Apagar pessoa: cascata transacional com manifest de confirmação
|
||||
|
||||
Duas actions (`src/app/(campaign)/campanha/actions/person.ts`), só `isCampaignUnrestricted` (coordenação/candidato — ação destrutiva transversal):
|
||||
|
||||
1. **`getPersonDeleteManifest`** (read-only): enumera `{ contact, leaderships (com municípios), stateDeputies (partido), pledgeCount, inviteCount, supporterCount, accounts (nome+role), hasProtectedAccount (role coordinator|candidate), fichaBlockedByOtherJoins (signature/subscription), fichaWillBeAnonymized }` — o diálogo lista exatamente isto antes de confirmar (padrão preview do import de CSV, sem staging/token: o manifest é derivado do dado vivo).
|
||||
2. **`deletePerson`** em `withPayloadTransaction`:
|
||||
- `reloadUnrestrictedActor` + assert; advisory lock `person-delete:{contactID}` (padrão `acquireTextAdvisoryLocks`).
|
||||
- Re-enumeração fresca dentro da tx; `hasProtectedAccount` → abortar com mensagem (conta coordenação/candidato nunca apagável → a pessoa inteira está protegida).
|
||||
- **Ordem de delete (FKs NOT NULL + `ON DELETE set null` = bloqueiam):** `votePledge` (leadership in ids) → `campaignInvite` (leadership in ids; cobre `created_by_id` NOT NULL) → `supporter` (contact = id) → `leadership` (contact = id) → `stateDeputy` (contact = id) → `campaignUser` (contact = id; o `beforeDelete` existente limpa passkeys + notificações) → limpeza de vínculos de assessoria dos usuários deletados (`municipality_rels`/`activity_rels` — verificar FK em execução; `leadership_rels`/`state_deputy_rels` já são `ON DELETE cascade`) → ficha `contact`: **delete** se nenhum outro join restar (signature/subscription), senão **anonimizar** (tombstone, precedente `removeSupporterData`). `leadership.user`, `campaign_demand.leadership_id`, `activity.leadership_id` fazem SET NULL sozinhos.
|
||||
- Contrato LGPD: `removeSupporterData` (anônima ficha quando sem joins) é o precedente da casa; aqui o gesto é de remoção total, então a ficha é deletada quando possível, anonimizada quando outro join público a referencia (declarado no diálogo).
|
||||
|
||||
- UI: `DeletePersonButton` (AlertDialog, fetch do manifest ao abrir, lista o que será removido, confirmação destrutiva, `router.refresh` + toast no fim) — padrão `RemoveSupporterDataButton`.
|
||||
|
||||
### D5 — Filtros salvos: 2º call site do padrão B18 (sem genericizar)
|
||||
|
||||
Opções: A) copiar o módulo de storage (155 linhas) para Pessoas | B) **extrair factory mínima `createSavedFilterStore({ storageKey, isHrefValid })`** com contrato B18 preservado, e reescrever `municipalitySavedFilters.ts` como delegado (API pública idêntica; suite `municipalitySavedFilters.unit.spec.ts` guarda) | C) sistema genérico de filtros salvos.
|
||||
Recomendação: **B** — storage é mecânica pura com 2 call sites reais; o veto FD2 é sobre o SISTEMA (server-sync, config-driven), não sobre o primitivo de storage. `MAX_ENTRIES=12`, `MAX_NAME_LENGTH=60`, rename-no-teto, sort alfabético, dedupe por href, snapshot caching, evento custom derivado da key, limpeza da legacy key — tudo preservado e coberto por testes.
|
||||
Rejeitadas: A porque duplica mecânica com semântica sutil (rename no teto, evento entre ilhas); C porque o veto FD2 é explícito e o padrão B18 é o teto.
|
||||
Hooks/controles/submenu permanecem **especializados por página** (hrefs, i18n, active-match com `isSameListHref` ignorando `page`): `usePeopleSavedFilters` + `useActivePeopleSavedFilter`, `SavePeopleFilterControl`, `PeopleNavSavedFilters` (variants sidebar + overflow). Se a refatoração do `MunicipalityNavSavedFilters` para componente presentacional compartilhado ficar neutra em comportamento e verde no e2e, extrai-se; senão, duplica-se o submenu (JSX fino).
|
||||
|
||||
### D6 — URL state e canonicalização
|
||||
|
||||
`src/utilities/people/peopleListUrl.ts` no contrato `campaignListUrl.ts` (parse/serialize/resolve/buildHref), basePath `/campanha/pessoas`. Estado: `{ page, q, capacity (multi — chips do omnibox, OR dentro do facet, como os demais filtros; ausência = todas), municipalities (multi), supportStatus (multi) }`. Filtros em memória pós-merge (capacidade, município, supportStatus); `q` + `supportStatus` também no where da fonte leadership (contém o volume); `q` no where de dobradinha/staff. `PEOPLE_PAGE_SIZE` (padrão 25, precedente das demais listas).
|
||||
|
||||
### D7 — Facets da omnibox
|
||||
|
||||
Opções de origem: A) facets derivados do conjunto filtrado atual (como a lista de Municípios deriva do where) | B) **facet options = municípios e status presentes no conjunto mergeado (escopo-restrito), sem reagir aos filtros ativos** (recomendada).
|
||||
Recomendação: **B** — os facets do merge em memória são baratos de derivar do conjunto escopo-restrito; reagir ao filtro ativo é polish de v1 posterior. Municípios rotulados via `loadMunicipalityPortfolioIndex` + `resolvedPortfolioEntriesById` (precedente lideranças); para assessor, o universo de municípios já é a carteira.
|
||||
Rejeitada: A porque exige re-derivar facets a cada mudança de estado sem ganho perceptível na v1.
|
||||
|
||||
### D8 — Colunas, partido e células
|
||||
|
||||
- **Nome:** texto da ficha; sufixo `(partido)` quando a pessoa tem dobradinha (única fonte `stateDeputy.party`). Sem link próprio na v1 (sem página de pessoa).
|
||||
- **Contato:** telefone em destaque + e-mail segunda linha discreta; sem telefone, e-mail como linha principal; sem nenhum → `—`.
|
||||
- **Assessora / Lidera / Aliada em:** chips de município truncados "+N" (`truncatedNamesLabel` de `campaignListUrl.ts`, padrão B159) — leitura pura, sem editor (sem edição inline nesta entrega). Facet "É assessora" = **coluna Assessora preenchida** (carteira não-vazia), como diz o canvas; staff sem carteira aparece em "Todas", não em "É assessora".
|
||||
- **Assessorado:** nomes de assessores responsáveis (união `leadership.advisors` + `stateDeputy.advisors`, via `loadCampaignUserNamesByIds`).
|
||||
- **Base:** `contact.city`.
|
||||
- **Ações:** WhatsApp (`whatsAppHrefForPhone` da ficha — só com telefone), Convite (`LeadershipInviteRowAction` reusada, só quando há engajamento de liderança), Apagar (só unrestricted, `DeletePersonButton`).
|
||||
- **Column picker:** novo `CampaignListId 'pessoas'` em `CAMPAIGN_LIST_IDS` (B17).
|
||||
- **Mobile (gate do humano 2026-08-09):** visual **edge-to-edge sem bordas** — omnibox sem label e sem borda, ocupando a largura toda, com a ação "Limpar" como **ícone circular com X no canto interno direito**; o feed de cards é separado por **linhas horizontais** (divide-y), não por cards bordados (padrão das outras listas); "Salvar filtro" vira **icon button no header** (chrome da página). FABs de quick actions (`CampaignQuickActionsHost` via `CampaignAppScrollChrome`) e Sollinha AI (`CampaignAISidebarShell`) são mounts globais do layout `(app)` — persistem automaticamente; verificar no craft que omnibox edge-to-edge e FABs não se sobrepõem (z-index). Desktop mantém o padrão das listas (omnibox + `CampaignTable`).
|
||||
|
||||
### D9 — Rota e nav
|
||||
|
||||
- `src/app/(campaign)/campanha/(app)/pessoas/page.tsx` + `loading.tsx`; gate `requireCampaignPageActor({ gate: 'staff' })` (leader → `/campanha/contatos`, lockdown); metadata via catálogo de páginas (adicionar entrada `pessoas`).
|
||||
- Sidebar: `Pessoas` em `staffNav` após `Dobradinhas`; constante `PEOPLE_NAV_HREF` (padrão `MUNICIPALITY_NAV_HREF`) para o submenu de filtros salvos em `CampaignSidebar` + `CampaignBottomNav` (overflow); bottom nav primária intacta (Pessoas cai no "Mais" automaticamente).
|
||||
|
||||
## Componentes / mudanças
|
||||
|
||||
- **`people/`** (novo domínio, espelhando `municipality/`):
|
||||
- `src/utilities/people/peopleListUrl.ts` — estado + parse/serialize/resolve/buildHref.
|
||||
- `src/utilities/people/peopleData.ts` — `loadPeopleListPageData` (3 fontes + merge + escopo + facets + paginação), `PeopleRowViewModel`, `mergePeopleSources` (puro).
|
||||
- `src/utilities/people/peopleSavedFilters.ts` (via factory D5) + `src/utilities/people/peopleFilters.ts` (toggle/clear/summary/hrefs) + `peopleOmnibox.ts` (chips/suggestions/apply).
|
||||
- **Actions:** `src/app/(campaign)/campanha/actions/person.ts` (`getPersonDeleteManifest`, `deletePerson`); schemas em `src/lib/schemas/personDelete.ts`.
|
||||
- **Componentes:** `src/components/campaign/people/PeopleFilters.tsx`, `PeopleListTable`/colunas (ou inline no page), `PeopleListMobileCards.tsx`, `DeletePersonButton.tsx`, `SavePeopleFilterControl.tsx`, `PeopleNavSavedFilters.tsx`, hooks `usePeopleSavedFilters.ts`.
|
||||
- **Shell:** `nav.ts` (`Pessoas` + `PEOPLE_NAV_HREF`), `CampaignSidebar.tsx`, `CampaignBottomNav.tsx` (slot do submenu), `campaignColumnVisibility.ts` (`pessoas`), catálogo de páginas/metadata.
|
||||
- **Storage:** `src/utilities/campaignSavedFilterStore.ts` (factory) + refactor delegado de `municipalitySavedFilters.ts`.
|
||||
- **Migration:** nenhuma — sem mudança de schema (cascata é camada de dados; FKs existentes mapeadas em D4).
|
||||
- **Access/Consent:** sem chave nova; convite reusa `lideranca-autopreenchimento` (fail-closed, intocado); overrides documentados em D2/D4.
|
||||
- **UI:** Impeccable C — rota nova + tabela + omnibox + filtros salvos; shape → craft → critique → polish; tokens `data-theme='campaign'`, shells do sistema de listas.
|
||||
|
||||
## Dados → forma
|
||||
|
||||
Não aplicável (vínculos, não métricas — intenção Dados: N/A). Nenhum agregado por pessoa na superfície; votos só aparecem como contagem no manifest de apagar.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer (schema/server mínimo):** `peopleListUrl` + `mergePeopleSources` + `loadPeopleListPageData` (3 fontes + escopo) + rota com colunas Nome/Contato — valida canonicalização, gate e escopo de assessor cedo (unidades + int).
|
||||
2. **Server completo:** filtros/facets/paginação + `personDelete` (manifest + cascata) + testes de domínio (unit merge/url; int escopo + cascata).
|
||||
3. **UI:** filtros/omnibox, 8 colunas, mobile cards, ações (WhatsApp/Convite/Apagar), column picker, filtros salvos (factory + controle + submenu), sidebar.
|
||||
4. **Gates + docs:** `pnpm gate:fast` na iteração; entrega com `pnpm push`; entrada no `docs/CHANGELOG-AGENTS.md`; commit do `*-impl.md`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Sistema genérico de filtros salvos (veto FD2); merge de `Contact` duplicados (v1: linhas duplicadas visíveis); "transferir engajamentos" (pós-eleição); edição inline de células; página de detalhe de pessoa; RecentVisitTracker (polish posterior); e2e amplo (smoke da rota + fluxo de apagar via int, não e2e).
|
||||
- Não criar loader genérico "busca de pessoa" para fora desta rota (1 call site).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Surpresas de FK na cascata** (`municipality_rels`/`activity_rels` podem ser `SET NULL` e exigir limpeza explícita; ordens de delete erradas = 500): mapear FKs reais em execução (`migrate:status`/drizzle inspect) antes de escrever a cascata; int test do caminho completo (pessoa com todos os papéis + passkey) valida.
|
||||
- **Refactor do B18 storage regredir comportamento**: API pública idêntica + suite unit existente + e2e de filtros salvos existente como rede de segurança; manter storage key e eventos idênticos.
|
||||
- **Semântica de access do staff source divergir do esperado**: overrides documentados + justificados com precedentes citados (D2); int test com assessor verifica que vê só a carteira dele e que contas fora dela não vazam.
|
||||
- **Volume** (pessoas × municípios nas células): chips truncados "+N"; sem paginação no nível de fonte (merge em memória com volume atual ~centenas).
|
||||
- **`NEXT_PUBLIC_SITE_URL`** (convite): fluxo existente, sem mudança — checklist de deploy já cobre.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (merge 1 linha/pessoa; 8 colunas; facets; filtros salvos B18 2º call site; WhatsApp/invite/delete com confirmação; sem apoiadores; roteamento intacto)
|
||||
- [x] Invariantes AGENTS/engineering-standards (transação + `req.transactionID` em toda escrita multi-collection; `overrideAccess` justificado e documentado; pessoa = join com `Contact`; consent fail-closed intocado; identif. em inglês / copy pt-BR; gates `staff`)
|
||||
- [x] Testes previstos: unit (`peopleListUrl` canonicalização; `mergePeopleSources` dedupe/escopo/partido; factory de storage), int (`loadPeopleListPageData` escopo assessor/unrestricted; cascata completa `deletePerson` incl. passkeys e ficha deletada/anonimizada; manifest; conta protegida bloqueia), e2e smoke (rota + filtro + salvar recorte)
|
||||
|
|
@ -121,6 +121,15 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'campaignSollinhaWidth',
|
||||
],
|
||||
},
|
||||
{
|
||||
prefixes: [
|
||||
`${CAMPAIGN_APP}/pessoas`,
|
||||
'src/components/campaign/people',
|
||||
'src/utilities/people',
|
||||
'src/utilities/campaignSavedFilterStore',
|
||||
],
|
||||
specs: ['campaignPeople'],
|
||||
},
|
||||
// Domains without a dedicated e2e family still wake campaign home smoke so
|
||||
// the affected classifier cannot return mode=none on an unmapped domain dir.
|
||||
{
|
||||
|
|
|
|||
20
src/app/(campaign)/campanha/(app)/pessoas/loading.tsx
Normal file
20
src/app/(campaign)/campanha/(app)/pessoas/loading.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/** List-shaped skeleton for /pessoas. */
|
||||
export default function PeopleLoading() {
|
||||
return (
|
||||
<CampaignPageShell aria-label="Carregando pessoas" aria-busy="true">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-5 w-96 max-w-full" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Skeleton className="h-11 w-full max-w-md" />
|
||||
<Skeleton className="h-11 w-40" />
|
||||
<Skeleton className="h-11 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full rounded-xl" />
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
408
src/app/(campaign)/campanha/(app)/pessoas/page.tsx
Normal file
408
src/app/(campaign)/campanha/(app)/pessoas/page.tsx
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
import config from '@payload-config'
|
||||
import { MessageCircleIcon, SearchXIcon } from 'lucide-react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
|
||||
import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton'
|
||||
import { PeopleFilters } from '@/components/campaign/people/PeopleFilters'
|
||||
import { PeopleListPageChrome } from '@/components/campaign/people/PeopleListPageChrome'
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
CampaignTransitionAnchor,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import {
|
||||
CampaignTable,
|
||||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
|
||||
import {
|
||||
resolvedPortfolioEntriesById,
|
||||
type ResolvedPortfolioEntry,
|
||||
} from '@/lib/municipalityPortfolio'
|
||||
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
|
||||
import { loadPeopleListPageData, type PeopleRowViewModel } from '@/utilities/people/peopleData'
|
||||
import {
|
||||
buildPeopleFilterHref,
|
||||
clearPeopleListFilters,
|
||||
type PeopleFilterOption,
|
||||
} from '@/utilities/people/peopleListFilters'
|
||||
import { buildPeopleListHref, resolvePeopleListUrl } from '@/utilities/people/peopleListUrl'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('pessoas')
|
||||
|
||||
type PeoplePageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
const PeopleListEmptyState = ({ hasFilters, scoped }: { hasFilters: boolean; scoped: boolean }) => (
|
||||
<Empty className="min-h-56">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma pessoa neste recorte</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste a busca e os filtros para ver as pessoas da campanha.
|
||||
{scoped ? ' Você só vê as pessoas dos seus municípios.' : null}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{hasFilters ? (
|
||||
<EmptyContent>
|
||||
<CampaignTransitionAnchor
|
||||
href={buildPeopleFilterHref(clearPeopleListFilters())}
|
||||
replace
|
||||
scroll={false}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
|
||||
>
|
||||
Limpar busca e filtros
|
||||
</CampaignTransitionAnchor>
|
||||
</EmptyContent>
|
||||
) : null}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
const peopleColumns = ({
|
||||
municipalityIndex,
|
||||
canDelete,
|
||||
}: {
|
||||
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
|
||||
canDelete: boolean
|
||||
}): 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>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
label: 'Contato',
|
||||
cellClassName: 'whitespace-normal',
|
||||
cell: (row) => (
|
||||
<span className="flex flex-col">
|
||||
{row.phone ? (
|
||||
<span className="font-medium tabular-nums">{formatBrazilianPhoneInput(row.phone)}</span>
|
||||
) : null}
|
||||
<span className={row.phone ? 'text-xs text-muted-foreground' : undefined}>
|
||||
{row.email ?? '—'}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'assessora',
|
||||
label: 'Assessora',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<PeopleMunicipalityCell ids={row.assessoraMunicipalityIDs} index={municipalityIndex} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'lidera',
|
||||
label: 'Lidera',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<PeopleMunicipalityCell ids={row.leadershipMunicipalityIDs} index={municipalityIndex} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'aliada',
|
||||
label: 'Aliada em',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<PeopleMunicipalityCell ids={row.deputyMunicipalityIDs} index={municipalityIndex} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'assessorado',
|
||||
label: 'Assessorado',
|
||||
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
|
||||
cell: (row) => row.assessoradoNames.join(', ') || '—',
|
||||
},
|
||||
{
|
||||
id: 'base',
|
||||
label: 'Base',
|
||||
cell: (row) => row.city ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Ações',
|
||||
head: (
|
||||
<CampaignTableHead align="right">
|
||||
<span className="sr-only">Ações</span>
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
cell: (row) => {
|
||||
const whatsAppHref = whatsAppHrefForPhone(row.phone)
|
||||
return (
|
||||
<div className="inline-flex items-center justify-end gap-1">
|
||||
{row.leadershipID !== null ? (
|
||||
<LeadershipInviteRowAction
|
||||
leadershipID={row.leadershipID}
|
||||
name={row.name}
|
||||
hasValidPhone={whatsAppHref !== null}
|
||||
/>
|
||||
) : 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>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10"
|
||||
disabled
|
||||
aria-label={`WhatsApp indisponível — ${row.name} sem celular`}
|
||||
>
|
||||
<MessageCircleIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete ? (
|
||||
<DeletePersonButton personName={row.name} contactId={row.contactID} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const PeopleMobileCards = ({
|
||||
rows,
|
||||
municipalityIndex,
|
||||
canDelete,
|
||||
empty,
|
||||
}: {
|
||||
rows: readonly PeopleRowViewModel[]
|
||||
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
|
||||
canDelete: boolean
|
||||
empty: ReactNode
|
||||
}) => (
|
||||
<ul data-view="mobile-cards" className="flex flex-col divide-y md:hidden">
|
||||
{rows.length === 0 ? (
|
||||
<li className="py-4">{empty}</li>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const whatsAppHref = whatsAppHrefForPhone(row.phone)
|
||||
const capacities: Array<{ label: string; ids: readonly number[] }> = [
|
||||
{ label: 'Assessora', ids: row.assessoraMunicipalityIDs },
|
||||
{ label: 'Lidera', ids: row.leadershipMunicipalityIDs },
|
||||
{ label: 'Aliada em', ids: row.deputyMunicipalityIDs },
|
||||
]
|
||||
return (
|
||||
<li key={row.contactID} className="flex flex-col gap-2 py-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
{row.name}
|
||||
{row.party ? <span className="text-muted-foreground"> ({row.party})</span> : null}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{row.phone
|
||||
? formatBrazilianPhoneInput(row.phone)
|
||||
: row.email
|
||||
? row.email
|
||||
: 'Sem contato registrado'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{row.leadershipID !== null ? (
|
||||
<LeadershipInviteRowAction
|
||||
leadershipID={row.leadershipID}
|
||||
name={row.name}
|
||||
hasValidPhone={whatsAppHref !== null}
|
||||
/>
|
||||
) : 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>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<DeletePersonButton personName={row.name} contactId={row.contactID} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{capacities.some((capacity) => capacity.ids.length > 0) ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{capacities.map((capacity) =>
|
||||
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} />
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{row.assessoradoNames.length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Assessorado por {row.assessoradoNames.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
{row.city ? <p className="text-xs text-muted-foreground">Base: {row.city}</p> : null}
|
||||
</li>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
|
||||
export default async function PeoplePage({ searchParams }: PeoplePageProps) {
|
||||
const rawSearchParams = await searchParams
|
||||
const canonicalUrl = resolvePeopleListUrl(rawSearchParams)
|
||||
if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref)
|
||||
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'staff' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
const [columnVisibility, listData, municipalityIndex] = await Promise.all([
|
||||
readCampaignColumnVisibility('pessoas'),
|
||||
loadPeopleListPageData(payload, user, canonicalUrl.state),
|
||||
loadMunicipalityPortfolioIndex(),
|
||||
])
|
||||
const resolvedUrl = resolvePeopleListUrl(rawSearchParams, listData.totalPages)
|
||||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
const { state } = resolvedUrl
|
||||
|
||||
const resolvedIndex = resolvedPortfolioEntriesById(municipalityIndex)
|
||||
const municipalityFilterOptions: PeopleFilterOption[] = listData.filterFacets.municipalityIDs
|
||||
.map((id) => {
|
||||
const entry = resolvedIndex.get(id)
|
||||
return entry ? { value: String(id), label: entry.name } : null
|
||||
})
|
||||
.filter((option): option is PeopleFilterOption => option !== null)
|
||||
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
|
||||
|
||||
const canDelete = isUnrestrictedCampaignRole(user.role)
|
||||
const columns = peopleColumns({ municipalityIndex: resolvedIndex, canDelete })
|
||||
const hasFilters = Boolean(
|
||||
state.q || state.capacities?.length || state.municipalities?.length || state.statuses?.length,
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<PeopleListPageChrome />
|
||||
<CampaignListPendingBoundary>
|
||||
<PeopleFilters
|
||||
state={state}
|
||||
municipalityFilterOptions={municipalityFilterOptions}
|
||||
trailing={
|
||||
<CampaignColumnPickerTrailing
|
||||
columnVisibility={columnVisibility}
|
||||
columns={toCampaignColumnPickerColumns(columns)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<CampaignListResults>
|
||||
<PeopleMobileCards
|
||||
rows={listData.rows}
|
||||
municipalityIndex={resolvedIndex}
|
||||
canDelete={canDelete}
|
||||
empty={
|
||||
<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'} />
|
||||
}
|
||||
/>
|
||||
{listData.rows.length ? (
|
||||
<CampaignListFooter
|
||||
totalDocs={listData.totalDocs}
|
||||
singular="pessoa"
|
||||
plural="pessoas"
|
||||
page={state.page}
|
||||
totalPages={listData.totalPages}
|
||||
hrefForPage={(page) => buildPeopleListHref(state, page)}
|
||||
/>
|
||||
) : null}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
36
src/app/(campaign)/campanha/actions/person.ts
Normal file
36
src/app/(campaign)/campanha/actions/person.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
'use server'
|
||||
|
||||
import {
|
||||
PERSON_DELETE_FORBIDDEN_MESSAGE,
|
||||
personDeleteInputSchema,
|
||||
} from '@/lib/schemas/personDelete'
|
||||
import {
|
||||
getCampaignActionContext,
|
||||
reloadUnrestrictedActor,
|
||||
} from '@/utilities/campaignActionContext'
|
||||
import { deletePersonRecord, loadPersonDeleteManifest } from '@/utilities/people/personDelete'
|
||||
|
||||
/**
|
||||
* C100 "Apagar pessoa" server actions. Both are coordinator/candidate-only
|
||||
* (`reloadUnrestrictedActor`): the cascade is transversal, wider than any
|
||||
* advisor carteira.
|
||||
*/
|
||||
|
||||
/** The confirmation dialog lists this verbatim before offering the destroy. */
|
||||
export const getPersonDeleteManifestAction = async (input: unknown) => {
|
||||
const data = personDeleteInputSchema.parse(input)
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
await reloadUnrestrictedActor(payload, actor, PERSON_DELETE_FORBIDDEN_MESSAGE)
|
||||
return loadPersonDeleteManifest(payload, data.contactId)
|
||||
}
|
||||
|
||||
export const deletePersonAction = async (input: unknown) => {
|
||||
const data = personDeleteInputSchema.parse(input)
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const currentActor = await reloadUnrestrictedActor(
|
||||
payload,
|
||||
actor,
|
||||
PERSON_DELETE_FORBIDDEN_MESSAGE,
|
||||
)
|
||||
return deletePersonRecord(payload, currentActor, data.contactId)
|
||||
}
|
||||
261
src/components/campaign/people/DeletePersonButton.tsx
Normal file
261
src/components/campaign/people/DeletePersonButton.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
'use client'
|
||||
|
||||
import { Trash2Icon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
deletePersonAction,
|
||||
getPersonDeleteManifestAction,
|
||||
} from '@/app/(campaign)/campanha/actions/person'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/AlertDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import {
|
||||
PERSON_DELETE_NOT_FOUND_MESSAGE,
|
||||
PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE,
|
||||
} from '@/lib/schemas/personDelete'
|
||||
import type { PersonDeleteManifest } from '@/utilities/people/personDelete'
|
||||
|
||||
type DeletePersonButtonProps = {
|
||||
personName: string
|
||||
contactId: number
|
||||
}
|
||||
|
||||
type ManifestState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'ready'; manifest: PersonDeleteManifest }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
const mapManifestError = (error: unknown): string => {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) return 'Pessoa não encontrada.'
|
||||
return 'Não foi possível carregar o que será apagado. Tente de novo.'
|
||||
}
|
||||
|
||||
const mapDeleteError = (error: unknown): string => {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
if (message === PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE) return message
|
||||
if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) return 'Pessoa não encontrada.'
|
||||
return 'Não foi possível apagar a pessoa. Tente de novo.'
|
||||
}
|
||||
|
||||
/**
|
||||
* The C100 destructive action: opens a confirmation that lists — from the
|
||||
* read-only manifest — everything the transactional cascade will remove, and
|
||||
* only then offers the destroy. Coordinator/candidate accounts make the whole
|
||||
* person protected; the ficha is deleted or anonymized (LGPD tombstone)
|
||||
* depending on public joins.
|
||||
*/
|
||||
export const DeletePersonButton = ({ personName, contactId }: DeletePersonButtonProps) => {
|
||||
const router = useRouter()
|
||||
const [manifest, setManifest] = useState<ManifestState>({ kind: 'idle' })
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleOpenChange = async (open: boolean) => {
|
||||
if (!open) return
|
||||
setDeleteError('')
|
||||
setManifest({ kind: 'loading' })
|
||||
try {
|
||||
const next = await getPersonDeleteManifestAction({ contactId })
|
||||
setManifest(
|
||||
next
|
||||
? { kind: 'ready', manifest: next }
|
||||
: {
|
||||
kind: 'error',
|
||||
message: mapManifestError(new Error(PERSON_DELETE_NOT_FOUND_MESSAGE)),
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
setManifest({ kind: 'error', message: mapManifestError(error) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (manifest.kind !== 'ready' || manifest.manifest.hasProtectedAccount) return
|
||||
setDeleting(true)
|
||||
setDeleteError('')
|
||||
try {
|
||||
const result = await deletePersonAction({ contactId })
|
||||
toast.success(
|
||||
result.contactDeleted
|
||||
? 'Pessoa apagada.'
|
||||
: result.contactAnonymized
|
||||
? 'Pessoa apagada. A ficha foi anonimizada porque ainda é referenciada por participações públicas.'
|
||||
: 'Pessoa apagada.',
|
||||
)
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
setDeleteError(mapDeleteError(error))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog onOpenChange={handleOpenChange}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10 text-destructive hover:text-destructive"
|
||||
aria-label={`Apagar pessoa ${personName}`}
|
||||
>
|
||||
<Trash2Icon className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Apagar pessoa</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. Serão removidos todos os vínculos de{' '}
|
||||
<span className="font-medium text-foreground">{personName}</span> com a campanha.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{manifest.kind === 'loading' ? (
|
||||
<div className="flex min-h-32 items-center justify-center">
|
||||
<Spinner aria-label="Carregando o que será apagado" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{manifest.kind === 'error' ? (
|
||||
<Alert variant="destructive" aria-live="polite">
|
||||
<AlertDescription>{manifest.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{manifest.kind === 'ready' ? (
|
||||
<>
|
||||
{manifest.manifest.hasProtectedAccount ? (
|
||||
<Alert variant="destructive" aria-live="polite">
|
||||
<AlertDescription>
|
||||
{PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE} A ação está bloqueada.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ul className="flex list-disc flex-col gap-1 pl-5 text-sm text-muted-foreground">
|
||||
{manifest.manifest.leaderships.length > 0 ? (
|
||||
<li>
|
||||
Liderança
|
||||
{manifest.manifest.leaderships.length > 1 ? 's' : ''} (
|
||||
{manifest.manifest.leaderships
|
||||
.map((leadership) =>
|
||||
leadership.municipalityNames.length
|
||||
? `${leadership.name} · ${leadership.municipalityNames.join(' · ')}`
|
||||
: leadership.name,
|
||||
)
|
||||
.join('; ')}
|
||||
)
|
||||
</li>
|
||||
) : null}
|
||||
{manifest.manifest.stateDeputies.length > 0 ? (
|
||||
<li>
|
||||
Dobradinha{manifest.manifest.stateDeputies.length > 1 ? 's' : ''} (
|
||||
{manifest.manifest.stateDeputies
|
||||
.map((deputy) =>
|
||||
deputy.party ? `${deputy.name} (${deputy.party})` : deputy.name,
|
||||
)
|
||||
.join('; ')}
|
||||
)
|
||||
</li>
|
||||
) : null}
|
||||
{manifest.manifest.pledgeCount > 0 ? (
|
||||
<li>
|
||||
{manifest.manifest.pledgeCount}{' '}
|
||||
{manifest.manifest.pledgeCount === 1 ? 'voto declarado' : 'votos declarados'}
|
||||
</li>
|
||||
) : null}
|
||||
{manifest.manifest.inviteCount > 0 ? (
|
||||
<li>
|
||||
{manifest.manifest.inviteCount}{' '}
|
||||
{manifest.manifest.inviteCount === 1
|
||||
? 'convite pendente'
|
||||
: 'convites pendentes'}
|
||||
</li>
|
||||
) : null}
|
||||
{manifest.manifest.supporterCount > 0 ? (
|
||||
<li>
|
||||
{manifest.manifest.supporterCount}{' '}
|
||||
{manifest.manifest.supporterCount === 1
|
||||
? 'cadastro de apoiador'
|
||||
: 'cadastros de apoiador'}
|
||||
</li>
|
||||
) : null}
|
||||
{manifest.manifest.municipalityUpdateCount > 0 ? (
|
||||
<li>
|
||||
{manifest.manifest.municipalityUpdateCount}{' '}
|
||||
{manifest.manifest.municipalityUpdateCount === 1
|
||||
? 'atualização de município'
|
||||
: 'atualizações de município'}
|
||||
</li>
|
||||
) : null}
|
||||
{manifest.manifest.calendarFeedCount > 0 ? (
|
||||
<li>
|
||||
{manifest.manifest.calendarFeedCount}{' '}
|
||||
{manifest.manifest.calendarFeedCount === 1
|
||||
? 'link de agenda'
|
||||
: 'links de agenda'}
|
||||
</li>
|
||||
) : null}
|
||||
{manifest.manifest.accounts.length > 0 ? (
|
||||
<li>
|
||||
{manifest.manifest.accounts.length}{' '}
|
||||
{manifest.manifest.accounts.length === 1
|
||||
? 'conta de acesso'
|
||||
: 'contas de acesso'}{' '}
|
||||
({manifest.manifest.accounts.map((account) => account.name).join('; ')})
|
||||
</li>
|
||||
) : null}
|
||||
<li>
|
||||
{manifest.manifest.fichaWillBeAnonymized
|
||||
? 'A ficha de contato será anonimizada (ainda é referenciada por participações públicas).'
|
||||
: 'Ficha de contato'}
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
{deleteError ? (
|
||||
<Alert variant="destructive" aria-live="polite">
|
||||
<AlertDescription>{deleteError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel type="button" className="min-h-11" disabled={deleting}>
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="min-h-11"
|
||||
disabled={
|
||||
manifest.kind !== 'ready' || manifest.manifest.hasProtectedAccount || deleting
|
||||
}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{deleting ? <Spinner data-icon="inline-start" /> : null}
|
||||
Apagar definitivamente
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
134
src/components/campaign/people/PeopleFilters.tsx
Normal file
134
src/components/campaign/people/PeopleFilters.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import { SavePeopleFilterControl } from '@/components/campaign/people/SavePeopleFilterControl'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import {
|
||||
buildPeopleFilterHref,
|
||||
type PeopleFilterOption,
|
||||
} from '@/utilities/people/peopleListFilters'
|
||||
import type { PeopleListState } from '@/utilities/people/peopleListUrl'
|
||||
import {
|
||||
applyPeopleOmniboxSuggestion,
|
||||
buildPeopleOmniboxChips,
|
||||
buildPeopleOmniboxSuggestionSeeds,
|
||||
clearPeopleOmnibox,
|
||||
filterPeopleOmniboxSuggestions,
|
||||
removePeopleOmniboxChip,
|
||||
type PeopleOmniboxAction,
|
||||
} from '@/utilities/people/peopleOmnibox'
|
||||
|
||||
export const PeopleFilters = ({
|
||||
state,
|
||||
municipalityFilterOptions,
|
||||
trailing,
|
||||
}: {
|
||||
state: PeopleListState
|
||||
municipalityFilterOptions: PeopleFilterOption[]
|
||||
trailing?: ReactNode
|
||||
}) => {
|
||||
const { navigate, isPending } = useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: buildPeopleFilterHref,
|
||||
})
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const municipalityLabelsById = useMemo(() => {
|
||||
const map = new Map<number, string>()
|
||||
for (const option of municipalityFilterOptions) {
|
||||
const id = Number(option.value)
|
||||
if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label)
|
||||
}
|
||||
for (const municipalityId of state.municipalities ?? []) {
|
||||
if (!map.has(municipalityId)) map.set(municipalityId, `Município #${municipalityId}`)
|
||||
}
|
||||
return map
|
||||
}, [municipalityFilterOptions, state.municipalities])
|
||||
|
||||
const chips = useMemo(
|
||||
() => buildPeopleOmniboxChips({ state, municipalityLabelsById }),
|
||||
[state, municipalityLabelsById],
|
||||
)
|
||||
|
||||
const suggestionSeeds = useMemo(
|
||||
() => buildPeopleOmniboxSuggestionSeeds({ municipalityFilterOptions }),
|
||||
[municipalityFilterOptions],
|
||||
)
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => filterPeopleOmniboxSuggestions(suggestionSeeds, query),
|
||||
[suggestionSeeds, query],
|
||||
)
|
||||
|
||||
const runAction = (action: PeopleOmniboxAction) => {
|
||||
if (action.kind === 'clear') {
|
||||
setQuery('')
|
||||
navigate(action.state)
|
||||
return
|
||||
}
|
||||
navigate(action.state)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<CampaignListOmnibox
|
||||
id="people-omnibox"
|
||||
label="Filtrar pessoas"
|
||||
placeholder="Buscar pessoa…"
|
||||
edgeToEdge
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
isPending={isPending}
|
||||
onSelectSuggestion={(suggestionId) => {
|
||||
runAction(applyPeopleOmniboxSuggestion({ state, suggestionId }))
|
||||
}}
|
||||
onCommitQuery={(text) => {
|
||||
runAction(applyPeopleOmniboxSuggestion({ state, suggestionId: `q:${text}` }))
|
||||
}}
|
||||
onRemoveChip={(chipId) => {
|
||||
runAction(removePeopleOmniboxChip({ state, chipId }))
|
||||
}}
|
||||
onClearAll={() => {
|
||||
runAction(clearPeopleOmnibox())
|
||||
}}
|
||||
trailing={
|
||||
<>
|
||||
{trailing}
|
||||
<SavePeopleFilterControl
|
||||
state={state}
|
||||
municipalityLabelsById={municipalityLabelsById}
|
||||
variant="trailing"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
{/* C100 mobile spec: "Salvar filtro" lives in the app header as an icon
|
||||
button; the trailing instance is desktop-only (the edge-to-edge
|
||||
omnibox hides its trailing cluster below `md`). Both instances share
|
||||
the same store, and only one is visible per breakpoint. */}
|
||||
<SetCampaignHeaderAction id="save-people-filter">
|
||||
<SavePeopleFilterControl
|
||||
state={state}
|
||||
municipalityLabelsById={municipalityLabelsById}
|
||||
variant="header"
|
||||
/>
|
||||
</SetCampaignHeaderAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
25
src/components/campaign/people/PeopleListPageChrome.tsx
Normal file
25
src/components/campaign/people/PeopleListPageChrome.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use client'
|
||||
|
||||
import { useActivePeopleSavedFilter } from '@/components/campaign/shared/usePeopleSavedFilters'
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { campaignPageChromeCatalog } from '@/lib/campaignPageChrome'
|
||||
|
||||
/**
|
||||
* When the current URL matches a saved people filter, the filter name becomes
|
||||
* the page subtitle (B133 pattern) — the omnibox keeps chips as the editable
|
||||
* recorte.
|
||||
*/
|
||||
export const PeopleListPageChrome = () => {
|
||||
const activeFilter = useActivePeopleSavedFilter()
|
||||
|
||||
if (!activeFilter) return null
|
||||
|
||||
return (
|
||||
<SetCampaignPageChrome
|
||||
chrome={{
|
||||
title: campaignPageChromeCatalog.pessoas.title,
|
||||
subtitle: activeFilter.name,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
154
src/components/campaign/people/PeopleNavSavedFilters.tsx
Normal file
154
src/components/campaign/people/PeopleNavSavedFilters.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
'use client'
|
||||
|
||||
import { Trash2Icon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
useActivePeopleSavedFilter,
|
||||
usePeopleSavedFilters,
|
||||
} from '@/components/campaign/shared/usePeopleSavedFilters'
|
||||
import {
|
||||
SidebarMenuAction,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from '@/components/ui/Sidebar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
removePeopleSavedFilter,
|
||||
savePeopleSavedFilter,
|
||||
type PeopleSavedFilter,
|
||||
} from '@/utilities/people/peopleSavedFilters'
|
||||
|
||||
const PEOPLE_SAVED_FILTERS_LABEL = 'Filtros salvos de Pessoas'
|
||||
|
||||
/**
|
||||
* The saved filters of `/campanha/pessoas` (B18 pattern, 2nd call site), in
|
||||
* the same two homes as the municipality submenu: the sidebar sub-list under
|
||||
* the Pessoas nav item, and the mobile "Mais" drawer (overflow). Always
|
||||
* visible when entries exist; one save repaints both homes.
|
||||
*/
|
||||
export const PeopleNavSavedFilters = ({
|
||||
variant = 'sidebar',
|
||||
onNavigate,
|
||||
}: {
|
||||
variant?: 'sidebar' | 'overflow'
|
||||
onNavigate: () => void
|
||||
}) => {
|
||||
const savedFilters = usePeopleSavedFilters()
|
||||
const activeFilter = useActivePeopleSavedFilter()
|
||||
|
||||
if (!savedFilters.length) return null
|
||||
|
||||
/** Where keyboard focus lands after a removal, resolved BEFORE the row the
|
||||
* button sits on unmounts (same contract as the municipality submenu). */
|
||||
const resolveSuccessor = (row: HTMLLIElement | null): HTMLElement | null | undefined => {
|
||||
const siblingRow = row?.nextElementSibling ?? row?.previousElementSibling
|
||||
if (variant === 'sidebar') {
|
||||
const menuItem = row?.closest('[data-sidebar="menu-item"]')
|
||||
return (
|
||||
siblingRow?.querySelector<HTMLElement>('[data-sidebar="menu-sub-button"]') ??
|
||||
menuItem?.querySelector<HTMLElement>('a')
|
||||
)
|
||||
}
|
||||
const section = row?.closest('[data-saved-filters="overflow"]')
|
||||
return (
|
||||
siblingRow?.querySelector<HTMLElement>('a') ??
|
||||
section?.nextElementSibling?.querySelector<HTMLElement>('a') ??
|
||||
section?.previousElementSibling?.querySelector<HTMLElement>('a:last-of-type')
|
||||
)
|
||||
}
|
||||
|
||||
const handleRemove = (entry: PeopleSavedFilter, row: HTMLLIElement | null) => {
|
||||
const successor = resolveSuccessor(row)
|
||||
|
||||
removePeopleSavedFilter(entry.href)
|
||||
successor?.focus()
|
||||
|
||||
// Undo instead of a confirmation: the entry restores to the same name and
|
||||
// the same alphabetical slot, so the cheap path is reversal, not a prompt.
|
||||
toast.success(`Filtro “${entry.name}” apagado.`, {
|
||||
action: {
|
||||
label: 'Desfazer',
|
||||
onClick: () => {
|
||||
if (savePeopleSavedFilter(entry) !== 'saved') {
|
||||
toast.error('Não foi possível restaurar o filtro.')
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (variant === 'overflow') {
|
||||
return (
|
||||
<div data-saved-filters="overflow" className="border-t border-border pb-1 pt-3">
|
||||
<p className="px-3 pb-1 text-xs font-medium text-muted-foreground">
|
||||
{PEOPLE_SAVED_FILTERS_LABEL}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{savedFilters.map((entry) => {
|
||||
const isActive = entry.href === activeFilter?.href
|
||||
|
||||
return (
|
||||
<li key={entry.href} className="flex items-center gap-1">
|
||||
<Link
|
||||
href={entry.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
title={entry.name}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium text-foreground outline-none',
|
||||
'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive && 'bg-muted text-primary',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => handleRemove(entry, event.currentTarget.closest('li'))}
|
||||
aria-label={`Apagar o filtro salvo ${entry.name}`}
|
||||
className="mr-1 shrink-0 rounded-md p-2.5 text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Trash2Icon aria-hidden className="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuSub aria-label={PEOPLE_SAVED_FILTERS_LABEL} className="-mt-0.5 py-0">
|
||||
{savedFilters.map((entry) => {
|
||||
const isActive = entry.href === activeFilter?.href
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem key={entry.href}>
|
||||
<SidebarMenuSubButton asChild isActive={isActive}>
|
||||
<Link
|
||||
href={entry.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
title={entry.name}
|
||||
>
|
||||
<span>{entry.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
<SidebarMenuAction
|
||||
type="button"
|
||||
onClick={(event) => handleRemove(entry, event.currentTarget.closest('li'))}
|
||||
aria-label={`Apagar o filtro salvo ${entry.name}`}
|
||||
className="top-1 group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100 md:opacity-0"
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
)
|
||||
}
|
||||
144
src/components/campaign/people/SavePeopleFilterControl.tsx
Normal file
144
src/components/campaign/people/SavePeopleFilterControl.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
'use client'
|
||||
|
||||
import { BookmarkPlusIcon } from 'lucide-react'
|
||||
import { useId, useState, type FormEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { usePeopleSavedFilters } from '@/components/campaign/shared/usePeopleSavedFilters'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
buildPeopleFilterHref,
|
||||
formatPeopleActiveFiltersSummary,
|
||||
} from '@/utilities/people/peopleListFilters'
|
||||
import type { PeopleListState } from '@/utilities/people/peopleListUrl'
|
||||
import {
|
||||
MAX_ENTRIES,
|
||||
MAX_NAME_LENGTH,
|
||||
savePeopleSavedFilter,
|
||||
} from '@/utilities/people/peopleSavedFilters'
|
||||
|
||||
type SavePeopleFilterControlProps = {
|
||||
/**
|
||||
* The APPLIED state — a search still inside the debounce window is not in the URL yet.
|
||||
*/
|
||||
state: PeopleListState
|
||||
/** Municipality id → name, for the default name and the summary (optional). */
|
||||
municipalityLabelsById?: ReadonlyMap<number, string>
|
||||
/**
|
||||
* `trailing` (desktop, beside the omnibox) or `header` (mobile icon button,
|
||||
* registered via `SetCampaignHeaderAction` — the C100 mobile spec).
|
||||
*/
|
||||
variant?: 'trailing' | 'header'
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the current recorte and keeps it in the sidebar (B18 pattern, 2nd call
|
||||
* site). Naming is confirmatory, so this is the explicit-submit exception the
|
||||
* edit-where-you-see rule already carves out for flows that need a note.
|
||||
*/
|
||||
export const SavePeopleFilterControl = ({
|
||||
state,
|
||||
municipalityLabelsById,
|
||||
variant = 'trailing',
|
||||
}: SavePeopleFilterControlProps) => {
|
||||
const savedFilters = usePeopleSavedFilters()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const nameId = useId()
|
||||
const errorId = `${nameId}-error`
|
||||
|
||||
const summary = formatPeopleActiveFiltersSummary(state, municipalityLabelsById)
|
||||
const href = buildPeopleFilterHref(state)
|
||||
const existing = savedFilters.find((entry) => entry.href === href)
|
||||
|
||||
// The bare list has no recorte to name — same gate the clear affordance uses.
|
||||
if (!summary) return null
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next)
|
||||
if (!next) return
|
||||
setName(existing?.name ?? summary.slice(0, MAX_NAME_LENGTH))
|
||||
setErrorMessage('')
|
||||
}
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (!name.trim()) {
|
||||
setErrorMessage('Dê um nome ao filtro.')
|
||||
return
|
||||
}
|
||||
|
||||
const result = savePeopleSavedFilter({ href, name })
|
||||
if (result === 'limit') {
|
||||
setErrorMessage(
|
||||
`Você já tem ${MAX_ENTRIES} filtros salvos. Apague um em Pessoas, na navegação, para salvar outro.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (result === 'failed') {
|
||||
setErrorMessage('Não foi possível salvar neste dispositivo. Tente de novo.')
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
toast.success(existing ? 'Nome atualizado.' : 'Filtro salvo em Pessoas, na navegação.')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn('min-h-11 shrink-0', variant === 'trailing' ? 'md:self-end' : 'md:hidden')}
|
||||
// SC 2.5.3: an accessible name has to CONTAIN the visible label, or
|
||||
// "clique em Salvar filtro" matches nothing by voice. The rename case
|
||||
// may add the target's name because it keeps the visible word first.
|
||||
aria-label={existing ? `Renomear o filtro salvo ${existing.name}` : undefined}
|
||||
>
|
||||
<BookmarkPlusIcon aria-hidden="true" />
|
||||
<span className={cn(variant === 'header' && 'sr-only')}>
|
||||
{existing ? 'Renomear' : 'Salvar filtro'}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" aria-label="Salvar filtro" className="w-80">
|
||||
{/* Portaled out of the search form above, so this nests no form. */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={nameId}>Nome do filtro</FieldLabel>
|
||||
<Input
|
||||
id={nameId}
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value)
|
||||
setErrorMessage('')
|
||||
}}
|
||||
maxLength={MAX_NAME_LENGTH}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
className="min-h-11"
|
||||
aria-invalid={errorMessage ? true : undefined}
|
||||
aria-describedby={errorMessage ? errorId : undefined}
|
||||
/>
|
||||
<FieldDescription>{summary}</FieldDescription>
|
||||
</Field>
|
||||
{errorMessage ? (
|
||||
<Alert variant="destructive" id={errorId}>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" className="min-h-11 w-full">
|
||||
{existing ? 'Atualizar nome' : 'Salvar'}
|
||||
</Button>
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -27,6 +27,13 @@ export type CampaignListOmniboxProps = {
|
|||
isPending?: boolean
|
||||
/** Domain controls beside the bar (e.g. save bookmark). */
|
||||
trailing?: ReactNode
|
||||
/**
|
||||
* C100: below `md` the trailing cluster (column picker, save bookmark) is
|
||||
* hidden — B184 already owns the mobile bar chrome (borderless field,
|
||||
* circle-X clear), and the page registers its own header controls instead.
|
||||
* `md:` restores the trailing cluster beside the framed desktop field.
|
||||
*/
|
||||
edgeToEdge?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -76,6 +83,7 @@ export const CampaignListOmnibox = ({
|
|||
onClearAll,
|
||||
isPending = false,
|
||||
trailing,
|
||||
edgeToEdge = false,
|
||||
}: CampaignListOmniboxProps) => {
|
||||
const reactId = useId()
|
||||
const id = idProp ?? `campaign-list-omnibox-${reactId}`
|
||||
|
|
@ -357,7 +365,12 @@ export const CampaignListOmnibox = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 md:pt-7">
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-wrap items-center gap-2 md:pt-7',
|
||||
edgeToEdge && 'hidden md:flex',
|
||||
)}
|
||||
>
|
||||
{trailing}
|
||||
{onClearAll && hasChips ? (
|
||||
<Button
|
||||
|
|
|
|||
43
src/components/campaign/shared/usePeopleSavedFilters.ts
Normal file
43
src/components/campaign/shared/usePeopleSavedFilters.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
'use client'
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { useMemo, useSyncExternalStore } from 'react'
|
||||
|
||||
import { isSameListHref } from '@/lib/listQueryMatch'
|
||||
import {
|
||||
listPeopleSavedFilters,
|
||||
subscribePeopleSavedFilters,
|
||||
type PeopleSavedFilter,
|
||||
} from '@/utilities/people/peopleSavedFilters'
|
||||
|
||||
/** A page is a position inside a recorte, not part of it (B18 pattern). */
|
||||
const PEOPLE_SAVED_FILTER_IGNORED_PARAMS = ['page'] as const
|
||||
|
||||
/**
|
||||
* Stable reference for the render that has no `localStorage` to read. Returning
|
||||
* a fresh `[]` here is the classic `useSyncExternalStore` render loop.
|
||||
*/
|
||||
const NO_SAVED_FILTERS: readonly PeopleSavedFilter[] = []
|
||||
|
||||
/**
|
||||
* The filter bar writes this store and the sidebar reads it, from trees that
|
||||
* never meet — so both subscribe rather than lift state, and one write repaints
|
||||
* both (same contract as `useMunicipalitySavedFilters`).
|
||||
*/
|
||||
export const usePeopleSavedFilters = (): readonly PeopleSavedFilter[] =>
|
||||
useSyncExternalStore(subscribePeopleSavedFilters, listPeopleSavedFilters, () => NO_SAVED_FILTERS)
|
||||
|
||||
/** The saved filter whose href matches the current list URL, if any. */
|
||||
export const useActivePeopleSavedFilter = (): PeopleSavedFilter | undefined => {
|
||||
const savedFilters = usePeopleSavedFilters()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
return useMemo(() => {
|
||||
const query = searchParams.toString()
|
||||
const currentHref = query ? `${pathname}?${query}` : pathname
|
||||
return savedFilters.find((entry) =>
|
||||
isSameListHref(currentHref, entry.href, PEOPLE_SAVED_FILTER_IGNORED_PARAMS),
|
||||
)
|
||||
}, [pathname, searchParams, savedFilters])
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation'
|
|||
import { useState, type FormEvent } from 'react'
|
||||
|
||||
import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth'
|
||||
import { PeopleNavSavedFilters } from '@/components/campaign/people/PeopleNavSavedFilters'
|
||||
import { MunicipalityNavSavedFilters } from '@/components/campaign/shell/MunicipalityNavSavedFilters'
|
||||
import {
|
||||
getCampaignBottomNav,
|
||||
|
|
@ -28,6 +29,7 @@ import { cn } from '@/lib/utils'
|
|||
import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient'
|
||||
import { type CampaignUserShellView } from '@/utilities/campaignUserProfile'
|
||||
import { clearMunicipalitySavedFilters } from '@/utilities/municipality/municipalitySavedFilters'
|
||||
import { clearPeopleSavedFilters } from '@/utilities/people/peopleSavedFilters'
|
||||
import { clearRecentVisits } from '@/utilities/recentVisits'
|
||||
|
||||
export const CampaignBottomNav = ({ user }: { user: CampaignUserShellView }) => {
|
||||
|
|
@ -44,6 +46,7 @@ export const CampaignBottomNav = ({ user }: { user: CampaignUserShellView }) =>
|
|||
clearRecentVisits()
|
||||
clearLastActedMunicipality()
|
||||
clearMunicipalitySavedFilters()
|
||||
clearPeopleSavedFilters()
|
||||
await clearCampaignPwaCaches()
|
||||
await logoutCampaign()
|
||||
}
|
||||
|
|
@ -144,6 +147,7 @@ export const CampaignBottomNav = ({ user }: { user: CampaignUserShellView }) =>
|
|||
variant="overflow"
|
||||
onNavigate={() => setOverflowOpen(false)}
|
||||
/>
|
||||
<PeopleNavSavedFilters variant="overflow" onNavigate={() => setOverflowOpen(false)} />
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useState, type FormEvent, type ReactNode } from 'react'
|
|||
import { LogOutIcon } from 'lucide-react'
|
||||
|
||||
import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth'
|
||||
import { PeopleNavSavedFilters } from '@/components/campaign/people/PeopleNavSavedFilters'
|
||||
import { CampaignUserAvatar } from '@/components/campaign/shared/CampaignUserAvatar'
|
||||
import { MunicipalityNavSavedFilters } from '@/components/campaign/shell/MunicipalityNavSavedFilters'
|
||||
import {
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
getCampaignSecondaryNav,
|
||||
isCampaignNavActive,
|
||||
MUNICIPALITY_NAV_HREF,
|
||||
PEOPLE_NAV_HREF,
|
||||
type CampaignNavItem,
|
||||
} from '@/components/campaign/shell/nav'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -35,6 +37,7 @@ import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient'
|
|||
import type { CampaignUserShellView } from '@/utilities/campaignUserProfile'
|
||||
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
|
||||
import { clearMunicipalitySavedFilters } from '@/utilities/municipality/municipalitySavedFilters'
|
||||
import { clearPeopleSavedFilters } from '@/utilities/people/peopleSavedFilters'
|
||||
import { clearRecentVisits } from '@/utilities/recentVisits'
|
||||
|
||||
export type CampaignSidebarUser = CampaignUserShellView
|
||||
|
|
@ -84,6 +87,7 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
|
|||
clearRecentVisits()
|
||||
clearLastActedMunicipality()
|
||||
clearMunicipalitySavedFilters()
|
||||
clearPeopleSavedFilters()
|
||||
await clearCampaignPwaCaches()
|
||||
// logoutCampaign redirects — no need to reset the pending flag on success.
|
||||
await logoutCampaign()
|
||||
|
|
@ -105,6 +109,9 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
|
|||
{item.href === MUNICIPALITY_NAV_HREF ? (
|
||||
<MunicipalityNavSavedFilters onNavigate={closeMobileSidebar} />
|
||||
) : null}
|
||||
{item.href === PEOPLE_NAV_HREF ? (
|
||||
<PeopleNavSavedFilters onNavigate={closeMobileSidebar} />
|
||||
) : null}
|
||||
</CampaignSidebarLink>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
BellIcon,
|
||||
BookOpenIcon,
|
||||
CalendarDaysIcon,
|
||||
ContactIcon,
|
||||
EllipsisVerticalIcon,
|
||||
HandshakeIcon,
|
||||
HomeIcon,
|
||||
|
|
@ -38,6 +39,12 @@ export type CampaignNavItem = {
|
|||
*/
|
||||
export const MUNICIPALITY_NAV_HREF = '/campanha/municipios'
|
||||
|
||||
/**
|
||||
* Named because the sidebar has to recognize this item to hang the C100 saved
|
||||
* filters under it — same contract as `MUNICIPALITY_NAV_HREF`.
|
||||
*/
|
||||
export const PEOPLE_NAV_HREF = '/campanha/pessoas'
|
||||
|
||||
const staffNav: CampaignNavItem[] = [
|
||||
{ title: 'Início', href: '/campanha', icon: HomeIcon },
|
||||
{ title: 'Quadro', href: '/campanha/quadro', icon: LayoutDashboardIcon },
|
||||
|
|
@ -46,6 +53,7 @@ const staffNav: CampaignNavItem[] = [
|
|||
{ title: 'Lideranças', href: '/campanha/liderancas', icon: HandshakeIcon },
|
||||
{ title: 'Organizações', href: '/campanha/organizacoes', icon: LandmarkIcon },
|
||||
{ title: 'Dobradinhas', href: '/campanha/dobradinhas', icon: Users2Icon },
|
||||
{ title: 'Pessoas', href: PEOPLE_NAV_HREF, icon: ContactIcon },
|
||||
{ title: 'Agenda', href: CAMPAIGN_AGENDA_HOME, icon: CalendarDaysIcon },
|
||||
{ title: 'Demandas', href: '/campanha/demandas', icon: InboxIcon },
|
||||
{ title: 'Apoiadores', href: '/campanha/apoiadores', icon: UsersIcon },
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const CAMPAIGN_LIST_IDS = [
|
|||
'demandas',
|
||||
'apoiadores',
|
||||
'territorios',
|
||||
'pessoas',
|
||||
] as const
|
||||
|
||||
export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number]
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ export const campaignPageChromeCatalog = {
|
|||
dobradinhas: {
|
||||
title: 'Dobradinhas',
|
||||
},
|
||||
pessoas: {
|
||||
title: 'Pessoas',
|
||||
},
|
||||
dobradinhasNova: {
|
||||
title: 'Nova dobradinha',
|
||||
},
|
||||
|
|
@ -178,6 +181,10 @@ const pathRules: PathRule[] = [
|
|||
match: (pathname) => pathname === '/campanha/agenda',
|
||||
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.agenda),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/pessoas',
|
||||
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.pessoas),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/atividades/nova',
|
||||
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividadesNova),
|
||||
|
|
|
|||
17
src/lib/schemas/personDelete.ts
Normal file
17
src/lib/schemas/personDelete.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { positiveRelationshipId } from '@/lib/schemas/primitives'
|
||||
|
||||
export const personDeleteInputSchema = z.object({
|
||||
contactId: positiveRelationshipId,
|
||||
})
|
||||
|
||||
/**
|
||||
* Refusal messages shared verbatim between the server action (thrown) and the
|
||||
* client dialog (rendered) — the `*_MESSAGE` constant convention. Client-safe.
|
||||
*/
|
||||
export const PERSON_DELETE_NOT_FOUND_MESSAGE = 'Pessoa não encontrada.'
|
||||
export const PERSON_DELETE_FORBIDDEN_MESSAGE =
|
||||
'Somente a coordenação e o candidato podem apagar pessoas.'
|
||||
export const PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE =
|
||||
'Esta pessoa tem conta de coordenação ou candidato e não pode ser apagada.'
|
||||
164
src/utilities/campaignSavedFilterStore.ts
Normal file
164
src/utilities/campaignSavedFilterStore.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Device-local named bookmarks of a list URL (B18 pattern, 2nd call site: C100).
|
||||
*
|
||||
* The municipality store was the first call site; the semantics are subtle
|
||||
* enough that a second copy would rot independently (rename-at-cap, the
|
||||
* cross-island change event, the snapshot cache), so the storage layer is this
|
||||
* one factory and each list keeps a thin domain module over it. The FD2 veto
|
||||
* is about a GENERIC saved-filters SYSTEM (server-sync, config-driven) — a
|
||||
* shared storage primitive with two concrete stores is the edit-the-owner
|
||||
* move, not that system.
|
||||
*
|
||||
* The canonical href IS the entry's identity: one shortcut per recorte, and
|
||||
* re-saving a slice that is already stored renames it instead of growing a
|
||||
* second row that navigates to the same place.
|
||||
*/
|
||||
|
||||
export const SAVED_FILTER_MAX_ENTRIES = 12
|
||||
|
||||
export const SAVED_FILTER_MAX_NAME_LENGTH = 60
|
||||
|
||||
export type CampaignSavedFilter = {
|
||||
/** Canonical list href (page 1) — also the entry's identity. */
|
||||
href: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type SaveCampaignSavedFilterResult = 'saved' | 'limit' | 'failed'
|
||||
|
||||
export type CampaignSavedFilterStoreOptions = {
|
||||
storageKey: string
|
||||
/** Rejects entries from another list (the href prefix is the guard). */
|
||||
isHrefValid: (href: string) => boolean
|
||||
}
|
||||
|
||||
export type CampaignSavedFilterStore = {
|
||||
list: () => readonly CampaignSavedFilter[]
|
||||
save: (entry: CampaignSavedFilter) => SaveCampaignSavedFilterResult
|
||||
remove: (href: string) => void
|
||||
clear: () => void
|
||||
subscribe: (onChange: () => void) => () => void
|
||||
}
|
||||
|
||||
export const createSavedFilterStore = ({
|
||||
storageKey,
|
||||
isHrefValid,
|
||||
}: CampaignSavedFilterStoreOptions): CampaignSavedFilterStore => {
|
||||
/**
|
||||
* Two islands share a store — the filter bar writes it, the sidebar reads it
|
||||
* — and they never share a React tree, so a write has to announce itself.
|
||||
* The native `storage` event covers other tabs and deliberately does not
|
||||
* fire in the tab that wrote, which is exactly the half this event adds.
|
||||
*/
|
||||
const changeEvent = `${storageKey}-change`
|
||||
|
||||
const empty: readonly CampaignSavedFilter[] = Object.freeze([])
|
||||
|
||||
/**
|
||||
* `useSyncExternalStore` compares snapshots by reference, so parsing storage
|
||||
* on every `getSnapshot` call would render forever. The cache is dropped by
|
||||
* every write and by a `storage` event from another tab.
|
||||
*/
|
||||
let snapshot: readonly CampaignSavedFilter[] | null = null
|
||||
|
||||
const isEntry = (value: unknown): value is CampaignSavedFilter => {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const entry = value as Record<string, unknown>
|
||||
return (
|
||||
typeof entry.href === 'string' &&
|
||||
isHrefValid(entry.href) &&
|
||||
typeof entry.name === 'string' &&
|
||||
entry.name.trim().length > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Alphabetical, not by recency: a named shortcut that reorders itself under
|
||||
* the cursor every time it is re-saved is a nav bug. Recency is Visitados'
|
||||
* job.
|
||||
*/
|
||||
const byName = (left: CampaignSavedFilter, right: CampaignSavedFilter): number =>
|
||||
left.name.localeCompare(right.name, 'pt-BR')
|
||||
|
||||
const read = (): readonly CampaignSavedFilter[] => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) return empty
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return empty
|
||||
const seen = new Set<string>()
|
||||
const entries = parsed.filter(isEntry).filter((entry) => {
|
||||
if (seen.has(entry.href)) return false
|
||||
seen.add(entry.href)
|
||||
return true
|
||||
})
|
||||
return entries.length ? entries.slice(0, SAVED_FILTER_MAX_ENTRIES).sort(byName) : empty
|
||||
} catch {
|
||||
return empty
|
||||
}
|
||||
}
|
||||
|
||||
const list = (): readonly CampaignSavedFilter[] => {
|
||||
if (typeof window === 'undefined') return empty
|
||||
snapshot ??= read()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const write = (entries: readonly CampaignSavedFilter[]): boolean => {
|
||||
let persisted = true
|
||||
try {
|
||||
if (entries.length) localStorage.setItem(storageKey, JSON.stringify(entries))
|
||||
else localStorage.removeItem(storageKey)
|
||||
} catch {
|
||||
// Quota or private mode. Readers are told to re-read either way, so the
|
||||
// submenu shows what actually persisted rather than a save that did not.
|
||||
persisted = false
|
||||
}
|
||||
snapshot = null
|
||||
window.dispatchEvent(new Event(changeEvent))
|
||||
return persisted
|
||||
}
|
||||
|
||||
const save = (entry: CampaignSavedFilter): SaveCampaignSavedFilterResult => {
|
||||
if (typeof window === 'undefined') return 'failed'
|
||||
const name = entry.name.trim().slice(0, SAVED_FILTER_MAX_NAME_LENGTH)
|
||||
if (!name) return 'failed'
|
||||
const others = list().filter((item) => item.href !== entry.href)
|
||||
// Renaming at the cap has to keep working, which is why the ceiling is
|
||||
// checked against the OTHER entries and not against the stored length.
|
||||
if (others.length >= SAVED_FILTER_MAX_ENTRIES) return 'limit'
|
||||
return write([...others, { href: entry.href, name }].sort(byName)) ? 'saved' : 'failed'
|
||||
}
|
||||
|
||||
const remove = (href: string): void => {
|
||||
if (typeof window === 'undefined') return
|
||||
const existing = list()
|
||||
const next = existing.filter((item) => item.href !== href)
|
||||
if (next.length === existing.length) return
|
||||
write(next)
|
||||
}
|
||||
|
||||
const clear = (): void => {
|
||||
if (typeof window === 'undefined') return
|
||||
write([])
|
||||
}
|
||||
|
||||
const subscribe = (onChange: () => void): (() => void) => {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
const handleChange = (event: Event) => {
|
||||
// `storage` fires for every key in the origin (recent visits, biometrics
|
||||
// flags); a null key means the whole store was cleared.
|
||||
if (event instanceof StorageEvent && event.key !== null && event.key !== storageKey) return
|
||||
snapshot = null
|
||||
onChange()
|
||||
}
|
||||
window.addEventListener(changeEvent, handleChange)
|
||||
window.addEventListener('storage', handleChange)
|
||||
return () => {
|
||||
window.removeEventListener(changeEvent, handleChange)
|
||||
window.removeEventListener('storage', handleChange)
|
||||
}
|
||||
}
|
||||
|
||||
return { list, save, remove, clear, subscribe }
|
||||
}
|
||||
|
|
@ -1,128 +1,40 @@
|
|||
/**
|
||||
* Named bookmarks of the municipality list URL (B18).
|
||||
*
|
||||
* Device-local on purpose: a nav shortcut never has to reach the RSC render, so
|
||||
* this is `localStorage` — the same call `recentVisits.ts` made, and unlike
|
||||
* B17's column cookie, which the server reads to filter columns.
|
||||
*
|
||||
* The canonical href IS the identity. One shortcut per recorte, so re-saving a
|
||||
* slice that is already stored renames it instead of growing a second submenu
|
||||
* row that navigates to the same place.
|
||||
* Named bookmarks of the municipality list URL (B18). The storage layer moved
|
||||
* to the shared `createSavedFilterStore` factory (2nd call site: C100 people);
|
||||
* this module keeps the B18 public API byte-for-byte so the sidebar, the save
|
||||
* control and the unit suite keep working unchanged.
|
||||
*/
|
||||
|
||||
import {
|
||||
createSavedFilterStore,
|
||||
SAVED_FILTER_MAX_ENTRIES,
|
||||
SAVED_FILTER_MAX_NAME_LENGTH,
|
||||
type CampaignSavedFilter,
|
||||
type SaveCampaignSavedFilterResult,
|
||||
} from '@/utilities/campaignSavedFilterStore'
|
||||
|
||||
export const STORAGE_KEY = 'teqo:campaign:municipality-saved-filters'
|
||||
|
||||
/**
|
||||
* A named bookmark is not a history entry: at the cap the save is refused with
|
||||
* a reason instead of silently evicting something the user deliberately named
|
||||
* (which is what `recentVisits`'s `slice` does, correctly, for dwell history).
|
||||
*/
|
||||
export const MAX_ENTRIES = 12
|
||||
export type MunicipalitySavedFilter = CampaignSavedFilter
|
||||
|
||||
export const MAX_NAME_LENGTH = 60
|
||||
export type SaveMunicipalitySavedFilterResult = SaveCampaignSavedFilterResult
|
||||
|
||||
/**
|
||||
* Two islands share this store — the filter bar writes it, the sidebar reads it
|
||||
* — and they never share a React tree, so a write has to announce itself. The
|
||||
* native `storage` event covers other tabs and deliberately does not fire in the
|
||||
* tab that wrote, which is exactly the half this event adds.
|
||||
*/
|
||||
const CHANGE_EVENT = 'teqo:campaign:municipality-saved-filters-change'
|
||||
export { SAVED_FILTER_MAX_ENTRIES as MAX_ENTRIES, SAVED_FILTER_MAX_NAME_LENGTH as MAX_NAME_LENGTH }
|
||||
|
||||
export type MunicipalitySavedFilter = {
|
||||
/** Canonical list href (page 1, no `compare`) — also the entry's identity. */
|
||||
href: string
|
||||
name: string
|
||||
}
|
||||
const store = createSavedFilterStore({
|
||||
storageKey: STORAGE_KEY,
|
||||
isHrefValid: (href) => href.startsWith('/campanha/municipios'),
|
||||
})
|
||||
|
||||
export type SaveMunicipalitySavedFilterResult = 'saved' | 'limit' | 'failed'
|
||||
export const listMunicipalitySavedFilters = store.list
|
||||
|
||||
const EMPTY: readonly MunicipalitySavedFilter[] = Object.freeze([])
|
||||
|
||||
/**
|
||||
* `useSyncExternalStore` compares snapshots by reference, so parsing storage on
|
||||
* every `getSnapshot` call would render forever. The cache is dropped by every
|
||||
* write and by a `storage` event from another tab.
|
||||
*/
|
||||
let snapshot: readonly MunicipalitySavedFilter[] | null = null
|
||||
|
||||
const isMunicipalitySavedFilter = (value: unknown): value is MunicipalitySavedFilter => {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const entry = value as Record<string, unknown>
|
||||
return (
|
||||
typeof entry.href === 'string' &&
|
||||
entry.href.startsWith('/campanha/municipios') &&
|
||||
typeof entry.name === 'string' &&
|
||||
entry.name.trim().length > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Alphabetical, not by recency: a named shortcut that reorders itself under the
|
||||
* cursor every time it is re-saved is a nav bug. Recency is Visitados' job.
|
||||
*/
|
||||
const byName = (left: MunicipalitySavedFilter, right: MunicipalitySavedFilter): number =>
|
||||
left.name.localeCompare(right.name, 'pt-BR')
|
||||
|
||||
const read = (): readonly MunicipalitySavedFilter[] => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return EMPTY
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return EMPTY
|
||||
const seen = new Set<string>()
|
||||
const entries = parsed.filter(isMunicipalitySavedFilter).filter((entry) => {
|
||||
if (seen.has(entry.href)) return false
|
||||
seen.add(entry.href)
|
||||
return true
|
||||
})
|
||||
return entries.length ? entries.slice(0, MAX_ENTRIES).sort(byName) : EMPTY
|
||||
} catch {
|
||||
return EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
export const listMunicipalitySavedFilters = (): readonly MunicipalitySavedFilter[] => {
|
||||
if (typeof window === 'undefined') return EMPTY
|
||||
snapshot ??= read()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const write = (entries: readonly MunicipalitySavedFilter[]): boolean => {
|
||||
let persisted = true
|
||||
try {
|
||||
if (entries.length) localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
|
||||
else localStorage.removeItem(STORAGE_KEY)
|
||||
} catch {
|
||||
// Quota or private mode. Readers are told to re-read either way, so the
|
||||
// submenu shows what actually persisted rather than a save that did not.
|
||||
persisted = false
|
||||
}
|
||||
snapshot = null
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT))
|
||||
return persisted
|
||||
}
|
||||
|
||||
export const saveMunicipalitySavedFilter = (
|
||||
export const saveMunicipalitySavedFilter: (
|
||||
entry: MunicipalitySavedFilter,
|
||||
): SaveMunicipalitySavedFilterResult => {
|
||||
if (typeof window === 'undefined') return 'failed'
|
||||
const name = entry.name.trim().slice(0, MAX_NAME_LENGTH)
|
||||
if (!name) return 'failed'
|
||||
const others = listMunicipalitySavedFilters().filter((item) => item.href !== entry.href)
|
||||
// Renaming at the cap has to keep working, which is why the ceiling is checked
|
||||
// against the OTHER entries and not against the stored length.
|
||||
if (others.length >= MAX_ENTRIES) return 'limit'
|
||||
return write([...others, { href: entry.href, name }].sort(byName)) ? 'saved' : 'failed'
|
||||
}
|
||||
) => SaveMunicipalitySavedFilterResult = store.save
|
||||
|
||||
export const removeMunicipalitySavedFilter = (href: string): void => {
|
||||
if (typeof window === 'undefined') return
|
||||
const existing = listMunicipalitySavedFilters()
|
||||
const next = existing.filter((item) => item.href !== href)
|
||||
if (next.length === existing.length) return
|
||||
write(next)
|
||||
}
|
||||
export const removeMunicipalitySavedFilter = store.remove
|
||||
|
||||
export const subscribeMunicipalitySavedFilters = store.subscribe
|
||||
|
||||
/** Legacy B18 disclosure key — removed by B124; cleared on logout. */
|
||||
const LEGACY_OPEN_STORAGE_KEY = 'teqo:campaign:municipality-saved-filters-open'
|
||||
|
|
@ -134,22 +46,5 @@ export const clearMunicipalitySavedFilters = (): void => {
|
|||
} catch {
|
||||
// A stale preference is a nicety.
|
||||
}
|
||||
write([])
|
||||
}
|
||||
|
||||
export const subscribeMunicipalitySavedFilters = (onChange: () => void): (() => void) => {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
const handleChange = (event: Event) => {
|
||||
// `storage` fires for every key in the origin (recent visits, biometrics
|
||||
// flags); a null key means the whole store was cleared.
|
||||
if (event instanceof StorageEvent && event.key !== null && event.key !== STORAGE_KEY) return
|
||||
snapshot = null
|
||||
onChange()
|
||||
}
|
||||
window.addEventListener(CHANGE_EVENT, handleChange)
|
||||
window.addEventListener('storage', handleChange)
|
||||
return () => {
|
||||
window.removeEventListener(CHANGE_EVENT, handleChange)
|
||||
window.removeEventListener('storage', handleChange)
|
||||
}
|
||||
store.clear()
|
||||
}
|
||||
|
|
|
|||
474
src/utilities/people/peopleData.ts
Normal file
474
src/utilities/people/peopleData.ts
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { isSupportStatus, type SupportStatus } from '@/lib/schemas/leadership'
|
||||
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
|
||||
import { municipalityIdsByAdvisorIds } from '@/utilities/advisorData'
|
||||
import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { loadCampaignUserNamesByIds } from '@/utilities/loadNamesByIds'
|
||||
import {
|
||||
buildPeopleDeputySourceWhere,
|
||||
buildPeopleLeadershipSourceWhere,
|
||||
buildPeopleStaffSourceWhere,
|
||||
peoplePageSize,
|
||||
type PeopleListState,
|
||||
} from '@/utilities/people/peopleListUrl'
|
||||
import { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData'
|
||||
|
||||
/**
|
||||
* The unified people list (C100): three domain sources — leadership, dobradinha
|
||||
* and staff accounts — each read with the access of its own collection, then
|
||||
* merged in memory by `Contact` id so one physical person is ONE row whose
|
||||
* capacities fill columns.
|
||||
*
|
||||
* Access contract (impl plan D2/D3): the actor's scope is enforced by the
|
||||
* merge, never by widening a domain where. An advisor sees only rows with at
|
||||
* least one capacity municipality in his carteira; unrestricted actors see
|
||||
* every person with any role. The staff source reads with a justified
|
||||
* `overrideAccess` (the `contact` field is identity-gated and would be cut
|
||||
* from the depth-1 populate otherwise) — same precedent as
|
||||
* `loadAdvisorListPageData` and `canReadCampaignUserPhone`'s same-municipality
|
||||
* rule: the surface never exposes a row the merge scope did not authorize.
|
||||
*/
|
||||
|
||||
type PeopleStaffCapacity = {
|
||||
id: number
|
||||
name: string
|
||||
role: CampaignUser['role']
|
||||
municipalityIDs: number[]
|
||||
}
|
||||
|
||||
export type PeopleRowViewModel = {
|
||||
contactID: number
|
||||
name: string
|
||||
phone: string | null
|
||||
email: string | null
|
||||
city: string | null
|
||||
/** `stateDeputy.party` — the one party source (at most one dobradinha per ficha). */
|
||||
party: string | null
|
||||
leadershipID: number | null
|
||||
leadershipMunicipalityIDs: number[]
|
||||
supportStatus: SupportStatus | null
|
||||
hasAppAccess: boolean
|
||||
deputyID: number | null
|
||||
deputyMunicipalityIDs: number[]
|
||||
staff: PeopleStaffCapacity[]
|
||||
/** Union of the staff accounts' carteiras — the Assessora column. */
|
||||
assessoraMunicipalityIDs: number[]
|
||||
/** Union of every capacity's municipalities — scope and the Município facet. */
|
||||
capacityMunicipalityIDs: number[]
|
||||
/** Union of leadership + dobradinha advisors, names resolved (Assessorado). */
|
||||
assessoradoNames: string[]
|
||||
}
|
||||
|
||||
/** Merge intermediate: the ids the loader resolves into `assessoradoNames`. */
|
||||
export type MergedPerson = Omit<PeopleRowViewModel, 'assessoradoNames'> & {
|
||||
leadershipAdvisorIDs: number[]
|
||||
deputyAdvisorIDs: number[]
|
||||
}
|
||||
|
||||
type PeopleLeadershipSource = {
|
||||
id: number
|
||||
contactID: number
|
||||
name: string
|
||||
phone: string | null
|
||||
email: string | null
|
||||
city: string | null
|
||||
municipalityIDs: number[]
|
||||
supportStatus: SupportStatus | null
|
||||
hasAppAccess: boolean
|
||||
advisorIDs: number[]
|
||||
}
|
||||
|
||||
type PeopleDeputySource = {
|
||||
id: number
|
||||
contactID: number
|
||||
name: string
|
||||
phone: string | null
|
||||
email: string | null
|
||||
city: string | null
|
||||
party: string | null
|
||||
municipalityIDs: number[]
|
||||
advisorIDs: number[]
|
||||
}
|
||||
|
||||
type PeopleStaffSource = {
|
||||
id: number
|
||||
contactID: number
|
||||
name: string
|
||||
phone: string | null
|
||||
email: string | null
|
||||
city: string | null
|
||||
role: CampaignUser['role']
|
||||
municipalityIDs: number[]
|
||||
}
|
||||
|
||||
export type PeopleMergeSources = {
|
||||
leaderships: readonly PeopleLeadershipSource[]
|
||||
deputies: readonly PeopleDeputySource[]
|
||||
staff: readonly PeopleStaffSource[]
|
||||
}
|
||||
|
||||
const sortedDistinct = (values: Iterable<number>): number[] =>
|
||||
[...new Set(values)].sort((left, right) => left - right)
|
||||
|
||||
/**
|
||||
* One row per `contactID`. The ficha fields come from the first source that
|
||||
* carries them (leadership, then dobradinha, then staff — same ficha, so the
|
||||
* data cannot disagree); capacities accumulate across sources. Staff with an
|
||||
* EMPTY carteira still count as a capacity: being staff is the papel, the
|
||||
* carteira only fills the Assessora column (gate 2026-08-09).
|
||||
*/
|
||||
export const mergePeopleSources = (sources: PeopleMergeSources): MergedPerson[] => {
|
||||
const byContact = new Map<number, MergedPerson>()
|
||||
|
||||
const personFor = (contactID: number): MergedPerson => {
|
||||
const existing = byContact.get(contactID)
|
||||
if (existing) return existing
|
||||
const person: MergedPerson = {
|
||||
contactID,
|
||||
name: 'Contato',
|
||||
phone: null,
|
||||
email: null,
|
||||
city: null,
|
||||
party: null,
|
||||
leadershipID: null,
|
||||
leadershipMunicipalityIDs: [],
|
||||
supportStatus: null,
|
||||
hasAppAccess: false,
|
||||
leadershipAdvisorIDs: [],
|
||||
deputyID: null,
|
||||
deputyMunicipalityIDs: [],
|
||||
deputyAdvisorIDs: [],
|
||||
staff: [],
|
||||
assessoraMunicipalityIDs: [],
|
||||
capacityMunicipalityIDs: [],
|
||||
}
|
||||
byContact.set(contactID, person)
|
||||
return person
|
||||
}
|
||||
|
||||
for (const leadership of sources.leaderships) {
|
||||
const person = personFor(leadership.contactID)
|
||||
person.name = leadership.name
|
||||
person.phone = leadership.phone
|
||||
person.email = leadership.email
|
||||
person.city = leadership.city
|
||||
person.leadershipID = leadership.id
|
||||
person.leadershipMunicipalityIDs = [...leadership.municipalityIDs]
|
||||
person.supportStatus = leadership.supportStatus
|
||||
person.hasAppAccess = leadership.hasAppAccess
|
||||
person.leadershipAdvisorIDs = [...leadership.advisorIDs]
|
||||
}
|
||||
|
||||
for (const deputy of sources.deputies) {
|
||||
const person = personFor(deputy.contactID)
|
||||
if (person.leadershipID === null) {
|
||||
person.name = deputy.name
|
||||
person.phone = deputy.phone
|
||||
person.email = deputy.email
|
||||
person.city = deputy.city
|
||||
}
|
||||
person.party = deputy.party
|
||||
person.deputyID = deputy.id
|
||||
person.deputyMunicipalityIDs = [...deputy.municipalityIDs]
|
||||
person.deputyAdvisorIDs = [...deputy.advisorIDs]
|
||||
}
|
||||
|
||||
for (const staff of sources.staff) {
|
||||
const person = personFor(staff.contactID)
|
||||
if (person.leadershipID === null && person.deputyID === null) {
|
||||
person.name = staff.name
|
||||
person.phone = staff.phone
|
||||
person.email = staff.email
|
||||
person.city = staff.city
|
||||
}
|
||||
person.staff.push({
|
||||
id: staff.id,
|
||||
name: staff.name,
|
||||
role: staff.role,
|
||||
municipalityIDs: [...staff.municipalityIDs],
|
||||
})
|
||||
}
|
||||
|
||||
for (const person of byContact.values()) {
|
||||
const assessora = sortedDistinct(person.staff.flatMap((account) => account.municipalityIDs))
|
||||
person.assessoraMunicipalityIDs = assessora
|
||||
person.capacityMunicipalityIDs = sortedDistinct([
|
||||
...person.leadershipMunicipalityIDs,
|
||||
...person.deputyMunicipalityIDs,
|
||||
...assessora,
|
||||
])
|
||||
}
|
||||
|
||||
return [...byContact.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisor viewer scope: a row stays iff at least one capacity municipality is
|
||||
* in the actor's carteira. `null` = unrestricted (no filter). Staff with an
|
||||
* empty carteira have no capacity municipality and are therefore only visible
|
||||
* to unrestricted actors — the consistent consequence of the inclusion rule.
|
||||
*/
|
||||
export const scopePeopleRows = (
|
||||
rows: readonly MergedPerson[],
|
||||
accessibleMunicipalityIds: ReadonlySet<number> | null,
|
||||
): MergedPerson[] => {
|
||||
if (accessibleMunicipalityIds === null) return [...rows]
|
||||
return rows.filter((row) =>
|
||||
row.capacityMunicipalityIDs.some((id) => accessibleMunicipalityIds.has(id)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory list filters (C100): capacities OR within the facet (same as every
|
||||
* other list facet), municipalities intersect any capacity, statuses only
|
||||
* match leadership rows. `q` already ran at the source level.
|
||||
*/
|
||||
export const filterPeopleRows = (
|
||||
rows: readonly MergedPerson[],
|
||||
state: PeopleListState,
|
||||
): MergedPerson[] =>
|
||||
rows.filter((row) => {
|
||||
if (state.capacities?.length) {
|
||||
const wanted = new Set(state.capacities)
|
||||
const matches =
|
||||
(wanted.has('assessora') && row.assessoraMunicipalityIDs.length > 0) ||
|
||||
(wanted.has('lideranca') && row.leadershipID !== null) ||
|
||||
(wanted.has('dobradinha') && row.deputyID !== null)
|
||||
if (!matches) return false
|
||||
}
|
||||
if (state.municipalities?.length) {
|
||||
const selected = new Set(state.municipalities)
|
||||
if (!row.capacityMunicipalityIDs.some((id) => selected.has(id))) return false
|
||||
}
|
||||
if (state.statuses?.length) {
|
||||
if (row.supportStatus === null || !state.statuses.includes(row.supportStatus)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export type PeopleListFilterFacets = {
|
||||
/** Municipality ids present across the scoped rows (selected values unioned in). */
|
||||
municipalityIDs: number[]
|
||||
/** Leadership statuses present across the scoped rows (selected values unioned in). */
|
||||
statuses: SupportStatus[]
|
||||
}
|
||||
|
||||
export const peopleFilterFacetsFromRows = (
|
||||
rows: readonly MergedPerson[],
|
||||
state: PeopleListState,
|
||||
): PeopleListFilterFacets => {
|
||||
const municipalities = new Set<number>(state.municipalities ?? [])
|
||||
const statuses = new Set<SupportStatus>(state.statuses ?? [])
|
||||
for (const row of rows) {
|
||||
for (const id of row.capacityMunicipalityIDs) municipalities.add(id)
|
||||
if (row.supportStatus !== null) statuses.add(row.supportStatus)
|
||||
}
|
||||
return {
|
||||
municipalityIDs: [...municipalities].sort((left, right) => left - right),
|
||||
statuses: leadershipStatusesInOrder(statuses),
|
||||
}
|
||||
}
|
||||
|
||||
const leadershipStatusesInOrder = (statuses: ReadonlySet<SupportStatus>): SupportStatus[] =>
|
||||
(['engajado', 'a_abordar', 'em_disputa', 'negativo'] as const).filter((status) =>
|
||||
statuses.has(status),
|
||||
)
|
||||
|
||||
const EMPTY_FACETS: PeopleListFilterFacets = { municipalityIDs: [], statuses: [] }
|
||||
|
||||
type ContactSummary = {
|
||||
id: number
|
||||
name: string
|
||||
phone: string | null
|
||||
email: string | null
|
||||
city: string | null
|
||||
}
|
||||
|
||||
const contactSummary = (contact: Contact | number | null | undefined): ContactSummary => {
|
||||
if (contact !== null && typeof contact === 'object') {
|
||||
return {
|
||||
id: contact.id,
|
||||
name: contact.name ?? 'Contato',
|
||||
phone: contact.phone ?? null,
|
||||
email: contact.email ?? null,
|
||||
city: contact.city ?? null,
|
||||
}
|
||||
}
|
||||
return { id: Number(contact), name: 'Contato', phone: null, email: null, city: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* The page data for `/campanha/pessoas`. `q` runs at the source level; the
|
||||
* capacity/municipality/status filters, the advisor scope and the facets run
|
||||
* over the merged rows. Advisor names for the Assessorado column are resolved
|
||||
* for the visible page only.
|
||||
*/
|
||||
export const loadPeopleListPageData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
state: PeopleListState,
|
||||
): Promise<{
|
||||
rows: PeopleRowViewModel[]
|
||||
totalDocs: number
|
||||
totalPages: number
|
||||
filterFacets: PeopleListFilterFacets
|
||||
}> => {
|
||||
if (!isCampaignStaff(user)) {
|
||||
return { rows: [], totalDocs: 0, totalPages: 0, filterFacets: EMPTY_FACETS }
|
||||
}
|
||||
|
||||
const [leadershipResult, deputyResult, staffResult] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'leadership',
|
||||
where: buildPeopleLeadershipSourceWhere(state),
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: {
|
||||
contact: true,
|
||||
municipalities: true,
|
||||
advisors: true,
|
||||
supportStatus: true,
|
||||
user: true,
|
||||
},
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: buildPeopleDeputySourceWhere(state),
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { contact: true, slug: true, party: true, advisors: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: buildPeopleStaffSourceWhere(state),
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { name: true, role: true, contact: true },
|
||||
// Intentional admin bypass (impl plan D2): the `contact` field is
|
||||
// identity-gated (`canReadCampaignUserIdentity`) and the actor scope is
|
||||
// enforced by the merge below — same rationale as `loadAdvisorListPageData`.
|
||||
overrideAccess: true,
|
||||
}),
|
||||
])
|
||||
|
||||
const [deputyMunicipalityIdsByDeputy, staffMunicipalityIdsByStaff] = await Promise.all([
|
||||
municipalityIdsByStateDeputyIds(
|
||||
payload,
|
||||
deputyResult.docs.map((doc) => doc.id),
|
||||
),
|
||||
municipalityIdsByAdvisorIds(
|
||||
payload,
|
||||
staffResult.docs.map((doc) => doc.id),
|
||||
),
|
||||
])
|
||||
|
||||
const leaderships: PeopleLeadershipSource[] = (leadershipResult.docs as Leadership[]).map(
|
||||
(doc) => {
|
||||
const contact = contactSummary(doc.contact)
|
||||
return {
|
||||
id: doc.id,
|
||||
contactID: contact.id,
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
email: contact.email,
|
||||
city: contact.city,
|
||||
municipalityIDs: uniqueRelationshipIds(doc.municipalities),
|
||||
supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null,
|
||||
hasAppAccess: relationshipId(doc.user) !== null,
|
||||
advisorIDs: uniqueRelationshipIds(doc.advisors),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const deputies: PeopleDeputySource[] = (deputyResult.docs as StateDeputy[]).map((doc) => {
|
||||
const contact = contactSummary(doc.contact)
|
||||
return {
|
||||
id: doc.id,
|
||||
contactID: contact.id,
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
email: contact.email,
|
||||
city: contact.city,
|
||||
party: doc.party ?? null,
|
||||
municipalityIDs: deputyMunicipalityIdsByDeputy.get(doc.id) ?? [],
|
||||
advisorIDs: uniqueRelationshipIds(doc.advisors),
|
||||
}
|
||||
})
|
||||
|
||||
const staff: PeopleStaffSource[] = (staffResult.docs as CampaignUser[]).map((doc) => {
|
||||
const contact = contactSummary(doc.contact)
|
||||
return {
|
||||
id: doc.id,
|
||||
contactID: contact.id,
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
email: contact.email,
|
||||
city: contact.city,
|
||||
role: doc.role,
|
||||
municipalityIDs: staffMunicipalityIdsByStaff.get(doc.id) ?? [],
|
||||
}
|
||||
})
|
||||
|
||||
const merged = mergePeopleSources({ leaderships, deputies, staff })
|
||||
const accessibleMunicipalityIds =
|
||||
user.role === 'advisor' ? new Set(await getAdvisorMunicipalityIds(payload, user.id)) : null
|
||||
const scoped = scopePeopleRows(merged, accessibleMunicipalityIds)
|
||||
const filterFacets = peopleFilterFacetsFromRows(scoped, state)
|
||||
const filtered = filterPeopleRows(scoped, state)
|
||||
const sorted = [...filtered].sort(
|
||||
(left, right) =>
|
||||
left.name.localeCompare(right.name, 'pt-BR') || left.contactID - right.contactID,
|
||||
)
|
||||
|
||||
const totalDocs = sorted.length
|
||||
const totalPages = Math.ceil(totalDocs / peoplePageSize)
|
||||
const pageRows = sorted.slice((state.page - 1) * peoplePageSize, state.page * peoplePageSize)
|
||||
|
||||
const advisorIDs = new Set<number>()
|
||||
for (const row of pageRows) {
|
||||
for (const id of row.leadershipAdvisorIDs) advisorIDs.add(id)
|
||||
for (const id of row.deputyAdvisorIDs) advisorIDs.add(id)
|
||||
}
|
||||
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'))
|
||||
return {
|
||||
contactID: person.contactID,
|
||||
name: person.name,
|
||||
phone: person.phone,
|
||||
email: person.email,
|
||||
city: person.city,
|
||||
party: person.party,
|
||||
leadershipID: person.leadershipID,
|
||||
leadershipMunicipalityIDs: person.leadershipMunicipalityIDs,
|
||||
supportStatus: person.supportStatus,
|
||||
hasAppAccess: person.hasAppAccess,
|
||||
deputyID: person.deputyID,
|
||||
deputyMunicipalityIDs: person.deputyMunicipalityIDs,
|
||||
staff: person.staff,
|
||||
assessoraMunicipalityIDs: person.assessoraMunicipalityIDs,
|
||||
capacityMunicipalityIDs: person.capacityMunicipalityIDs,
|
||||
assessoradoNames,
|
||||
}
|
||||
})
|
||||
|
||||
return { rows, totalDocs, totalPages, filterFacets }
|
||||
}
|
||||
15
src/utilities/people/peopleLabels.ts
Normal file
15
src/utilities/people/peopleLabels.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* People list display labels and the capacity enum (C100). Client-safe: a
|
||||
* plain string table, no Payload/DB — same contract as `leadershipLabels`.
|
||||
*/
|
||||
|
||||
/** The three capacity facets — derived from the columns, OR within the facet. */
|
||||
export const PEOPLE_CAPACITIES = ['assessora', 'lideranca', 'dobradinha'] as const
|
||||
|
||||
export type PeopleCapacity = (typeof PEOPLE_CAPACITIES)[number]
|
||||
|
||||
export const peopleCapacityLabels: Record<PeopleCapacity, string> = {
|
||||
assessora: 'É assessora',
|
||||
lideranca: 'É liderança',
|
||||
dobradinha: 'É dobradinha',
|
||||
}
|
||||
130
src/utilities/people/peopleListFilters.ts
Normal file
130
src/utilities/people/peopleListFilters.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* People list filter affordances (C100): Capacidade / Município / Apoio
|
||||
* toggles, active-filter summary and clear-filters. Multi-select facets are OR
|
||||
* within the facet, like every other list.
|
||||
*/
|
||||
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
|
||||
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
|
||||
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
|
||||
import { PEOPLE_CAPACITIES, peopleCapacityLabels } from '@/utilities/people/peopleLabels'
|
||||
import {
|
||||
buildPeopleListHref,
|
||||
parsePeopleListParams,
|
||||
peopleListStateToRawParams,
|
||||
type PeopleListState,
|
||||
} from '@/utilities/people/peopleListUrl'
|
||||
|
||||
export type PeopleFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type PeopleMultiFilterParam = 'capacity' | 'municipality' | 'status'
|
||||
|
||||
export const peopleCapacityFilterOptions: PeopleFilterOption[] = PEOPLE_CAPACITIES.map((value) => ({
|
||||
value,
|
||||
label: peopleCapacityLabels[value],
|
||||
}))
|
||||
|
||||
export const peopleStatusFilterOptions: PeopleFilterOption[] = leadershipSupportStatuses.map(
|
||||
(value) => ({ value, label: supportStatusLabels[value] }),
|
||||
)
|
||||
|
||||
/**
|
||||
* Writes the RAW param and lets `parsePeopleListParams` validate, so no branch
|
||||
* has to cast a `string[]` into the state's narrow value types.
|
||||
*/
|
||||
const setPeopleMultiFilterValues = (
|
||||
state: PeopleListState,
|
||||
param: PeopleMultiFilterParam,
|
||||
values: string[],
|
||||
): PeopleListState =>
|
||||
parsePeopleListParams({
|
||||
...peopleListStateToRawParams({ ...state, page: 1 }, 1),
|
||||
[param]: values,
|
||||
})
|
||||
|
||||
const getPeopleMultiFilterValues = (
|
||||
state: PeopleListState,
|
||||
param: PeopleMultiFilterParam,
|
||||
): string[] => {
|
||||
if (param === 'capacity') return state.capacities ?? []
|
||||
if (param === 'status') return state.statuses ?? []
|
||||
return (state.municipalities ?? []).map(String)
|
||||
}
|
||||
|
||||
const togglePeopleMultiFilterValue = (
|
||||
state: PeopleListState,
|
||||
param: PeopleMultiFilterParam,
|
||||
value: string,
|
||||
): PeopleListState => {
|
||||
const current = getPeopleMultiFilterValues(state, param)
|
||||
return setPeopleMultiFilterValues(
|
||||
state,
|
||||
param,
|
||||
current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value],
|
||||
)
|
||||
}
|
||||
|
||||
export const togglePeopleCapacityFilter = (
|
||||
state: PeopleListState,
|
||||
value: string,
|
||||
): PeopleListState => {
|
||||
if (!PEOPLE_CAPACITIES.some((capacity) => capacity === value)) return state
|
||||
return togglePeopleMultiFilterValue(state, 'capacity', value)
|
||||
}
|
||||
|
||||
export const togglePeopleMunicipalityFilter = (
|
||||
state: PeopleListState,
|
||||
value: string,
|
||||
): PeopleListState => {
|
||||
if (!/^[1-9]\d*$/.test(value)) return state
|
||||
return togglePeopleMultiFilterValue(state, 'municipality', value)
|
||||
}
|
||||
|
||||
export const togglePeopleStatusFilter = (
|
||||
state: PeopleListState,
|
||||
value: string,
|
||||
): PeopleListState => {
|
||||
if (!leadershipSupportStatuses.some((status) => status === value)) return state
|
||||
return togglePeopleMultiFilterValue(state, 'status', value)
|
||||
}
|
||||
|
||||
/** Drop every filter and the search. */
|
||||
export const clearPeopleListFilters = (): PeopleListState => ({ page: 1 })
|
||||
|
||||
export const buildPeopleFilterHref = (next: PeopleListState): string => buildPeopleListHref(next, 1)
|
||||
|
||||
const municipalityLabel = (
|
||||
id: number,
|
||||
labels?: ReadonlyMap<number, string> | Readonly<Record<number, string>>,
|
||||
): string => {
|
||||
if (!labels) return `Município #${id}`
|
||||
if (labels instanceof Map) return labels.get(id) ?? `Município #${id}`
|
||||
const record = labels as Readonly<Record<number, string>>
|
||||
return record[id] ?? `Município #${id}`
|
||||
}
|
||||
|
||||
export const formatPeopleActiveFiltersSummary = (
|
||||
state: PeopleListState,
|
||||
municipalityLabelsById?: ReadonlyMap<number, string> | Readonly<Record<number, string>>,
|
||||
): string | null => {
|
||||
const parts: string[] = []
|
||||
if (state.capacities?.length) {
|
||||
parts.push(
|
||||
truncatedNamesLabel(state.capacities.map((capacity) => peopleCapacityLabels[capacity])),
|
||||
)
|
||||
}
|
||||
if (state.municipalities?.length) {
|
||||
parts.push(
|
||||
truncatedNamesLabel(
|
||||
state.municipalities.map((id) => municipalityLabel(id, municipalityLabelsById)),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (state.statuses?.length) {
|
||||
parts.push(truncatedNamesLabel(state.statuses.map((status) => supportStatusLabels[status])))
|
||||
}
|
||||
if (state.q) parts.push(`Busca "${state.q}"`)
|
||||
return parts.length ? parts.join(' · ') : null
|
||||
}
|
||||
139
src/utilities/people/peopleListUrl.ts
Normal file
139
src/utilities/people/peopleListUrl.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* People list URL contract (C100): state, param parse/canonicalize, source-level
|
||||
* Payload `where`s and hrefs. Own module, same shape as `leadershipListUrl` —
|
||||
* no sort in v1: the merged list is name-ordered by construction.
|
||||
*
|
||||
* The three source-level wheres only cover what a single domain collection can
|
||||
* express (`q` everywhere, `statuses`/`municipalities` on leadership). Capacity
|
||||
* and the deputy/staff municipality filters are applied in memory over the
|
||||
* merged rows (`peopleData.ts`) — reverse relations and cross-source unions.
|
||||
*/
|
||||
import type { Where } from 'payload'
|
||||
|
||||
import { leadershipSupportStatuses, type SupportStatus } from '@/lib/schemas/leadership'
|
||||
import {
|
||||
allParamValues,
|
||||
buildListHref,
|
||||
firstValue,
|
||||
normalizedText,
|
||||
parseExhaustiveEnumParam,
|
||||
resolveListUrl,
|
||||
strictDecimalInteger,
|
||||
type RawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
import { PEOPLE_CAPACITIES, type PeopleCapacity } from '@/utilities/people/peopleLabels'
|
||||
|
||||
export const peoplePageSize = 25
|
||||
|
||||
export type PeopleListState = {
|
||||
page: number
|
||||
q?: string
|
||||
capacities?: PeopleCapacity[]
|
||||
municipalities?: number[]
|
||||
statuses?: SupportStatus[]
|
||||
}
|
||||
|
||||
export type PeopleListSearchParams = RawSearchParams
|
||||
|
||||
const peopleListParamNames = ['q', 'capacity', 'municipality', 'status', 'page'] as const
|
||||
const peopleListParamNameSet = new Set<string>(peopleListParamNames)
|
||||
|
||||
const peopleCapacitySet = new Set<string>(PEOPLE_CAPACITIES)
|
||||
const supportStatusSet = new Set<string>(leadershipSupportStatuses)
|
||||
|
||||
export const parsePeopleListParams = (params: PeopleListSearchParams): PeopleListState => {
|
||||
const rawPage = strictDecimalInteger(firstValue(params.page))
|
||||
const q = normalizedText(firstValue(params.q))
|
||||
const capacities = parseExhaustiveEnumParam<PeopleCapacity>(params.capacity, peopleCapacitySet)
|
||||
const municipalities = allParamValues(params.municipality)
|
||||
.map((token) => strictDecimalInteger(token))
|
||||
.filter((id): id is number => typeof id === 'number' && id > 0)
|
||||
const statuses = parseExhaustiveEnumParam<SupportStatus>(params.status, supportStatusSet)
|
||||
|
||||
return {
|
||||
page: rawPage ?? 1,
|
||||
...(q ? { q } : {}),
|
||||
...(capacities.length ? { capacities } : {}),
|
||||
...(municipalities.length ? { municipalities } : {}),
|
||||
...(statuses.length ? { statuses } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const peopleListStateToRawParams = (
|
||||
state: PeopleListState,
|
||||
page = state.page,
|
||||
): PeopleListSearchParams => ({
|
||||
page: String(page),
|
||||
q: state.q,
|
||||
capacity: state.capacities,
|
||||
municipality: state.municipalities?.map(String),
|
||||
status: state.statuses,
|
||||
})
|
||||
|
||||
/** Expects already-canonical state (from parse or a rule-preserving toggle). */
|
||||
export const serializeCanonicalPeopleListSearchParams = (
|
||||
canonicalState: PeopleListState,
|
||||
): URLSearchParams => {
|
||||
const params = new URLSearchParams()
|
||||
if (canonicalState.q) params.set('q', canonicalState.q)
|
||||
for (const capacity of canonicalState.capacities ?? []) params.append('capacity', capacity)
|
||||
for (const municipality of canonicalState.municipalities ?? []) {
|
||||
params.append('municipality', String(municipality))
|
||||
}
|
||||
for (const status of canonicalState.statuses ?? []) params.append('status', status)
|
||||
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
|
||||
return params
|
||||
}
|
||||
|
||||
const buildPeopleListSearchParams = (state: PeopleListState, page = state.page): URLSearchParams =>
|
||||
serializeCanonicalPeopleListSearchParams(
|
||||
parsePeopleListParams(peopleListStateToRawParams(state, page)),
|
||||
)
|
||||
|
||||
export const buildPeopleListHref = (state: PeopleListState, page: number): string =>
|
||||
buildListHref(state, buildPeopleListSearchParams, '/campanha/pessoas', page)
|
||||
|
||||
export const resolvePeopleListUrl = (
|
||||
params: PeopleListSearchParams,
|
||||
totalPages?: number,
|
||||
): {
|
||||
state: PeopleListState
|
||||
href: string
|
||||
redirectHref?: string
|
||||
} =>
|
||||
resolveListUrl({
|
||||
params,
|
||||
paramNameSet: peopleListParamNameSet,
|
||||
parse: parsePeopleListParams,
|
||||
buildSearchParams: buildPeopleListSearchParams,
|
||||
basePath: '/campanha/pessoas',
|
||||
totalPages,
|
||||
})
|
||||
|
||||
/** Leadership source: `q` + the domain filters a leadership row can express. */
|
||||
export const buildPeopleLeadershipSourceWhere = (state: PeopleListState): Where => {
|
||||
const filters: Where[] = []
|
||||
if (state.q) filters.push({ 'contact.name': { contains: state.q } })
|
||||
if (state.statuses?.length) filters.push({ supportStatus: { in: state.statuses } })
|
||||
if (state.municipalities?.length) {
|
||||
filters.push({ municipalities: { in: state.municipalities } })
|
||||
}
|
||||
return filters.length ? { and: filters } : {}
|
||||
}
|
||||
|
||||
/** Dobradinha source: `q` only (municipalities are a reverse relation — in memory). */
|
||||
export const buildPeopleDeputySourceWhere = (state: PeopleListState): Where => {
|
||||
const filters: Where[] = []
|
||||
if (state.q) filters.push({ 'contact.name': { contains: state.q } })
|
||||
return filters.length ? { and: filters } : {}
|
||||
}
|
||||
|
||||
/** Staff source: every staff account with a ficha (carteira is in memory). */
|
||||
export const buildPeopleStaffSourceWhere = (state: PeopleListState): Where => {
|
||||
const filters: Where[] = [
|
||||
{ role: { in: ['advisor', 'coordinator', 'candidate'] } },
|
||||
{ contact: { exists: true } },
|
||||
]
|
||||
if (state.q) filters.push({ 'contact.name': { contains: state.q } })
|
||||
return { and: filters }
|
||||
}
|
||||
172
src/utilities/people/peopleOmnibox.ts
Normal file
172
src/utilities/people/peopleOmnibox.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* People list omnibox adapter (C100). Pure / client-safe.
|
||||
*/
|
||||
import {
|
||||
createOmniboxSuggestionSeed,
|
||||
filterOmniboxSuggestionSeeds,
|
||||
type CampaignListOmniboxChip,
|
||||
type CampaignListOmniboxSuggestion,
|
||||
} from '@/lib/campaignListOmnibox'
|
||||
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
|
||||
import { peopleCapacityLabels } from '@/utilities/people/peopleLabels'
|
||||
import {
|
||||
clearPeopleListFilters,
|
||||
peopleCapacityFilterOptions,
|
||||
peopleStatusFilterOptions,
|
||||
togglePeopleCapacityFilter,
|
||||
togglePeopleMunicipalityFilter,
|
||||
togglePeopleStatusFilter,
|
||||
type PeopleFilterOption,
|
||||
} from '@/utilities/people/peopleListFilters'
|
||||
import type { PeopleListState } from '@/utilities/people/peopleListUrl'
|
||||
|
||||
export type PeopleOmniboxAction =
|
||||
| { kind: 'url'; state: PeopleListState }
|
||||
| { kind: 'clear'; state: PeopleListState }
|
||||
|
||||
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
|
||||
|
||||
export const buildPeopleOmniboxChips = ({
|
||||
state,
|
||||
municipalityLabelsById,
|
||||
}: {
|
||||
state: PeopleListState
|
||||
municipalityLabelsById: ReadonlyMap<number, string>
|
||||
}): CampaignListOmniboxChip[] => {
|
||||
const chips: CampaignListOmniboxChip[] = []
|
||||
|
||||
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
|
||||
|
||||
for (const capacity of state.capacities ?? []) {
|
||||
chips.push({
|
||||
id: `capacity:${capacity}`,
|
||||
label: chipLabel('Capacidade', peopleCapacityLabels[capacity]),
|
||||
})
|
||||
}
|
||||
|
||||
for (const municipalityId of state.municipalities ?? []) {
|
||||
chips.push({
|
||||
id: `municipality:${municipalityId}`,
|
||||
label: chipLabel(
|
||||
'Município',
|
||||
municipalityLabelsById.get(municipalityId) ?? `Município #${municipalityId}`,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
for (const status of state.statuses ?? []) {
|
||||
chips.push({ id: `status:${status}`, label: chipLabel('Apoio', supportStatusLabels[status]) })
|
||||
}
|
||||
|
||||
return chips
|
||||
}
|
||||
|
||||
export const buildPeopleOmniboxSuggestionSeeds = ({
|
||||
municipalityFilterOptions,
|
||||
}: {
|
||||
municipalityFilterOptions: readonly PeopleFilterOption[]
|
||||
}) => {
|
||||
const seeds = []
|
||||
|
||||
for (const option of peopleCapacityFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `capacity:${option.value}`,
|
||||
group: 'Capacidade',
|
||||
label: option.label,
|
||||
keywords: ['capacidade', 'papel', 'assessora', 'lideranca', 'dobradinha'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of municipalityFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed({
|
||||
id: `municipality:${option.value}`,
|
||||
group: 'Município',
|
||||
label: option.label,
|
||||
keywords: ['municipio'],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of peopleStatusFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `status:${option.value}`,
|
||||
group: 'Apoio',
|
||||
label: option.label,
|
||||
keywords: ['apoio', 'status', 'situacao'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return seeds
|
||||
}
|
||||
|
||||
export const filterPeopleOmniboxSuggestions = (
|
||||
seeds: ReturnType<typeof buildPeopleOmniboxSuggestionSeeds>,
|
||||
query: string,
|
||||
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
|
||||
|
||||
export const applyPeopleOmniboxSuggestion = ({
|
||||
state,
|
||||
suggestionId,
|
||||
}: {
|
||||
state: PeopleListState
|
||||
suggestionId: string
|
||||
}): PeopleOmniboxAction => {
|
||||
if (suggestionId.startsWith('q:')) {
|
||||
const q = suggestionId.slice(2)
|
||||
return { kind: 'url', state: { ...state, page: 1, q: q || undefined } }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('capacity:')) {
|
||||
return { kind: 'url', state: togglePeopleCapacityFilter(state, suggestionId.slice(9)) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('municipality:')) {
|
||||
return { kind: 'url', state: togglePeopleMunicipalityFilter(state, suggestionId.slice(13)) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('status:')) {
|
||||
return { kind: 'url', state: togglePeopleStatusFilter(state, suggestionId.slice(7)) }
|
||||
}
|
||||
|
||||
return { kind: 'url', state }
|
||||
}
|
||||
|
||||
export const removePeopleOmniboxChip = ({
|
||||
state,
|
||||
chipId,
|
||||
}: {
|
||||
state: PeopleListState
|
||||
chipId: string
|
||||
}): PeopleOmniboxAction => {
|
||||
if (chipId === 'q') return { kind: 'url', state: { ...state, page: 1, q: undefined } }
|
||||
|
||||
if (chipId.startsWith('capacity:')) {
|
||||
return { kind: 'url', state: togglePeopleCapacityFilter(state, chipId.slice(9)) }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('municipality:')) {
|
||||
return { kind: 'url', state: togglePeopleMunicipalityFilter(state, chipId.slice(13)) }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('status:')) {
|
||||
return { kind: 'url', state: togglePeopleStatusFilter(state, chipId.slice(7)) }
|
||||
}
|
||||
|
||||
return { kind: 'url', state }
|
||||
}
|
||||
|
||||
export const clearPeopleOmnibox = (): PeopleOmniboxAction => ({
|
||||
kind: 'clear',
|
||||
state: clearPeopleListFilters(),
|
||||
})
|
||||
37
src/utilities/people/peopleSavedFilters.ts
Normal file
37
src/utilities/people/peopleSavedFilters.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Named bookmarks of the people list URL (C100) — the 2nd call site of the B18
|
||||
* pattern, specialized per the FD2 veto: this store is concrete and
|
||||
* device-local, backed by the shared storage factory.
|
||||
*/
|
||||
|
||||
import {
|
||||
createSavedFilterStore,
|
||||
SAVED_FILTER_MAX_ENTRIES,
|
||||
SAVED_FILTER_MAX_NAME_LENGTH,
|
||||
type CampaignSavedFilter,
|
||||
type SaveCampaignSavedFilterResult,
|
||||
} from '@/utilities/campaignSavedFilterStore'
|
||||
|
||||
export const STORAGE_KEY = 'teqo:campaign:people-saved-filters'
|
||||
|
||||
export type PeopleSavedFilter = CampaignSavedFilter
|
||||
|
||||
export type SavePeopleSavedFilterResult = SaveCampaignSavedFilterResult
|
||||
|
||||
export { SAVED_FILTER_MAX_ENTRIES as MAX_ENTRIES, SAVED_FILTER_MAX_NAME_LENGTH as MAX_NAME_LENGTH }
|
||||
|
||||
const store = createSavedFilterStore({
|
||||
storageKey: STORAGE_KEY,
|
||||
isHrefValid: (href) => href.startsWith('/campanha/pessoas'),
|
||||
})
|
||||
|
||||
export const listPeopleSavedFilters = store.list
|
||||
|
||||
export const savePeopleSavedFilter: (entry: PeopleSavedFilter) => SavePeopleSavedFilterResult =
|
||||
store.save
|
||||
|
||||
export const removePeopleSavedFilter = store.remove
|
||||
|
||||
export const clearPeopleSavedFilters = store.clear
|
||||
|
||||
export const subscribePeopleSavedFilters = store.subscribe
|
||||
498
src/utilities/people/personDelete.ts
Normal file
498
src/utilities/people/personDelete.ts
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
PERSON_DELETE_FORBIDDEN_MESSAGE,
|
||||
PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE,
|
||||
} from '@/lib/schemas/personDelete'
|
||||
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
|
||||
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
|
||||
import {
|
||||
withPayloadTransaction,
|
||||
type PayloadTransactionRequest,
|
||||
} from '@/utilities/payloadTransaction'
|
||||
import {
|
||||
acquireTextAdvisoryLocks,
|
||||
POSTGRES_DEDUP_LOCK_MESSAGE,
|
||||
} from '@/utilities/postgresTransactionLocks'
|
||||
|
||||
/**
|
||||
* "Apagar pessoa" (C100): a destructive, transactional cascade from one
|
||||
* `Contact` ficha across every campaign role, preceded by a read-only manifest
|
||||
* the confirmation dialog lists verbatim. Coordinator/candidate accounts are
|
||||
* never deletable — a person holding one is a protected person.
|
||||
*
|
||||
* Every `overrideAccess` read/write below is an INTENTIONAL BYPASS justified
|
||||
* by this module's own authorization: the caller must be an unrestricted actor
|
||||
* (reloaded fresh by the server action) and the destructive path re-enumerates
|
||||
* every row it touches inside the transaction before deleting — the manifest
|
||||
* preview and the protected-account check are the scope proof, the same
|
||||
* documented pattern `removeSupporterData` established.
|
||||
*
|
||||
* The FK map this order respects (verified against the committed schema —
|
||||
* NOT NULL + `ON DELETE set null` = a delete that would block):
|
||||
*
|
||||
* - `vote_pledge.leadership_id`, `campaign_invite.leadership_id` → delete
|
||||
* pledges and invites BEFORE the leadership rows;
|
||||
* - `campaign_invite.created_by_id`, `municipality_update.author_id`,
|
||||
* `calendar_feed.created_by_id`, `supporter_import_batch.actor_id` → rows
|
||||
* authored by the deleted accounts must go BEFORE the accounts;
|
||||
* - `campaign_web_authn_credential.user_id`, `notification.recipient_id`,
|
||||
* `push_subscription.user_id` → cleaned by `campaignUser.beforeDelete`
|
||||
* (passkeys + notifications), no hand-spelled copy here;
|
||||
* - every advisor join table (`municipality_rels`, `activity_rels`,
|
||||
* `leadership_rels`, `state_deputy_rels`) cascades on `campaign_user_id`
|
||||
* — deleting the accounts removes the carteira/assessorado links;
|
||||
* - `signature.contact_id`, `subscription.contact_id` → NOT NULL public-site
|
||||
* joins: when either exists the ficha is ANONYMIZED (LGPD tombstone,
|
||||
* `removeSupporterData` precedent), never deleted.
|
||||
*/
|
||||
|
||||
type PersonDeletePayload = Pick<Payload, 'find' | 'findByID' | 'delete' | 'update' | 'db'>
|
||||
|
||||
export type PersonDeleteManifest = {
|
||||
contact: { id: number; name: string; phone: string | null; email: string | null }
|
||||
leaderships: Array<{ id: number; name: string; municipalityNames: string[] }>
|
||||
stateDeputies: Array<{ id: number; name: string; party: string | null }>
|
||||
pledgeCount: number
|
||||
inviteCount: number
|
||||
supporterCount: number
|
||||
municipalityUpdateCount: number
|
||||
calendarFeedCount: number
|
||||
accounts: Array<{ id: number; name: string; role: CampaignUser['role'] }>
|
||||
hasProtectedAccount: boolean
|
||||
/** signature/subscription still reference the ficha → it gets anonymized, not deleted. */
|
||||
fichaWillBeAnonymized: boolean
|
||||
}
|
||||
|
||||
type DeleteEnumeration = {
|
||||
leadershipIDs: number[]
|
||||
accountIDs: number[]
|
||||
supporterCount: number
|
||||
signatureCount: number
|
||||
subscriptionCount: number
|
||||
}
|
||||
|
||||
const enumeratePersonJoins = async (
|
||||
payload: PersonDeletePayload,
|
||||
contactID: number,
|
||||
req?: PayloadTransactionRequest,
|
||||
): Promise<DeleteEnumeration> => {
|
||||
const [leaderships, accounts, supporters, signatures, subscriptions] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'leadership',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'supporter',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'signature',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'subscription',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
leadershipIDs: leaderships.docs.map((doc) => doc.id),
|
||||
accountIDs: accounts.docs.map((doc) => doc.id),
|
||||
supporterCount: supporters.totalDocs,
|
||||
signatureCount: signatures.totalDocs,
|
||||
subscriptionCount: subscriptions.totalDocs,
|
||||
}
|
||||
}
|
||||
|
||||
const anonymizeContact = async (
|
||||
payload: PersonDeletePayload,
|
||||
contactID: number,
|
||||
req: PayloadTransactionRequest,
|
||||
): Promise<void> => {
|
||||
// Must match Contact phone validation: DDD + 9 + 8 digits (`^[1-9]{2}9\d{8}$`).
|
||||
// Same tombstone as `removeSupporterData` — one per id, so it is unique.
|
||||
const tombstonePhone = `999${String(contactID).padStart(8, '0')}`
|
||||
await acquireContactPhoneLocks(payload, req, [tombstonePhone])
|
||||
await payload.update({
|
||||
collection: 'contact',
|
||||
id: contactID,
|
||||
data: {
|
||||
name: 'Titular removido',
|
||||
email: null,
|
||||
phone: tombstonePhone,
|
||||
gender: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only enumeration for the confirmation dialog: every row the cascade
|
||||
* would touch, with the ficha fate spelled out. No writes, no locks.
|
||||
*/
|
||||
export const loadPersonDeleteManifest = async (
|
||||
payload: PersonDeletePayload,
|
||||
contactID: number,
|
||||
): Promise<PersonDeleteManifest | null> => {
|
||||
let contact: Contact
|
||||
try {
|
||||
// Intentional admin bypass: the route gate already asserted an unrestricted
|
||||
// actor; `Contact` read is admin-gated and the manifest is exactly what
|
||||
// authorizes the destructive confirmation.
|
||||
contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: contactID,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const [leaderships, stateDeputies, accounts] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'leadership',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { contact: true, municipalities: true },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { contact: true, party: true },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { name: true, role: true },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
])
|
||||
|
||||
const leadershipIDs = leaderships.docs.map((doc) => doc.id)
|
||||
const accountIDs = accounts.docs.map((doc) => doc.id)
|
||||
|
||||
const [pledgeResult, inviteResult, municipalityUpdateResult, calendarFeedResult, joinCounts] =
|
||||
await Promise.all([
|
||||
payload.find({
|
||||
collection: 'votePledge',
|
||||
where: { leadership: { in: leadershipIDs } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'campaignInvite',
|
||||
where: { leadership: { in: leadershipIDs } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'municipalityUpdate',
|
||||
where: { author: { in: accountIDs } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'calendarFeed',
|
||||
where: { createdBy: { in: accountIDs } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
enumeratePersonJoins(payload, contactID),
|
||||
])
|
||||
|
||||
const municipalityIDs = [
|
||||
...new Set(
|
||||
(leaderships.docs as Leadership[]).flatMap((doc) =>
|
||||
(doc.municipalities ?? []).map(relationshipId).filter((id): id is number => id !== null),
|
||||
),
|
||||
),
|
||||
]
|
||||
const municipalityNames = new Map<number, string>()
|
||||
if (municipalityIDs.length > 0) {
|
||||
const municipalities = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { id: { in: municipalityIDs } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { name: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
for (const municipality of municipalities.docs) {
|
||||
municipalityNames.set(municipality.id, municipality.name)
|
||||
}
|
||||
}
|
||||
|
||||
const leadershipRows = (leaderships.docs as Leadership[]).map((doc) => ({
|
||||
id: doc.id,
|
||||
name: typeof doc.contact === 'object' && doc.contact !== null ? doc.contact.name : 'Contato',
|
||||
municipalityNames: (doc.municipalities ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null)
|
||||
.map((id) => municipalityNames.get(id))
|
||||
.filter((name): name is string => name !== undefined),
|
||||
}))
|
||||
|
||||
const deputyRows = (stateDeputies.docs as StateDeputy[]).map((doc) => ({
|
||||
id: doc.id,
|
||||
name: typeof doc.contact === 'object' && doc.contact !== null ? doc.contact.name : 'Contato',
|
||||
party: doc.party ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
contact: {
|
||||
id: contact.id,
|
||||
name: contact.name ?? 'Contato',
|
||||
phone: contact.phone ?? null,
|
||||
email: contact.email ?? null,
|
||||
},
|
||||
leaderships: leadershipRows,
|
||||
stateDeputies: deputyRows,
|
||||
pledgeCount: pledgeResult.totalDocs,
|
||||
inviteCount: inviteResult.totalDocs,
|
||||
supporterCount: joinCounts.supporterCount,
|
||||
municipalityUpdateCount: municipalityUpdateResult.totalDocs,
|
||||
calendarFeedCount: calendarFeedResult.totalDocs,
|
||||
accounts: accounts.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
role: doc.role,
|
||||
})),
|
||||
hasProtectedAccount: accounts.docs.some(
|
||||
(account) => account.role === 'coordinator' || account.role === 'candidate',
|
||||
),
|
||||
fichaWillBeAnonymized: joinCounts.signatureCount > 0 || joinCounts.subscriptionCount > 0,
|
||||
}
|
||||
}
|
||||
|
||||
export type PersonDeleteResult = {
|
||||
removed: true
|
||||
contactDeleted: boolean
|
||||
contactAnonymized: boolean
|
||||
deletedAccounts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The destructive cascade, one transaction, one advisory lock per person.
|
||||
* Re-enumerates inside the transaction (the manifest is a preview, never the
|
||||
* authority) and aborts on a coordinator/candidate account.
|
||||
*/
|
||||
export const deletePersonRecord = async (
|
||||
payload: PersonDeletePayload,
|
||||
actor: CampaignUser,
|
||||
contactID: number,
|
||||
): Promise<PersonDeleteResult> =>
|
||||
withPayloadTransaction(payload, async ({ req }) => {
|
||||
if (!isCampaignUnrestricted(actor)) {
|
||||
throw new Error(PERSON_DELETE_FORBIDDEN_MESSAGE)
|
||||
}
|
||||
if (payload.db.name !== 'postgres') {
|
||||
throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
|
||||
}
|
||||
await acquireTextAdvisoryLocks(payload, req, [`person-delete:${contactID}`])
|
||||
|
||||
const { leadershipIDs, accountIDs, supporterCount, signatureCount, subscriptionCount } =
|
||||
await enumeratePersonJoins(payload, contactID, req)
|
||||
|
||||
if (accountIDs.length > 0) {
|
||||
const accounts = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { id: { in: accountIDs } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { role: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const protectedAccount = accounts.docs.find(
|
||||
(account) => account.role === 'coordinator' || account.role === 'candidate',
|
||||
)
|
||||
if (protectedAccount) {
|
||||
throw new Error(PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
// Order matters — see the module contract. All bypasses are justified by
|
||||
// the enumeration + protected-account check above (removeSupporterData
|
||||
// precedent): the unrestricted actor's scope was proven before any write.
|
||||
if (leadershipIDs.length > 0) {
|
||||
await payload.delete({
|
||||
collection: 'votePledge',
|
||||
where: { leadership: { in: leadershipIDs } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'campaignInvite',
|
||||
where: { leadership: { in: leadershipIDs } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
if (accountIDs.length > 0) {
|
||||
// Invites the deleted accounts CREATED for other leaderships, authored
|
||||
// municipality updates and calendar feeds all carry NOT NULL FKs with
|
||||
// `ON DELETE set null` — they must go before the accounts themselves.
|
||||
await payload.delete({
|
||||
collection: 'campaignInvite',
|
||||
where: { createdBy: { in: accountIDs } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'municipalityUpdate',
|
||||
where: { author: { in: accountIDs } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'calendarFeed',
|
||||
where: { createdBy: { in: accountIDs } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
await payload.delete({
|
||||
collection: 'supporterImportBatch',
|
||||
where: { actor: { in: accountIDs } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
if (supporterCount > 0) {
|
||||
await payload.delete({
|
||||
collection: 'supporter',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
if (leadershipIDs.length > 0) {
|
||||
await payload.delete({
|
||||
collection: 'leadership',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
{
|
||||
const deputies = await payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (deputies.docs.length > 0) {
|
||||
await payload.delete({
|
||||
collection: 'stateDeputy',
|
||||
where: { id: { in: deputies.docs.map((doc) => doc.id) } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Per-id so `CampaignUser.beforeDelete` (passkeys + notifications) runs for
|
||||
// every account — bulk `where` deletes would skip the hooks.
|
||||
for (const accountID of accountIDs) {
|
||||
await payload.delete({
|
||||
collection: 'campaignUser',
|
||||
id: accountID,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
|
||||
let contactDeleted = false
|
||||
let contactAnonymized = false
|
||||
if (signatureCount > 0 || subscriptionCount > 0) {
|
||||
await anonymizeContact(payload, contactID, req)
|
||||
contactAnonymized = true
|
||||
} else {
|
||||
await payload.delete({
|
||||
collection: 'contact',
|
||||
id: contactID,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
contactDeleted = true
|
||||
}
|
||||
|
||||
return {
|
||||
removed: true,
|
||||
contactDeleted,
|
||||
contactAnonymized,
|
||||
deletedAccounts: accountIDs.length,
|
||||
}
|
||||
})
|
||||
56
tests/e2e/campaignPeople.e2e.spec.ts
Normal file
56
tests/e2e/campaignPeople.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
/**
|
||||
* C100 — the unified people list: staff lands on `/campanha/pessoas`, sees the
|
||||
* merged person row, and the route stays locked for leaders.
|
||||
*/
|
||||
test.describe('Pessoas — lista unificada', () => {
|
||||
test('staff opens the people list and sees the merged leadership person', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Pessoas'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
const { contactName } = await fixtures.createStaffLeadership({
|
||||
namePrefix: 'Liderança Pessoas',
|
||||
municipalities: [municipality],
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
|
||||
await page.getByRole('link', { name: 'Pessoas', exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/campanha\/pessoas$/)
|
||||
await expect(campaignPageChrome(page, 'Pessoas')).toBeVisible()
|
||||
|
||||
// The merged row carries the leadership person with its capacity chips
|
||||
// (desktop table — the mobile card twin is `md:hidden` at this viewport).
|
||||
await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible()
|
||||
// The coordinator account itself is a staff person (with a ficha) — ≥ 2 rows.
|
||||
await expect(page.getByText(/^\d+ pessoas?$/)).toBeVisible()
|
||||
|
||||
// The omnibox search narrows the recorte (canonical URL, chips mounted).
|
||||
const omnibox = page.getByRole('combobox', { name: 'Filtrar pessoas' })
|
||||
await omnibox.fill(contactName.slice(0, 8))
|
||||
await omnibox.press('Enter')
|
||||
await expect(page).toHaveURL(/q=/)
|
||||
await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible()
|
||||
})
|
||||
|
||||
test('leader cannot open the people page', async ({ campaign, page }) => {
|
||||
const { fixtures } = campaign
|
||||
const phone = fixtures.phone()
|
||||
const leader = await fixtures.createCampaignUser('leader', {
|
||||
name: fixtures.value('Liderança Sem Acesso'),
|
||||
username: phone,
|
||||
})
|
||||
|
||||
await campaign.login(page, phone, leader.password)
|
||||
await page.goto('/campanha/pessoas')
|
||||
|
||||
await expect(page).toHaveURL(/\/campanha\/contatos/)
|
||||
await expect(page.getByRole('heading', { name: 'Contatos' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
154
tests/int/peopleList.int.spec.ts
Normal file
154
tests/int/peopleList.int.spec.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import config from '@/payload.config'
|
||||
import { loadPeopleListPageData } from '@/utilities/people/peopleData'
|
||||
|
||||
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('C100 — lista unificada de pessoas (merge por Contact)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('merges leadership, dobradinha and staff rows into one row per ficha', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
const leadershipContact = await fixtures.createContact({ name: 'Maria de Jesus' })
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact: leadershipContact,
|
||||
municipalities: [municipality],
|
||||
})
|
||||
|
||||
const deputyContact = await fixtures.createContact({ name: 'Ana Lima' })
|
||||
const deputy = await fixtures.createStateDeputy({ contact: deputyContact, party: 'PCdoB' })
|
||||
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const pureContact = await fixtures.createContact({ name: 'Só Ficha' })
|
||||
|
||||
const result = await loadPeopleListPageData(payload, coordinator, { page: 1 })
|
||||
|
||||
const maria = result.rows.find((row) => row.contactID === relationId(leadershipContact))
|
||||
expect(maria).toMatchObject({
|
||||
name: 'Maria de Jesus',
|
||||
leadershipID: leadership.id,
|
||||
supportStatus: 'engajado',
|
||||
})
|
||||
expect(maria?.leadershipMunicipalityIDs).toContain(municipality.id)
|
||||
|
||||
const ana = result.rows.find((row) => row.contactID === relationId(deputyContact))
|
||||
expect(ana).toMatchObject({ name: 'Ana Lima', deputyID: deputy.id, party: 'PCdoB' })
|
||||
|
||||
// Staff with a ficha enters even with an EMPTY carteira (gate 2026-08-09).
|
||||
const advisorRow = result.rows.find((row) => row.contactID === relationId(advisor.contact))
|
||||
expect(advisorRow?.staff.map((account) => account.id)).toContain(advisor.id)
|
||||
expect(advisorRow?.assessoraMunicipalityIDs).toEqual([])
|
||||
|
||||
// A bare Contact with no role never enters.
|
||||
expect(result.rows.some((row) => row.contactID === relationId(pureContact))).toBe(false)
|
||||
expect(result.filterFacets.municipalityIDs).toContain(municipality.id)
|
||||
expect(result.filterFacets.statuses).toContain('engajado')
|
||||
})
|
||||
|
||||
it('merges an account sharing the ficha (phone reuse) into the leadership row', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact({ name: 'Dupla Função' })
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact,
|
||||
municipalities: [municipality],
|
||||
})
|
||||
const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
|
||||
expect(relationId(account.contact)).toBe(contact.id)
|
||||
|
||||
const result = await loadPeopleListPageData(payload, coordinator, { page: 1 })
|
||||
const rows = result.rows.filter((row) => row.contactID === contact.id)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]?.leadershipID).toBe(leadership.id)
|
||||
expect(rows[0]?.staff.some((staff) => staff.id === account.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('scopes every source to the advisor carteira', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const stranger = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered, [advisor])
|
||||
|
||||
const ownContact = await fixtures.createContact({ name: 'Própria Liderança' })
|
||||
await fixtures.createLeadership({ contact: ownContact, municipalities: [administered] })
|
||||
const alienContact = await fixtures.createContact({ name: 'Liderança Alheia' })
|
||||
await fixtures.createLeadership({ contact: alienContact, municipalities: [stranger] })
|
||||
|
||||
const deputyContact = await fixtures.createContact({ name: 'Aliada Local' })
|
||||
const deputy = await fixtures.createStateDeputy({ contact: deputyContact, party: 'PT' })
|
||||
fixtures.touchMunicipality(administered)
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: administered.id,
|
||||
data: { stateDeputies: [deputy.id] },
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
const colleague = await fixtures.createCampaignUser('advisor')
|
||||
const unassigned = await fixtures.createCampaignUser('advisor')
|
||||
await fixtures.assignMunicipalityAdvisors(administered, [advisor, colleague])
|
||||
|
||||
const result = await loadPeopleListPageData(payload, advisor, { page: 1 })
|
||||
const contactIDs = result.rows.map((row) => row.contactID)
|
||||
|
||||
expect(contactIDs).toContain(relationId(ownContact))
|
||||
expect(contactIDs).not.toContain(relationId(alienContact))
|
||||
expect(contactIDs).toContain(relationId(deputyContact))
|
||||
expect(contactIDs).toContain(relationId(colleague.contact))
|
||||
// The advisor himself has a carteira in the portfolio — he sees himself.
|
||||
expect(contactIDs).toContain(relationId(advisor.contact))
|
||||
// A staff account with an EMPTY carteira is outside every portfolio.
|
||||
expect(contactIDs).not.toContain(relationId(unassigned.contact))
|
||||
})
|
||||
|
||||
it('searches by name across the sources', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const maria = await fixtures.createContact({ name: 'Maria de Jesus' })
|
||||
await fixtures.createLeadership({ contact: maria, municipalities: [municipality] })
|
||||
const joao = await fixtures.createContact({ name: 'João do Brejo' })
|
||||
await fixtures.createLeadership({ contact: joao, municipalities: [municipality] })
|
||||
|
||||
const result = await loadPeopleListPageData(payload, coordinator, { page: 1, q: 'Maria' })
|
||||
const names = result.rows.map((row) => row.name)
|
||||
|
||||
expect(names).toContain('Maria de Jesus')
|
||||
expect(names).not.toContain('João do Brejo')
|
||||
})
|
||||
|
||||
it('returns empty data for a leader (lockdown)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const leader = await fixtures.createCampaignUser('leader')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact({ name: 'Alguém' })
|
||||
await fixtures.createLeadership({ contact, municipalities: [municipality] })
|
||||
|
||||
const result = await loadPeopleListPageData(payload, leader, { page: 1 })
|
||||
|
||||
expect(result.rows).toEqual([])
|
||||
expect(result.totalDocs).toBe(0)
|
||||
expect(result.totalPages).toBe(0)
|
||||
})
|
||||
})
|
||||
182
tests/int/personDelete.int.spec.ts
Normal file
182
tests/int/personDelete.int.spec.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload, Where } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
PERSON_DELETE_FORBIDDEN_MESSAGE,
|
||||
PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE,
|
||||
} from '@/lib/schemas/personDelete'
|
||||
import config from '@/payload.config'
|
||||
import { deletePersonRecord, loadPersonDeleteManifest } from '@/utilities/people/personDelete'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const countRemaining = async (
|
||||
collection:
|
||||
| 'contact'
|
||||
| 'leadership'
|
||||
| 'stateDeputy'
|
||||
| 'campaignUser'
|
||||
| 'votePledge'
|
||||
| 'supporter',
|
||||
where: Where,
|
||||
): Promise<number> => {
|
||||
const result = await payload.find({
|
||||
collection,
|
||||
where,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return result.totalDocs
|
||||
}
|
||||
|
||||
describe('C100 — apagar pessoa (manifest + cascata)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('lists every cascade target in the manifest', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
const contact = await fixtures.createContact({ name: 'Maria de Jesus' })
|
||||
const leadership = await fixtures.createLeadership({ contact, municipalities: [municipality] })
|
||||
await fixtures.createVotePledge({ leadership, municipality })
|
||||
const advisor = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
|
||||
await fixtures.createStateDeputy({ contact, party: 'PT' })
|
||||
await fixtures.createInvite({ leadership, createdBy: coordinator })
|
||||
await fixtures.createSupporter({ contact })
|
||||
|
||||
const manifest = await loadPersonDeleteManifest(payload, contact.id)
|
||||
|
||||
expect(manifest).not.toBeNull()
|
||||
expect(manifest?.contact.name).toBe('Maria de Jesus')
|
||||
expect(manifest?.leaderships).toHaveLength(1)
|
||||
expect(manifest?.leaderships[0]?.municipalityNames).toContain(municipality.name)
|
||||
expect(manifest?.stateDeputies).toHaveLength(1)
|
||||
expect(manifest?.stateDeputies[0]?.party).toBe('PT')
|
||||
expect(manifest?.pledgeCount).toBe(1)
|
||||
expect(manifest?.inviteCount).toBe(1)
|
||||
expect(manifest?.supporterCount).toBe(1)
|
||||
expect(manifest?.accounts.map((account) => account.id)).toContain(advisor.id)
|
||||
expect(manifest?.hasProtectedAccount).toBe(false)
|
||||
expect(manifest?.fichaWillBeAnonymized).toBe(false)
|
||||
})
|
||||
|
||||
it('cascade-deletes every campaign row and the ficha', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact({ name: 'João do Brejo' })
|
||||
const leadership = await fixtures.createLeadership({ contact, municipalities: [municipality] })
|
||||
await fixtures.createVotePledge({ leadership, municipality })
|
||||
await fixtures.createStateDeputy({ contact, party: 'PCdoB' })
|
||||
const advisor = await fixtures.createCampaignUser('advisor', { phone: contact.phone })
|
||||
await fixtures.createSupporter({ contact })
|
||||
|
||||
const result = await deletePersonRecord(payload, coordinator, contact.id)
|
||||
|
||||
expect(result.removed).toBe(true)
|
||||
expect(result.contactDeleted).toBe(true)
|
||||
expect(result.contactAnonymized).toBe(false)
|
||||
expect(result.deletedAccounts).toBe(1)
|
||||
|
||||
expect(await countRemaining('contact', { id: { equals: contact.id } })).toBe(0)
|
||||
expect(await countRemaining('leadership', { id: { equals: leadership.id } })).toBe(0)
|
||||
expect(await countRemaining('stateDeputy', { contact: { equals: contact.id } })).toBe(0)
|
||||
expect(await countRemaining('campaignUser', { id: { equals: advisor.id } })).toBe(0)
|
||||
expect(await countRemaining('votePledge', { leadership: { equals: leadership.id } })).toBe(0)
|
||||
expect(await countRemaining('supporter', { contact: { equals: contact.id } })).toBe(0)
|
||||
})
|
||||
|
||||
it('anonymizes the ficha when a public join still references it', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact({ name: 'Ana Lima' })
|
||||
await fixtures.createLeadership({ contact, municipalities: [municipality] })
|
||||
const consent = await fixtures.createConsent()
|
||||
const subscription = await payload.create({
|
||||
collection: 'subscription',
|
||||
data: { contact: contact.id, consent: consent.id },
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
try {
|
||||
const manifest = await loadPersonDeleteManifest(payload, contact.id)
|
||||
expect(manifest?.fichaWillBeAnonymized).toBe(true)
|
||||
|
||||
const result = await deletePersonRecord(payload, coordinator, contact.id)
|
||||
expect(result.contactDeleted).toBe(false)
|
||||
expect(result.contactAnonymized).toBe(true)
|
||||
|
||||
const remaining = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: contact.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(remaining.name).toBe('Titular removido')
|
||||
expect(remaining.phone).toMatch(/^999\d{8}$/)
|
||||
expect(remaining.email).toBeNull()
|
||||
expect(remaining.city).toBeNull()
|
||||
} finally {
|
||||
await payload.delete({
|
||||
collection: 'subscription',
|
||||
id: subscription.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to delete a person holding a coordinator account', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact({ name: 'Protegida' })
|
||||
await fixtures.createLeadership({ contact, municipalities: [municipality] })
|
||||
await fixtures.createCampaignUser('coordinator', { phone: contact.phone })
|
||||
|
||||
const manifest = await loadPersonDeleteManifest(payload, contact.id)
|
||||
expect(manifest?.hasProtectedAccount).toBe(true)
|
||||
|
||||
await expect(deletePersonRecord(payload, coordinator, contact.id)).rejects.toThrow(
|
||||
PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE,
|
||||
)
|
||||
expect(await countRemaining('leadership', { contact: { equals: contact.id } })).toBe(1)
|
||||
})
|
||||
|
||||
it('refuses non-unrestricted actors', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact({ name: 'Alguém' })
|
||||
await fixtures.createLeadership({ contact, municipalities: [municipality] })
|
||||
|
||||
await expect(deletePersonRecord(payload, advisor, contact.id)).rejects.toThrow(
|
||||
PERSON_DELETE_FORBIDDEN_MESSAGE,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a null manifest for an unknown contact', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
await fixtures.createCampaignUser('coordinator')
|
||||
|
||||
const manifest = await loadPersonDeleteManifest(payload, 999999999)
|
||||
expect(manifest).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -420,6 +420,9 @@ describe('src/utilities top-level is pinned', () => {
|
|||
'campaignInviteRepository.ts',
|
||||
'campaignJsonMutationRoute.ts',
|
||||
'campaignListUrl.ts',
|
||||
// C100 — shared storage primitive behind the B18 saved-filters stores
|
||||
// (municipality + people): pure client-side factory, no Payload/Next.
|
||||
'campaignSavedFilterStore.ts',
|
||||
'campaignPageActor.ts',
|
||||
'campaignPasswordReset.ts',
|
||||
'campaignPwa.ts',
|
||||
|
|
@ -549,6 +552,10 @@ describe('advisor scope fragment comes from access/shared.ts', () => {
|
|||
'src/utilities/leadership/leadershipData.ts',
|
||||
'src/utilities/leadership/leadershipListUrl.ts',
|
||||
'src/utilities/municipality/municipalityTriggers.ts',
|
||||
// C100 — the people list filters the leadership SOURCE by municipality ids
|
||||
// at query level (the same distinct form as `leadershipListUrl.ts`); the
|
||||
// actor scope is enforced by the merge, never by this where.
|
||||
'src/utilities/people/peopleListUrl.ts',
|
||||
// B155 — the municipality list surface reads `leadership.municipalities`
|
||||
// by reverse batch; same distinct form as `leadershipData.ts` above.
|
||||
'src/utilities/municipality/municipalityViewModels.ts',
|
||||
|
|
|
|||
126
tests/unit/peopleListUrl.unit.spec.ts
Normal file
126
tests/unit/peopleListUrl.unit.spec.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPeopleDeputySourceWhere,
|
||||
buildPeopleLeadershipSourceWhere,
|
||||
buildPeopleListHref,
|
||||
buildPeopleStaffSourceWhere,
|
||||
parsePeopleListParams,
|
||||
resolvePeopleListUrl,
|
||||
serializeCanonicalPeopleListSearchParams,
|
||||
} from '@/utilities/people/peopleListUrl'
|
||||
|
||||
describe('people list URL contract', () => {
|
||||
it('defaults to page 1 with no filters', () => {
|
||||
expect(parsePeopleListParams({})).toEqual({ page: 1 })
|
||||
})
|
||||
|
||||
it('validates page as a positive decimal integer', () => {
|
||||
expect(parsePeopleListParams({ page: '4' }).page).toBe(4)
|
||||
expect(parsePeopleListParams({ page: '0' }).page).toBe(1)
|
||||
expect(parsePeopleListParams({ page: '-2' }).page).toBe(1)
|
||||
expect(parsePeopleListParams({ page: 'abc' }).page).toBe(1)
|
||||
})
|
||||
|
||||
it('trims q, omits it when empty and takes the first repeated cell', () => {
|
||||
expect(parsePeopleListParams({ q: ' ana ' }).q).toBe('ana')
|
||||
expect(parsePeopleListParams({ q: ' ' }).q).toBeUndefined()
|
||||
expect(parsePeopleListParams({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro')
|
||||
})
|
||||
|
||||
it('parses and dedupes known capacity values', () => {
|
||||
expect(parsePeopleListParams({ capacity: 'assessora' }).capacities).toEqual(['assessora'])
|
||||
expect(
|
||||
parsePeopleListParams({ capacity: ['lideranca', 'dobradinha', 'lideranca', 'unknown'] })
|
||||
.capacities,
|
||||
).toEqual(['lideranca', 'dobradinha'])
|
||||
})
|
||||
|
||||
it('treats selecting every capacity as no filter (canonical "todas")', () => {
|
||||
const all = parsePeopleListParams({ capacity: ['assessora', 'lideranca', 'dobradinha'] })
|
||||
expect(all.capacities).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses and dedupes integer municipality ids and known status values', () => {
|
||||
expect(
|
||||
parsePeopleListParams({
|
||||
municipality: ['12', '12', '0', 'abc', '3'],
|
||||
status: ['engajado', 'em_disputa', 'unknown'],
|
||||
}),
|
||||
).toEqual({ page: 1, municipalities: [12, 3], statuses: ['engajado', 'em_disputa'] })
|
||||
})
|
||||
|
||||
it('serializes canonical params in stable order and omits defaults', () => {
|
||||
const params = serializeCanonicalPeopleListSearchParams({
|
||||
page: 1,
|
||||
q: 'ana',
|
||||
capacities: ['lideranca'],
|
||||
municipalities: [3, 12],
|
||||
statuses: ['engajado'],
|
||||
})
|
||||
expect(params.toString()).toBe(
|
||||
'q=ana&capacity=lideranca&municipality=3&municipality=12&status=engajado',
|
||||
)
|
||||
})
|
||||
|
||||
it('builds hrefs through the canonical serializer and drops page 1', () => {
|
||||
expect(buildPeopleListHref({ page: 1, capacities: ['dobradinha'] }, 1)).toBe(
|
||||
'/campanha/pessoas?capacity=dobradinha',
|
||||
)
|
||||
expect(buildPeopleListHref({ page: 3 }, 3)).toBe('/campanha/pessoas?page=3')
|
||||
})
|
||||
|
||||
it('resolves a bare URL to the bare path and rejects unsupported params', () => {
|
||||
const resolved = resolvePeopleListUrl({ q: 'ana' })
|
||||
expect(resolved.state).toEqual({ page: 1, q: 'ana' })
|
||||
expect(resolved.href).toBe('/campanha/pessoas?q=ana')
|
||||
expect(resolved.redirectHref).toBeUndefined()
|
||||
|
||||
const unsupported = resolvePeopleListUrl({ q: 'ana', sort: 'name' })
|
||||
expect(unsupported.redirectHref).toBe('/campanha/pessoas?q=ana')
|
||||
})
|
||||
|
||||
it('clamps an out-of-range page to totalPages', () => {
|
||||
const resolved = resolvePeopleListUrl({ page: '9' }, 3)
|
||||
expect(resolved.state.page).toBe(3)
|
||||
expect(resolved.redirectHref).toBe('/campanha/pessoas?page=3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('people list source-level wheres', () => {
|
||||
it('builds the leadership where from q, statuses and municipalities', () => {
|
||||
expect(buildPeopleLeadershipSourceWhere({ page: 1 })).toEqual({})
|
||||
expect(
|
||||
buildPeopleLeadershipSourceWhere({
|
||||
page: 1,
|
||||
q: 'ana',
|
||||
statuses: ['engajado'],
|
||||
municipalities: [3],
|
||||
}),
|
||||
).toEqual({
|
||||
and: [
|
||||
{ 'contact.name': { contains: 'ana' } },
|
||||
{ supportStatus: { in: ['engajado'] } },
|
||||
{ municipalities: { in: [3] } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('restricts the deputy where to q only', () => {
|
||||
expect(buildPeopleDeputySourceWhere({ page: 1, q: 'ana', statuses: ['engajado'] })).toEqual({
|
||||
and: [{ 'contact.name': { contains: 'ana' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('always restricts the staff source to staff roles with a ficha', () => {
|
||||
expect(buildPeopleStaffSourceWhere({ page: 1 })).toEqual({
|
||||
and: [
|
||||
{ role: { in: ['advisor', 'coordinator', 'candidate'] } },
|
||||
{ contact: { exists: true } },
|
||||
],
|
||||
})
|
||||
expect(buildPeopleStaffSourceWhere({ page: 1, q: 'ana' }).and).toContainEqual({
|
||||
'contact.name': { contains: 'ana' },
|
||||
})
|
||||
})
|
||||
})
|
||||
224
tests/unit/peopleMerge.unit.spec.ts
Normal file
224
tests/unit/peopleMerge.unit.spec.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
filterPeopleRows,
|
||||
mergePeopleSources,
|
||||
peopleFilterFacetsFromRows,
|
||||
scopePeopleRows,
|
||||
type MergedPerson,
|
||||
type PeopleMergeSources,
|
||||
} from '@/utilities/people/peopleData'
|
||||
|
||||
const source: PeopleMergeSources = {
|
||||
leaderships: [
|
||||
{
|
||||
id: 10,
|
||||
contactID: 100,
|
||||
name: 'Maria Souza',
|
||||
phone: '71999990001',
|
||||
email: 'maria@example.com',
|
||||
city: 'Feira de Santana',
|
||||
municipalityIDs: [3, 4],
|
||||
supportStatus: 'engajado',
|
||||
hasAppAccess: true,
|
||||
advisorIDs: [21],
|
||||
},
|
||||
],
|
||||
deputies: [
|
||||
{
|
||||
id: 20,
|
||||
contactID: 101,
|
||||
name: 'Ana Lima',
|
||||
phone: null,
|
||||
email: 'ana@example.com',
|
||||
city: 'Camaçari',
|
||||
party: 'PCdoB',
|
||||
municipalityIDs: [5],
|
||||
advisorIDs: [22, 23],
|
||||
},
|
||||
],
|
||||
staff: [
|
||||
{
|
||||
id: 31,
|
||||
contactID: 100,
|
||||
name: 'Maria Souza',
|
||||
phone: '71999990001',
|
||||
email: 'maria@example.com',
|
||||
city: 'Feira de Santana',
|
||||
role: 'advisor',
|
||||
municipalityIDs: [3, 6],
|
||||
},
|
||||
{
|
||||
id: 32,
|
||||
contactID: 102,
|
||||
name: 'João Pereira',
|
||||
phone: null,
|
||||
email: null,
|
||||
city: null,
|
||||
role: 'advisor',
|
||||
municipalityIDs: [],
|
||||
},
|
||||
{
|
||||
id: 33,
|
||||
contactID: 103,
|
||||
name: 'Jorge Solla',
|
||||
phone: null,
|
||||
email: null,
|
||||
city: null,
|
||||
role: 'candidate',
|
||||
municipalityIDs: [1],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const byContact = (rows: readonly MergedPerson[], contactID: number): MergedPerson => {
|
||||
const row = rows.find((person) => person.contactID === contactID)
|
||||
if (!row) throw new Error(`Missing merged person ${contactID}`)
|
||||
return row
|
||||
}
|
||||
|
||||
describe('mergePeopleSources (C100)', () => {
|
||||
it('merges one row per contact, accumulating capacities', () => {
|
||||
const rows = mergePeopleSources(source)
|
||||
|
||||
expect(rows.map((row) => row.contactID).sort()).toEqual([100, 101, 102, 103])
|
||||
|
||||
const maria = byContact(rows, 100)
|
||||
expect(maria.name).toBe('Maria Souza')
|
||||
expect(maria.phone).toBe('71999990001')
|
||||
expect(maria.email).toBe('maria@example.com')
|
||||
expect(maria.city).toBe('Feira de Santana')
|
||||
expect(maria.leadershipID).toBe(10)
|
||||
expect(maria.leadershipMunicipalityIDs).toEqual([3, 4])
|
||||
expect(maria.supportStatus).toBe('engajado')
|
||||
expect(maria.hasAppAccess).toBe(true)
|
||||
expect(maria.staff.map((account) => account.id)).toEqual([31])
|
||||
expect(maria.assessoraMunicipalityIDs).toEqual([3, 6])
|
||||
expect(maria.capacityMunicipalityIDs).toEqual([3, 4, 6])
|
||||
})
|
||||
|
||||
it('carries the party from the dobradinha and keeps its municipalities', () => {
|
||||
const ana = byContact(mergePeopleSources(source), 101)
|
||||
expect(ana.party).toBe('PCdoB')
|
||||
expect(ana.deputyID).toBe(20)
|
||||
expect(ana.deputyMunicipalityIDs).toEqual([5])
|
||||
expect(ana.capacityMunicipalityIDs).toEqual([5])
|
||||
})
|
||||
|
||||
it('keeps a staff account with an EMPTY carteira as a capacity (gate 2026-08-09)', () => {
|
||||
const joao = byContact(mergePeopleSources(source), 102)
|
||||
expect(joao.staff).toHaveLength(1)
|
||||
expect(joao.assessoraMunicipalityIDs).toEqual([])
|
||||
expect(joao.capacityMunicipalityIDs).toEqual([])
|
||||
})
|
||||
|
||||
it('sorts and dedupes the capacity municipality unions', () => {
|
||||
const maria = byContact(mergePeopleSources(source), 100)
|
||||
expect(maria.assessoraMunicipalityIDs).toEqual([3, 6])
|
||||
expect(maria.capacityMunicipalityIDs).toEqual([3, 4, 6])
|
||||
})
|
||||
|
||||
it('merges multiple staff accounts sharing one ficha into one row', () => {
|
||||
const rows = mergePeopleSources({
|
||||
leaderships: [],
|
||||
deputies: [],
|
||||
staff: [
|
||||
{
|
||||
id: 41,
|
||||
contactID: 200,
|
||||
name: 'Dupla',
|
||||
phone: null,
|
||||
email: null,
|
||||
city: null,
|
||||
role: 'advisor',
|
||||
municipalityIDs: [1],
|
||||
},
|
||||
{
|
||||
id: 42,
|
||||
contactID: 200,
|
||||
name: 'Dupla',
|
||||
phone: null,
|
||||
email: null,
|
||||
city: null,
|
||||
role: 'coordinator',
|
||||
municipalityIDs: [2],
|
||||
},
|
||||
],
|
||||
})
|
||||
const person = byContact(rows, 200)
|
||||
expect(person.staff.map((account) => account.id).sort()).toEqual([41, 42])
|
||||
expect(person.assessoraMunicipalityIDs).toEqual([1, 2])
|
||||
expect(rows).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopePeopleRows (advisor viewer)', () => {
|
||||
it('keeps every row for unrestricted actors', () => {
|
||||
const rows = mergePeopleSources(source)
|
||||
expect(scopePeopleRows(rows, null)).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('keeps rows with any capacity municipality in the portfolio', () => {
|
||||
const rows = mergePeopleSources(source)
|
||||
const scoped = scopePeopleRows(rows, new Set([6]))
|
||||
expect(scoped.map((row) => row.contactID).sort()).toEqual([100])
|
||||
})
|
||||
|
||||
it('drops rows without any capacity municipality in the portfolio', () => {
|
||||
const rows = mergePeopleSources(source)
|
||||
const scoped = scopePeopleRows(rows, new Set([1]))
|
||||
expect(scoped.map((row) => row.contactID)).toEqual([103])
|
||||
})
|
||||
|
||||
it('hides staff with an empty carteira from every advisor portfolio', () => {
|
||||
const rows = mergePeopleSources(source)
|
||||
const scoped = scopePeopleRows(rows, new Set([3, 4, 5, 6]))
|
||||
expect(scoped.some((row) => row.contactID === 102)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterPeopleRows', () => {
|
||||
const rows = mergePeopleSources(source)
|
||||
|
||||
it('filters by capacity with OR semantics within the facet', () => {
|
||||
expect(
|
||||
filterPeopleRows(rows, { page: 1, capacities: ['lideranca'] }).map((row) => row.contactID),
|
||||
).toEqual([100])
|
||||
expect(
|
||||
filterPeopleRows(rows, { page: 1, capacities: ['lideranca', 'dobradinha'] })
|
||||
.map((row) => row.contactID)
|
||||
.sort(),
|
||||
).toEqual([100, 101])
|
||||
expect(
|
||||
filterPeopleRows(rows, { page: 1, capacities: ['assessora'] })
|
||||
.map((row) => row.contactID)
|
||||
.sort(),
|
||||
).toEqual([100, 103])
|
||||
})
|
||||
|
||||
it('filters by municipality intersecting any capacity', () => {
|
||||
expect(
|
||||
filterPeopleRows(rows, { page: 1, municipalities: [5] }).map((row) => row.contactID),
|
||||
).toEqual([101])
|
||||
expect(
|
||||
filterPeopleRows(rows, { page: 1, municipalities: [3] }).map((row) => row.contactID),
|
||||
).toEqual([100])
|
||||
expect(filterPeopleRows(rows, { page: 1, municipalities: [99] })).toEqual([])
|
||||
})
|
||||
|
||||
it('filters by leadership status, dropping non-leadership rows', () => {
|
||||
expect(
|
||||
filterPeopleRows(rows, { page: 1, statuses: ['engajado'] }).map((row) => row.contactID),
|
||||
).toEqual([100])
|
||||
expect(filterPeopleRows(rows, { page: 1, statuses: ['negativo'] })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('peopleFilterFacetsFromRows', () => {
|
||||
it('unions the capacity municipalities and statuses of the scoped rows with the selected values', () => {
|
||||
const rows = mergePeopleSources(source)
|
||||
const facets = peopleFilterFacetsFromRows(rows, { page: 1, municipalities: [99] })
|
||||
expect(facets.municipalityIDs).toEqual([1, 3, 4, 5, 6, 99])
|
||||
expect(facets.statuses).toEqual(['engajado'])
|
||||
})
|
||||
})
|
||||
100
tests/unit/peopleSavedFilters.unit.spec.ts
Normal file
100
tests/unit/peopleSavedFilters.unit.spec.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
clearPeopleSavedFilters,
|
||||
listPeopleSavedFilters,
|
||||
removePeopleSavedFilter,
|
||||
savePeopleSavedFilter,
|
||||
STORAGE_KEY,
|
||||
subscribePeopleSavedFilters,
|
||||
} from '@/utilities/people/peopleSavedFilters'
|
||||
|
||||
/**
|
||||
* C100 saved-filter store — the same behavioral contract as the B18
|
||||
* municipality suite, exercised through the shared `createSavedFilterStore`
|
||||
* factory (the municipality module keeps its own pinned suite).
|
||||
*/
|
||||
|
||||
const hrefFor = (page = 1): string => `/campanha/pessoas?q=${page}`
|
||||
|
||||
const stub = (entries: unknown): void => {
|
||||
if (entries === null) localStorage.removeItem(STORAGE_KEY)
|
||||
else localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
|
||||
}
|
||||
|
||||
describe('peopleSavedFilters storage', () => {
|
||||
beforeEach(() => {
|
||||
clearPeopleSavedFilters()
|
||||
vi.spyOn(Storage.prototype, 'setItem')
|
||||
vi.spyOn(Storage.prototype, 'removeItem')
|
||||
})
|
||||
|
||||
it('returns an empty list for an empty store', () => {
|
||||
expect(listPeopleSavedFilters()).toEqual([])
|
||||
stub('not-json')
|
||||
expect(listPeopleSavedFilters()).toEqual([])
|
||||
stub({ href: '/campanha/pessoas' })
|
||||
expect(listPeopleSavedFilters()).toEqual([])
|
||||
})
|
||||
|
||||
it('saves entries alphabetically and rejects foreign hrefs', () => {
|
||||
savePeopleSavedFilter({ href: hrefFor(1), name: 'Zona da Mata' })
|
||||
savePeopleSavedFilter({ href: '/campanha/municipios?q=x', name: 'De outra lista' })
|
||||
savePeopleSavedFilter({ href: hrefFor(2), name: 'Àgua Fria' })
|
||||
expect(listPeopleSavedFilters().map((entry) => entry.name)).toEqual([
|
||||
'Àgua Fria',
|
||||
'Zona da Mata',
|
||||
])
|
||||
expect(
|
||||
listPeopleSavedFilters().every((entry) => entry.href.startsWith('/campanha/pessoas')),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('renames an entry with the same href instead of duplicating it', () => {
|
||||
savePeopleSavedFilter({ href: hrefFor(1), name: 'Primeiro nome' })
|
||||
expect(savePeopleSavedFilter({ href: hrefFor(1), name: 'Segundo nome' })).toBe('saved')
|
||||
expect(listPeopleSavedFilters()).toEqual([{ href: hrefFor(1), name: 'Segundo nome' }])
|
||||
})
|
||||
|
||||
it('trims and caps the name', () => {
|
||||
savePeopleSavedFilter({ href: hrefFor(1), name: ` ${'a'.repeat(80)} ` })
|
||||
expect(listPeopleSavedFilters()[0]?.name).toBe('a'.repeat(60))
|
||||
})
|
||||
|
||||
it('refuses a blank name', () => {
|
||||
expect(savePeopleSavedFilter({ href: hrefFor(1), name: ' ' })).toBe('failed')
|
||||
expect(listPeopleSavedFilters()).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses a save past the entry cap, but still allows renames', () => {
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
savePeopleSavedFilter({ href: hrefFor(index), name: `Recorte ${index}` })
|
||||
}
|
||||
expect(savePeopleSavedFilter({ href: hrefFor(99), name: 'Um a mais' })).toBe('limit')
|
||||
expect(listPeopleSavedFilters()).toHaveLength(12)
|
||||
expect(savePeopleSavedFilter({ href: hrefFor(0), name: 'Renomeado no teto' })).toBe('saved')
|
||||
expect(listPeopleSavedFilters().some((entry) => entry.name === 'Renomeado no teto')).toBe(true)
|
||||
})
|
||||
|
||||
it('removes and clears entries', () => {
|
||||
savePeopleSavedFilter({ href: hrefFor(1), name: 'Um' })
|
||||
savePeopleSavedFilter({ href: hrefFor(2), name: 'Dois' })
|
||||
removePeopleSavedFilter(hrefFor(1))
|
||||
expect(listPeopleSavedFilters()).toEqual([{ href: hrefFor(2), name: 'Dois' }])
|
||||
clearPeopleSavedFilters()
|
||||
expect(listPeopleSavedFilters()).toEqual([])
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('caches the snapshot until a write and notifies subscribers', () => {
|
||||
savePeopleSavedFilter({ href: hrefFor(1), name: 'Um' })
|
||||
const first = listPeopleSavedFilters()
|
||||
expect(listPeopleSavedFilters()).toBe(first)
|
||||
const subscriber = vi.fn()
|
||||
const unsubscribe = subscribePeopleSavedFilters(subscriber)
|
||||
savePeopleSavedFilter({ href: hrefFor(2), name: 'Dois' })
|
||||
expect(subscriber).toHaveBeenCalledTimes(1)
|
||||
expect(listPeopleSavedFilters()).not.toBe(first)
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue