mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
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:
parent
a18e2f15f5
commit
e995aefbd4
63 changed files with 14067 additions and 226 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||

|
||||

|
||||
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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.**
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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.',
|
||||
)
|
||||
192
src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx
Normal file
192
src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }))
|
||||
}
|
||||
96
src/app/(campaign)/campanha/(app)/planos/formActions.ts
Normal file
96
src/app/(campaign)/campanha/(app)/planos/formActions.ts
Normal 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}`)
|
||||
}
|
||||
42
src/app/(campaign)/campanha/(app)/planos/novo/page.tsx
Normal file
42
src/app/(campaign)/campanha/(app)/planos/novo/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
src/app/(campaign)/campanha/(app)/planos/page.tsx
Normal file
97
src/app/(campaign)/campanha/(app)/planos/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
src/app/(campaign)/campanha/actions/actionPlan.ts
Normal file
192
src/app/(campaign)/campanha/actions/actionPlan.ts
Normal 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)
|
||||
}
|
||||
533
src/collections/ActionPlan.ts
Normal file
533
src/collections/ActionPlan.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
56
src/components/campaign/ActionPlanCard.tsx
Normal file
56
src/components/campaign/ActionPlanCard.tsx
Normal 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>
|
||||
)
|
||||
229
src/components/campaign/ActionPlanFilters.tsx
Normal file
229
src/components/campaign/ActionPlanFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
435
src/components/campaign/ActionPlanForm.tsx
Normal file
435
src/components/campaign/ActionPlanForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
src/components/campaign/ActionPlanLifecycleDialog.tsx
Normal file
109
src/components/campaign/ActionPlanLifecycleDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
src/components/campaign/ActionPlanList.tsx
Normal file
10
src/components/campaign/ActionPlanList.tsx
Normal 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>
|
||||
)
|
||||
61
src/components/campaign/ActionPlanPagination.tsx
Normal file
61
src/components/campaign/ActionPlanPagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/components/campaign/ActionPlanStatusBadge.tsx
Normal file
19
src/components/campaign/ActionPlanStatusBadge.tsx
Normal 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>
|
||||
)
|
||||
56
src/components/campaign/ActionPlanTabNav.tsx
Normal file
56
src/components/campaign/ActionPlanTabNav.tsx
Normal 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>
|
||||
)
|
||||
68
src/components/campaign/ActionPlanTaskChecklist.tsx
Normal file
68
src/components/campaign/ActionPlanTaskChecklist.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
src/components/campaign/ActionPlanTaskFields.tsx
Normal file
130
src/components/campaign/ActionPlanTaskFields.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
src/components/campaign/ActionPlanUpdateFeed.tsx
Normal file
50
src/components/campaign/ActionPlanUpdateFeed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
src/components/campaign/ActionPlanUpdateForm.tsx
Normal file
64
src/components/campaign/ActionPlanUpdateForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
163
src/components/campaign/ContactCombobox.tsx
Normal file
163
src/components/campaign/ContactCombobox.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
319
src/components/campaign/NucleusTerritoryFields.tsx
Normal file
319
src/components/campaign/NucleusTerritoryFields.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
281
src/lib/schemas/actionPlan.ts
Normal file
281
src/lib/schemas/actionPlan.ts
Normal 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>
|
||||
8606
src/migrations/20260718_222832_add_action_plan.json
Normal file
8606
src/migrations/20260718_222832_add_action_plan.json
Normal file
File diff suppressed because it is too large
Load diff
119
src/migrations/20260718_222832_add_action_plan.ts
Normal file
119
src/migrations/20260718_222832_add_action_plan.ts
Normal 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";
|
||||
`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
60
src/utilities/actionPlanDetailTabUi.ts
Normal file
60
src/utilities/actionPlanDetailTabUi.ts
Normal 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)
|
||||
}
|
||||
193
src/utilities/actionPlanFormData.ts
Normal file
193
src/utilities/actionPlanFormData.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
48
src/utilities/actionPlanLeadershipOptions.ts
Normal file
48
src/utilities/actionPlanLeadershipOptions.ts
Normal 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'))
|
||||
}
|
||||
105
src/utilities/actionPlanPageData.ts
Normal file
105
src/utilities/actionPlanPageData.ts
Normal 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),
|
||||
)
|
||||
209
src/utilities/actionPlanUi.ts
Normal file
209
src/utilities/actionPlanUi.ts
Normal 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 } : {}),
|
||||
}
|
||||
}
|
||||
70
src/utilities/actionPlanUpcomingPreview.ts
Normal file
70
src/utilities/actionPlanUpcomingPreview.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
287
src/utilities/actionPlanViewModels.ts
Normal file
287
src/utilities/actionPlanViewModels.ts
Normal 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,
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
114
src/utilities/campaignTerritoryValidation.ts
Normal file
114
src/utilities/campaignTerritoryValidation.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
tests/e2e/campaign-action-plans.e2e.spec.ts
Normal file
73
tests/e2e/campaign-action-plans.e2e.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
242
tests/int/campaignActionPlan.int.spec.ts
Normal file
242
tests/int/campaignActionPlan.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ describe('campaign visual foundation', () => {
|
|||
],
|
||||
0,
|
||||
new Date('2026-07-18T12:00:00-03:00'),
|
||||
[],
|
||||
)
|
||||
|
||||
expect(view.supportCounts).toEqual({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue