Add action-plan agenda vertical for /campanha/planos.

Ship C3 mobilization events with the actionPlan collection, list/detail/forms UI, upcoming-event blocks, access control, and migration.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-18 20:56:20 -03:00
parent a18e2f15f5
commit e995aefbd4
63 changed files with 14067 additions and 226 deletions

View file

@ -10,6 +10,7 @@ alwaysApply: false
- **MVP Ciclo 1 entregue e enviado em `main` (2026-07-18):** ondas 1–8 (auth/RBAC, domínio+UI de Núcleo/Liderança/Inteligência/Atualizações/Convites, dashboard, hardening) + refactors de composição Next.js, primitivos compartilhados, locks PostgreSQL, fixtures E2E e migration consolidada `20260718_010733_consolidate_campaign_schema`.
- **Ciclo 2 entregue, mesclado e deployado (2026-07-18):** A1 território multi-município/bairro, A3 baseline TSE 2022 (modelo + import), B1 overview da lista, C1 compartilhar página e D1 PWA — detalhes no item datado abaixo.
- **A2 entregue e mesclado em `main` (2026-07-18):** cadastro estático município↔ZE (`bahiaTseZones`, TSE 2024) + chips opt-in no formulário (`NucleusTerritoryAndZonesFields` / `territorySuggestions`); sem migration. Plano: [`docs/plans/zonas-por-municipio.md`](../../../docs/plans/zonas-por-municipio.md).
- **C3 Planos de Ação implementado (2026-07-18):** collection `actionPlan` + vertical `/campanha/planos` (lista com tabs Próximos/Todos/Realizados/Rascunhos, detalhe com tabs Visão geral/Tarefas/Atualizações, forms novo/editar), blocos "Próximos eventos" no overview de núcleos e no dashboard; `startAt` opcional só em rascunho; access por `coordinators`/`leadership`; migration `20260718_222832_add_action_plan`.
- Fluxos operacionais cobertos: login isolado, CRUD/arquivo de núcleos por slug, designação de coordenador (`geral`), CRM de lideranças, estimativas sugerir/confirmar com versão UUID, feed de atualizações, convites WhatsApp (`wa.me` + hash de uso único), dashboard com agregados só de núcleos ativos.
- Pendência operacional (não de código): Consent `lideranca-autopreenchimento` + lote jurídico LGPD antes de dados reais; smoke pós-deploy Vercel. Ver Onda 0 em [`docs/roadmap.md`](../../../docs/roadmap.md).
- Em 2026-07-17, a listagem de Núcleos passou a aplicar filtros automaticamente pelo estado da URL, sem botão de confirmação, com comboboxes territoriais estritos compartilhados com o formulário.

View file

@ -40,7 +40,7 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas.
4. **Campaign LGPD consent text is not yet configured in production.** Invites and self-service leadership flows fail closed until an admin creates `Consent.key = 'lideranca-autopreenchimento'` with counsel-approved copy (see deploy checklist).
**Recently resolved (2026-07-18):** the `/campanha` nuclei MVP shipped (auth/RBAC, nuclei, leaderships, vote estimates, updates, WhatsApp invites, dashboard) with consolidated migration `20260718_010733_consolidate_campaign_schema`. Later the same day, cycle 2 was merged and deployed: multi-municipality/neighborhood territory arrays (migration `20260718_190559_territorio_multi_municipio_bairro`), TSE 2022 election baseline collections + `pnpm db:seed:tse` (migration `20260718_195854_add_election_results`), the filtered nuclei-list overview panel, the nucleus share dialog, and the installable `/campanha` PWA. Still on 2026-07-18, **A2** shipped: static Bahia municipality↔TSE zone map (`src/lib/bahiaTseZones.ts` from TSE 2024 `detalhe_votacao_munzona` BA) plus opt-in territory/zone suggestion chips in the nucleus form (`territorySuggestions`, `NucleusTerritoryAndZonesFields`) — no migration, no forced equality on save. Earlier (2026-07-15): local Postgres via `docker-compose.yml` (Postgres 17, `teqo_test`); Payload migrations baselined; dev/test database guards; `post`/`tag` news routes deployed (see "Posts & Tags" below).
**Recently resolved (2026-07-18):** the `/campanha` nuclei MVP shipped (auth/RBAC, nuclei, leaderships, vote estimates, updates, WhatsApp invites, dashboard) with consolidated migration `20260718_010733_consolidate_campaign_schema`. Later the same day, cycle 2 was merged and deployed: multi-municipality/neighborhood territory arrays (migration `20260718_190559_territorio_multi_municipio_bairro`), TSE 2022 election baseline collections + `pnpm db:seed:tse` (migration `20260718_195854_add_election_results`), the filtered nuclei-list overview panel, the nucleus share dialog, and the installable `/campanha` PWA. Still on 2026-07-18, **A2** shipped: static Bahia municipality↔TSE zone map (`src/lib/bahiaTseZones.ts` from TSE 2024 `detalhe_votacao_munzona` BA) plus opt-in territory/zone suggestion chips in the nucleus form (`territorySuggestions`, `NucleusTerritoryAndZonesFields`) — no migration, no forced equality on save. Also the same day: C3 Planos de Ação (`actionPlan` + `/campanha/planos`, migration `20260718_222832_add_action_plan`, blocos "Próximos eventos"). Earlier (2026-07-15): local Postgres via `docker-compose.yml` (Postgres 17, `teqo_test`); Payload migrations baselined; dev/test database guards; `post`/`tag` news routes deployed (see "Posts & Tags" below).
## Posts & Tags (news / publications)

View file

@ -1,8 +1,8 @@
# Eventos / presença e agenda de mobilização
Status: rascunho
Atualizado em: 2026-07-17
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos", linha 54)
Status: implementado
Atualizado em: 2026-07-18
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Janela 2, ordem 9 — C3)
Responsável: —
## Referência visual (UX Pilot)
@ -14,113 +14,50 @@ Dois designs cobrem este plano:
| Lista de planos (`/campanha/planos`) | [`Planos-de-Acao.png`](../design-refs/latest/Planos-de-Acao.png) · [`Planos-de-Acao.html`](../design-refs/latest/Planos-de-Acao.html) |
| Form novo plano | [`Novo-Plano-de-Acao.png`](../design-refs/latest/Novo-Plano-de-Acao.png) · [`Novo-Plano-de-Acao.html`](../design-refs/latest/Novo-Plano-de-Acao.html) |
![Lista de planos de ação](../design-refs/latest/Planos-de-Acao.png)
![Form de novo plano de ação](../design-refs/latest/Novo-Plano-de-Acao.png)
Como usar:
- **Lista — adotar:** tabs de janela/status ("Próximos" default, "Todos", "Realizados", "Rascunhos"), filtros "Tipo de ação" e "Território", cards com badge de `kind` (Caminhada, Reunião de apoio, Porta a porta, Comício, Panfletagem — cada tipo com cor própria clara), badge de `status` (Confirmado verde, Planejado azul, Rascunho outline, Realizado cinza), data/hora + município, chip do Território de Identidade, "Resp: {nome}" e barra de progresso de tarefas ("2/5 tarefas"). O card de rascunho com "Data a definir" esmaecido é um bom estado para `startAt` pendente em rascunho — mas atenção: o plano define `startAt` obrigatório; se rascunho puder ficar sem data, registrar em "Questões em aberto".
- **Form — adotar:** seções "Informações básicas" (título, tipo, descrição opcional), "Data e horário" (data + início obrigatórios, término opcional com helper "Deixe em branco se a duração for indefinida"), "Território" (municípios multi-chip + território derivado automático — mesmo padrão de [`Formulario-Territorio.png`](../design-refs/latest/Formulario-Territorio.png), confirmando que o bloco nasce no modelo multi-município), "Pessoas responsáveis" (coordenadores com "+ Adicionar", responsável principal) e "Status inicial" (Planejado | Rascunho como radio cards).
- **Falta desenhar (não bloqueia):** o detalhe do plano com tabs "Visão geral / Tarefas / Atualizações" não veio nesta leva — seguir o padrão `NucleusTabNav` do detalhe do núcleo; o checklist de tarefas pode reusar o formato de linhas do design da lista.
- **Ajustar cores:** paleta antiga no HTML/PNG; implementar com tokens do tema `campaign`. A entrada "Agenda" do bottom nav só entra quando este domínio existir.
- **Lista — adotar:** tabs de janela/status ("Próximos" default, "Todos", "Realizados", "Rascunhos"), filtros "Tipo de ação" e "Território", cards com badge de `kind`/`status`, data/hora + município, chip do Território de Identidade, "Resp: {nome}" e progresso de tarefas. Rascunho sem `startAt` mostra "Data a definir".
- **Form — adotar:** seções "Informações básicas", "Data e horário", "Território" (reusa `NucleusTerritoryFields`), "Pessoas" (coordenadores, responsável, liderança vinculada) e status inicial (Planejado | Rascunho).
- **Detalhe:** tabs Visão geral / Tarefas / Atualizações no padrão `NucleusTabNav` (sem design nesta leva).
- **Ajustar cores:** tokens do tema `campaign`. Entrada "Planos" no nav (`src/components/campaign/nav.ts`).
## Contexto
O roadmap lista "Eventos / presença e agenda de mobilização" como próximo ciclo da vertical `/campanha` (plano-arquitetura §4). Hoje a campanha só modela território (núcleo) e reporte (atualização); não há como planejar, escalar e acompanhar ações de campo — caminhadas, comícios, carreatas, panfletagens, reuniões de apoio, lançamentos, convenções, atos, entrevistas — nem ver o que vem a seguir.
A pesquisa de jargão de marketing político brasileiro separa três conceitos que o item do roadmap agrupa:
1. **Gestão de agenda do candidato** — o tempo do candidato é o recurso mais escasso; a agenda aloca esse tempo entre eventos de mobilização, mídia, encontros com lideranças, gravações e descanso. Agenda é instrumento estratégico, não lista de compromissos.
2. **Tipos de ação de mobilização** — caminhadas, carreatas, comícios, panfletagens, porta a porta, reuniões de apoio (lideranças/categorias profissionais/grupos religiosos/sindicais), eventos programados (lançamentos, convenções, atos), entrevistas/aparições. Distinção entre alta intensidade (rua) e baixa intensidade (digital/WhatsApp).
3. **Plano de ação de mobilização** — planejamento antecipado, coordenação centralizada, divisão clara de tarefas com responsáveis, prazos e KPIs, supervisor monitorando execução e conformidade eleitoral.
A decisão de produto (2026-07-17) é modelar isso como **uma única entidade "Plano de Ação"** (`actionPlan`), onde cada documento é uma ação/evento calendarizável, com checklist de tarefas e feed de atualizações — enxuto o suficiente para um MVP e compatível com o bloco "Próximos eventos" já reservado no overview da lista de núcleos ([overview-lista-nucleos.md](overview-lista-nucleos.md)).
Cada documento `actionPlan` é uma ação/evento calendarizável (caminhada, comício, etc.), com checklist de tarefas e feed de atualizações, alimentando o bloco "Próximos eventos" no overview de núcleos e no dashboard.
## Decisões travadas
- **Granularidade: entidade única `actionPlan`.** Cada documento É uma ação/evento (caminhada, comício, etc.), não um guarda-chuva de várias ocorrências. Isso mantém o MVP enxuto (uma collection, uma migration, um CRUD) e torna a entidade calendar-addressable via `startAt`. (Decisão de produto 2026-07-17.)
- **Território próprio, sem link a `electoralNucleus`.** O plano carrega seu próprio bloco de território, reusando as mesmas validações Bahia do núcleo. O escopo de acesso vem dos campos explícitos (`coordinators`, `leadership`), não de um núcleo vinculado. (Decisão de produto 2026-07-17.) Planos podem cobrir um território sem ser de um núcleo específico.
- **O bloco de território nasce no modelo vigente do núcleo** (nota de sequenciamento 2026-07-17). Se [`territorio-multi-municipio-bairro.md`](territorio-multi-municipio-bairro.md) já tiver sido implementado (ordem recomendada no roadmap), `actionPlan` nasce com `regions[]`/`cities[]`/`neighborhoods[]` e as mesmas 6 regras de validação — nunca com o modelo single antigo, que criaria migração dupla. O bloco "Próximos eventos" no overview filtra por território com a mesma semântica da lista de núcleos (`contains` sobre arrays).
- **`startAt` obrigatório + `endAt` opcional.** Sem data/hora de início não há agenda nem bloco "Próximos eventos". `deadline` (prazo de conclusão do plano) é distinto e opcional.
- **Pessoas = `Contact` + `campaignUser`, nunca cadastro paralelo.** `coordinators` → `campaignUser` (hasMany, só `geral`/`coordenador`, mesmo `filterOptions` do núcleo). `responsible` → `Contact` (pessoa responsável em campo, não precisa ser usuário nem liderança). `leadership` → `leadership` (relação opcional à junção `Contact`↔núcleo, para marcar a liderança que conduz a ação). Honra os três campos propostos pelo produto sem inventar "pessoa" paralela (convenção AGENTS.md).
- **`kind` como enum de tipos de mobilização.** `caminhada`, `comicio`, `carreatas`, `panfletagem`, `porta_a_porta`, `reuniao_apoio`, `lancamento`, `convencao`, `ato`, `entrevista`, `producao_conteudo`, `digital`, `outro`. Valores em pt-BR (são dados/labels, não identificadores).
- **`status` como enum de ciclo de vida.** `rascunho`, `planejado`, `confirmado`, `realizado`, `cancelado`. Transições para `cancelado`/`realizado` restritas a `geral` ou coordenador atribuído (espelha `canManageNucleusLifecycle`).
- **`slug` canônico imutável após criação.** Mesmo padrão de `setCanonicalNucleusSlug`: derivado de `title`, único, indexado, readOnly. Rotas em `/campanha/planos/[slug]` (segmento `planos` é dado/SEO em pt-BR, não identificador).
- **`tasks` e `updates` como arrays no MVP.** `tasks`: `{ title, responsible→Contact, due, done, doneAt }`. `updates`: append-only `{ author→campaignUser, body, createdAt }`, com `author`/`createdAt`/`doneAt` derivados no servidor. Uma collection dedicada `actionUpdate` (espelhando `nucleusUpdate`, feed imutável paginado) fica como follow-up.
- **Access control por campos explícitos.** `geral` vê/edita tudo; `coordenador` vê planos onde está em `coordinators`; `lideranca` vê planos onde `leadership` é uma das suas lideranças engajadas (IDs calculados e cacheados em `req.context`, mesmo padrão de `getAccessibleNucleusIds`). Escrita de `lideranca` restrita a toggle de `tasks.done` e append de `updates` no próprio plano (escopo estreito).
- **Sem `Consent` para o plano.** Plano é dado interno de staff, não coleta de PII pública. Captura nominal de presentes/apoiadores (tocaria `Contact` + `Consent`) fica fora do MVP.
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`actionPlan`, `ActionPlan`, `ActionPlanForm`, `loadActionPlanListPageData`), labels admin e strings visíveis em pt-BR.
- **Granularidade: entidade única `actionPlan`.**
- **Território próprio, sem link a `electoralNucleus`.** Arrays `regions`/`cities`/`neighborhoods` + validação Bahia compartilhada (`campaignTerritoryValidation.ts`).
- **Filtro de território na lista/overview usa `{ equals }`** sobre arrays (semântica A1), não `contains`.
- **`startAt` opcional só em `rascunho`; obrigatório ao passar para `planejado`/`confirmado`/`realizado`/`cancelado`.** Card mostra "Data a definir" quando ausente. (Decisão de produto 2026-07-18.)
- **Pessoas = `Contact` + `campaignUser`:** `coordinators` → `campaignUser`, `responsible` → `Contact`, `leadership` → `leadership` (opcional; escopo de leitura para `lideranca`).
- **`kind` / `status` enums em pt-BR** (valores de dados).
- **`slug` canônico imutável** a partir do `title`.
- **`tasks` e `updates` como arrays no MVP** (append-only para updates; `doneAt`/`author`/`createdAt` derivados no servidor).
- **Access:** `geral` tudo; `coordenador` onde está em `coordinators` (e pode criar, auto-incluído); `lideranca` onde `leadership` ∈ engajadas — escrita só toggle `tasks.done` + append `updates`.
- **Transações:** escritas de domínio via `withPayloadTransaction` (`src/utilities/payloadTransaction.ts`).
- **Sem `Consent`** (dado interno de staff).
## Questões em aberto
## Questões em aberto (resolvidas na implementação)
- **`lideranca` edita tarefas?** Recomendação: sim, só toggle `done` (e append de `update`), sem editar título/datas/responsáveis. Confirmar com produto.
- **`tasks.responsible` aceita qualquer `Contact` ou só acessíveis?** Recomendação: só contatos no escopo do ator (`getAccessibleContactIds`), espelhando o contato principal do núcleo. Definir com produto.
- **Filtro de janela de data na lista.** Recomendação: filtro de "próximos" (`startAt >= now`), "passados" (`startAt < now`) e intervalo livre. Default: próximos, ordenado por `startAt` asc.
- **Bloco "Próximos eventos" no dashboard `/campanha`.** O overview de `/campanha/nucleos` já reserva o bloco; o dashboard geral também ganha? Recomendação: sim, mesmo recorte (todos os planos não cancelados com `startAt >= now`), sem filtros de lista.
- **Repetição/recorrência.** Eventos recorrentes (ex.: panfletagem toda sábado) ficam fora do MVP — cada ocorrência é um plano. Reavaliar quando o volume justificar.
- **`actionUpdate` dedicado.** Migrar o array `updates` para collection separada (imutável, paginada, autor derivado) em follow-up se o feed crescer.
- **`lideranca` edita tarefas?** Sim — só toggle `done` + append `update`.
- **`tasks.responsible`:** contatos no escopo do ator (`getAccessibleContactIds` via busca do combobox).
- **Filtro de janela:** tabs Próximos / Todos / Realizados / Rascunhos; default Próximos.
- **Bloco no dashboard:** sim, sem filtros de lista.
- **Recorrência / `actionUpdate` dedicado:** fora do MVP.
## Abordagem proposta
## Entrega (2026-07-18)
```mermaid
flowchart LR
User["campaignUser autenticado"]
Gen["geral: todos os planos"]
Coord["coordenador: coordinators contains user.id"]
Lid["lideranca: leadership in engagedLeaderships(user)"]
Read["canReadActionPlan -> Where"]
Plan["actionPlan<br/>title, kind, startAt/endAt, deadline, status<br/>coordinators, leadership, responsible<br/>territorio proprio, location, notes<br/>tasks[], updates[]"]
- Collection `actionPlan` + migration `20260718_222832_add_action_plan`
- Vertical `/campanha/planos` (lista, detalhe com tabs, novo/editar)
- Blocos "Próximos eventos" no overview de núcleos e no dashboard
- Access control + testes int `tests/int/campaignActionPlan.int.spec.ts`
User --> Gen
User --> Coord
User --> Lid
Gen --> Read
Coord --> Read
Lid --> Read
Read --> Plan
```
## Revisões
Componentes:
- **`src/collections/ActionPlan.ts`** (novo): collection em admin group `Campanha`, `useAsTitle: title`. Campos conforme "Decisões travadas". Hooks `beforeValidate` (slug canonico + validação de territorio Bahia + `startAt < endAt`) e `beforeChange` (derivar `createdBy`, `tasks.doneAt`, `updates.author/createdAt`).
- **`src/utilities/campaignAccess.ts`** (extensão): `canCreateActionPlan`, `canReadActionPlan` (retorna `Where` por papel), `canUpdateActionPlan`, `canDeleteActionPlan`, `canSetActionPlanSystemField`, `canSetActionPlanStatus`, e `getAccessibleLeadershipIds` (cache em `req.context`, espelha `getAccessibleNucleusIds`).
- **`src/app/(campaign)/campanha/(app)/planos/page.tsx`**: lista paginada com filtros (kind, status, territorio, janela `startAt`), reusando `NucleusFilters`/`NucleusList`/`NucleusPagination` como referência de layout e `components/ui/*`.
- **`src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx`**: detalhe com `NucleusTabNav`-style (Visão geral, Tarefas, Atualizações).
- **`src/app/(campaign)/campanha/(app)/planos/novo/page.tsx`** e **`planos/[slug]/editar/page.tsx`**: form reusando `NucleusForm`/`NucleusTerritoryFields` como referência.
- **`planos/formActions.ts`**, **`planos/[slug]/actionPlanFormActions.ts`**: server actions; escritas multi-collection (criar plano + update inicial; toggle tarefa + append update) envolvem `payload.db.beginTransaction/commitTransaction/rollbackTransaction` com `req: { transactionID }` (padrão AGENTS.md).
- **`src/components/campaign/CampaignBottomNav.tsx`**: adicionar entrada "Planos".
- **Bloco "Próximos eventos"**: ativar no overview de `/campanha/nucleos` (já desenhado em [overview-lista-nucleos.md](overview-lista-nucleos.md)) e no dashboard `/campanha`, consumindo `actionPlan` com `startAt >= now`, `status in [planejado, confirmado]`, ordenado por `startAt` asc, respeitando os mesmos filtros de territorio da página.
## Ondas (lean, espelhando o ciclo de Núcleos)
1. Domínio: collection `ActionPlan.ts`, hooks, access control em `campaignAccess.ts`, migration `add_action_plan`, `pnpm generate:types`, `pnpm generate:importmap`, `tsc --noEmit`, lint.
2. Lista `/campanha/planos` + filtros + ativação do bloco "Próximos eventos" no overview de `/campanha/nucleos`.
3. Detalhe `/campanha/planos/[slug]` com tabs (Visão geral, Tarefas, Atualizações).
4. Forms novo/editar + server actions transacionais + toggle de tarefa + append de update.
5. Dashboard "Próximos eventos" + E2E por papel + responsividade 360/390/768/1440 + hardening + Aikido por arquivo editado.
## Dependências
- **[`territorio-multi-municipio-bairro.md`](territorio-multi-municipio-bairro.md) — pré-requisito de sequenciamento** (2026-07-17): o bloco de território de `actionPlan` nasce no modelo de arrays. Ver "Decisões travadas".
- Fora isso, nenhuma de outro plano. Reusa validações Bahia (`src/lib/bahiaTerritories.ts`, `src/lib/cities.ts`), access control existente (`src/utilities/campaignAccess.ts`), `slugify` (`src/utilities/slug.ts`), UI `components/ui/*` e padrões de `NucleusForm`/`NucleusFilters`/`NucleusList`/`NucleusTabNav`.
- O bloco "Próximos eventos" do overview de `/campanha/nucleos` ([overview-lista-nucleos.md](overview-lista-nucleos.md)) é consumidor direto e só ganha UI quando esta collection existir.
- 2026-07-18: auditoria pré-implementação — operador de território `equals` (não `contains`); transações via `withPayloadTransaction`; nav em `nav.ts`; validação de território extraída para helper compartilhado; `startAt` opcional só em rascunho.
## Não escopo
- Collection dedicada `actionUpdate` (feed imutável paginado) — follow-up; MVP usa array.
- Captura nominal de presentes/apoiadores (tocaria `Contact` + `Consent`) — fora do MVP.
- Visão de calendário/mapa (depende do item mapa/PostGIS do roadmap).
- Link a `electoralNucleus` (decisão: território próprio).
- Recorrência de eventos — cada ocorrência é um plano no MVP.
- Estimativa estatística de votos em eventos.
- Notificações/lembretes de evento (item separado do roadmap, [notifications.md](notifications.md)).
## Referências
- `docs/roadmap.md` (linha 54)
- `docs/plans/overview-lista-nucleos.md` — bloco "Próximos eventos" já desenhado
- `src/collections/ElectoralNucleus.ts` — padrão de collection, hooks de slug/território, campos derivados
- `src/utilities/campaignAccess.ts` — `getAccessibleNucleusIds`, `getFreshCampaignUser`, padrões de access por papel
- `src/lib/bahiaTerritories.ts`, `src/lib/cities.ts` — validação de território Bahia
- `src/utilities/slug.ts` — `slugify`
- AGENTS.md — Campaign auth, naming conventions, transações multi-collection, workflow de migrations
- Collection dedicada `actionUpdate`, captura nominal de presentes, calendário/mapa, link a `electoralNucleus`, recorrência, notificações (D2).

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta; C6 registrado — escala/DRY pós-C2 para depois do merge)
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta; C3 Planos de Ação implementado; C6 registrado — escala/DRY pós-C2)
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
@ -112,7 +112,7 @@ flowchart TD
C1["C1 Compartilhar página ✓"]
C2["C2 Cadastro nominal de apoiadores"]
C6["C6 Escala e DRY pós-C2<br/>(import/listas/forms)"]
C3["C3 Eventos / agenda de mobilização"]
C3["C3 Eventos / agenda de mobilização ✓"]
C4["C4 Demandas"]
C5["C5 Operação dia D / GOTV<br/>(proposto, a validar)"]
end
@ -146,7 +146,7 @@ flowchart TD
JUR -.chave de push.-> D2
```
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (engenharia — produção espera o jurídico), além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, C1 e D1 — ver "Ciclo 2" acima. Destravados por eles: A4 (A2/A3 + B1 prontos), C3/C4 (A1 pronto), C6 (após merge de C2) e D2 push (D1 pronto; falta a chave jurídica).
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (engenharia — produção espera o jurídico), além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, C1, C3 e D1 — ver "Ciclo 2" acima e C3 abaixo. Destravados por eles: A4 (A2/A3 + B1 prontos), C4 (A1 + C3 prontos), C6 (após merge de C2) e D2 push (D1 pronto; falta a chave jurídica).
### Sequência de execução por janela do calendário
@ -168,7 +168,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (enge
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta 2026-07-18 — merge/deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6 |
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3 |
| 9 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6 |
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6 |
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6 |
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**

View file

@ -45,7 +45,7 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) {
const now = new Date()
const [{ result, scope }, overview] = await Promise.all([
loadNucleusListPageData(payload, user, rawSearchParams),
loadNucleusListOverviewData(payload, user, canonicalUrl.state),
loadNucleusListOverviewData(payload, user, canonicalUrl.state, now),
])
const resolvedUrl = resolveNucleusListUrl(rawSearchParams, result.totalPages)

View file

@ -0,0 +1,60 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { notFound, redirect } from 'next/navigation'
import { searchActionPlanContactOptions } from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
import { updateActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions'
import { ActionPlanForm } from '@/components/campaign/ActionPlanForm'
import { getActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
ActionPlanNotFoundError,
getActionPlanEditPageData,
} from '@/utilities/actionPlanPageData'
import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions'
type EditActionPlanPageProps = {
params: Promise<{ slug: string }>
}
export default async function EditActionPlanPage({ params }: EditActionPlanPageProps) {
const [{ slug }, user, payload] = await Promise.all([
params,
getCampaignUser(),
getPayload({ config }),
])
if (!user) redirect('/campanha/login')
if (user.role === 'lideranca') redirect('/campanha/planos')
if (!slug) notFound()
const [view, coordinators, leadershipOptions] = await Promise.all([
getActionPlanEditPageData(payload, user, slug).catch((error) => {
if (error instanceof ActionPlanNotFoundError) notFound()
throw error
}),
getEligibleNucleusCoordinatorOptions(payload, user),
getActionPlanLeadershipOptions(payload, user),
])
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<header className="flex flex-col gap-2">
<p className="text-sm font-medium text-primary">Planos de ação</p>
<h1 className="text-2xl font-bold tracking-tight">Editar {view.title}</h1>
<p className="text-muted-foreground">
Atualize os detalhes do plano sem alterar o título original.
</p>
</header>
<ActionPlanForm
action={updateActionPlanFormAction}
plan={view}
coordinators={coordinators}
canManageCoordinators={user.role === 'geral'}
leadershipOptions={leadershipOptions}
submitLabel="Salvar alterações"
searchContacts={searchActionPlanContactOptions}
/>
</div>
)
}

View file

@ -0,0 +1,59 @@
'use server'
import { redirect } from 'next/navigation'
import {
cancelActionPlan,
markActionPlanRealized,
} from '@/app/(campaign)/campanha/actions/actionPlan'
import { requiredRelationshipFormValue } from '@/lib/formData'
import type { ActionPlan } from '@/payload-types'
export type ActionPlanLifecycleFormState = {
message?: string
}
const setActionPlanLifecycleFormAction = async (
formData: FormData,
mutate: (planId: number) => Promise<Pick<ActionPlan, 'slug'>>,
failureMessage: string,
): Promise<ActionPlanLifecycleFormState> => {
let planId: number
try {
planId = requiredRelationshipFormValue(formData, 'id')
} catch {
return {
message: 'Não foi possível identificar o plano. Atualize a página e tente novamente.',
}
}
let planSlug: string
try {
const plan = await mutate(planId)
planSlug = plan.slug
} catch {
return { message: failureMessage }
}
redirect(`/campanha/planos/${planSlug}`)
}
export const cancelActionPlanFormAction = async (
_state: ActionPlanLifecycleFormState,
formData: FormData,
): Promise<ActionPlanLifecycleFormState> =>
setActionPlanLifecycleFormAction(
formData,
cancelActionPlan,
'Não foi possível cancelar este plano. Verifique seu acesso ou atualize a página e tente novamente.',
)
export const markActionPlanRealizedFormAction = async (
_state: ActionPlanLifecycleFormState,
formData: FormData,
): Promise<ActionPlanLifecycleFormState> =>
setActionPlanLifecycleFormAction(
formData,
markActionPlanRealized,
'Não foi possível marcar este plano como realizado. Verifique seu acesso ou atualize a página e tente novamente.',
)

View file

@ -0,0 +1,192 @@
import config from '@payload-config'
import { PencilIcon, PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import {
CancelActionPlanDialog,
MarkActionPlanRealizedDialog,
} from '@/components/campaign/ActionPlanLifecycleDialog'
import { ActionPlanStatusBadge } from '@/components/campaign/ActionPlanStatusBadge'
import { ActionPlanTabNav } from '@/components/campaign/ActionPlanTabNav'
import { ActionPlanTaskChecklist } from '@/components/campaign/ActionPlanTaskChecklist'
import { ActionPlanUpdateFeed } from '@/components/campaign/ActionPlanUpdateFeed'
import { ActionPlanUpdateForm } from '@/components/campaign/ActionPlanUpdateForm'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
ActionPlanNotFoundError,
getActionPlanDetailPageData,
resolveAccessibleActionPlanContext,
} from '@/utilities/actionPlanPageData'
import {
getActionPlanDetailTabRedirect,
resolveActionPlanDetailTab,
} from '@/utilities/actionPlanDetailTabUi'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
type ActionPlanDetailPageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function ActionPlanDetailPage({
params,
searchParams,
}: ActionPlanDetailPageProps) {
const [{ slug }, query, user, payload] = await Promise.all([
params,
searchParams,
getCampaignUser(),
getPayload({ config }),
])
if (!user) redirect('/campanha/login')
if (!slug) notFound()
const activeTab = resolveActionPlanDetailTab(query)
const context = await resolveAccessibleActionPlanContext(payload, user, slug).catch((error) => {
if (error instanceof ActionPlanNotFoundError) notFound()
throw error
})
const view = getActionPlanDetailPageData(context)
const canonicalTabRedirect = getActionPlanDetailTabRedirect(view.slug, query)
if (canonicalTabRedirect) redirect(canonicalTabRedirect)
const canEdit = user.role === 'geral' || user.role === 'coordenador'
const canManageLifecycle =
canEdit && view.status !== 'realizado' && view.status !== 'cancelado'
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<header className="flex flex-col gap-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight">{view.title}</h1>
<ActionPlanStatusBadge status={view.status} />
</div>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground">
<Badge variant="secondary">{actionPlanKindLabels[view.kind]}</Badge>
<span>
{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'}
</span>
{view.territoryLabel ? <span>· {view.territoryLabel}</span> : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{canEdit ? (
<Button asChild variant="outline" className="min-h-11">
<Link href={`/campanha/planos/${view.slug}/editar`}>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Editar
</Link>
</Button>
) : null}
<Button asChild variant="outline" className="min-h-11">
<Link href={`/campanha/planos/${view.slug}?tab=updates&newUpdate=1`}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova atualização
</Link>
</Button>
{canManageLifecycle ? (
<>
<MarkActionPlanRealizedDialog planId={view.id} />
<CancelActionPlanDialog planId={view.id} />
</>
) : null}
</div>
</div>
</header>
<ActionPlanTabNav activeTab={activeTab} planSlug={view.slug} searchParams={query} />
{activeTab === 'overview' ? (
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Detalhes</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{view.description ? (
<p className="whitespace-pre-wrap">{view.description}</p>
) : (
<p className="text-muted-foreground">Sem descrição.</p>
)}
<dl className="grid gap-2 sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">Início</dt>
<dd>{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'A definir'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Término</dt>
<dd>{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Prazo de conclusão</dt>
<dd>{view.deadline ? formatBahiaDateTimeLabel(view.deadline) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Responsável</dt>
<dd>{view.responsibleName ?? 'Não definido'}</dd>
</div>
</dl>
{view.territoryNotes ? (
<div>
<p className="text-muted-foreground">Observações do território</p>
<p className="whitespace-pre-wrap">{view.territoryNotes}</p>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Coordenação</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{view.coordinators.length ? (
<ul role="list" className="flex flex-col gap-1">
{view.coordinators.map((coordinator) => (
<li key={coordinator.id}>{coordinator.name}</li>
))}
</ul>
) : (
<p className="text-muted-foreground">Nenhum coordenador vinculado.</p>
)}
<p className="text-muted-foreground">
{view.tasks.filter((task) => task.done).length}/{view.tasks.length} tarefas
concluídas
</p>
{view.createdByName ? (
<p className="text-muted-foreground">Criado por {view.createdByName}</p>
) : null}
</CardContent>
</Card>
</div>
) : null}
{activeTab === 'tasks' ? (
<Card>
<CardHeader>
<CardTitle>Tarefas</CardTitle>
</CardHeader>
<CardContent>
<ActionPlanTaskChecklist planId={view.id} tasks={view.tasks} />
</CardContent>
</Card>
) : null}
{activeTab === 'updates' ? (
<div className="flex flex-col gap-4">
<ActionPlanUpdateForm planId={view.id} />
<ActionPlanUpdateFeed updates={view.updates} />
</div>
) : null}
</div>
)
}

View file

@ -0,0 +1,24 @@
'use server'
import { toggleActionPlanTask } from '@/app/(campaign)/campanha/actions/actionPlan'
export type ToggleActionPlanTaskResult = { ok: true } | { ok: false; message: string }
export const toggleActionPlanTaskAction = async (
planId: number,
taskId: string,
done: boolean,
): Promise<ToggleActionPlanTaskResult> => {
try {
await toggleActionPlanTask(planId, taskId, done)
return { ok: true }
} catch (error) {
return {
ok: false,
message:
error instanceof Error
? error.message
: 'Não foi possível atualizar a tarefa. Atualize a página e tente novamente.',
}
}
}

View file

@ -0,0 +1,44 @@
'use server'
import { revalidatePath } from 'next/cache'
import { ZodError } from 'zod'
import { appendActionPlanUpdate } from '@/app/(campaign)/campanha/actions/actionPlan'
import {
FormDataBoundaryError,
requiredFormText,
requiredRelationshipFormValue,
validationFieldErrors,
} from '@/lib/formData'
export type ActionPlanUpdateFormState = {
status?: 'success'
message?: string
fieldErrors?: Record<string, string[]>
}
export const createActionPlanUpdateFormAction = async (
_state: ActionPlanUpdateFormState,
formData: FormData,
): Promise<ActionPlanUpdateFormState> => {
try {
const planId = requiredRelationshipFormValue(formData, 'planId')
const body = requiredFormText(formData, 'body')
await appendActionPlanUpdate(planId, body)
revalidatePath('/campanha/planos/[slug]', 'page')
return { status: 'success', message: 'Atualização registrada com sucesso.' }
} catch (error) {
if (error instanceof FormDataBoundaryError) {
return { fieldErrors: { [error.field]: [error.message] } }
}
if (error instanceof ZodError) {
return { fieldErrors: validationFieldErrors(error) }
}
return {
message:
error instanceof Error
? error.message
: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
}
}
}

View file

@ -0,0 +1,42 @@
'use server'
import config from '@payload-config'
import { getPayload } from 'payload'
import { getCampaignUser } from '@/utilities/campaignAuth'
export type ActionPlanContactOption = {
id: number
name: string
phone: string
}
const CONTACT_OPTION_LIMIT = 20
export const searchActionPlanContactOptions = async (
query: string,
): Promise<ActionPlanContactOption[]> => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) throw new Error('Autenticação necessária.')
const trimmed = typeof query === 'string' ? query.trim().slice(0, 120) : ''
const digits = trimmed.replace(/\D/g, '')
const result = await payload.find({
collection: 'contact',
where: trimmed
? {
or: [{ name: { contains: trimmed } }, { phone: { contains: digits || trimmed } }],
}
: {},
depth: 0,
limit: CONTACT_OPTION_LIMIT,
page: 1,
sort: 'name',
select: { name: true, phone: true },
user,
overrideAccess: false,
})
return result.docs.map(({ id, name, phone }) => ({ id, name, phone }))
}

View file

@ -0,0 +1,96 @@
'use server'
import config from '@payload-config'
import { getPayload } from 'payload'
import { redirect } from 'next/navigation'
import { ZodError } from 'zod'
import { createActionPlan, updateActionPlan } from '@/app/(campaign)/campanha/actions/actionPlan'
import type { ActionPlanFormState } from '@/components/campaign/ActionPlanForm'
import {
FormDataBoundaryError,
optionalFormText,
validationFieldErrors,
} from '@/lib/formData'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
parseActionPlanCreateFormData,
parseActionPlanUpdateFormData,
} from '@/utilities/actionPlanFormData'
import { slugify } from '@/utilities/slug'
const getActionError = (error: unknown): ActionPlanFormState => {
if (error instanceof FormDataBoundaryError) {
return { fieldErrors: { [error.field]: [error.message] } }
}
if (error instanceof ZodError) {
return { fieldErrors: validationFieldErrors(error) }
}
if (error instanceof Error && /já existe|unique|duplicate key/i.test(error.message)) {
return { fieldErrors: { title: ['Já existe um plano com este título.'] } }
}
return { message: 'Não foi possível concluir a ação. Atualize a página e tente novamente.' }
}
const existingActionPlanState = async (formData: FormData): Promise<ActionPlanFormState | null> => {
const title = optionalFormText(formData, 'title')
const slug = title ? slugify(title) : ''
if (!slug) return null
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) return null
const result = await payload.find({
collection: 'actionPlan',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
pagination: false,
select: { slug: true },
user,
overrideAccess: false,
})
const existing = result.docs[0]
return existing
? {
fieldErrors: { title: ['Já existe um plano com este título.'] },
existingHref: `/campanha/planos/${existing.slug}`,
submittedTitle: title,
}
: null
}
export const createActionPlanFormAction = async (
_state: ActionPlanFormState,
formData: FormData,
): Promise<ActionPlanFormState> => {
let planSlug: string
try {
const plan = await createActionPlan(parseActionPlanCreateFormData(formData))
planSlug = plan.slug
} catch (error) {
const existing = await existingActionPlanState(formData)
if (existing) return existing
return getActionError(error)
}
redirect(`/campanha/planos/${planSlug}`)
}
export const updateActionPlanFormAction = async (
_state: ActionPlanFormState,
formData: FormData,
): Promise<ActionPlanFormState> => {
let planSlug: string
try {
const input = parseActionPlanUpdateFormData(formData)
const plan = await updateActionPlan(input)
planSlug = plan.slug
} catch (error) {
return getActionError(error)
}
redirect(`/campanha/planos/${planSlug}`)
}

View file

@ -0,0 +1,42 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { redirect } from 'next/navigation'
import { searchActionPlanContactOptions } from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
import { createActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions'
import { ActionPlanForm } from '@/components/campaign/ActionPlanForm'
import { getActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions'
export default async function NewActionPlanPage() {
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (user.role !== 'geral' && user.role !== 'coordenador') redirect('/campanha/planos')
const [coordinators, leadershipOptions] = await Promise.all([
getEligibleNucleusCoordinatorOptions(payload, user),
getActionPlanLeadershipOptions(payload, user),
])
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<header className="flex flex-col gap-2">
<p className="text-sm font-medium text-primary">Planos de ação</p>
<h1 className="text-2xl font-bold tracking-tight">Novo plano</h1>
<p className="text-muted-foreground">
Defina a ação, quando e onde ela acontece e quem responde por ela.
</p>
</header>
<ActionPlanForm
action={createActionPlanFormAction}
coordinators={coordinators}
canManageCoordinators={user.role === 'geral'}
leadershipOptions={leadershipOptions}
submitLabel="Criar plano"
searchContacts={searchActionPlanContactOptions}
/>
</div>
)
}

View file

@ -0,0 +1,97 @@
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { ActionPlanFilters } from '@/components/campaign/ActionPlanFilters'
import { ActionPlanList } from '@/components/campaign/ActionPlanList'
import { ActionPlanPagination } from '@/components/campaign/ActionPlanPagination'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import type { ActionPlan } from '@/payload-types'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadActionPlanListPageData } from '@/utilities/actionPlanPageData'
import { buildActionPlanFiltersKey, resolveActionPlanListUrl } from '@/utilities/actionPlanUi'
import { toActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
type ActionPlanListPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function ActionPlanListPage({ searchParams }: ActionPlanListPageProps) {
const rawSearchParams = await searchParams
const canonicalUrl = resolveActionPlanListUrl(rawSearchParams)
if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref)
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) return null
const now = new Date()
const { result, state } = await loadActionPlanListPageData(payload, user, rawSearchParams, now)
const resolvedUrl = resolveActionPlanListUrl(rawSearchParams, result.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const canCreate = user.role === 'geral' || user.role === 'coordenador'
return (
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold tracking-tight">Planos de ação</h1>
<p className="text-muted-foreground">
Organize caminhadas, comícios, panfletagens e demais ações de campanha.
</p>
</div>
{canCreate ? (
<Button asChild className="min-h-11">
<Link href="/campanha/planos/novo">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo plano
</Link>
</Button>
) : null}
</header>
<ActionPlanFilters key={buildActionPlanFiltersKey(state)} state={state} />
{result.docs.length ? (
<>
<ActionPlanList
plans={result.docs.map((plan) => toActionPlanListViewModel(plan as ActionPlan))}
/>
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground">
{result.totalDocs} {result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}
</p>
<ActionPlanPagination state={state} totalPages={result.totalPages} />
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum plano encontrado</EmptyTitle>
<EmptyDescription>
Ajuste os filtros ou a janela selecionada. Você só vê planos dentro do seu escopo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/planos">Limpar filtros</Link>
</Button>
</EmptyContent>
</Empty>
)}
</div>
)
}

View file

@ -0,0 +1,192 @@
'use server'
import type { Payload } from 'payload'
import {
actionPlanCreateSchema,
actionPlanUpdateSchema,
type ActionPlanCreateInput,
type ActionPlanUpdateInput,
} from '@/lib/schemas/actionPlan'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
const MAX_ACTION_PLAN_UPDATE_BODY_LENGTH = 4000
export const createActionPlanRecord = async (
payload: Payload,
actor: CampaignUser,
input: ActionPlanCreateInput,
) => {
const data = actionPlanCreateSchema.parse(input)
return payload.create({
collection: 'actionPlan',
data: data as never,
depth: 0,
user: actor,
overrideAccess: false,
})
}
export const updateActionPlanRecord = async (
payload: Payload,
actor: CampaignUser,
input: ActionPlanUpdateInput,
) => {
const { id, ...data } = actionPlanUpdateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
return payload.update({
collection: 'actionPlan',
id,
data,
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização do plano.' },
)
}
const setActionPlanLifecycleStatusRecord = async (
payload: Payload,
actor: CampaignUser,
id: number,
status: 'realizado' | 'cancelado',
) =>
withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
return payload.update({
collection: 'actionPlan',
id,
data: { status },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível atualizar o status do plano.' },
)
export const toggleActionPlanTaskRecord = async (
payload: Payload,
actor: CampaignUser,
planId: number,
taskId: string,
done: boolean,
) =>
withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
const plan = await payload.findByID({
collection: 'actionPlan',
id: planId,
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
const tasks = plan.tasks ?? []
const taskIndex = tasks.findIndex((task) => task.id === taskId)
if (taskIndex === -1) throw new Error('Tarefa não encontrada.')
const nextTasks = tasks.map((task, index) => (index === taskIndex ? { ...task, done } : task))
return payload.update({
collection: 'actionPlan',
id: planId,
data: { tasks: nextTasks },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível atualizar a tarefa do plano.' },
)
export const appendActionPlanUpdateRecord = async (
payload: Payload,
actor: CampaignUser,
planId: number,
body: string,
) => {
const trimmedBody = body.trim()
if (!trimmedBody) throw new Error('Informe o texto da atualização.')
if (trimmedBody.length > MAX_ACTION_PLAN_UPDATE_BODY_LENGTH) {
throw new Error('Atualização muito longa. Reduza o texto e tente novamente.')
}
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
const plan = await payload.findByID({
collection: 'actionPlan',
id: planId,
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
const updates = plan.updates ?? []
const nextUpdates = [...updates, { body: trimmedBody }]
return payload.update({
collection: 'actionPlan',
id: planId,
data: { updates: nextUpdates },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível registrar a atualização do plano.' },
)
}
export const createActionPlan = async (input: ActionPlanCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
return createActionPlanRecord(payload, actor, input)
}
export const updateActionPlan = async (input: ActionPlanUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
return updateActionPlanRecord(payload, actor, input)
}
export const cancelActionPlan = async (id: number) => {
const { payload, actor } = await getCampaignActionContext()
return setActionPlanLifecycleStatusRecord(payload, actor, id, 'cancelado')
}
export const markActionPlanRealized = async (id: number) => {
const { payload, actor } = await getCampaignActionContext()
return setActionPlanLifecycleStatusRecord(payload, actor, id, 'realizado')
}
export const toggleActionPlanTask = async (planId: number, taskId: string, done: boolean) => {
const { payload, actor } = await getCampaignActionContext()
return toggleActionPlanTaskRecord(payload, actor, planId, taskId, done)
}
export const appendActionPlanUpdate = async (planId: number, body: string) => {
const { payload, actor } = await getCampaignActionContext()
return appendActionPlanUpdateRecord(payload, actor, planId, body)
}

View file

@ -0,0 +1,533 @@
import type {
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
CollectionConfig,
} from 'payload'
import { APIError } from 'payload'
import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import {
actionPlanKindLabels,
actionPlanKinds,
actionPlanStatusLabels,
actionPlanStatuses,
} from '@/lib/schemas/actionPlan'
import {
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
MAX_NUCLEUS_REGIONS,
} from '@/lib/schemas/nucleus'
import {
canCreateActionPlan,
canCreateActionPlanCoordinators,
canDeleteActionPlan,
canManageActionPlanCoordinators,
canReadActionPlan,
canSetActionPlanStatus,
canSetActionPlanSystemField,
canUpdateActionPlan,
} from '@/utilities/campaignAccess'
import { createCampaignTerritoryValidationHook } from '@/utilities/campaignTerritoryValidation'
import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions'
import { relationshipId } from '@/utilities/relationship'
import { slugify } from '@/utilities/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const ACTION_PLAN_KIND_OPTIONS = actionPlanKinds.map((value) => ({
value,
label: actionPlanKindLabels[value],
}))
const ACTION_PLAN_STATUS_OPTIONS = actionPlanStatuses.map((value) => ({
value,
label: actionPlanStatusLabels[value],
}))
const actionPlanStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
title: trimmedText(doc.title),
slug: doc.slug ?? null,
kind: doc.kind ?? null,
status: doc.status ?? null,
description: trimmedText(doc.description),
startAt: doc.startAt ?? null,
endAt: doc.endAt ?? null,
deadline: doc.deadline ?? null,
regions: Array.isArray(doc.regions) ? doc.regions.map(String) : [],
cities: Array.isArray(doc.cities) ? doc.cities.map(String) : [],
neighborhoods: Array.isArray(doc.neighborhoods) ? doc.neighborhoods.map(String) : [],
locality: trimmedText(doc.locality),
territoryNotes: trimmedText(doc.territoryNotes),
coordinators: (Array.isArray(doc.coordinators) ? doc.coordinators : [])
.map(relationshipId)
.filter((id): id is number => id !== null),
responsible: relationshipId(doc.responsible),
leadership: relationshipId(doc.leadership),
})
const validateActionPlanTerritory = createCampaignTerritoryValidationHook({
entityLabel: 'plano',
})
const setCanonicalActionPlanSlug: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
}) => {
if (!data) return data
const title = trimmedText(data.title ?? originalDoc?.title)
const slug = slugify(title)
if (!slug) {
throw new APIError('Informe um título com letras ou números.', 400)
}
if (operation === 'update' && data.title !== undefined && title !== originalDoc?.title) {
throw new APIError('O título do plano não pode ser alterado após a criação.', 409)
}
data.title = title
data.slug = operation === 'create' ? slug : originalDoc?.slug
return data
}
const validateActionPlanSchedule: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
}) => {
if (!data) return data
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
const status = typeof nextData.status === 'string' ? nextData.status : 'rascunho'
const startAt = nextData.startAt ?? null
const endAt = nextData.endAt ?? null
if (status !== 'rascunho' && !startAt) {
throw new APIError(
'Informe a data e horário de início ao planejar ou confirmar o plano.',
400,
)
}
if (startAt && endAt) {
const start = new Date(startAt as string)
const end = new Date(endAt as string)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || !(start < end)) {
throw new APIError('O horário de término deve ser posterior ao de início.', 400)
}
}
return data
}
const validateActionPlanCoordinators: CollectionBeforeValidateHook = async ({ data, req }) => {
if (!data) return data
if (data.coordinators === undefined) return data
const coordinatorValues = data.coordinators
const coordinatorIDs = Array.isArray(coordinatorValues)
? [...new Set(coordinatorValues.map(relationshipId).filter((id): id is number => id !== null))]
: []
if (coordinatorIDs.length === 0) return data
const eligibleCoordinators = await req.payload.find({
collection: 'campaignUser',
depth: 0,
pagination: false,
where: {
and: [{ id: { in: coordinatorIDs } }, eligibleNucleusCoordinatorWhere],
},
select: { name: true },
overrideAccess: true,
req,
})
if (eligibleCoordinators.docs.length !== coordinatorIDs.length) {
throw new APIError('Cada responsável deve ter papel de coordenação geral ou coordenador.', 400)
}
return data
}
const deriveActionPlanFields: CollectionBeforeChangeHook = ({ data, operation, originalDoc, req }) => {
if (!data) return data
if (operation === 'create' && req.user?.collection === 'campaignUser') {
data.createdBy = req.user.id
if (
req.user.role === 'coordenador' &&
(data.coordinators === undefined ||
(Array.isArray(data.coordinators) && data.coordinators.length === 0))
) {
data.coordinators = [req.user.id]
}
}
if (Array.isArray(data.tasks)) {
const previousTasks = Array.isArray(originalDoc?.tasks) ? originalDoc.tasks : []
data.tasks = data.tasks.map((task: Record<string, unknown>, index: number) => {
const previous = previousTasks[index] as Record<string, unknown> | undefined
const done = Boolean(task.done)
const previousDone = Boolean(previous?.done)
let doneAt = task.doneAt ?? previous?.doneAt ?? null
if (done && !previousDone) {
doneAt = new Date().toISOString()
} else if (!done) {
doneAt = null
}
return { ...task, done, doneAt }
})
}
if (Array.isArray(data.updates)) {
const previousCount = Array.isArray(originalDoc?.updates) ? originalDoc.updates.length : 0
data.updates = data.updates.map((update: Record<string, unknown>, index: number) => {
if (index < previousCount) {
const previous = (originalDoc?.updates as Record<string, unknown>[])[index]
return previous
}
return {
body: trimmedText(update.body),
author:
req.user?.collection === 'campaignUser'
? req.user.id
: (update.author ?? null),
createdAt: new Date().toISOString(),
}
})
}
if (operation === 'update' && req.user?.collection === 'campaignUser' && req.user.role === 'lideranca') {
const previous = (originalDoc ?? {}) as Record<string, unknown>
const merged = { ...previous, ...(data as Record<string, unknown>) }
if (
JSON.stringify(actionPlanStaffFieldSnapshot(previous)) !==
JSON.stringify(actionPlanStaffFieldSnapshot(merged))
) {
throw new APIError(
'Lideranças só podem marcar tarefas e registrar atualizações no plano.',
403,
)
}
if (Array.isArray(data.tasks) && Array.isArray(originalDoc?.tasks)) {
const previousTasks = originalDoc.tasks as Record<string, unknown>[]
if (data.tasks.length !== previousTasks.length) {
throw new APIError('Lideranças não podem adicionar ou remover tarefas.', 403)
}
for (const [index, task] of data.tasks.entries()) {
const previousTask = previousTasks[index]
const taskRecord = task as Record<string, unknown>
if (
trimmedText(taskRecord.title) !== trimmedText(previousTask.title) ||
relationshipId(taskRecord.responsible) !== relationshipId(previousTask.responsible) ||
(taskRecord.due ?? null) !== (previousTask.due ?? null)
) {
throw new APIError(
'Lideranças só podem marcar tarefas como concluídas.',
403,
)
}
}
} else if (Array.isArray(data.tasks) && !Array.isArray(originalDoc?.tasks)) {
throw new APIError('Lideranças não podem adicionar ou remover tarefas.', 403)
}
}
return data
}
export const ActionPlan: CollectionConfig = {
slug: 'actionPlan',
labels: {
singular: 'Plano de ação',
plural: 'Planos de ação',
},
admin: {
group: 'Campanha',
useAsTitle: 'title',
defaultColumns: ['title', 'kind', 'status', 'startAt', 'updatedAt'],
},
access: {
create: canCreateActionPlan,
read: canReadActionPlan,
update: canUpdateActionPlan,
delete: canDeleteActionPlan,
},
hooks: {
beforeValidate: [
setCanonicalActionPlanSlug,
validateActionPlanTerritory,
validateActionPlanSchedule,
validateActionPlanCoordinators,
],
beforeChange: [deriveActionPlanFields],
},
fields: [
{
name: 'title',
type: 'text',
label: 'Título',
required: true,
minLength: 2,
maxLength: 160,
index: true,
},
{
name: 'slug',
type: 'text',
label: 'Slug',
required: true,
unique: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
},
},
{
name: 'kind',
type: 'select',
label: 'Tipo de ação',
required: true,
index: true,
options: [...ACTION_PLAN_KIND_OPTIONS],
},
{
name: 'status',
type: 'select',
label: 'Status',
required: true,
defaultValue: 'rascunho',
index: true,
access: {
create: canSetActionPlanStatus,
update: canSetActionPlanStatus,
},
options: [...ACTION_PLAN_STATUS_OPTIONS],
},
{
name: 'description',
type: 'textarea',
label: 'Descrição',
maxLength: 4000,
},
{
name: 'startAt',
type: 'date',
label: 'Início',
index: true,
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'endAt',
type: 'date',
label: 'Término',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'deadline',
type: 'date',
label: 'Prazo de conclusão',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'regions',
type: 'text',
label: 'Territórios de identidade',
hasMany: true,
index: true,
maxRows: MAX_NUCLEUS_REGIONS,
validate: (value: unknown) => {
if (value === null || value === undefined) return true
if (!Array.isArray(value)) {
return 'Selecione um território de identidade válido da Bahia.'
}
return value.every(
(item) => typeof item === 'string' && isBahiaIdentityTerritory(item.trim()),
)
? true
: 'Selecione um território de identidade válido da Bahia.'
},
},
{
name: 'cities',
type: 'text',
label: 'Municípios',
hasMany: true,
maxLength: 120,
index: true,
maxRows: MAX_NUCLEUS_CITIES,
},
{
name: 'neighborhoods',
type: 'text',
label: 'Bairros',
hasMany: true,
maxLength: 160,
maxRows: MAX_NUCLEUS_NEIGHBORHOODS,
},
{
name: 'locality',
type: 'text',
label: 'Localidade',
maxLength: 160,
},
{
name: 'territoryNotes',
type: 'textarea',
label: 'Observações do território',
maxLength: 2000,
},
{
name: 'coordinators',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Coordenadores',
hasMany: true,
index: true,
access: {
create: canCreateActionPlanCoordinators,
update: canManageActionPlanCoordinators,
},
filterOptions: eligibleNucleusCoordinatorWhere,
},
{
name: 'responsible',
type: 'relationship',
relationTo: 'contact',
label: 'Responsável',
index: true,
},
{
name: 'leadership',
type: 'relationship',
relationTo: 'leadership',
label: 'Liderança',
index: true,
},
{
name: 'tasks',
type: 'array',
label: 'Tarefas',
labels: {
singular: 'Tarefa',
plural: 'Tarefas',
},
fields: [
{
name: 'title',
type: 'text',
label: 'Título',
required: true,
maxLength: 200,
},
{
name: 'responsible',
type: 'relationship',
relationTo: 'contact',
label: 'Responsável',
},
{
name: 'due',
type: 'date',
label: 'Prazo',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'done',
type: 'checkbox',
label: 'Concluída',
defaultValue: false,
},
{
name: 'doneAt',
type: 'date',
label: 'Concluída em',
admin: {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
},
},
],
},
{
name: 'updates',
type: 'array',
label: 'Atualizações',
labels: {
singular: 'Atualização',
plural: 'Atualizações',
},
fields: [
{
name: 'body',
type: 'textarea',
label: 'Texto',
required: true,
maxLength: 4000,
},
{
name: 'author',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Autor',
admin: {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
},
},
{
name: 'createdAt',
type: 'date',
label: 'Criada em',
admin: {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
},
},
],
},
{
name: 'createdBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Criado por',
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
},
},
],
}

View file

@ -1,11 +1,7 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import {
isBahiaIdentityTerritory,
isBahiaMunicipality,
territoriesForCities,
} from '@/lib/bahiaTerritories'
import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import {
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
@ -22,6 +18,7 @@ import {
canSetDerivedNucleusField,
canUpdateElectoralNucleus,
} from '@/utilities/campaignAccess'
import { createCampaignTerritoryValidationHook } from '@/utilities/campaignTerritoryValidation'
import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions'
import { acquirePrimaryContactInvariantLocks } from '@/utilities/primaryContactInvariantLock'
import { relationshipId } from '@/utilities/relationship'
@ -29,93 +26,12 @@ import { slugify } from '@/utilities/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const normalizeTextArray = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
const seen = new Set<string>()
const result: string[] = []
for (const item of value) {
const trimmed = typeof item === 'string' ? item.trim() : ''
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
result.push(trimmed)
}
return result
}
const resolveTerritoryArray = (
data: Record<string, unknown> | undefined,
originalDoc: Record<string, unknown> | undefined,
field: 'regions' | 'cities' | 'neighborhoods',
operation: 'create' | 'update' | 'delete',
): string[] => {
if (operation === 'update' && data && !(field in data)) {
return normalizeTextArray(originalDoc?.[field])
}
return normalizeTextArray(data?.[field])
}
const validateNucleusTerritoryAndZones: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
}) => {
const validateNucleusTerritoryAndZones: CollectionBeforeValidateHook = (args) => {
const validateTerritory = createCampaignTerritoryValidationHook({ entityLabel: 'núcleo' })
const data = validateTerritory(args)
if (!data) return data
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
const cities = resolveTerritoryArray(data, originalDoc, 'cities', operation)
const neighborhoods = resolveTerritoryArray(data, originalDoc, 'neighborhoods', operation)
let regions = resolveTerritoryArray(data, originalDoc, 'regions', operation)
const locality = trimmedText(nextData?.locality)
const territoryNotes = trimmedText(nextData?.territoryNotes)
if (cities.length > MAX_NUCLEUS_CITIES) {
throw new APIError(`Informe no máximo ${MAX_NUCLEUS_CITIES} municípios.`, 400)
}
if (regions.length > MAX_NUCLEUS_REGIONS) {
throw new APIError(`Informe no máximo ${MAX_NUCLEUS_REGIONS} territórios.`, 400)
}
if (neighborhoods.length > MAX_NUCLEUS_NEIGHBORHOODS) {
throw new APIError(`Informe no máximo ${MAX_NUCLEUS_NEIGHBORHOODS} bairros.`, 400)
}
for (const city of cities) {
if (!isBahiaMunicipality(city)) {
throw new APIError('Selecione um município válido da Bahia.', 400)
}
}
if (cities.length > 0) {
regions = territoriesForCities(cities)
} else {
for (const region of regions) {
if (!isBahiaIdentityTerritory(region)) {
throw new APIError('Selecione um território de identidade válido da Bahia.', 400)
}
}
}
if (neighborhoods.length > 0 && cities.length !== 1) {
throw new APIError(
cities.length === 0
? 'Informe o município antes do bairro.'
: 'Bairros só podem ser informados quando há exatamente um município.',
400,
)
}
if (regions.length === 0 && cities.length === 0 && !locality) {
throw new APIError(
'Informe o território de identidade, município ou localidade do núcleo.',
400,
)
}
data.regions = regions
data.cities = cities
data.neighborhoods = cities.length === 1 ? neighborhoods : []
data.locality = locality || null
data.territoryNotes = territoryNotes || null
const nextData = args.operation === 'update' ? { ...args.originalDoc, ...data } : data
const zones = Array.isArray(nextData.tseZones) ? nextData.tseZones : []
const zoneNumbers = zones.map((zone: { zoneNumber?: unknown }) => zone?.zoneNumber)

View file

@ -0,0 +1,56 @@
import Link from 'next/link'
import { CalendarIcon, MapPinIcon, UserIcon } from 'lucide-react'
import { ActionPlanStatusBadge } from '@/components/campaign/ActionPlanStatusBadge'
import { Badge } from '@/components/ui/Badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import type { ActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
const formatWhen = (plan: ActionPlanListViewModel): string => {
if (!plan.startAt) return 'Data a definir'
return plan.city ? `${formatBahiaDateTimeLabel(plan.startAt)} · ${plan.city}` : formatBahiaDateTimeLabel(plan.startAt)
}
export const ActionPlanCard = ({ plan }: { plan: ActionPlanListViewModel }) => (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{actionPlanKindLabels[plan.kind]}</Badge>
<ActionPlanStatusBadge status={plan.status} />
</div>
<CardTitle>
<Link
href={`/campanha/planos/${plan.slug}`}
className="text-foreground underline-offset-4 hover:underline"
>
{plan.title}
</Link>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<CalendarIcon aria-hidden="true" className="size-4 shrink-0" />
<span>{formatWhen(plan)}</span>
</div>
{plan.territoryLabel ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPinIcon aria-hidden="true" className="size-4 shrink-0" />
<Badge variant="outline">{plan.territoryLabel}</Badge>
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2 pt-1 text-sm">
<span className="flex items-center gap-1.5 text-muted-foreground">
<UserIcon aria-hidden="true" className="size-4 shrink-0" />
Resp: {plan.responsibleName ?? 'Não definido'}
</span>
{plan.taskProgress.total > 0 ? (
<span className="font-medium tabular-nums text-muted-foreground">
{plan.taskProgress.done}/{plan.taskProgress.total} tarefas
</span>
) : null}
</div>
</CardContent>
</Card>
)

View file

@ -0,0 +1,229 @@
'use client'
import { useRef, useState, useTransition } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { cn } from '@/lib/utils'
import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
import { territoryForCity } from '@/lib/bahiaTerritories'
import {
actionPlanTabLabels,
actionPlanTabs,
buildActionPlanListSearchParams,
type ActionPlanListState,
type ActionPlanTab,
} from '@/utilities/actionPlanUi'
import {
municipalityComboboxOptions,
territoryComboboxOptions,
} from '@/utilities/territoryComboboxOptions'
type FilterValues = {
kind: string
status: string
region: string
city: string
}
const valuesFromState = (state: ActionPlanListState): FilterValues => ({
kind: state.kind ?? '',
status: state.status ?? '',
region: state.region ?? '',
city: state.city ?? '',
})
const buildTabHref = (state: ActionPlanListState, tab: ActionPlanTab): string => {
const params = buildActionPlanListSearchParams({
page: 1,
tab,
kind: state.kind,
status: tab === 'todos' ? state.status : undefined,
region: state.region,
city: state.city,
})
const query = params.toString()
return query ? `/campanha/planos?${query}` : '/campanha/planos'
}
const ActionPlanTabSwitch = ({ state }: { state: ActionPlanListState }) => (
<nav aria-label="Janela de planos" className="flex flex-wrap gap-2">
{actionPlanTabs.map((tab) => {
const active = tab === state.tab
return (
<Button
key={tab}
asChild
variant={active ? 'default' : 'outline'}
className="min-h-11 rounded-[6px]"
>
<Link href={buildTabHref(state, tab)} aria-current={active ? 'page' : undefined}>
{actionPlanTabLabels[tab]}
</Link>
</Button>
)
})}
</nav>
)
export const ActionPlanFilters = ({ state }: { state: ActionPlanListState }) => {
const router = useRouter()
const pathname = usePathname()
const initialValues = valuesFromState(state)
const valuesRef = useRef(initialValues)
const [values, setValues] = useState(initialValues)
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const replaceValues = (nextValues: FilterValues) => {
valuesRef.current = nextValues
setValues(nextValues)
const params = buildActionPlanListSearchParams({
page: 1,
tab: state.tab,
kind: nextValues.kind as ActionPlanListState['kind'],
status: nextValues.status as ActionPlanListState['status'],
region: nextValues.region as ActionPlanListState['region'],
city: nextValues.city,
})
const query = params.toString()
startTransition(() => {
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
})
}
const updateKind = (kind: string) => replaceValues({ ...valuesRef.current, kind })
const updateStatus = (status: string) => replaceValues({ ...valuesRef.current, status })
const updateTerritory = (region: string) => {
const current = valuesRef.current
const city = current.city && region && territoryForCity(current.city) !== region ? '' : current.city
replaceValues({ ...current, region, city })
}
const updateCity = (city: string) => {
const current = valuesRef.current
const region = territoryForCity(city) ?? current.region
replaceValues({ ...current, city, region })
}
const clearFilters = () =>
replaceValues({ kind: '', status: '', region: '', city: '' })
const hasFilters = Object.values(values).some(Boolean)
return (
<div className="flex flex-col gap-3">
<ActionPlanTabSwitch state={state} />
<div
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending}
aria-busy={isPending}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
<div className="rounded-[6px] border bg-card">
<Button
type="button"
variant="ghost"
className="min-h-11 w-full justify-start rounded-[6px] px-3 lg:hidden"
aria-expanded={mobileFiltersOpen}
aria-controls="action-plan-filter-controls"
onClick={() => setMobileFiltersOpen((open) => !open)}
>
<FilterIcon data-icon="inline-start" aria-hidden="true" />
<span>Filtros</span>
<ChevronDownIcon data-icon="inline-end" className="ml-auto" aria-hidden="true" />
</Button>
<div
id="action-plan-filter-controls"
className={cn(
mobileFiltersOpen ? 'block' : 'hidden',
'border-t p-4 lg:block lg:border-t-0',
)}
>
<div
className={cn(
'grid gap-4',
state.tab === 'todos' ? 'lg:grid-cols-4' : 'lg:grid-cols-3',
)}
>
<Field>
<FieldLabel htmlFor="action-plan-kind">Tipo de ação</FieldLabel>
<NativeSelect
id="action-plan-kind"
value={values.kind}
onChange={(event) => updateKind(event.target.value)}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todos os tipos</NativeSelectOption>
{Object.entries(actionPlanKindLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
{state.tab === 'todos' ? (
<Field>
<FieldLabel htmlFor="action-plan-status">Status</FieldLabel>
<NativeSelect
id="action-plan-status"
value={values.status}
onChange={(event) => updateStatus(event.target.value)}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todos os status</NativeSelectOption>
{Object.entries(actionPlanStatusLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
) : null}
<Field>
<FieldLabel htmlFor="action-plan-lookup-a">Território de identidade</FieldLabel>
<StrictCombobox
id="action-plan-lookup-a"
options={territoryComboboxOptions}
value={values.region}
onValueChange={updateTerritory}
/>
</Field>
<Field>
<FieldLabel htmlFor="action-plan-lookup-b">Município</FieldLabel>
<StrictCombobox
id="action-plan-lookup-b"
options={municipalityComboboxOptions(values.region)}
value={values.city}
onValueChange={updateCity}
/>
</Field>
</div>
{hasFilters ? (
<div className="mt-4 flex justify-end">
<Button
type="button"
variant="ghost"
className="min-h-11 rounded-[6px]"
onClick={clearFilters}
>
<XIcon data-icon="inline-start" aria-hidden="true" />
Limpar filtros
</Button>
</div>
) : null}
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,435 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/ContactCombobox'
import { ActionPlanTaskFields } from '@/components/campaign/ActionPlanTaskFields'
import { NucleusTerritoryFields } from '@/components/campaign/NucleusTerritoryFields'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions'
import type { ActionPlanFormViewModel } from '@/utilities/actionPlanViewModels'
import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime'
import type { NucleusCoordinatorOption } from '@/utilities/nucleusCoordinatorOptions'
export type ActionPlanFormState = {
message?: string
fieldErrors?: Record<string, string[]>
existingHref?: string
submittedTitle?: string
}
type ActionPlanFormAction = (
state: ActionPlanFormState,
formData: FormData,
) => Promise<ActionPlanFormState>
export type { ActionPlanLeadershipOption }
export type ActionPlanFormFieldsProps = {
coordinators?: NucleusCoordinatorOption[]
canManageCoordinators?: boolean
leadershipOptions?: ActionPlanLeadershipOption[]
plan?: ActionPlanFormViewModel
fieldErrors?: Record<string, string[]>
submittedTitle?: string
searchContacts: (query: string) => Promise<ContactComboboxOption[]>
}
const editableStatuses = ['rascunho', 'planejado'] as const
export const ActionPlanFormFields = ({
coordinators = [],
canManageCoordinators = false,
leadershipOptions = [],
plan,
fieldErrors = {},
submittedTitle,
searchContacts,
}: ActionPlanFormFieldsProps) => {
const errorFor = (name: string) => fieldErrors[name]?.[0]
const statusIsEditable =
!plan || editableStatuses.includes(plan.status as (typeof editableStatuses)[number])
const [status, setStatus] = useState<(typeof editableStatuses)[number]>(
plan && editableStatuses.includes(plan.status as (typeof editableStatuses)[number])
? (plan.status as (typeof editableStatuses)[number])
: 'rascunho',
)
const [responsible, setResponsible] = useState<ContactComboboxOption | null>(
plan?.responsible
? { id: plan.responsible.id, name: plan.responsible.name, phone: plan.responsible.phone }
: null,
)
return (
<FieldGroup>
{statusIsEditable ? (
<input type="hidden" name="status" value={status} />
) : (
<input type="hidden" name="status" value={plan?.status ?? 'rascunho'} />
)}
<Card>
<CardHeader>
<CardTitle>Informações básicas</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<Field data-invalid={Boolean(errorFor('title'))}>
<FieldLabel htmlFor="title">Título *</FieldLabel>
<Input
id="title"
name="title"
defaultValue={submittedTitle ?? plan?.title}
minLength={2}
maxLength={160}
className="min-h-11"
required
readOnly={Boolean(plan)}
aria-invalid={Boolean(errorFor('title'))}
aria-describedby={errorFor('title') ? 'title-error' : undefined}
/>
{errorFor('title') ? <FieldError id="title-error">{errorFor('title')}</FieldError> : null}
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={Boolean(errorFor('kind'))}>
<FieldLabel htmlFor="kind">Tipo de ação *</FieldLabel>
<NativeSelect
id="kind"
name="kind"
defaultValue={plan?.kind}
required
aria-invalid={Boolean(errorFor('kind'))}
aria-describedby={errorFor('kind') ? 'kind-error' : undefined}
className="w-full **:data-[slot=native-select]:min-h-11"
>
<NativeSelectOption value="">Selecione um tipo</NativeSelectOption>
{Object.entries(actionPlanKindLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
{errorFor('kind') ? <FieldError id="kind-error">{errorFor('kind')}</FieldError> : null}
</Field>
<Field>
<FieldLegend>Status *</FieldLegend>
{statusIsEditable ? (
<>
<ToggleGroup
type="single"
value={status}
onValueChange={(value) => value && setStatus(value as typeof status)}
variant="outline"
className="flex w-full flex-wrap justify-start"
aria-label="Status do plano"
>
<ToggleGroupItem value="planejado">Planejado</ToggleGroupItem>
<ToggleGroupItem value="rascunho">Rascunho</ToggleGroupItem>
</ToggleGroup>
<FieldDescription>
Rascunhos não exigem data. Planos planejados exigem data e horário de início.
</FieldDescription>
</>
) : (
<FieldDescription>
Status atual: {actionPlanStatusLabels[plan?.status ?? ''] ?? plan?.status}. Use as
ações do detalhe para marcar como realizado ou cancelado.
</FieldDescription>
)}
</Field>
</div>
<Field data-invalid={Boolean(errorFor('description'))}>
<FieldLabel htmlFor="description">Descrição</FieldLabel>
<Textarea
id="description"
name="description"
defaultValue={plan?.description ?? ''}
maxLength={4000}
className="min-h-24"
aria-invalid={Boolean(errorFor('description'))}
aria-describedby={errorFor('description') ? 'description-error' : undefined}
/>
{errorFor('description') ? (
<FieldError id="description-error">{errorFor('description')}</FieldError>
) : null}
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Data e horário</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-3">
<Field data-invalid={Boolean(errorFor('startAt'))}>
<FieldLabel htmlFor="startAt">
Início {status !== 'rascunho' ? '*' : null}
</FieldLabel>
<Input
id="startAt"
name="startAt"
type="datetime-local"
defaultValue={plan?.startAt ? formatIsoAsBahiaDateTimeInput(plan.startAt) : ''}
required={status !== 'rascunho'}
className="min-h-11"
aria-invalid={Boolean(errorFor('startAt'))}
aria-describedby={errorFor('startAt') ? 'startAt-error' : undefined}
/>
{errorFor('startAt') ? (
<FieldError id="startAt-error">{errorFor('startAt')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('endAt'))}>
<FieldLabel htmlFor="endAt">Término</FieldLabel>
<Input
id="endAt"
name="endAt"
type="datetime-local"
defaultValue={plan?.endAt ? formatIsoAsBahiaDateTimeInput(plan.endAt) : ''}
className="min-h-11"
aria-invalid={Boolean(errorFor('endAt'))}
aria-describedby={errorFor('endAt') ? 'endAt-error' : undefined}
/>
{errorFor('endAt') ? <FieldError id="endAt-error">{errorFor('endAt')}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errorFor('deadline'))}>
<FieldLabel htmlFor="deadline">Prazo de conclusão</FieldLabel>
<Input
id="deadline"
name="deadline"
type="datetime-local"
defaultValue={plan?.deadline ? formatIsoAsBahiaDateTimeInput(plan.deadline) : ''}
className="min-h-11"
aria-invalid={Boolean(errorFor('deadline'))}
aria-describedby={errorFor('deadline') ? 'deadline-error' : undefined}
/>
{errorFor('deadline') ? (
<FieldError id="deadline-error">{errorFor('deadline')}</FieldError>
) : null}
</Field>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Território</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<NucleusTerritoryFields values={plan} fieldErrors={fieldErrors} />
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={Boolean(errorFor('locality'))}>
<FieldLabel htmlFor="locality">Localidade</FieldLabel>
<Input
id="locality"
name="locality"
defaultValue={plan?.locality ?? ''}
maxLength={160}
className="min-h-11"
aria-invalid={Boolean(errorFor('locality'))}
aria-describedby={errorFor('locality') ? 'locality-error' : undefined}
/>
{errorFor('locality') ? (
<FieldError id="locality-error">{errorFor('locality')}</FieldError>
) : null}
</Field>
</div>
<FieldDescription>
Obrigatório: informe ao menos território de identidade, município ou localidade.
</FieldDescription>
<Field data-invalid={Boolean(errorFor('territoryNotes'))}>
<FieldLabel htmlFor="territoryNotes">Observações do território</FieldLabel>
<Textarea
id="territoryNotes"
name="territoryNotes"
defaultValue={plan?.territoryNotes ?? ''}
maxLength={2000}
className="min-h-24"
aria-invalid={Boolean(errorFor('territoryNotes'))}
aria-describedby={errorFor('territoryNotes') ? 'territoryNotes-error' : undefined}
/>
{errorFor('territoryNotes') ? (
<FieldError id="territoryNotes-error">{errorFor('territoryNotes')}</FieldError>
) : null}
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pessoas</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<Field data-invalid={Boolean(errorFor('responsible'))}>
<FieldLabel>Responsável</FieldLabel>
<ContactCombobox
name="responsible"
label="Responsável pelo plano"
current={responsible}
search={searchContacts}
onChange={setResponsible}
/>
{errorFor('responsible') ? (
<FieldError id="responsible-error">{errorFor('responsible')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('leadership'))}>
<FieldLabel htmlFor="leadership">Liderança vinculada</FieldLabel>
<NativeSelect
id="leadership"
name="leadership"
defaultValue={plan?.leadership?.id ? String(plan.leadership.id) : ''}
aria-invalid={Boolean(errorFor('leadership'))}
aria-describedby={errorFor('leadership') ? 'leadership-error' : undefined}
className="w-full **:data-[slot=native-select]:min-h-11"
>
<NativeSelectOption value="">Nenhuma</NativeSelectOption>
{leadershipOptions.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>
Quando vinculada, a liderança engajada vê o plano e pode marcar tarefas e registrar
atualizações.
</FieldDescription>
{errorFor('leadership') ? (
<FieldError id="leadership-error">{errorFor('leadership')}</FieldError>
) : null}
</Field>
{canManageCoordinators ? (
<FieldSet
aria-invalid={Boolean(errorFor('coordinators'))}
aria-describedby={errorFor('coordinators') ? 'coordinators-error' : undefined}
>
<FieldLegend>Coordenadores responsáveis</FieldLegend>
<FieldDescription>
Coordenadores selecionados podem editar este plano e gerenciar suas tarefas.
</FieldDescription>
<input type="hidden" name="coordinatorsSubmitted" value="1" />
{coordinators.length ? (
<div data-slot="checkbox-group" className="grid gap-2 sm:grid-cols-2">
{coordinators.map((coordinator) => (
<FieldLabel key={coordinator.id}>
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
<Checkbox
name="coordinators"
value={String(coordinator.id)}
defaultChecked={
plan
? plan.coordinators.some(({ id }) => id === coordinator.id)
: coordinator.isCurrent
}
/>
<span>
{coordinator.name}
{coordinator.isCurrent ? ' (você)' : ''}
</span>
</Field>
</FieldLabel>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Nenhum coordenador disponível.</p>
)}
{errorFor('coordinators') ? (
<FieldError id="coordinators-error">{errorFor('coordinators')}</FieldError>
) : null}
</FieldSet>
) : null}
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tarefas</CardTitle>
</CardHeader>
<CardContent>
<ActionPlanTaskFields
initialTasks={plan?.tasks ?? []}
searchContacts={searchContacts}
error={errorFor('tasksJson')}
/>
</CardContent>
</Card>
</FieldGroup>
)
}
export const ActionPlanForm = ({
action,
coordinators,
canManageCoordinators = false,
leadershipOptions = [],
plan,
submitLabel,
searchContacts,
}: ActionPlanFormFieldsProps & {
action: ActionPlanFormAction
submitLabel: string
}) => {
const [state, formAction, pending] = useActionState(action, {})
return (
<form action={formAction} className="flex max-w-3xl flex-col gap-6">
{plan ? <input type="hidden" name="id" value={plan.id} /> : null}
{state.message ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível salvar</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
{state.existingHref ? (
<Button asChild variant="outline" className="min-h-11 w-fit">
<Link href={state.existingHref}>Abrir plano existente</Link>
</Button>
) : null}
<ActionPlanFormFields
coordinators={coordinators}
canManageCoordinators={canManageCoordinators}
leadershipOptions={leadershipOptions}
plan={plan}
fieldErrors={state.fieldErrors}
submittedTitle={state.submittedTitle}
searchContacts={searchContacts}
/>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button asChild variant="outline" className="min-h-11">
<Link href={plan ? `/campanha/planos/${plan.slug}` : '/campanha/planos'}>Cancelar</Link>
</Button>
<Button type="submit" className="min-h-11" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Salvando…' : submitLabel}
</Button>
</div>
</form>
)
}

View file

@ -0,0 +1,109 @@
'use client'
import { useActionState } from 'react'
import { CheckCircle2Icon, XCircleIcon } from 'lucide-react'
import {
cancelActionPlanFormAction,
markActionPlanRealizedFormAction,
} from '@/app/(campaign)/campanha/(app)/planos/[slug]/lifecycleFormActions'
import { Alert, AlertDescription, AlertTitle } 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'
type LifecycleDialogProps = {
planId: number
}
export const CancelActionPlanDialog = ({ planId }: LifecycleDialogProps) => {
const [state, action, pending] = useActionState(cancelActionPlanFormAction, {})
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" variant="destructive" className="min-h-11">
<XCircleIcon data-icon="inline-start" aria-hidden="true" />
Cancelar plano
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancelar este plano?</AlertDialogTitle>
<AlertDialogDescription>
O plano será marcado como cancelado. Os dados permanecem preservados no sistema.
</AlertDialogDescription>
</AlertDialogHeader>
{state.message ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível cancelar</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<form action={action}>
<input type="hidden" name="id" value={planId} />
<AlertDialogFooter>
<AlertDialogCancel type="button" className="min-h-11" disabled={pending}>
Voltar
</AlertDialogCancel>
<Button type="submit" variant="destructive" className="min-h-11" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Cancelando…' : 'Confirmar cancelamento'}
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
)
}
export const MarkActionPlanRealizedDialog = ({ planId }: LifecycleDialogProps) => {
const [state, action, pending] = useActionState(markActionPlanRealizedFormAction, {})
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" className="min-h-11">
<CheckCircle2Icon data-icon="inline-start" aria-hidden="true" />
Marcar como realizado
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Marcar este plano como realizado?</AlertDialogTitle>
<AlertDialogDescription>
Use esta ação após a execução da atividade. O status não poderá voltar a rascunho ou
planejado por aqui.
</AlertDialogDescription>
</AlertDialogHeader>
{state.message ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível concluir</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<form action={action}>
<input type="hidden" name="id" value={planId} />
<AlertDialogFooter>
<AlertDialogCancel type="button" className="min-h-11" disabled={pending}>
Voltar
</AlertDialogCancel>
<Button type="submit" className="min-h-11" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Salvando…' : 'Confirmar'}
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -0,0 +1,10 @@
import { ActionPlanCard } from '@/components/campaign/ActionPlanCard'
import type { ActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
export const ActionPlanList = ({ plans }: { plans: ActionPlanListViewModel[] }) => (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{plans.map((plan) => (
<ActionPlanCard key={plan.id} plan={plan} />
))}
</div>
)

View file

@ -0,0 +1,61 @@
import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/Pagination'
import { buildActionPlanListHref, type ActionPlanListState } from '@/utilities/actionPlanUi'
export const ActionPlanPagination = ({
state,
totalPages,
}: {
state: ActionPlanListState
totalPages: number
}) => {
if (totalPages <= 1) return null
const visiblePages = getNucleusPaginationPages(state.page, totalPages)
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={buildActionPlanListHref(state, Math.max(1, state.page - 1))}
aria-disabled={state.page <= 1}
className={state.page <= 1 ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
{visiblePages.map((page, index) => {
const previousPage = visiblePages[index - 1]
const hasGap = previousPage !== undefined && page - previousPage > 1
return (
<PaginationItem key={page} className="contents">
{hasGap ? <PaginationEllipsis /> : null}
<PaginationLink
href={buildActionPlanListHref(state, page)}
isActive={page === state.page}
aria-label={`Ir para a página ${page}`}
>
{page}
</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
href={buildActionPlanListHref(state, Math.min(totalPages, state.page + 1))}
aria-disabled={state.page >= totalPages}
className={state.page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}

View file

@ -0,0 +1,19 @@
import { Badge, type badgeVariants } from '@/components/ui/Badge'
import { actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
import type { ActionPlanStatus } from '@/utilities/actionPlanUi'
type BadgeVariant = NonNullable<Parameters<typeof badgeVariants>[0]>['variant']
const statusVariant: Record<ActionPlanStatus, BadgeVariant> = {
rascunho: 'outline',
planejado: 'secondary',
confirmado: 'default',
realizado: 'estimate-confirmed',
cancelado: 'destructive',
}
export const ActionPlanStatusBadge = ({ status }: { status: ActionPlanStatus }) => (
<Badge variant={statusVariant[status]} data-action-plan-status={status}>
{actionPlanStatusLabels[status]}
</Badge>
)

View file

@ -0,0 +1,56 @@
import Link from 'next/link'
import { cn } from '@/lib/utils'
import {
actionPlanDetailTabs,
buildActionPlanDetailTabHref,
type ActionPlanDetailSearchParams,
type ActionPlanDetailTab,
} from '@/utilities/actionPlanDetailTabUi'
const tabLabels: Record<ActionPlanDetailTab, string> = {
overview: 'Visão geral',
tasks: 'Tarefas',
updates: 'Atualizações',
}
export const ActionPlanTabNav = ({
activeTab,
planSlug,
searchParams,
}: {
activeTab: ActionPlanDetailTab
planSlug: string
searchParams: ActionPlanDetailSearchParams
}) => (
<nav
aria-label="Seções do plano"
className="h-14 min-w-0 overflow-x-auto overflow-y-hidden border-b"
>
<ul
role="list"
className="m-0 flex h-full min-w-max list-none items-stretch gap-1 p-0 md:gap-0 lg:gap-1"
>
{actionPlanDetailTabs.map((tab) => {
const isActive = tab === activeTab
return (
<li key={tab} className="m-0 h-full p-0">
<Link
href={buildActionPlanDetailTabHref(planSlug, tab, searchParams)}
scroll={false}
aria-current={isActive ? 'page' : undefined}
className={cn(
'relative inline-flex h-full items-center whitespace-nowrap px-4 text-sm font-medium text-muted-foreground outline-none transition-colors md:px-2 lg:px-4',
'hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50',
'after:absolute after:inset-x-4 after:bottom-0 after:h-0.5 after:bg-transparent',
isActive && 'font-semibold text-primary after:bg-primary',
)}
>
{tabLabels[tab]}
</Link>
</li>
)
})}
</ul>
</nav>
)

View file

@ -0,0 +1,68 @@
'use client'
import { startTransition, useOptimistic } from 'react'
import { toast } from 'sonner'
import { toggleActionPlanTaskAction } from '@/app/(campaign)/campanha/(app)/planos/[slug]/taskActions'
import { Checkbox } from '@/components/ui/Checkbox'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import type { ActionPlanTaskViewModel } from '@/utilities/actionPlanViewModels'
type ActionPlanTaskChecklistProps = {
planId: number
tasks: ActionPlanTaskViewModel[]
}
export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskChecklistProps) => {
const [optimisticTasks, setOptimisticDone] = useOptimistic(
tasks,
(currentTasks, { taskId, done }: { taskId: string; done: boolean }) =>
currentTasks.map((task) => (task.id === taskId ? { ...task, done } : task)),
)
const toggle = (taskId: string | null, done: boolean) => {
if (!taskId) return
startTransition(async () => {
setOptimisticDone({ taskId, done })
const result = await toggleActionPlanTaskAction(planId, taskId, done)
if (!result.ok) toast.error(result.message)
})
}
if (!optimisticTasks.length) {
return <p className="text-sm text-muted-foreground">Nenhuma tarefa cadastrada.</p>
}
return (
<ul role="list" className="flex flex-col gap-2">
{optimisticTasks.map((task) => (
<li
key={task.id ?? task.title}
className="flex items-start gap-3 rounded-lg border p-3"
data-done={task.done}
>
<Checkbox
checked={task.done}
onCheckedChange={(checked) => toggle(task.id, checked === true)}
aria-label={`Marcar tarefa "${task.title}" como ${task.done ? 'pendente' : 'concluída'}`}
className="mt-0.5"
/>
<div className="flex flex-1 flex-col gap-0.5">
<span className={task.done ? 'line-through text-muted-foreground' : undefined}>
{task.title}
</span>
<span className="text-xs text-muted-foreground">
{[
task.responsibleName ? `Resp: ${task.responsibleName}` : null,
task.due ? `Prazo: ${formatBahiaDateTimeLabel(task.due)}` : null,
]
.filter(Boolean)
.join(' · ')}
</span>
</div>
</li>
))}
</ul>
)
}

View file

@ -0,0 +1,130 @@
'use client'
import { useId, useState } from 'react'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/ContactCombobox'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime'
export type ActionPlanTaskFieldValue = {
key: string
title: string
responsible: ContactComboboxOption | null
due: string
done: boolean
}
type ActionPlanTaskFieldsProps = {
initialTasks: Array<{
id: string | null
title: string
responsible: ContactComboboxOption | null
due: string | null
done: boolean
}>
searchContacts: (query: string) => Promise<ContactComboboxOption[]>
error?: string
}
let taskKeySequence = 0
const nextTaskKey = (): string => {
taskKeySequence += 1
return `new-${taskKeySequence}`
}
const serializeTasks = (tasks: ActionPlanTaskFieldValue[]) =>
JSON.stringify(
tasks
.filter((task) => task.title.trim().length > 0)
.map((task) => ({
title: task.title.trim(),
...(task.responsible ? { responsible: task.responsible.id } : {}),
...(task.due ? { due: task.due } : {}),
done: task.done,
})),
)
export const ActionPlanTaskFields = ({
initialTasks,
searchContacts,
error,
}: ActionPlanTaskFieldsProps) => {
const fieldId = useId()
const [tasks, setTasks] = useState<ActionPlanTaskFieldValue[]>(() =>
initialTasks.map((task) => ({
key: task.id ?? nextTaskKey(),
title: task.title,
responsible: task.responsible,
due: task.due ? formatIsoAsBahiaDateTimeInput(task.due) : '',
done: task.done,
})),
)
const updateTask = (key: string, patch: Partial<ActionPlanTaskFieldValue>) => {
setTasks((current) => current.map((task) => (task.key === key ? { ...task, ...patch } : task)))
}
const addTask = () => {
setTasks((current) => [
...current,
{ key: nextTaskKey(), title: '', responsible: null, due: '', done: false },
])
}
const removeTask = (key: string) => {
setTasks((current) => current.filter((task) => task.key !== key))
}
return (
<Field data-invalid={Boolean(error)}>
<FieldLabel htmlFor={fieldId}>Tarefas</FieldLabel>
<input type="hidden" id={fieldId} name="tasksJson" value={serializeTasks(tasks)} readOnly />
<div className="flex flex-col gap-3">
{tasks.map((task, index) => (
<div key={task.key} className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-start">
<div className="grid flex-1 gap-3 sm:grid-cols-[2fr_1fr_1fr]">
<Input
aria-label={`Título da tarefa ${index + 1}`}
placeholder="Título da tarefa"
value={task.title}
maxLength={200}
className="min-h-11"
onChange={(event) => updateTask(task.key, { title: event.target.value })}
/>
<ContactCombobox
label={`Responsável da tarefa ${index + 1}`}
current={task.responsible}
search={searchContacts}
onChange={(contact) => updateTask(task.key, { responsible: contact })}
/>
<Input
aria-label={`Prazo da tarefa ${index + 1}`}
type="datetime-local"
value={task.due}
className="min-h-11"
onChange={(event) => updateTask(task.key, { due: event.target.value })}
/>
</div>
<Button
type="button"
variant="ghost"
className="min-h-11 shrink-0"
onClick={() => removeTask(task.key)}
aria-label={`Remover tarefa ${index + 1}`}
>
<Trash2Icon aria-hidden="true" />
</Button>
</div>
))}
</div>
<Button type="button" variant="outline" className="min-h-11 w-fit" onClick={addTask}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Adicionar tarefa
</Button>
{error ? <FieldError id={`${fieldId}-error`}>{error}</FieldError> : null}
</Field>
)
}

View file

@ -0,0 +1,50 @@
import { MessageSquareTextIcon } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/Empty'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import type { ActionPlanUpdateViewModel } from '@/utilities/actionPlanViewModels'
const UpdateCard = ({ update }: { update: ActionPlanUpdateViewModel }) => (
<article>
<Card>
<CardHeader>
<CardTitle className="text-base">{update.authorName ?? 'Autor removido'}</CardTitle>
<p className="text-sm text-muted-foreground">
{update.createdAt ? formatBahiaDateTimeLabel(update.createdAt) : ''}
</p>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap">{update.body}</p>
</CardContent>
</Card>
</article>
)
export const ActionPlanUpdateFeed = ({ updates }: { updates: ActionPlanUpdateViewModel[] }) => {
if (!updates.length) {
return (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<MessageSquareTextIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma atualização registrada</EmptyTitle>
<EmptyDescription>
Envie o primeiro reporte para iniciar o histórico cronológico deste plano.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
const sortedUpdates = [...updates].reverse()
return (
<div className="flex flex-col gap-4">
{sortedUpdates.map((update, index) => (
<UpdateCard key={update.id ?? index} update={update} />
))}
</div>
)
}

View file

@ -0,0 +1,64 @@
'use client'
import { useActionState, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { createActionPlanUpdateFormAction } from '@/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
export const ActionPlanUpdateForm = ({ planId }: { planId: number }) => {
const router = useRouter()
const formRef = useRef<HTMLFormElement>(null)
const [state, formAction, pending] = useActionState(createActionPlanUpdateFormAction, {})
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
formRef.current?.reset()
router.refresh()
}, [router, state.message, state.status])
const bodyError = state.fieldErrors?.body?.[0]
return (
<Card>
<CardHeader>
<CardTitle>Nova atualização</CardTitle>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="flex flex-col gap-4">
<input type="hidden" name="planId" value={planId} />
{state.message && state.status !== 'success' ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível enviar</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<Field data-invalid={Boolean(bodyError)}>
<FieldLabel htmlFor="update-body">Texto da atualização *</FieldLabel>
<Textarea
id="update-body"
name="body"
required
maxLength={4000}
className="min-h-24"
aria-invalid={Boolean(bodyError)}
aria-describedby={bodyError ? 'update-body-error' : undefined}
/>
{bodyError ? <FieldError id="update-body-error">{bodyError}</FieldError> : null}
</Field>
<Button type="submit" className="min-h-11 w-fit" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Enviando…' : 'Enviar atualização'}
</Button>
</form>
</CardContent>
</Card>
)
}

View file

@ -17,10 +17,8 @@ export const CampaignBottomNav = ({ role }: { role: CampaignUser['role'] }) => {
className="fixed inset-x-0 bottom-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden"
>
<ul
className={cn(
'm-0 grid list-none p-0',
navItems.length >= 3 ? 'grid-cols-3' : 'grid-cols-2',
)}
className="m-0 grid list-none p-0"
style={{ gridTemplateColumns: `repeat(${navItems.length}, minmax(0, 1fr))` }}
>
{navItems.map((item) => {
const active = isCampaignNavActive(pathname, item.href)

View file

@ -30,7 +30,10 @@ import {
EmptyTitle,
} from '@/components/ui/Empty'
import { Progress } from '@/components/ui/Progress'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import type {
DashboardQueueItem,
GeneralDashboardViewModel,
@ -118,6 +121,45 @@ const QueueList = ({
</Card>
)
const UpcomingActionPlansCard = ({ plans }: { plans: ActionPlanUpcomingPreviewRecord[] }) => (
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>Próximos eventos</CardTitle>
<Button asChild variant="ghost" size="sm" className="min-h-11">
<Link href="/campanha/planos">
Ver todos
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</Button>
</CardHeader>
<CardContent>
{plans.length ? (
<ul className="flex flex-col gap-3">
{plans.map((plan) => (
<li key={plan.id} className="flex flex-col gap-1 border-b pb-3 last:border-b-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{actionPlanKindLabels[plan.kind]}</Badge>
<span className="text-sm text-muted-foreground">
{formatBahiaDateTimeLabel(plan.startAt)}
{plan.city ? ` · ${plan.city}` : ''}
</span>
</div>
<Link
href={`/campanha/planos/${plan.slug}`}
className="text-sm text-primary underline-offset-4 hover:underline"
>
{plan.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhum evento agendado</p>
)}
</CardContent>
</Card>
)
const GeneralDashboard = ({ view }: { view: GeneralDashboardViewModel }) => (
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@ -202,6 +244,8 @@ const GeneralDashboard = ({ view }: { view: GeneralDashboardViewModel }) => (
/>
</div>
</section>
<UpcomingActionPlansCard plans={view.upcomingActionPlans} />
</div>
)
@ -218,6 +262,9 @@ const CoordinatorDashboard = ({
<h1 className="text-2xl font-bold tracking-tight">Sua região</h1>
<p className="text-muted-foreground">O que precisa de atenção nos seus núcleos?</p>
</header>
<UpcomingActionPlansCard plans={view.upcomingActionPlans} />
{view.cards.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{view.cards.map((card) => (
@ -277,6 +324,9 @@ const LeadershipDashboard = ({
Envie seu reporte e acompanhe a estimativa confirmada.
</p>
</header>
<UpcomingActionPlansCard plans={view.upcomingActionPlans} />
{view.cards.length ? (
<div className="grid gap-4 md:grid-cols-2">
{view.cards.map((card) => (

View file

@ -0,0 +1,163 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChevronsUpDownIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandDialog,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/Command'
import { Spinner } from '@/components/ui/Spinner'
export type ContactComboboxOption = {
id: number
name: string
phone: string
}
type ContactComboboxProps = {
name?: string
label: string
current: ContactComboboxOption | null
search: (query: string) => Promise<ContactComboboxOption[]>
emptyOptionLabel?: string
onChange?: (contact: ContactComboboxOption | null) => void
}
const contactLabel = (contact: ContactComboboxOption | null, emptyLabel: string): string =>
contact ? `${contact.name} · ${contact.phone}` : emptyLabel
export const ContactCombobox = ({
name,
label,
current,
search,
emptyOptionLabel = 'Nenhum responsável',
onChange,
}: ContactComboboxProps) => {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [selected, setSelected] = useState<ContactComboboxOption | null>(current)
const [options, setOptions] = useState<ContactComboboxOption[]>([])
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)
const requestId = useRef(0)
useEffect(() => {
if (!open) return
const currentRequestId = ++requestId.current
const timeout = window.setTimeout(() => {
setLoading(true)
setFailed(false)
void search(query)
.then((nextOptions) => {
if (requestId.current !== currentRequestId) return
setOptions(nextOptions)
})
.catch(() => {
if (requestId.current !== currentRequestId) return
setFailed(true)
})
.finally(() => {
if (requestId.current === currentRequestId) setLoading(false)
})
}, 250)
return () => window.clearTimeout(timeout)
}, [open, query, search])
const choose = (contact: ContactComboboxOption | null) => {
setSelected(contact)
setOpen(false)
setQuery('')
onChange?.(contact)
}
const visibleOptions = options.filter((option) => option.id !== selected?.id)
return (
<>
{name ? <input type="hidden" name={name} value={selected?.id ?? ''} /> : null}
<Button
type="button"
variant="outline"
className="min-h-11 w-full justify-between font-normal"
aria-label={`${label}: ${contactLabel(selected, emptyOptionLabel)}`}
onClick={() => setOpen(true)}
>
<span className="truncate">{contactLabel(selected, emptyOptionLabel)}</span>
<ChevronsUpDownIcon aria-hidden="true" />
</Button>
<CommandDialog
open={open}
onOpenChange={setOpen}
title={label}
description="Busque por nome ou celular."
showCloseButton
>
<Command shouldFilter={false} label="Buscar contato por nome ou celular">
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Buscar por nome ou celular"
aria-label="Buscar contato por nome ou celular"
/>
<CommandList aria-busy={loading}>
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner aria-hidden="true" />
Buscando…
</div>
) : failed ? (
<p className="py-6 text-center text-sm text-destructive">
Não foi possível buscar contatos.
</p>
) : (
<>
<CommandGroup heading="Seleção">
<CommandItem value="none" onSelect={() => choose(null)}>
{emptyOptionLabel}
</CommandItem>
</CommandGroup>
{selected ? (
<CommandGroup heading="Selecionado">
<CommandItem
value={`current-${selected.id}`}
data-checked
onSelect={() => choose(selected)}
>
<span className="truncate">{contactLabel(selected, emptyOptionLabel)}</span>
</CommandItem>
</CommandGroup>
) : null}
{visibleOptions.length ? (
<CommandGroup heading="Contatos">
{visibleOptions.map((contact) => (
<CommandItem
key={contact.id}
value={`contact-${contact.id}`}
onSelect={() => choose(contact)}
>
<span className="truncate">{contactLabel(contact, emptyOptionLabel)}</span>
</CommandItem>
))}
</CommandGroup>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
Nenhum contato encontrado.
</p>
)}
</>
)}
</CommandList>
</Command>
</CommandDialog>
</>
)
}

View file

@ -3,6 +3,8 @@ import Link from 'next/link'
import { Badge } from '@/components/ui/Badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/Progress'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import type { NucleusListOverviewViewModel } from '@/utilities/nucleusListOverviewViewModels'
import { nucleusUpdateKindLabels } from '@/utilities/nucleusUpdateUi'
@ -122,6 +124,40 @@ export const NucleusListOverview = ({
</CardContent>
</Card>
{/* Extension points for future roadmap domains (Eventos / Demandas): plug new cards here when those collections exist. */}
<Card>
<CardHeader>
<CardTitle>Próximos eventos</CardTitle>
</CardHeader>
<CardContent>
{view.upcomingActionPlans.length ? (
<ul className="flex flex-col gap-3">
{view.upcomingActionPlans.map((plan) => (
<li
key={plan.id}
className="flex flex-col gap-1 border-b pb-3 last:border-b-0 last:pb-0"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{actionPlanKindLabels[plan.kind]}</Badge>
<span className="text-sm text-muted-foreground">
{formatBahiaDateTimeLabel(plan.startAt)}
{plan.city ? ` · ${plan.city}` : ''}
</span>
</div>
<Link
href={`/campanha/planos/${plan.slug}`}
className="text-sm text-primary underline-offset-4 hover:underline"
>
{plan.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhum evento agendado</p>
)}
</CardContent>
</Card>
{/* Extension points for future roadmap domains (Demandas): plug new cards here when those collections exist. */}
</section>
)

View file

@ -0,0 +1,319 @@
'use client'
import { useMemo, useState } from 'react'
import { XIcon } from 'lucide-react'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { TerritorySuggestionChips } from '@/components/campaign/TerritorySuggestionChips'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { InputGroup, InputGroupInput } from '@/components/ui/input-group'
import { territoriesForCities } from '@/lib/bahiaTerritories'
import {
dedupeTrimmedStrings,
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
MAX_NUCLEUS_REGIONS,
} from '@/lib/schemas/nucleus'
import { buildTerritorySuggestions } from '@/lib/territorySuggestions'
import {
allMunicipalityComboboxOptions,
territoryComboboxOptions,
} from '@/utilities/territoryComboboxOptions'
type TerritoryValues = {
regions?: string[] | null
cities?: string[] | null
neighborhoods?: string[] | null
}
const ChipList = ({
values,
onRemove,
removeLabel,
}: {
values: string[]
onRemove: (value: string) => void
removeLabel: (value: string) => string
}) => (
<div className="flex flex-wrap gap-1">
{values.map((value) => (
<Badge key={value} asChild variant="secondary" className="h-8 gap-1 rounded-sm px-2.5 py-1">
<button type="button" onClick={() => onRemove(value)} aria-label={removeLabel(value)}>
<span>{value}</span>
<XIcon aria-hidden="true" className="size-3" />
</button>
</Badge>
))}
</div>
)
/**
* Territory-only fields (regions/cities/neighborhoods) shared by any campaign
* form that needs Bahia territory selection without TSE zones — see
* `NucleusTerritoryAndZonesFields` for the nucleus variant that also manages
* TSE zones.
*/
export const NucleusTerritoryFields = ({
values,
fieldErrors = {},
}: {
values?: TerritoryValues
fieldErrors?: Record<string, string[]>
}) => {
const [regions, setRegions] = useState(() => dedupeTrimmedStrings(values?.regions ?? []))
const [cities, setCities] = useState(() => dedupeTrimmedStrings(values?.cities ?? []))
const [neighborhoods, setNeighborhoods] = useState(() =>
dedupeTrimmedStrings(values?.neighborhoods ?? []),
)
const [cityDraft, setCityDraft] = useState('')
const [regionDraft, setRegionDraft] = useState('')
const [neighborhoodDraft, setNeighborhoodDraft] = useState('')
const [regionError, setRegionError] = useState<string>()
const [cityError, setCityError] = useState<string>()
const [neighborhoodError, setNeighborhoodError] = useState<string>()
const errorFor = (name: string) => fieldErrors[name]?.[0]
const derivedRegions = useMemo(() => territoriesForCities(cities), [cities])
const displayRegions = cities.length > 0 ? derivedRegions : regions
const regionsAreDerived = cities.length > 0
const neighborhoodsEnabled = cities.length === 1
const { citySuggestions } = useMemo(
() =>
buildTerritorySuggestions({
cities,
regions: displayRegions,
tseZones: [],
}),
[cities, displayRegions],
)
const visibleRegionError = regionError ?? errorFor('regions')
const visibleCityError = cityError ?? errorFor('cities')
const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods')
const availableCityOptions = useMemo(() => {
const selected = new Set(cities)
return allMunicipalityComboboxOptions.filter((option) => !selected.has(option.value))
}, [cities])
const availableRegionOptions = useMemo(() => {
const selected = new Set(regions)
return territoryComboboxOptions.filter((option) => !selected.has(option.value))
}, [regions])
const addCity = (nextCity: string) => {
if (!nextCity) return
if (cities.length >= MAX_NUCLEUS_CITIES) {
setCityError(`Informe no máximo ${MAX_NUCLEUS_CITIES} municípios.`)
setCityDraft('')
return
}
setCities((current) => {
const next = dedupeTrimmedStrings([...current, nextCity])
if (next.length !== 1) setNeighborhoods([])
return next
})
setCityDraft('')
setCityError(undefined)
setRegionError(undefined)
}
const removeCity = (city: string) => {
setCities((current) => {
const next = current.filter((item) => item !== city)
if (next.length !== 1) setNeighborhoods([])
return next
})
setCityError(undefined)
}
const addRegion = (nextRegion: string) => {
if (!nextRegion || regionsAreDerived) return
if (regions.length >= MAX_NUCLEUS_REGIONS) {
setRegionError(`Informe no máximo ${MAX_NUCLEUS_REGIONS} territórios.`)
setRegionDraft('')
return
}
setRegions((current) => dedupeTrimmedStrings([...current, nextRegion]))
setRegionDraft('')
setRegionError(undefined)
}
const removeRegion = (region: string) => {
if (regionsAreDerived) return
setRegions((current) => current.filter((item) => item !== region))
setRegionError(undefined)
}
const commitNeighborhood = (value: string) => {
if (!neighborhoodsEnabled) {
setNeighborhoodDraft('')
return
}
const trimmed = value.trim()
if (!trimmed) {
setNeighborhoodDraft('')
return
}
if (neighborhoods.length >= MAX_NUCLEUS_NEIGHBORHOODS) {
setNeighborhoodError(`Informe no máximo ${MAX_NUCLEUS_NEIGHBORHOODS} bairros.`)
setNeighborhoodDraft('')
return
}
setNeighborhoods((current) => dedupeTrimmedStrings([...current, trimmed]))
setNeighborhoodDraft('')
setNeighborhoodError(undefined)
}
return (
<div className="grid gap-4 sm:grid-cols-2">
{cities.map((city) => (
<input key={`city-${city}`} type="hidden" name="cities" value={city} />
))}
{!regionsAreDerived
? regions.map((region) => (
<input key={`region-${region}`} type="hidden" name="regions" value={region} />
))
: null}
{neighborhoodsEnabled
? neighborhoods.map((neighborhood) => (
<input
key={`neighborhood-${neighborhood}`}
type="hidden"
name="neighborhoods"
value={neighborhood}
/>
))
: null}
<Field data-invalid={Boolean(visibleRegionError)} className="sm:col-span-2">
<FieldLabel htmlFor="plan-lookup-a">Territórios de identidade</FieldLabel>
{displayRegions.length ? (
<ChipList
values={displayRegions}
onRemove={removeRegion}
removeLabel={(region) => `Remover território ${region}`}
/>
) : null}
{regionsAreDerived ? (
<FieldDescription>
Derivados automaticamente dos municípios selecionados. Remova municípios para editar
territórios manualmente.
</FieldDescription>
) : (
<StrictCombobox
id="plan-lookup-a"
options={availableRegionOptions}
value={regionDraft}
onValueChange={(nextRegion) => {
if (nextRegion) addRegion(nextRegion)
else setRegionDraft('')
}}
onInvalid={() => {
setRegionError('Selecione um território de identidade válido da Bahia.')
}}
error={visibleRegionError}
/>
)}
{visibleRegionError ? (
<FieldError id="plan-lookup-a-error">{visibleRegionError}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(visibleCityError)} className="sm:col-span-2">
<FieldLabel htmlFor="plan-lookup-b">Municípios</FieldLabel>
<TerritorySuggestionChips
kind="city"
suggestions={citySuggestions}
onAccept={(suggestion) => addCity(suggestion.city)}
/>
{cities.length ? (
<ChipList
values={cities}
onRemove={removeCity}
removeLabel={(city) => `Remover município ${city}`}
/>
) : null}
<StrictCombobox
id="plan-lookup-b"
options={availableCityOptions}
value={cityDraft}
onValueChange={(nextCity) => {
if (nextCity) addCity(nextCity)
else setCityDraft('')
}}
onInvalid={() => {
setCityError('Selecione um município válido da Bahia.')
}}
error={visibleCityError}
/>
<FieldDescription>
Selecione um ou mais municípios. Territórios são derivados automaticamente; bairros só
ficam disponíveis com exatamente um município.
</FieldDescription>
{visibleCityError ? (
<FieldError id="plan-lookup-b-error">{visibleCityError}</FieldError>
) : null}
</Field>
<Field
data-disabled={!neighborhoodsEnabled}
data-invalid={Boolean(visibleNeighborhoodError)}
className="sm:col-span-2"
>
<FieldLabel htmlFor="plan-neighborhoodDraft">Bairros</FieldLabel>
{neighborhoodsEnabled && neighborhoods.length ? (
<ChipList
values={neighborhoods}
onRemove={(neighborhood) =>
setNeighborhoods((current) => current.filter((item) => item !== neighborhood))
}
removeLabel={(neighborhood) => `Remover bairro ${neighborhood}`}
/>
) : null}
<InputGroup className="min-h-11 h-auto flex-wrap gap-1 p-1">
<InputGroupInput
id="plan-neighborhoodDraft"
value={neighborhoodDraft}
disabled={!neighborhoodsEnabled}
placeholder={
neighborhoodsEnabled
? 'Digite um bairro e pressione Enter'
: 'Selecione exatamente um município para informar bairros'
}
autoComplete="address-level3"
aria-invalid={Boolean(visibleNeighborhoodError)}
aria-describedby={
visibleNeighborhoodError ? 'plan-neighborhoods-error' : 'plan-neighborhoods-description'
}
className="min-w-48"
onChange={(event) => {
setNeighborhoodDraft(event.target.value)
setNeighborhoodError(undefined)
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commitNeighborhood(neighborhoodDraft)
} else if (
event.key === 'Backspace' &&
!neighborhoodDraft &&
neighborhoods.length > 0
) {
setNeighborhoods((current) => current.slice(0, -1))
}
}}
onBlur={() => commitNeighborhood(neighborhoodDraft)}
/>
</InputGroup>
<FieldDescription id="plan-neighborhoods-description">
Use Enter para adicionar. Disponível somente quando há exatamente um município.
</FieldDescription>
{visibleNeighborhoodError ? (
<FieldError id="plan-neighborhoods-error">{visibleNeighborhoodError}</FieldError>
) : null}
</Field>
</div>
)
}

View file

@ -1,4 +1,4 @@
import { HomeIcon, Layers3Icon, UsersIcon, type LucideIcon } from 'lucide-react'
import { CalendarDaysIcon, HomeIcon, Layers3Icon, UsersIcon, type LucideIcon } from 'lucide-react'
import type { CampaignUser } from '@/payload-types'
import { canAccessSupporterArea } from '@/utilities/supporterUi'
@ -20,6 +20,11 @@ export const campaignNav: CampaignNavItem[] = [
href: '/campanha/nucleos',
icon: Layers3Icon,
},
{
title: 'Planos',
href: '/campanha/planos',
icon: CalendarDaysIcon,
},
{
title: 'Apoiadores',
href: '/campanha/apoiadores',

View file

@ -0,0 +1,281 @@
import { z } from 'zod'
import {
isBahiaIdentityTerritory,
isBahiaMunicipality,
territoriesForCities,
} from '@/lib/bahiaTerritories'
import {
dedupeTrimmedStrings,
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
MAX_NUCLEUS_REGIONS,
} from '@/lib/schemas/nucleus'
import {
positiveRelationshipId,
trimmedNullableText,
trimmedOptionalText,
} from '@/lib/schemas/primitives'
export const actionPlanKinds = [
'caminhada',
'comicio',
'carreata',
'panfletagem',
'porta_a_porta',
'reuniao_apoio',
'lancamento',
'convencao',
'ato',
'entrevista',
'producao_conteudo',
'digital',
'outro',
] as const
export const actionPlanStatuses = [
'rascunho',
'planejado',
'confirmado',
'realizado',
'cancelado',
] as const
export const actionPlanKindLabels: Record<(typeof actionPlanKinds)[number], string> = {
caminhada: 'Caminhada',
comicio: 'Comício',
carreata: 'Carreata',
panfletagem: 'Panfletagem',
porta_a_porta: 'Porta a porta',
reuniao_apoio: 'Reunião de apoio',
lancamento: 'Lançamento',
convencao: 'Convenção',
ato: 'Ato',
entrevista: 'Entrevista',
producao_conteudo: 'Produção de conteúdo',
digital: 'Digital',
outro: 'Outro',
}
export const actionPlanStatusLabels: Record<(typeof actionPlanStatuses)[number], string> = {
rascunho: 'Rascunho',
planejado: 'Planejado',
confirmado: 'Confirmado',
realizado: 'Realizado',
cancelado: 'Cancelado',
}
const regionsArraySchema = z
.array(z.string())
.max(MAX_NUCLEUS_REGIONS)
.transform(dedupeTrimmedStrings)
.superRefine((regions, context) => {
for (const [index, region] of regions.entries()) {
if (!isBahiaIdentityTerritory(region)) {
context.addIssue({
code: 'custom',
message: 'Selecione um território de identidade válido da Bahia.',
path: [index],
})
}
}
})
const citiesArraySchema = z
.array(z.string())
.max(MAX_NUCLEUS_CITIES)
.transform(dedupeTrimmedStrings)
.superRefine((cities, context) => {
for (const [index, city] of cities.entries()) {
if (city.length > 120 || !isBahiaMunicipality(city)) {
context.addIssue({
code: 'custom',
message: 'Selecione um município válido da Bahia.',
path: [index],
})
}
}
})
const neighborhoodsArraySchema = z
.array(z.string().max(160))
.max(MAX_NUCLEUS_NEIGHBORHOODS)
.transform(dedupeTrimmedStrings)
const taskSchema = z.object({
title: z.string().trim().min(1).max(200),
responsible: positiveRelationshipId.optional(),
due: z.string().datetime().optional().nullable(),
done: z.boolean().optional(),
})
const actionPlanFieldsSchema = z.object({
title: z.string().trim().min(2).max(160),
kind: z.enum(actionPlanKinds),
status: z.enum(actionPlanStatuses).optional(),
description: trimmedOptionalText(4000),
startAt: z.string().datetime().optional().nullable(),
endAt: z.string().datetime().optional().nullable(),
deadline: z.string().datetime().optional().nullable(),
regions: regionsArraySchema.optional(),
cities: citiesArraySchema.optional(),
neighborhoods: neighborhoodsArraySchema.optional(),
locality: trimmedOptionalText(160),
territoryNotes: trimmedOptionalText(2000),
coordinators: z.array(positiveRelationshipId).optional(),
responsible: positiveRelationshipId.optional(),
leadership: positiveRelationshipId.optional(),
tasks: z.array(taskSchema).optional(),
})
type TerritoryValidationInput = {
regions?: string[] | null
cities?: string[] | null
neighborhoods?: string[] | null
locality?: string | null
}
const validateTerritory = (
data: TerritoryValidationInput,
context: z.RefinementCtx,
mode: 'create' | 'patch',
) => {
const regions = data.regions ?? []
const cities = data.cities ?? []
const neighborhoods = data.neighborhoods ?? []
const locality = data.locality ?? undefined
const geographyWasFullyProvided =
data.regions !== undefined && data.cities !== undefined && data.locality !== undefined
if (
regions.length === 0 &&
cities.length === 0 &&
!locality &&
(mode === 'create' || geographyWasFullyProvided)
) {
context.addIssue({
code: 'custom',
message: 'Informe o território de identidade, município ou localidade do plano.',
path: ['cities'],
})
}
if (neighborhoods.length > 0 && cities.length !== 1) {
context.addIssue({
code: 'custom',
message:
cities.length === 0
? 'Informe o município antes do bairro.'
: 'Bairros só podem ser informados quando há exatamente um município.',
path: ['neighborhoods'],
})
}
}
const validateSchedule = (
data: {
status?: (typeof actionPlanStatuses)[number] | null
startAt?: string | null
endAt?: string | null
},
context: z.RefinementCtx,
mode: 'create' | 'patch',
) => {
const status = data.status ?? (mode === 'create' ? 'rascunho' : undefined)
if (status && status !== 'rascunho' && !data.startAt) {
context.addIssue({
code: 'custom',
message: 'Informe a data e horário de início ao planejar ou confirmar o plano.',
path: ['startAt'],
})
}
if (data.startAt && data.endAt) {
const start = new Date(data.startAt)
const end = new Date(data.endAt)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || !(start < end)) {
context.addIssue({
code: 'custom',
message: 'O horário de término deve ser posterior ao de início.',
path: ['endAt'],
})
}
}
}
export const actionPlanCreateSchema = actionPlanFieldsSchema
.extend({
status: z.enum(['rascunho', 'planejado']).default('rascunho'),
})
.superRefine((data, context) => {
validateTerritory(data, context, 'create')
validateSchedule(data, context, 'create')
})
.transform((data) => {
const cities = data.cities ?? []
const regions =
cities.length > 0
? territoriesForCities(cities)
: (data.regions ?? []).filter(isBahiaIdentityTerritory)
const neighborhoods = cities.length === 1 ? (data.neighborhoods ?? []) : []
return {
...data,
regions,
cities,
neighborhoods,
startAt: data.startAt ?? null,
endAt: data.endAt ?? null,
deadline: data.deadline ?? null,
}
})
export const actionPlanUpdateSchema = actionPlanFieldsSchema
.partial()
.extend({
id: positiveRelationshipId,
regions: regionsArraySchema.nullable().optional(),
cities: citiesArraySchema.nullable().optional(),
neighborhoods: neighborhoodsArraySchema.nullable().optional(),
locality: trimmedNullableText(160),
territoryNotes: trimmedNullableText(2000),
description: trimmedNullableText(4000),
startAt: z.string().datetime().nullable().optional(),
endAt: z.string().datetime().nullable().optional(),
deadline: z.string().datetime().nullable().optional(),
responsible: positiveRelationshipId.nullable().optional(),
leadership: positiveRelationshipId.nullable().optional(),
status: z.enum(actionPlanStatuses).optional(),
})
.superRefine((data, context) => {
validateTerritory(data, context, 'patch')
validateSchedule(data, context, 'patch')
})
.transform((data) => {
const cities = data.cities === undefined ? undefined : (data.cities ?? [])
const neighborhoods =
data.neighborhoods === undefined
? undefined
: cities !== undefined && cities.length !== 1
? []
: (data.neighborhoods ?? [])
const regions =
cities === undefined
? data.regions === undefined
? undefined
: (data.regions ?? []).filter(isBahiaIdentityTerritory)
: cities.length > 0
? territoriesForCities(cities)
: data.regions === undefined
? []
: (data.regions ?? []).filter(isBahiaIdentityTerritory)
return {
...data,
...(regions !== undefined ? { regions } : {}),
...(cities !== undefined ? { cities } : {}),
...(neighborhoods !== undefined ? { neighborhoods } : {}),
}
})
export type ActionPlanCreateInput = z.input<typeof actionPlanCreateSchema>
export type ActionPlanUpdateInput = z.input<typeof actionPlanUpdateSchema>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,119 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_action_plan_kind" AS ENUM('caminhada', 'comicio', 'carreata', 'panfletagem', 'porta_a_porta', 'reuniao_apoio', 'lancamento', 'convencao', 'ato', 'entrevista', 'producao_conteudo', 'digital', 'outro');
CREATE TYPE "public"."enum_action_plan_status" AS ENUM('rascunho', 'planejado', 'confirmado', 'realizado', 'cancelado');
CREATE TABLE "action_plan_tasks" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"title" varchar NOT NULL,
"responsible_id" integer,
"due" timestamp(3) with time zone,
"done" boolean DEFAULT false,
"done_at" timestamp(3) with time zone
);
CREATE TABLE "action_plan_updates" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"body" varchar NOT NULL,
"author_id" integer,
"created_at" timestamp(3) with time zone
);
CREATE TABLE "action_plan" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar NOT NULL,
"slug" varchar NOT NULL,
"kind" "enum_action_plan_kind" NOT NULL,
"status" "enum_action_plan_status" DEFAULT 'rascunho' NOT NULL,
"description" varchar,
"start_at" timestamp(3) with time zone,
"end_at" timestamp(3) with time zone,
"deadline" timestamp(3) with time zone,
"locality" varchar,
"territory_notes" varchar,
"responsible_id" integer,
"leadership_id" integer,
"created_by_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "action_plan_texts" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"text" varchar
);
CREATE TABLE "action_plan_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"campaign_user_id" integer
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "action_plan_id" integer;
ALTER TABLE "action_plan_tasks" ADD CONSTRAINT "action_plan_tasks_responsible_id_contact_id_fk" FOREIGN KEY ("responsible_id") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "action_plan_tasks" ADD CONSTRAINT "action_plan_tasks_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."action_plan"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "action_plan_updates" ADD CONSTRAINT "action_plan_updates_author_id_campaign_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "action_plan_updates" ADD CONSTRAINT "action_plan_updates_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."action_plan"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "action_plan" ADD CONSTRAINT "action_plan_responsible_id_contact_id_fk" FOREIGN KEY ("responsible_id") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "action_plan" ADD CONSTRAINT "action_plan_leadership_id_leadership_id_fk" FOREIGN KEY ("leadership_id") REFERENCES "public"."leadership"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "action_plan" ADD CONSTRAINT "action_plan_created_by_id_campaign_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "action_plan_texts" ADD CONSTRAINT "action_plan_texts_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."action_plan"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "action_plan_rels" ADD CONSTRAINT "action_plan_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."action_plan"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "action_plan_rels" ADD CONSTRAINT "action_plan_rels_campaign_user_fk" FOREIGN KEY ("campaign_user_id") REFERENCES "public"."campaign_user"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "action_plan_tasks_order_idx" ON "action_plan_tasks" USING btree ("_order");
CREATE INDEX "action_plan_tasks_parent_id_idx" ON "action_plan_tasks" USING btree ("_parent_id");
CREATE INDEX "action_plan_tasks_responsible_idx" ON "action_plan_tasks" USING btree ("responsible_id");
CREATE INDEX "action_plan_updates_order_idx" ON "action_plan_updates" USING btree ("_order");
CREATE INDEX "action_plan_updates_parent_id_idx" ON "action_plan_updates" USING btree ("_parent_id");
CREATE INDEX "action_plan_updates_author_idx" ON "action_plan_updates" USING btree ("author_id");
CREATE INDEX "action_plan_title_idx" ON "action_plan" USING btree ("title");
CREATE UNIQUE INDEX "action_plan_slug_idx" ON "action_plan" USING btree ("slug");
CREATE INDEX "action_plan_kind_idx" ON "action_plan" USING btree ("kind");
CREATE INDEX "action_plan_status_idx" ON "action_plan" USING btree ("status");
CREATE INDEX "action_plan_start_at_idx" ON "action_plan" USING btree ("start_at");
CREATE INDEX "action_plan_responsible_idx" ON "action_plan" USING btree ("responsible_id");
CREATE INDEX "action_plan_leadership_idx" ON "action_plan" USING btree ("leadership_id");
CREATE INDEX "action_plan_created_by_idx" ON "action_plan" USING btree ("created_by_id");
CREATE INDEX "action_plan_updated_at_idx" ON "action_plan" USING btree ("updated_at");
CREATE INDEX "action_plan_created_at_idx" ON "action_plan" USING btree ("created_at");
CREATE INDEX "action_plan_texts_order_parent" ON "action_plan_texts" USING btree ("order","parent_id");
CREATE INDEX "action_plan_texts_text_idx" ON "action_plan_texts" USING btree ("text");
CREATE INDEX "action_plan_rels_order_idx" ON "action_plan_rels" USING btree ("order");
CREATE INDEX "action_plan_rels_parent_idx" ON "action_plan_rels" USING btree ("parent_id");
CREATE INDEX "action_plan_rels_path_idx" ON "action_plan_rels" USING btree ("path");
CREATE INDEX "action_plan_rels_campaign_user_id_idx" ON "action_plan_rels" USING btree ("campaign_user_id");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_action_plan_fk" FOREIGN KEY ("action_plan_id") REFERENCES "public"."action_plan"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_action_plan_id_idx" ON "payload_locked_documents_rels" USING btree ("action_plan_id");
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_action_plan_fk";
DROP INDEX IF EXISTS "payload_locked_documents_rels_action_plan_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "action_plan_id";
DROP TABLE IF EXISTS "action_plan_tasks" CASCADE;
DROP TABLE IF EXISTS "action_plan_updates" CASCADE;
DROP TABLE IF EXISTS "action_plan_texts" CASCADE;
DROP TABLE IF EXISTS "action_plan_rels" CASCADE;
DROP TABLE IF EXISTS "action_plan" CASCADE;
DROP TYPE IF EXISTS "public"."enum_action_plan_kind";
DROP TYPE IF EXISTS "public"."enum_action_plan_status";
`)
}

View file

@ -7,6 +7,7 @@ import * as migration_20260718_010733_consolidate_campaign_schema from './202607
import * as migration_20260718_190559_territorio_multi_municipio_bairro from './20260718_190559_territorio_multi_municipio_bairro';
import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results';
import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter';
import * as migration_20260718_222832_add_action_plan from './20260718_222832_add_action_plan';
export const migrations = [
{
@ -52,6 +53,11 @@ export const migrations = [
{
up: migration_20260718_222656_add_supporter.up,
down: migration_20260718_222656_add_supporter.down,
name: '20260718_222656_add_supporter'
name: '20260718_222656_add_supporter',
},
{
up: migration_20260718_222832_add_action_plan.up,
down: migration_20260718_222832_add_action_plan.down,
name: '20260718_222832_add_action_plan',
},
];

View file

@ -75,6 +75,7 @@ export interface Config {
leadership: Leadership;
supporter: Supporter;
nucleusUpdate: NucleusUpdate;
actionPlan: ActionPlan;
electionTally: ElectionTally;
electionCandidateVote: ElectionCandidateVote;
electionCandidate: ElectionCandidate;
@ -101,6 +102,7 @@ export interface Config {
leadership: LeadershipSelect<false> | LeadershipSelect<true>;
supporter: SupporterSelect<false> | SupporterSelect<true>;
nucleusUpdate: NucleusUpdateSelect<false> | NucleusUpdateSelect<true>;
actionPlan: ActionPlanSelect<false> | ActionPlanSelect<true>;
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
electionCandidateVote: ElectionCandidateVoteSelect<false> | ElectionCandidateVoteSelect<true>;
electionCandidate: ElectionCandidateSelect<false> | ElectionCandidateSelect<true>;
@ -491,6 +493,63 @@ export interface NucleusUpdate {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "actionPlan".
*/
export interface ActionPlan {
id: number;
title: string;
slug: string;
kind:
| 'caminhada'
| 'comicio'
| 'carreata'
| 'panfletagem'
| 'porta_a_porta'
| 'reuniao_apoio'
| 'lancamento'
| 'convencao'
| 'ato'
| 'entrevista'
| 'producao_conteudo'
| 'digital'
| 'outro';
status: 'rascunho' | 'planejado' | 'confirmado' | 'realizado' | 'cancelado';
description?: string | null;
startAt?: string | null;
endAt?: string | null;
deadline?: string | null;
regions?: string[] | null;
cities?: string[] | null;
neighborhoods?: string[] | null;
locality?: string | null;
territoryNotes?: string | null;
coordinators?: (number | CampaignUser)[] | null;
responsible?: (number | null) | Contact;
leadership?: (number | null) | Leadership;
tasks?:
| {
title: string;
responsible?: (number | null) | Contact;
due?: string | null;
done?: boolean | null;
doneAt?: string | null;
id?: string | null;
}[]
| null;
updates?:
| {
body: string;
author?: (number | null) | CampaignUser;
createdAt?: string | null;
id?: string | null;
}[]
| null;
createdBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* Totais oficiais TSE por município e zona (dado público).
*
@ -854,6 +913,10 @@ export interface PayloadLockedDocument {
relationTo: 'nucleusUpdate';
value: number | NucleusUpdate;
} | null)
| ({
relationTo: 'actionPlan';
value: number | ActionPlan;
} | null)
| ({
relationTo: 'electionTally';
value: number | ElectionTally;
@ -1140,6 +1203,49 @@ export interface NucleusUpdateSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "actionPlan_select".
*/
export interface ActionPlanSelect<T extends boolean = true> {
title?: T;
slug?: T;
kind?: T;
status?: T;
description?: T;
startAt?: T;
endAt?: T;
deadline?: T;
regions?: T;
cities?: T;
neighborhoods?: T;
locality?: T;
territoryNotes?: T;
coordinators?: T;
responsible?: T;
leadership?: T;
tasks?:
| T
| {
title?: T;
responsible?: T;
due?: T;
done?: T;
doneAt?: T;
id?: T;
};
updates?:
| T
| {
body?: T;
author?: T;
createdAt?: T;
id?: T;
};
createdBy?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "electionTally_select".

View file

@ -7,6 +7,7 @@ import { fileURLToPath } from 'url'
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
import { pt } from 'payload/i18n/pt'
import { ActionPlan } from './collections/ActionPlan'
import { CampaignInvite } from './collections/CampaignInvite'
import { CampaignUser } from './collections/CampaignUser'
import { Consent } from './collections/Consent'
@ -51,6 +52,7 @@ export default buildConfig({
Leadership,
Supporter,
NucleusUpdate,
ActionPlan,
ElectionTally,
ElectionCandidateVote,
ElectionCandidate,

View file

@ -0,0 +1,60 @@
export const actionPlanDetailTabs = ['overview', 'tasks', 'updates'] as const
export type ActionPlanDetailTab = (typeof actionPlanDetailTabs)[number]
export type ActionPlanDetailSearchParams = Record<string, string | string[] | undefined>
const tabQueryKeys: Record<ActionPlanDetailTab, readonly string[]> = {
overview: [],
tasks: [],
updates: ['newUpdate'],
}
const firstValue = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value
const appendQueryValue = (
params: URLSearchParams,
key: string,
value: string | string[] | undefined,
) => {
for (const item of Array.isArray(value) ? value : [value]) {
if (item !== undefined) params.append(key, item)
}
}
const isAllowedTab = (value: string | undefined): value is ActionPlanDetailTab =>
actionPlanDetailTabs.includes(value as ActionPlanDetailTab)
export const resolveActionPlanDetailTab = (
searchParams: ActionPlanDetailSearchParams,
): ActionPlanDetailTab => {
if (firstValue(searchParams.newUpdate) === '1') return 'updates'
const requestedTab = firstValue(searchParams.tab)
return isAllowedTab(requestedTab) ? requestedTab : 'overview'
}
export const buildActionPlanDetailTabHref = (
planSlug: string,
tab: ActionPlanDetailTab,
searchParams: ActionPlanDetailSearchParams,
): string => {
const params = new URLSearchParams()
for (const key of tabQueryKeys[tab]) {
appendQueryValue(params, key, searchParams[key])
}
params.set('tab', tab)
return `/campanha/planos/${planSlug}?${params.toString()}`
}
export const getActionPlanDetailTabRedirect = (
planSlug: string,
searchParams: ActionPlanDetailSearchParams,
): string | null => {
const activeTab = resolveActionPlanDetailTab(searchParams)
const requestedTab = searchParams.tab
if (typeof requestedTab === 'string' && requestedTab === activeTab) return null
return buildActionPlanDetailTabHref(planSlug, activeTab, searchParams)
}

View file

@ -0,0 +1,193 @@
import 'server-only'
import {
bahiaIdentityTerritories,
type BahiaIdentityTerritory,
isBahiaMunicipality,
territoriesForCities,
} from '@/lib/bahiaTerritories'
import {
actionPlanKinds,
actionPlanStatuses,
type ActionPlanCreateInput,
type ActionPlanUpdateInput,
} from '@/lib/schemas/actionPlan'
import {
boundedJsonFormValue,
FormDataBoundaryError,
nullableRelationshipFormValue,
optionalFormText,
repeatedFormTexts,
repeatedRelationshipFormValues,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { parseBahiaDateTimeInput } from '@/utilities/campaignTime'
type ParsedActionPlanTask = {
title: string
responsible?: number
due?: string
done?: boolean
}
const parseDateTimeFormField = (formData: FormData, field: string): string | undefined => {
const value = optionalFormText(formData, field)
if (!value) return undefined
const iso = parseBahiaDateTimeInput(value)
if (!iso) throw new FormDataBoundaryError(field, 'Data e horário inválidos.')
return iso
}
const parseTasksFormData = (formData: FormData): ParsedActionPlanTask[] => {
const raw = boundedJsonFormValue(formData, 'tasksJson', 20_000)
if (raw === undefined) return []
if (!Array.isArray(raw)) {
throw new FormDataBoundaryError('tasksJson', 'Lista de tarefas inválida.')
}
return raw.map((item, index) => {
if (typeof item !== 'object' || item === null) {
throw new FormDataBoundaryError('tasksJson', `Tarefa ${index + 1} inválida.`)
}
const record = item as Record<string, unknown>
const title = typeof record.title === 'string' ? record.title.trim() : ''
if (!title) {
throw new FormDataBoundaryError('tasksJson', `Informe o título da tarefa ${index + 1}.`)
}
if (title.length > 200) {
throw new FormDataBoundaryError(
'tasksJson',
`O título da tarefa ${index + 1} é muito longo.`,
)
}
const responsible =
typeof record.responsible === 'number' &&
Number.isInteger(record.responsible) &&
record.responsible > 0
? record.responsible
: undefined
const dueRaw = typeof record.due === 'string' ? record.due : undefined
const due = dueRaw ? (parseBahiaDateTimeInput(dueRaw) ?? undefined) : undefined
if (dueRaw && !due) {
throw new FormDataBoundaryError('tasksJson', `Prazo inválido na tarefa ${index + 1}.`)
}
const done = typeof record.done === 'boolean' ? record.done : undefined
return {
title,
...(responsible ? { responsible } : {}),
...(due ? { due } : {}),
...(done !== undefined ? { done } : {}),
}
})
}
const parseSharedActionPlanFormData = (formData: FormData) => {
const cities = repeatedFormTexts(formData, 'cities')
const regions =
cities.length > 0
? territoriesForCities(cities)
: (repeatedFormTexts(formData, 'regions') as BahiaIdentityTerritory[])
const neighborhoods = cities.length === 1 ? repeatedFormTexts(formData, 'neighborhoods') : []
const responsible = nullableRelationshipFormValue(formData, 'responsible')
const leadership = nullableRelationshipFormValue(formData, 'leadership')
return {
title: optionalFormText(formData, 'title') ?? '',
kind: optionalFormText(formData, 'kind') as (typeof actionPlanKinds)[number],
status: optionalFormText(formData, 'status') as (typeof actionPlanStatuses)[number] | undefined,
description: optionalFormText(formData, 'description'),
startAt: parseDateTimeFormField(formData, 'startAt'),
endAt: parseDateTimeFormField(formData, 'endAt'),
deadline: parseDateTimeFormField(formData, 'deadline'),
regions,
cities,
neighborhoods,
locality: optionalFormText(formData, 'locality'),
territoryNotes: optionalFormText(formData, 'territoryNotes'),
responsible,
leadership,
tasks: parseTasksFormData(formData),
}
}
const validateTerritoryFormData = (
territory: {
regions: string[]
cities: string[]
neighborhoods: string[]
locality?: string
},
{ requireGeography }: { requireGeography: boolean },
) => {
for (const region of territory.regions) {
if (!bahiaIdentityTerritories.includes(region as never)) {
throw new FormDataBoundaryError('regions', 'Território de identidade inválido.')
}
}
for (const city of territory.cities) {
if (!isBahiaMunicipality(city)) {
throw new FormDataBoundaryError('cities', 'Município inválido.')
}
}
if (territory.neighborhoods.length > 0 && territory.cities.length !== 1) {
throw new FormDataBoundaryError(
'neighborhoods',
territory.cities.length === 0
? 'Informe o município antes do bairro.'
: 'Bairros só podem ser informados quando há exatamente um município.',
)
}
if (
requireGeography &&
territory.regions.length === 0 &&
territory.cities.length === 0 &&
!territory.locality
) {
throw new FormDataBoundaryError(
'cities',
'Informe o território de identidade, município ou localidade do plano.',
)
}
}
export const parseActionPlanCreateFormData = (formData: FormData): ActionPlanCreateInput => {
const shared = parseSharedActionPlanFormData(formData)
validateTerritoryFormData(shared, { requireGeography: false })
const coordinatorIds = repeatedRelationshipFormValues(formData, 'coordinators')
return {
...shared,
status: (shared.status ?? 'rascunho') as ActionPlanCreateInput['status'],
responsible: shared.responsible ?? undefined,
leadership: shared.leadership ?? undefined,
coordinators: coordinatorIds.length ? coordinatorIds : undefined,
}
}
export const parseActionPlanUpdateFormData = (formData: FormData): ActionPlanUpdateInput => {
const shared = parseSharedActionPlanFormData(formData)
validateTerritoryFormData(shared, { requireGeography: true })
const coordinatorIds = repeatedRelationshipFormValues(formData, 'coordinators')
// Checkboxes only submit when checked, so a hidden marker distinguishes
// "coordinators section rendered but all unchecked" from "not editable" (coordenador view).
const hasCoordinators = formData.has('coordinatorsSubmitted')
return {
...shared,
id: requiredRelationshipFormValue(formData, 'id'),
status: shared.status as ActionPlanUpdateInput['status'],
regions: shared.regions,
cities: shared.cities,
neighborhoods: shared.neighborhoods,
locality: shared.locality ?? null,
territoryNotes: shared.territoryNotes ?? null,
description: shared.description ?? null,
startAt: shared.startAt ?? null,
endAt: shared.endAt ?? null,
deadline: shared.deadline ?? null,
responsible: shared.responsible,
leadership: shared.leadership,
coordinators: hasCoordinators ? coordinatorIds : undefined,
}
}

View file

@ -0,0 +1,48 @@
import 'server-only'
import type { Payload } from 'payload'
import type { CampaignUser, Contact, Leadership } from '@/payload-types'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
export type ActionPlanLeadershipOption = {
id: number
label: string
}
/**
* Engaged leaderships visible to the actor (for linking a plan to a leadership).
* Uses overrideAccess: false so Payload row-level access applies.
*/
export const getActionPlanLeadershipOptions = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
): Promise<ActionPlanLeadershipOption[]> => {
const result = await payload.find({
collection: 'leadership',
where: { supportStatus: { equals: 'engajado' } },
depth: 1,
limit: 200,
sort: 'id',
select: {
contact: true,
supportStatus: true,
},
user,
overrideAccess: false,
})
return result.docs
.map((doc) => {
const leadership = doc as Leadership
const id = relationshipId(leadership.id)
if (!id) return null
const contact = leadership.contact
const label = isPopulatedRelationship<Contact>(contact)
? contact.name
: `Liderança #${id}`
return { id, label }
})
.filter((option): option is ActionPlanLeadershipOption => option !== null)
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
}

View file

@ -0,0 +1,105 @@
import type { Payload } from 'payload'
import type { ActionPlan, CampaignUser } from '@/payload-types'
import {
actionPlanDetailSelect,
actionPlanFormSelect,
actionPlanListSelect,
toActionPlanDetailViewModel,
toActionPlanFormViewModel,
} from '@/utilities/actionPlanViewModels'
import {
actionPlanPageSize,
buildActionPlanListWhere,
parseActionPlanListParams,
} from '@/utilities/actionPlanUi'
type ActionPlanListSearchParams = Record<string, string | string[] | undefined>
export class ActionPlanNotFoundError extends Error {
override name = 'ActionPlanNotFoundError'
constructor() {
super('Plano de ação não encontrado.')
}
}
export type AccessibleActionPlanContext = {
id: number
slug: string
document: ActionPlan
}
export const loadActionPlanListPageData = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
searchParams: Promise<ActionPlanListSearchParams> | ActionPlanListSearchParams,
now = new Date(),
) => {
const state = parseActionPlanListParams(await searchParams)
const result = await payload.find({
collection: 'actionPlan',
depth: 1,
limit: actionPlanPageSize,
page: state.page,
sort: 'startAt',
where: buildActionPlanListWhere(state, now),
select: actionPlanListSelect,
user,
overrideAccess: false,
})
return { result, state }
}
const loadAccessibleActionPlanBySlug = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
planSlug: string,
select: typeof actionPlanDetailSelect | typeof actionPlanFormSelect,
): Promise<ActionPlan> => {
const result = await payload.find({
collection: 'actionPlan',
where: { slug: { equals: planSlug } },
depth: 1,
limit: 1,
pagination: false,
select,
user,
overrideAccess: false,
})
const plan = result.docs[0]
if (!plan) throw new ActionPlanNotFoundError()
return plan as ActionPlan
}
export const resolveAccessibleActionPlanContext = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
planSlug: string,
): Promise<AccessibleActionPlanContext> => {
const document = await loadAccessibleActionPlanBySlug(
payload,
user,
planSlug,
actionPlanDetailSelect,
)
return {
id: document.id,
slug: document.slug,
document,
}
}
export const getActionPlanDetailPageData = (context: AccessibleActionPlanContext) =>
toActionPlanDetailViewModel(context.document)
export const getActionPlanEditPageData = async (
payload: Payload,
user: CampaignUser,
planSlug: string,
) =>
toActionPlanFormViewModel(
await loadAccessibleActionPlanBySlug(payload, user, planSlug, actionPlanFormSelect),
)

View file

@ -0,0 +1,209 @@
import type { Where } from 'payload'
import {
bahiaIdentityTerritories,
bahiaMunicipalities,
type BahiaIdentityTerritory,
territoryForCity,
} from '@/lib/bahiaTerritories'
import { actionPlanKinds, actionPlanStatuses } from '@/lib/schemas/actionPlan'
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
export const actionPlanPageSize = 25
export const actionPlanTabs = ['proximos', 'todos', 'realizados', 'rascunhos'] as const
export type ActionPlanTab = (typeof actionPlanTabs)[number]
export type ActionPlanKind = (typeof actionPlanKinds)[number]
export type ActionPlanStatus = (typeof actionPlanStatuses)[number]
export const actionPlanTabLabels: Record<ActionPlanTab, string> = {
proximos: 'Próximos',
todos: 'Todos',
realizados: 'Realizados',
rascunhos: 'Rascunhos',
}
export type ActionPlanListState = {
page: number
tab: ActionPlanTab
kind?: ActionPlanKind
status?: ActionPlanStatus
region?: BahiaIdentityTerritory
city?: string
}
type RawSearchParams = Record<string, string | string[] | undefined>
export const actionPlanListParamNames = ['tab', 'kind', 'status', 'region', 'city', 'page'] as const
const actionPlanListParamNameSet = new Set<string>(actionPlanListParamNames)
const firstValue = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value
const normalizedText = (value: string | undefined): string | undefined => {
if (typeof value !== 'string') return undefined
const normalized = value.trim()
return normalized || undefined
}
const strictDecimalInteger = (value: string | undefined): number | undefined => {
if (!value || !/^[1-9]\d*$/.test(value)) return undefined
const number = Number(value)
return Number.isSafeInteger(number) ? number : undefined
}
const canonicalTerritoryBySearchValue = new Map(
bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]),
)
const canonicalMunicipalityBySearchValue = new Map(
bahiaMunicipalities.map((city) => [normalizeSearchPhrase(city), city]),
)
const canonicalOfficialValue = <Value extends string>(
value: string | undefined,
canonicalValues: ReadonlyMap<string, Value>,
): Value | undefined => {
const normalized = normalizedText(value)
return normalized ? canonicalValues.get(normalizeSearchPhrase(normalized)) : undefined
}
const isActionPlanTab = (value: string | undefined): value is ActionPlanTab =>
actionPlanTabs.includes(value as ActionPlanTab)
const isActionPlanKind = (value: string | undefined): value is ActionPlanKind =>
actionPlanKinds.includes(value as ActionPlanKind)
const isActionPlanStatus = (value: string | undefined): value is ActionPlanStatus =>
actionPlanStatuses.includes(value as ActionPlanStatus)
export const parseActionPlanListParams = (params: RawSearchParams): ActionPlanListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const rawTab = firstValue(params.tab)
const tab = isActionPlanTab(rawTab) ? rawTab : 'proximos'
const rawKind = firstValue(params.kind)
const kind = isActionPlanKind(rawKind) ? rawKind : undefined
const rawStatus = tab === 'todos' ? firstValue(params.status) : undefined
const status = isActionPlanStatus(rawStatus) ? rawStatus : undefined
const region = canonicalOfficialValue(firstValue(params.region), canonicalTerritoryBySearchValue)
const canonicalCity = canonicalOfficialValue(
firstValue(params.city),
canonicalMunicipalityBySearchValue,
)
const city =
canonicalCity && (!region || territoryForCity(canonicalCity) === region)
? canonicalCity
: undefined
return {
page: rawPage ?? 1,
tab,
...(kind ? { kind } : {}),
...(status ? { status } : {}),
...(region ? { region } : {}),
...(city ? { city } : {}),
}
}
export const buildActionPlanListWhere = (state: ActionPlanListState, now: Date): Where => {
const filters: Where[] = []
if (state.kind) filters.push({ kind: { equals: state.kind } })
if (state.region) filters.push({ regions: { equals: state.region } })
if (state.city) filters.push({ cities: { equals: state.city } })
if (state.tab === 'proximos') {
filters.push({ status: { in: ['planejado', 'confirmado'] } })
filters.push({ startAt: { greater_than_equal: now.toISOString() } })
} else if (state.tab === 'realizados') {
filters.push({ status: { equals: 'realizado' } })
} else if (state.tab === 'rascunhos') {
filters.push({ status: { equals: 'rascunho' } })
} else if (state.status) {
filters.push({ status: { equals: state.status } })
}
return filters.length ? { and: filters } : {}
}
export const buildActionPlanListSearchParams = (
state: ActionPlanListState,
page = state.page,
): URLSearchParams => {
const canonicalState = parseActionPlanListParams({
page: String(page),
tab: state.tab,
kind: state.kind,
status: state.status,
region: state.region,
city: state.city,
})
const params = new URLSearchParams()
if (canonicalState.tab !== 'proximos') params.set('tab', canonicalState.tab)
if (canonicalState.kind) params.set('kind', canonicalState.kind)
if (canonicalState.status) params.set('status', canonicalState.status)
if (canonicalState.region) params.set('region', canonicalState.region)
if (canonicalState.city) params.set('city', canonicalState.city)
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
return params
}
export const buildActionPlanFiltersKey = (state: ActionPlanListState): string =>
buildActionPlanListSearchParams(state).toString()
export const buildActionPlanListHref = (state: ActionPlanListState, page: number): string => {
const params = buildActionPlanListSearchParams(state, page)
const query = params.toString()
return query ? `/campanha/planos?${query}` : '/campanha/planos'
}
const inspectRawActionPlanListParams = (
params: RawSearchParams,
): { hasUnsupportedParams: boolean; query: string } => {
const serialized = new URLSearchParams()
let hasUnsupportedParams = false
for (const [name, value] of Object.entries(params)) {
if (!actionPlanListParamNameSet.has(name)) {
hasUnsupportedParams = true
continue
}
if (value === undefined) continue
for (const item of Array.isArray(value) ? value : [value]) {
serialized.append(name, item)
}
}
return { hasUnsupportedParams, query: serialized.toString() }
}
export const resolveActionPlanListUrl = (
params: RawSearchParams,
totalPages?: number,
): {
state: ActionPlanListState
href: string
redirectHref?: string
} => {
const parsedState = parseActionPlanListParams(params)
const page =
totalPages !== undefined && totalPages > 0 && parsedState.page > totalPages
? totalPages
: parsedState.page
const state = page === parsedState.page ? parsedState : { ...parsedState, page }
const canonicalParams = buildActionPlanListSearchParams(state)
const canonicalQuery = canonicalParams.toString()
const href = canonicalQuery ? `/campanha/planos?${canonicalQuery}` : '/campanha/planos'
const raw = inspectRawActionPlanListParams(params)
const needsRedirect = raw.hasUnsupportedParams || raw.query !== canonicalQuery
return {
state,
href,
...(needsRedirect ? { redirectHref: href } : {}),
}
}

View file

@ -0,0 +1,70 @@
import type { Payload, Where } from 'payload'
import type { ActionPlan, CampaignUser } from '@/payload-types'
export const actionPlanUpcomingPreviewLimit = 3
const upcomingActionPlanSelect = {
title: true,
slug: true,
kind: true,
startAt: true,
cities: true,
} as const
export type ActionPlanUpcomingPreviewRecord = {
id: number
slug: string
title: string
kind: ActionPlan['kind']
startAt: string
city: string | null
}
export type ActionPlanUpcomingPreviewFilters = {
region?: string
city?: string
}
export const loadUpcomingActionPlansPreview = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
now: Date,
options: {
limit?: number
filters?: ActionPlanUpcomingPreviewFilters
} = {},
): Promise<ActionPlanUpcomingPreviewRecord[]> => {
const limit = options.limit ?? actionPlanUpcomingPreviewLimit
const filters: Where[] = [
{ status: { in: ['planejado', 'confirmado'] } },
{ startAt: { greater_than_equal: now.toISOString() } },
]
if (options.filters?.region) {
filters.push({ regions: { equals: options.filters.region } })
}
if (options.filters?.city) {
filters.push({ cities: { equals: options.filters.city } })
}
const result = await payload.find({
collection: 'actionPlan',
where: { and: filters },
depth: 0,
limit,
page: 1,
sort: 'startAt',
select: upcomingActionPlanSelect,
user,
overrideAccess: false,
})
return result.docs.map((plan) => ({
id: plan.id,
slug: plan.slug,
title: plan.title,
kind: plan.kind,
startAt: plan.startAt as string,
city: Array.isArray(plan.cities) && plan.cities.length > 0 ? plan.cities[0] : null,
}))
}

View file

@ -0,0 +1,287 @@
import type { ActionPlan, CampaignUser, Contact } from '@/payload-types'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
const asStringArray = (value: string[] | null | undefined): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
export const formatActionPlanTerritoryLabel = ({
neighborhoods = [],
locality,
cities = [],
regions = [],
}: {
neighborhoods?: string[] | null
locality?: string | null
cities?: string[] | null
regions?: string[] | null
}): string =>
[
(neighborhoods ?? []).join(', '),
locality,
(cities ?? []).join(', '),
(regions ?? []).join(', '),
]
.filter(Boolean)
.join(' · ')
export const actionPlanListSelect = {
title: true,
slug: true,
kind: true,
status: true,
startAt: true,
endAt: true,
regions: true,
cities: true,
neighborhoods: true,
locality: true,
responsible: true,
tasks: { done: true },
} as const
export type ActionPlanListViewModel = {
id: number
title: string
slug: string
kind: ActionPlan['kind']
status: ActionPlan['status']
startAt: string | null
endAt: string | null
territoryLabel: string
city: string | null
responsibleName: string | null
taskProgress: { done: number; total: number }
}
const relationshipName = (
relationship: number | Contact | CampaignUser | null | undefined,
): string | null =>
isPopulatedRelationship<Contact | CampaignUser>(relationship) ? relationship.name : null
export const toActionPlanListViewModel = (plan: ActionPlan): ActionPlanListViewModel => {
const cities = asStringArray(plan.cities)
const tasks = plan.tasks ?? []
return {
id: plan.id,
title: plan.title,
slug: plan.slug,
kind: plan.kind,
status: plan.status,
startAt: plan.startAt ?? null,
endAt: plan.endAt ?? null,
territoryLabel: formatActionPlanTerritoryLabel(plan),
city: cities[0] ?? null,
responsibleName: relationshipName(plan.responsible),
taskProgress: {
done: tasks.filter((task) => Boolean(task.done)).length,
total: tasks.length,
},
}
}
export const actionPlanFormSelect = {
title: true,
slug: true,
kind: true,
status: true,
description: true,
startAt: true,
endAt: true,
deadline: true,
regions: true,
cities: true,
neighborhoods: true,
locality: true,
territoryNotes: true,
coordinators: true,
responsible: true,
leadership: true,
tasks: true,
} as const
export type ActionPlanFormTaskViewModel = {
id: string | null
title: string
responsible: { id: number; name: string; phone: string } | null
due: string | null
done: boolean
}
export type ActionPlanFormViewModel = {
id: number
title: string
slug: string
kind: ActionPlan['kind']
status: ActionPlan['status']
description: string | null
startAt: string | null
endAt: string | null
deadline: string | null
regions: string[]
cities: string[]
neighborhoods: string[]
locality: string | null
territoryNotes: string | null
coordinators: Array<{ id: number; name: string }>
responsible: { id: number; name: string; phone: string } | null
leadership: { id: number; label: string } | null
tasks: ActionPlanFormTaskViewModel[]
}
export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewModel => ({
id: plan.id,
title: plan.title,
slug: plan.slug,
kind: plan.kind,
status: plan.status,
description: plan.description ?? null,
startAt: plan.startAt ?? null,
endAt: plan.endAt ?? null,
deadline: plan.deadline ?? null,
regions: asStringArray(plan.regions),
cities: asStringArray(plan.cities),
neighborhoods: asStringArray(plan.neighborhoods),
locality: plan.locality ?? null,
territoryNotes: plan.territoryNotes ?? null,
coordinators:
plan.coordinators
?.filter((coordinator): coordinator is CampaignUser =>
isPopulatedRelationship<CampaignUser>(coordinator),
)
.map(({ id, name }) => ({ id, name })) ?? [],
responsible: isPopulatedRelationship<Contact>(plan.responsible)
? { id: plan.responsible.id, name: plan.responsible.name, phone: plan.responsible.phone }
: null,
leadership: (() => {
const leadershipId = relationshipId(plan.leadership)
if (!leadershipId) return null
if (isPopulatedRelationship(plan.leadership)) {
const contact = plan.leadership.contact
const contactName = isPopulatedRelationship<Contact>(contact)
? contact.name
: `Liderança #${leadershipId}`
return { id: leadershipId, label: contactName }
}
return { id: leadershipId, label: `Liderança #${leadershipId}` }
})(),
tasks: (plan.tasks ?? []).map((task) => ({
id: task.id ?? null,
title: task.title,
responsible: isPopulatedRelationship<Contact>(task.responsible)
? { id: task.responsible.id, name: task.responsible.name, phone: task.responsible.phone }
: null,
due: task.due ?? null,
done: Boolean(task.done),
})),
})
export const actionPlanDetailSelect = {
title: true,
slug: true,
kind: true,
status: true,
description: true,
startAt: true,
endAt: true,
deadline: true,
regions: true,
cities: true,
neighborhoods: true,
locality: true,
territoryNotes: true,
coordinators: true,
responsible: true,
tasks: true,
updates: true,
createdBy: true,
updatedAt: true,
createdAt: true,
} as const
export type ActionPlanTaskViewModel = {
id: string | null
title: string
responsibleId: number | null
responsibleName: string | null
due: string | null
done: boolean
doneAt: string | null
}
export type ActionPlanUpdateViewModel = {
id: string | null
body: string
authorName: string | null
createdAt: string | null
}
export type ActionPlanDetailViewModel = {
id: number
title: string
slug: string
kind: ActionPlan['kind']
status: ActionPlan['status']
description: string | null
startAt: string | null
endAt: string | null
deadline: string | null
regions: string[]
cities: string[]
neighborhoods: string[]
locality: string | null
territoryNotes: string | null
territoryLabel: string
coordinators: Array<{ id: number; name: string }>
responsibleId: number | null
responsibleName: string | null
tasks: ActionPlanTaskViewModel[]
updates: ActionPlanUpdateViewModel[]
createdByName: string | null
updatedAt: string
createdAt: string
}
export const toActionPlanDetailViewModel = (plan: ActionPlan): ActionPlanDetailViewModel => ({
id: plan.id,
title: plan.title,
slug: plan.slug,
kind: plan.kind,
status: plan.status,
description: plan.description ?? null,
startAt: plan.startAt ?? null,
endAt: plan.endAt ?? null,
deadline: plan.deadline ?? null,
regions: asStringArray(plan.regions),
cities: asStringArray(plan.cities),
neighborhoods: asStringArray(plan.neighborhoods),
locality: plan.locality ?? null,
territoryNotes: plan.territoryNotes ?? null,
territoryLabel: formatActionPlanTerritoryLabel(plan),
coordinators:
plan.coordinators
?.filter((coordinator): coordinator is CampaignUser =>
isPopulatedRelationship<CampaignUser>(coordinator),
)
.map(({ id, name }) => ({ id, name })) ?? [],
responsibleId: relationshipId(plan.responsible),
responsibleName: relationshipName(plan.responsible),
tasks: (plan.tasks ?? []).map((task) => ({
id: task.id ?? null,
title: task.title,
responsibleId: relationshipId(task.responsible),
responsibleName: relationshipName(task.responsible),
due: task.due ?? null,
done: Boolean(task.done),
doneAt: task.doneAt ?? null,
})),
updates: (plan.updates ?? []).map((update) => ({
id: update.id ?? null,
body: update.body,
authorName: relationshipName(update.author),
createdAt: update.createdAt ?? null,
})),
createdByName: relationshipName(plan.createdBy),
updatedAt: plan.updatedAt,
createdAt: plan.createdAt,
})

View file

@ -10,8 +10,12 @@ type ContactID = number
const ACCESSIBLE_NUCLEUS_IDS_CONTEXT_KEY = 'campaignAccessibleNucleusIds'
const ACCESSIBLE_CONTACT_IDS_CONTEXT_KEY = 'campaignAccessibleContactIds'
const ACCESSIBLE_LEADERSHIP_IDS_CONTEXT_KEY = 'campaignAccessibleLeadershipIds'
const CAMPAIGN_USER_PHONE_ACCESS_CONTEXT_KEY = 'campaignUserPhoneAccess'
type LeadershipID = number
type AccessibleLeadershipIDs = LeadershipID[] | null
export const isPayloadAdmin = (user: CampaignActor): user is User => user?.collection === 'users'
const isCampaignUser = (user: CampaignActor): user is CampaignUser =>
@ -337,6 +341,59 @@ export const canReadElectionData: Access = ({ req }) =>
/** Election reference data is mutated only by Payload admins (or CLI with overrideAccess). */
export const canMutateElectionData: Access = ({ req }) => isPayloadAdmin(req.user)
const canStaffCreateActionPlan: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser?.role === 'geral' || currentUser?.role === 'coordenador'
}
export const canCreateActionPlan: Access = canStaffCreateActionPlan
export const canReadActionPlan: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser) return false
if (isCampaignGeneral(currentUser)) return true
if (currentUser.role === 'coordenador') {
return {
coordinators: {
contains: currentUser.id,
},
}
}
if (currentUser.role === 'lideranca') {
const leadershipIDs = await getAccessibleLeadershipIds(req, currentUser)
return {
leadership: {
in: leadershipIDs ?? [],
},
}
}
return false
}
/** Same row scope as read — liderança write limits are enforced in `beforeChange`. */
export const canUpdateActionPlan: Access = canReadActionPlan
export const canDeleteActionPlan: Access = ({ req }) => isPayloadAdmin(req.user)
export const canSetActionPlanSystemField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
export const canSetActionPlanStatus: FieldAccess = canStaffCreateActionPlan
export const canCreateActionPlanCoordinators: FieldAccess = canStaffCreateActionPlan
export const canManageActionPlanCoordinators: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignGeneral(await getFreshCampaignUser(req))
}
type DynamicFind = (args: {
collection: string
depth: number
@ -570,3 +627,72 @@ export const getAccessibleContactIds = async (
return ids
}
/**
* Returns null for unrestricted general coordination, otherwise the engaged
* leadership IDs currently linked to the authenticated campaign user.
*/
export const getAccessibleLeadershipIds = async (
req: PayloadRequest,
user: CampaignActor = req.user,
): Promise<AccessibleLeadershipIDs> => {
const currentUser =
isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user
if (!isCampaignUser(currentUser)) return []
if (isCampaignGeneral(currentUser)) return null
const context = req.context as Record<string, unknown>
const cacheKey = `${ACCESSIBLE_LEADERSHIP_IDS_CONTEXT_KEY}:${currentUser.id}:${currentUser.role}`
const cached = context[cacheKey]
if (Array.isArray(cached)) {
return cached.filter((id): id is number => typeof id === 'number')
}
const collections = req.payload.collections as Record<string, unknown>
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
let ids: LeadershipID[] = []
if (currentUser.role === 'lideranca' && collections.leadership) {
const result = await find({
collection: 'leadership',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { id: true },
where: {
and: [{ user: { equals: currentUser.id } }, { supportStatus: { equals: 'engajado' } }],
},
})
ids = result.docs.map((doc) => relationshipId(doc.id)).filter((id): id is number => id !== null)
}
if (currentUser.role === 'coordenador' && collections.leadership) {
const nucleusIDs = await getAccessibleNucleusIds(req, currentUser)
const result = await find({
collection: 'leadership',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { id: true },
where: {
nucleus: {
in: nucleusIDs ?? [],
},
},
})
ids = result.docs.map((doc) => relationshipId(doc.id)).filter((id): id is number => id !== null)
}
const uniqueIDs = [...new Set(ids)]
context[cacheKey] = uniqueIDs
return uniqueIDs
}

View file

@ -2,6 +2,7 @@ import type { Payload } from 'payload'
import type { CampaignUser } from '@/payload-types'
import { isSupportStatus } from '@/lib/schemas/leadership'
import { loadUpcomingActionPlansPreview } from '@/utilities/actionPlanUpcomingPreview'
import { getBahiaWeekRange } from '@/utilities/campaignTime'
import { loadCoordinatorSummaries } from '@/utilities/nucleusCoordinatorOptions'
import {
@ -102,7 +103,7 @@ export const getCampaignDashboardPageData = async (
now = new Date(),
) => {
const week = getBahiaWeekRange(now)
const [nucleusResult, leadershipResult, updateResult] = await Promise.all([
const [nucleusResult, leadershipResult, updateResult, upcomingActionPlans] = await Promise.all([
payload.find({
collection: 'electoralNucleus',
where: { status: { equals: 'ativo' } },
@ -142,6 +143,7 @@ export const getCampaignDashboardPageData = async (
overrideAccess: false,
})
: Promise.resolve({ totalDocs: 0 }),
loadUpcomingActionPlansPreview(payload, user, now),
])
const rawNuclei = nucleusResult.docs as unknown as RawDashboardNucleus[]
@ -184,8 +186,14 @@ export const getCampaignDashboardPageData = async (
})
if (user.role === 'geral') {
return buildGeneralDashboardViewModel(nuclei, leaderships, updateResult.totalDocs, now)
return buildGeneralDashboardViewModel(
nuclei,
leaderships,
updateResult.totalDocs,
now,
upcomingActionPlans,
)
}
return buildScopedDashboardViewModel(user.role, nuclei, leaderships, now)
return buildScopedDashboardViewModel(user.role, nuclei, leaderships, now, upcomingActionPlans)
}

View file

@ -1,5 +1,6 @@
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
import type { SupportStatus } from '@/lib/schemas/leadership'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import { formatNucleusTerritoryLabel } from '@/utilities/nucleusUi'
export type CoordinatorSummary = {
@ -56,6 +57,7 @@ export type GeneralDashboardViewModel = {
withoutRecentUpdate: DashboardQueueItem[]
pendingEstimate: DashboardQueueItem[]
}
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
export type CoordinatorDashboardCard = {
@ -94,10 +96,12 @@ export type ScopedDashboardViewModel =
| {
role: 'coordenador'
cards: CoordinatorDashboardCard[]
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
| {
role: 'lideranca'
cards: LeadershipDashboardCard[]
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
const percentage = (part: number, total: number): number =>
@ -168,6 +172,7 @@ export const buildGeneralDashboardViewModel = (
leaderships: DashboardLeadershipRecord[],
updatesThisWeek: number,
now: Date,
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[],
): GeneralDashboardViewModel => {
const coordinatedCount = nuclei.filter(({ coordinators }) => coordinators.length > 0).length
const confirmedCount = nuclei.filter(
@ -202,6 +207,7 @@ export const buildGeneralDashboardViewModel = (
)
.map(toQueueItem),
},
upcomingActionPlans,
}
}
@ -210,6 +216,7 @@ export const buildScopedDashboardViewModel = (
nuclei: DashboardNucleusRecord[],
leaderships: DashboardLeadershipRecord[],
now: Date,
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[],
): ScopedDashboardViewModel => {
if (role === 'lideranca') {
return {
@ -224,6 +231,7 @@ export const buildScopedDashboardViewModel = (
confirmedVoteEstimate: nucleus.confirmedVoteEstimate,
coordinators: coordinatorContacts(nucleus.coordinators),
})),
upcomingActionPlans,
}
}
const { countsByNucleus } = aggregateLeaderships(leaderships)
@ -247,5 +255,6 @@ export const buildScopedDashboardViewModel = (
disputed: 0,
},
})),
upcomingActionPlans,
}
}

View file

@ -0,0 +1,114 @@
import type { CollectionBeforeValidateHook } from 'payload'
import { APIError } from 'payload'
import {
isBahiaIdentityTerritory,
isBahiaMunicipality,
territoriesForCities,
} from '@/lib/bahiaTerritories'
import {
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
MAX_NUCLEUS_REGIONS,
} from '@/lib/schemas/nucleus'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
export const normalizeTerritoryTextArray = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
const seen = new Set<string>()
const result: string[] = []
for (const item of value) {
const trimmed = typeof item === 'string' ? item.trim() : ''
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
result.push(trimmed)
}
return result
}
const resolveTerritoryArray = (
data: Record<string, unknown> | undefined,
originalDoc: Record<string, unknown> | undefined,
field: 'regions' | 'cities' | 'neighborhoods',
operation: 'create' | 'update' | 'delete',
): string[] => {
if (operation === 'update' && data && !(field in data)) {
return normalizeTerritoryTextArray(originalDoc?.[field])
}
return normalizeTerritoryTextArray(data?.[field])
}
export type CampaignTerritoryValidationOptions = {
/** Portuguese entity label used in the "require territory" error (e.g. "núcleo", "plano"). */
entityLabel: string
}
/**
* Shared Bahia territory validation for campaign collections that own
* regions/cities/neighborhoods/locality/territoryNotes (núcleo, plano de ação).
*/
export const createCampaignTerritoryValidationHook = (
options: CampaignTerritoryValidationOptions,
): CollectionBeforeValidateHook => {
return ({ data, operation, originalDoc }) => {
if (!data) return data
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
const cities = resolveTerritoryArray(data, originalDoc, 'cities', operation)
const neighborhoods = resolveTerritoryArray(data, originalDoc, 'neighborhoods', operation)
let regions = resolveTerritoryArray(data, originalDoc, 'regions', operation)
const locality = trimmedText(nextData?.locality)
const territoryNotes = trimmedText(nextData?.territoryNotes)
if (cities.length > MAX_NUCLEUS_CITIES) {
throw new APIError(`Informe no máximo ${MAX_NUCLEUS_CITIES} municípios.`, 400)
}
if (regions.length > MAX_NUCLEUS_REGIONS) {
throw new APIError(`Informe no máximo ${MAX_NUCLEUS_REGIONS} territórios.`, 400)
}
if (neighborhoods.length > MAX_NUCLEUS_NEIGHBORHOODS) {
throw new APIError(`Informe no máximo ${MAX_NUCLEUS_NEIGHBORHOODS} bairros.`, 400)
}
for (const city of cities) {
if (!isBahiaMunicipality(city)) {
throw new APIError('Selecione um município válido da Bahia.', 400)
}
}
if (cities.length > 0) {
regions = territoriesForCities(cities)
} else {
for (const region of regions) {
if (!isBahiaIdentityTerritory(region)) {
throw new APIError('Selecione um território de identidade válido da Bahia.', 400)
}
}
}
if (neighborhoods.length > 0 && cities.length !== 1) {
throw new APIError(
cities.length === 0
? 'Informe o município antes do bairro.'
: 'Bairros só podem ser informados quando há exatamente um município.',
400,
)
}
if (regions.length === 0 && cities.length === 0 && !locality) {
throw new APIError(
`Informe o território de identidade, município ou localidade do ${options.entityLabel}.`,
400,
)
}
data.regions = regions
data.cities = cities
data.neighborhoods = cities.length === 1 ? neighborhoods : []
data.locality = locality || null
data.territoryNotes = territoryNotes || null
return data
}
}

View file

@ -73,6 +73,49 @@ const addCivilDays = (civil: CivilDateTime, days: number): CivilDateTime => {
}
}
const bahiaDateTimeInputPattern = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
/**
* Converts a `datetime-local` input value (interpreted as Bahia civil time,
* since campaign staff operate in that timezone) into a UTC ISO instant.
* Returns null when the value does not match the expected input shape.
*/
export const parseBahiaDateTimeInput = (value: string): string | null => {
const match = bahiaDateTimeInputPattern.exec(value)
if (!match) return null
const [, year, month, day, hour, minute] = match
return zonedCivilToInstant({
year: Number(year),
month: Number(month),
day: Number(day),
hour: Number(hour),
minute: Number(minute),
second: 0,
}).toISOString()
}
/** Formats a UTC ISO instant as a `datetime-local` input value in Bahia civil time. */
export const formatIsoAsBahiaDateTimeInput = (iso: string): string => {
const { year, month, day, hour, minute } = getZonedParts(new Date(iso))
const pad = (value: number): string => String(value).padStart(2, '0')
return `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}`
}
const bahiaDateTimeDisplayFormatter = new Intl.DateTimeFormat('pt-BR', {
timeZone: BAHIA_TIME_ZONE,
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
/** Formats a UTC ISO instant as `dd/mm/aaaa às hh:mm` in Bahia civil time. */
export const formatBahiaDateTimeLabel = (iso: string): string =>
bahiaDateTimeDisplayFormatter.format(new Date(iso)).replace(', ', ' às ')
export const getBahiaWeekRange = (now: Date): { start: Date; end: Date } => {
const local = getZonedParts(now)
const localMidnight = { ...local, hour: 0, minute: 0, second: 0 }

View file

@ -1,6 +1,7 @@
import type { Payload } from 'payload'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import { loadUpcomingActionPlansPreview } from '@/utilities/actionPlanUpcomingPreview'
import {
buildNucleusListOverviewViewModel,
nucleusListOverviewPreviewLimit,
@ -54,17 +55,26 @@ export const loadNucleusListOverviewData = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
state: NucleusListState,
now = new Date(),
): Promise<NucleusListOverviewViewModel | null> => {
const nucleusResult = await payload.find({
collection: 'electoralNucleus',
where: buildNucleusListWhere(state),
depth: 0,
pagination: false,
select:
user.role === 'lideranca' ? overviewLeadershipNucleusSelect : overviewStaffNucleusSelect,
user,
overrideAccess: false,
})
const upcomingFilters = {
...(state.region ? { region: state.region } : {}),
...(state.city ? { city: state.city } : {}),
}
const [nucleusResult, upcomingActionPlans] = await Promise.all([
payload.find({
collection: 'electoralNucleus',
where: buildNucleusListWhere(state),
depth: 0,
pagination: false,
select:
user.role === 'lideranca' ? overviewLeadershipNucleusSelect : overviewStaffNucleusSelect,
user,
overrideAccess: false,
}),
loadUpcomingActionPlansPreview(payload, user, now, { filters: upcomingFilters }),
])
const rawNuclei = nucleusResult.docs as unknown as RawOverviewNucleus[]
if (rawNuclei.length === 0) return null
@ -133,5 +143,6 @@ export const loadNucleusListOverviewData = async (
nuclei,
recentUpdates,
role: user.role,
upcomingActionPlans,
})
}

View file

@ -1,4 +1,5 @@
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
export const nucleusListOverviewPreviewLimit = 3
@ -34,6 +35,7 @@ export type NucleusListOverviewViewModel = {
percent: number
}
recentUpdates: NucleusListOverviewUpdateRecord[]
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
const percentage = (part: number, total: number): number =>
@ -43,10 +45,12 @@ export const buildNucleusListOverviewViewModel = ({
nuclei,
recentUpdates,
role,
upcomingActionPlans,
}: {
nuclei: NucleusListOverviewNucleusRecord[]
recentUpdates: NucleusListOverviewUpdateRecord[]
role: CampaignUser['role']
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}): NucleusListOverviewViewModel => {
const totalFiltered = nuclei.length
const confirmedCount = nuclei.filter(
@ -77,5 +81,6 @@ export const buildNucleusListOverviewViewModel = ({
percent: percentage(coordinatedCount, totalFiltered),
},
recentUpdates,
upcomingActionPlans,
}
}

View file

@ -0,0 +1,73 @@
/* eslint-disable check-file/filename-naming-convention */
import { test, expect } from './fixtures/campaignE2EFixtures'
test.describe('Campaign action plan journeys', () => {
test.describe.configure({ mode: 'parallel' })
test('creates a planned action and opens it from the list', async ({ campaign, page }) => {
test.setTimeout(240_000)
const generalEmail = `${campaign.fixtures.value('plan-general')}@example.com`
const password = campaign.fixtures.value('PlanPassword')
const planTitle = campaign.fixtures.value('Caminhada E2E')
await campaign.transaction(async (payload, req) => {
await payload.create({
collection: 'campaignUser',
data: {
name: campaign.fixtures.value('Coordenação Geral Planos'),
email: generalEmail,
password,
role: 'geral',
},
depth: 0,
req,
})
})
await campaign.login(page, generalEmail, password)
await page.goto(`${campaign.baseURL}/campanha/planos/novo`)
await page.getByLabel('Título *').fill(planTitle)
await page.getByLabel('Tipo de ação *').selectOption('caminhada')
await page.getByRole('button', { name: 'Planejado', exact: true }).click()
await page.locator('#startAt').fill('2026-08-20T09:00')
await page.getByLabel('Municípios').fill('Salvador')
await page.getByRole('option', { name: 'Salvador', exact: true }).click()
await page.getByRole('button', { name: 'Criar plano' }).click()
await expect(page).toHaveURL(new RegExp(`/campanha/planos/.+`), { timeout: 30_000 })
await expect(page.getByRole('heading', { name: planTitle })).toBeVisible()
await page.goto(`${campaign.baseURL}/campanha/planos?tab=todos`)
await expect(page.getByRole('link', { name: planTitle })).toBeVisible()
})
test('hides create affordance for leadership role', async ({ campaign, page }) => {
test.slow()
const phone = campaign.fixtures.phone()
const password = campaign.fixtures.value('LeadershipPlanPassword')
await campaign.transaction(async (payload, req) => {
await payload.create({
collection: 'campaignUser',
data: {
name: campaign.fixtures.value('Liderança Planos'),
username: phone,
password,
role: 'lideranca',
phone,
},
depth: 0,
req,
})
})
await campaign.login(
page,
`+55 (${phone.slice(0, 2)}) ${phone.slice(2)}`,
password,
)
await page.goto(`${campaign.baseURL}/campanha/planos`)
await expect(page.getByRole('heading', { name: /Planos/i })).toBeVisible()
await expect(page.getByRole('link', { name: /Novo plano/i })).toHaveCount(0)
})
})

View file

@ -21,6 +21,7 @@ type CampaignCollection =
| 'users'
| 'campaignInvite'
| 'nucleusUpdate'
| 'actionPlan'
| 'leadership'
| 'supporter'
| 'electoralNucleus'
@ -76,6 +77,7 @@ const emptyOwnedIDs = (): OwnedIDs => ({
users: new Set(),
campaignInvite: new Set(),
nucleusUpdate: new Set(),
actionPlan: new Set(),
leadership: new Set(),
supporter: new Set(),
electoralNucleus: new Set(),
@ -271,6 +273,14 @@ export class CampaignFixtures {
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'actionPlan',
where: {
or: [{ title: { contains: this.runID } }, { slug: { contains: this.runID } }],
},
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'consent',
where: { key: { contains: this.runID } },
@ -283,7 +293,8 @@ export class CampaignFixtures {
for (const user of roots[1].docs) this.own('campaignUser', user)
for (const contact of roots[2].docs) this.own('contact', contact)
for (const nucleus of roots[3].docs) this.own('electoralNucleus', nucleus)
for (const consent of roots[4].docs) this.own('consent', consent)
for (const plan of roots[4].docs) this.own('actionPlan', plan)
for (const consent of roots[5].docs) this.own('consent', consent)
}
async createCampaignUser(
@ -557,6 +568,7 @@ export class CampaignFixtures {
}
const lockedDocumentConditions = [
['action_plan_id', this.owned.actionPlan],
['campaign_invite_id', this.owned.campaignInvite],
['campaign_user_id', this.owned.campaignUser],
['consent_id', this.owned.consent],
@ -624,6 +636,7 @@ export class CampaignFixtures {
for (const collection of [
'campaignInvite',
'nucleusUpdate',
'actionPlan',
'leadership',
'supporter',
'electoralNucleus',

View file

@ -0,0 +1,242 @@
// @vitest-environment node
import { beforeAll, describe, expect, it } from 'vitest'
import { getPayload, type Payload } from 'payload'
import {
actionPlanCreateSchema,
actionPlanUpdateSchema,
} from '@/lib/schemas/actionPlan'
import config from '@/payload.config'
import {
canCreateActionPlan,
canReadActionPlan,
canUpdateActionPlan,
getAccessibleLeadershipIds,
} from '@/utilities/campaignAccess'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const validPlanInput = {
get title() {
return campaignFixtures().value('Caminhada Centro')
},
kind: 'caminhada' as const,
status: 'planejado' as const,
startAt: new Date(Date.now() + 86_400_000).toISOString(),
regions: ['Metropolitano de Salvador'],
cities: ['Salvador'],
locality: 'Centro',
}
describe('action plan domain', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('requires startAt outside rascunho and allows drafts without it', () => {
expect(
actionPlanCreateSchema.safeParse({
...validPlanInput,
status: 'rascunho',
startAt: undefined,
}).success,
).toBe(true)
expect(
actionPlanCreateSchema.safeParse({
...validPlanInput,
status: 'planejado',
startAt: undefined,
}).success,
).toBe(false)
expect(
actionPlanUpdateSchema.safeParse({
id: 1,
status: 'confirmado',
startAt: null,
}).success,
).toBe(false)
})
it('requires territory geography on create', () => {
const result = actionPlanCreateSchema.safeParse({
title: validPlanInput.title,
kind: 'caminhada',
status: 'rascunho',
})
expect(result.success).toBe(false)
})
it('scopes create/read/update by campaign role', async () => {
const fixtures = campaignFixtures()
const geral = await fixtures.createCampaignUser('geral')
const coordinator = await fixtures.createCampaignUser('coordenador')
const otherCoordinator = await fixtures.createCampaignUser('coordenador')
const leadershipUser = await fixtures.createCampaignUser('lideranca')
const contact = await fixtures.createContact()
const nucleus = await fixtures.createNucleus({
coordinators: [coordinator.id],
})
const leadership = await fixtures.createLeadership({
contact: contact.id,
nucleus: nucleus.id,
user: leadershipUser.id,
supportStatus: 'engajado',
})
const createAsGeral = await canCreateActionPlan({
req: { user: geral, payload, context: {} } as never,
})
const createAsCoordinator = await canCreateActionPlan({
req: { user: coordinator, payload, context: {} } as never,
})
const createAsLeadership = await canCreateActionPlan({
req: { user: leadershipUser, payload, context: {} } as never,
})
expect(createAsGeral).toBe(true)
expect(createAsCoordinator).toBe(true)
expect(createAsLeadership).toBe(false)
const plan = await payload.create({
collection: 'actionPlan',
data: {
...validPlanInput,
coordinators: [coordinator.id],
leadership: leadership.id,
responsible: contact.id,
createdBy: geral.id,
} as never,
overrideAccess: true,
})
fixtures.own('actionPlan', plan.id)
const geralRead = await canReadActionPlan({
req: { user: geral, payload, context: {} } as never,
})
expect(geralRead).toBe(true)
const coordinatorRead = await canReadActionPlan({
req: { user: coordinator, payload, context: {} } as never,
})
expect(coordinatorRead).toEqual({
coordinators: { contains: coordinator.id },
})
const otherCoordinatorRead = await canReadActionPlan({
req: { user: otherCoordinator, payload, context: {} } as never,
})
expect(otherCoordinatorRead).toEqual({
coordinators: { contains: otherCoordinator.id },
})
const leadershipRead = await canReadActionPlan({
req: { user: leadershipUser, payload, context: {} } as never,
})
expect(leadershipRead).toEqual({
leadership: { in: [leadership.id] },
})
const leadershipIds = await getAccessibleLeadershipIds(
{ user: leadershipUser, payload, context: {} } as never,
leadershipUser,
)
expect(leadershipIds).toEqual([leadership.id])
const coordinatorUpdate = await canUpdateActionPlan({
req: { user: coordinator, payload, context: {} } as never,
})
expect(coordinatorUpdate).toEqual({
coordinators: { contains: coordinator.id },
})
const visibleToCoordinator = await payload.find({
collection: 'actionPlan',
where: { id: { equals: plan.id } },
user: coordinator,
overrideAccess: false,
depth: 0,
})
expect(visibleToCoordinator.totalDocs).toBe(1)
const hiddenFromOther = await payload.find({
collection: 'actionPlan',
where: { id: { equals: plan.id } },
user: otherCoordinator,
overrideAccess: false,
depth: 0,
})
expect(hiddenFromOther.totalDocs).toBe(0)
const visibleToLeadership = await payload.find({
collection: 'actionPlan',
where: { id: { equals: plan.id } },
user: leadershipUser,
overrideAccess: false,
depth: 0,
})
expect(visibleToLeadership.totalDocs).toBe(1)
})
it('lets leadership toggle task done but rejects title edits', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordenador')
const leadershipUser = await fixtures.createCampaignUser('lideranca')
const contact = await fixtures.createContact()
const nucleus = await fixtures.createNucleus({
coordinators: [coordinator.id],
})
const leadership = await fixtures.createLeadership({
contact: contact.id,
nucleus: nucleus.id,
user: leadershipUser.id,
supportStatus: 'engajado',
})
const plan = await payload.create({
collection: 'actionPlan',
data: {
...validPlanInput,
title: fixtures.value('Plano Tarefas'),
coordinators: [coordinator.id],
leadership: leadership.id,
tasks: [{ title: 'Levar faixas', done: false }],
} as never,
overrideAccess: true,
})
fixtures.own('actionPlan', plan.id)
const toggled = await payload.update({
collection: 'actionPlan',
id: plan.id,
data: {
tasks: [{ title: 'Levar faixas', done: true }],
} as never,
user: leadershipUser,
overrideAccess: false,
})
expect(toggled.tasks?.[0]?.done).toBe(true)
expect(toggled.tasks?.[0]?.doneAt).toBeTruthy()
await expect(
payload.update({
collection: 'actionPlan',
id: plan.id,
data: {
title: 'Título alterado pela liderança',
} as never,
user: leadershipUser,
overrideAccess: false,
}),
).rejects.toThrow(/só podem marcar tarefas|não pode ser alterado|Lideranças/i)
})
})

View file

@ -37,21 +37,21 @@ describe('campaign nucleus list overview page data', () => {
const filteredA = await fixtures.createNucleus({
name: fixtures.value('Overview A'),
region: 'Velho Chico',
city: 'Bom Jesus da Lapa',
regions: ['Velho Chico'],
cities: ['Bom Jesus da Lapa'],
coordinators: [author.id],
organizationKind: 'territorial',
})
const filteredB = await fixtures.createNucleus({
name: fixtures.value('Overview B'),
region: 'Velho Chico',
city: 'Bom Jesus da Lapa',
regions: ['Velho Chico'],
cities: ['Bom Jesus da Lapa'],
organizationKind: 'territorial',
})
const outside = await fixtures.createNucleus({
name: fixtures.value('Overview fora'),
region: 'Metropolitano de Salvador',
city: 'Salvador',
regions: ['Metropolitano de Salvador'],
cities: ['Salvador'],
organizationKind: 'territorial',
})
@ -131,15 +131,15 @@ describe('campaign nucleus list overview page data', () => {
const assigned = await fixtures.createNucleus({
name: fixtures.value('Atribuído overview'),
region: 'Metropolitano de Salvador',
city: 'Salvador',
regions: ['Metropolitano de Salvador'],
cities: ['Salvador'],
coordinators: [coordinator.id],
organizationKind: 'territorial',
})
const other = await fixtures.createNucleus({
name: fixtures.value('Não atribuído'),
region: 'Metropolitano de Salvador',
city: 'Salvador',
regions: ['Metropolitano de Salvador'],
cities: ['Salvador'],
coordinators: [otherCoordinator.id],
organizationKind: 'territorial',
})
@ -184,8 +184,8 @@ describe('campaign nucleus list overview page data', () => {
const nucleus = await fixtures.createNucleus({
name: fixtures.value('Núcleo liderança'),
region: 'Metropolitano de Salvador',
city: 'Salvador',
regions: ['Metropolitano de Salvador'],
cities: ['Salvador'],
coordinators: [staffAuthor.id],
organizationKind: 'territorial',
})

View file

@ -109,6 +109,7 @@ describe('campaign visual foundation', () => {
],
0,
new Date('2026-07-18T12:00:00-03:00'),
[],
)
expect(view.supportCounts).toEqual({

View file

@ -26,6 +26,9 @@ const dashboardPayload = (leaderships: Array<{ nucleus: number; supportStatus?:
if (collection === 'nucleusUpdate') {
return { docs: [], totalDocs: 0 }
}
if (collection === 'actionPlan') {
return { docs: [], totalDocs: 0 }
}
throw new Error(`Unexpected collection: ${collection}`)
}),
}) as unknown as Payload

View file

@ -42,6 +42,7 @@ describe('buildNucleusListOverviewViewModel', () => {
nucleus({ id: 4 }),
],
recentUpdates: [],
upcomingActionPlans: [],
})
expect(view.totalFiltered).toBe(4)
@ -66,6 +67,7 @@ describe('buildNucleusListOverviewViewModel', () => {
nucleus({ id: 2, proposedVoteEstimate: 400 }),
],
recentUpdates: [],
upcomingActionPlans: [],
})
expect(view.estimate).not.toHaveProperty('pendingSuggestionsCount')
@ -92,6 +94,7 @@ describe('NucleusListOverview', () => {
createdAt: '2026-07-17T12:00:00.000Z',
},
],
upcomingActionPlans: [],
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
@ -116,6 +119,7 @@ describe('NucleusListOverview', () => {
nucleus({ id: 2, proposedVoteEstimate: 400 }),
],
recentUpdates: [],
upcomingActionPlans: [],
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))

View file

@ -27,23 +27,27 @@ import { matchesAtWordStart } from '@/utilities/wordStartFilter'
describe('campaign nucleus UI contracts', () => {
afterEach(cleanup)
it('exposes home, nuclei, and supporters navigation scoped by role', () => {
it('exposes home, nuclei, action-plan, and supporters navigation scoped by role', () => {
expect(campaignNav.map(({ href, title }) => ({ href, title }))).toEqual([
{ href: '/campanha', title: 'Início' },
{ href: '/campanha/nucleos', title: 'Núcleos' },
{ href: '/campanha/planos', title: 'Planos' },
{ href: '/campanha/apoiadores', title: 'Apoiadores' },
])
expect(getCampaignNav('geral').map(({ href }) => href)).toEqual([
'/campanha',
'/campanha/nucleos',
'/campanha/planos',
'/campanha/apoiadores',
])
expect(getCampaignNav('lideranca').map(({ href, title }) => ({ href, title }))).toEqual([
{ href: '/campanha', title: 'Início' },
{ href: '/campanha/nucleos', title: 'Meus núcleos' },
{ href: '/campanha/planos', title: 'Planos' },
])
expect(isCampaignNavActive('/campanha/nucleos/nucleo-chapada', '/campanha/nucleos')).toBe(true)
expect(isCampaignNavActive('/campanha/nucleos', '/campanha')).toBe(false)
expect(isCampaignNavActive('/campanha/planos/novo', '/campanha/planos')).toBe(true)
expect(isCampaignNavActive('/campanha/apoiadores/1', '/campanha/apoiadores')).toBe(true)
})