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

Planos de ação

+

Editar {view.title}

+

+ Atualize os detalhes do plano sem alterar o título original. +

+
+ +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/lifecycleFormActions.ts b/src/app/(campaign)/campanha/(app)/planos/[slug]/lifecycleFormActions.ts new file mode 100644 index 00000000..fff1b81a --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/lifecycleFormActions.ts @@ -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>, + failureMessage: string, +): Promise => { + 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 => + 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 => + setActionPlanLifecycleFormAction( + formData, + markActionPlanRealized, + 'Não foi possível marcar este plano como realizado. Verifique seu acesso ou atualize a página e tente novamente.', + ) diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx new file mode 100644 index 00000000..9e5e2a8a --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx @@ -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> +} + +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 ( +
+
+
+
+
+

{view.title}

+ +
+
+ {actionPlanKindLabels[view.kind]} + + {view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'} + + {view.territoryLabel ? · {view.territoryLabel} : null} +
+
+
+ {canEdit ? ( + + ) : null} + + {canManageLifecycle ? ( + <> + + + + ) : null} +
+
+
+ + + + {activeTab === 'overview' ? ( +
+ + + Detalhes + + + {view.description ? ( +

{view.description}

+ ) : ( +

Sem descrição.

+ )} +
+
+
Início
+
{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'A definir'}
+
+
+
Término
+
{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}
+
+
+
Prazo de conclusão
+
{view.deadline ? formatBahiaDateTimeLabel(view.deadline) : '—'}
+
+
+
Responsável
+
{view.responsibleName ?? 'Não definido'}
+
+
+ {view.territoryNotes ? ( +
+

Observações do território

+

{view.territoryNotes}

+
+ ) : null} +
+
+ + + + Coordenação + + + {view.coordinators.length ? ( +
    + {view.coordinators.map((coordinator) => ( +
  • {coordinator.name}
  • + ))} +
+ ) : ( +

Nenhum coordenador vinculado.

+ )} +

+ {view.tasks.filter((task) => task.done).length}/{view.tasks.length} tarefas + concluídas +

+ {view.createdByName ? ( +

Criado por {view.createdByName}

+ ) : null} +
+
+
+ ) : null} + + {activeTab === 'tasks' ? ( + + + Tarefas + + + + + + ) : null} + + {activeTab === 'updates' ? ( +
+ + +
+ ) : null} +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/taskActions.ts b/src/app/(campaign)/campanha/(app)/planos/[slug]/taskActions.ts new file mode 100644 index 00000000..c7c72f29 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/taskActions.ts @@ -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 => { + 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.', + } + } +} diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts b/src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts new file mode 100644 index 00000000..b45896d4 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts @@ -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 +} + +export const createActionPlanUpdateFormAction = async ( + _state: ActionPlanUpdateFormState, + formData: FormData, +): Promise => { + 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.', + } + } +} diff --git a/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts b/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts new file mode 100644 index 00000000..dfb9176f --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts @@ -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 => { + 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 })) +} diff --git a/src/app/(campaign)/campanha/(app)/planos/formActions.ts b/src/app/(campaign)/campanha/(app)/planos/formActions.ts new file mode 100644 index 00000000..0794eec6 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/planos/formActions.ts @@ -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 => { + 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 => { + 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 => { + 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}`) +} diff --git a/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx b/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx new file mode 100644 index 00000000..49c9aa60 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx @@ -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 ( +
+
+

Planos de ação

+

Novo plano

+

+ Defina a ação, quando e onde ela acontece e quem responde por ela. +

+
+ +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/planos/page.tsx b/src/app/(campaign)/campanha/(app)/planos/page.tsx new file mode 100644 index 00000000..b72ddecc --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/planos/page.tsx @@ -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> +} + +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 ( +
+
+
+

Planos de ação

+

+ Organize caminhadas, comícios, panfletagens e demais ações de campanha. +

+
+ {canCreate ? ( + + ) : null} +
+ + + + {result.docs.length ? ( + <> + toActionPlanListViewModel(plan as ActionPlan))} + /> +
+

+ {result.totalDocs} {result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'} +

+ +
+ + ) : ( + + + + + Nenhum plano encontrado + + Ajuste os filtros ou a janela selecionada. Você só vê planos dentro do seu escopo. + + + + + + + )} +
+ ) +} diff --git a/src/app/(campaign)/campanha/actions/actionPlan.ts b/src/app/(campaign)/campanha/actions/actionPlan.ts new file mode 100644 index 00000000..e4a43959 --- /dev/null +++ b/src/app/(campaign)/campanha/actions/actionPlan.ts @@ -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) +} diff --git a/src/collections/ActionPlan.ts b/src/collections/ActionPlan.ts new file mode 100644 index 00000000..a9debbac --- /dev/null +++ b/src/collections/ActionPlan.ts @@ -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) => ({ + 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, index: number) => { + const previous = previousTasks[index] as Record | 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, index: number) => { + if (index < previousCount) { + const previous = (originalDoc?.updates as Record[])[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 + const merged = { ...previous, ...(data as Record) } + 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[] + 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 + 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, + }, + }, + ], +} diff --git a/src/collections/ElectoralNucleus.ts b/src/collections/ElectoralNucleus.ts index 2d6fecc9..ee8d1f84 100644 --- a/src/collections/ElectoralNucleus.ts +++ b/src/collections/ElectoralNucleus.ts @@ -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() - 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 | undefined, - originalDoc: Record | 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) diff --git a/src/components/campaign/ActionPlanCard.tsx b/src/components/campaign/ActionPlanCard.tsx new file mode 100644 index 00000000..c81f5ef5 --- /dev/null +++ b/src/components/campaign/ActionPlanCard.tsx @@ -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 }) => ( + + +
+ {actionPlanKindLabels[plan.kind]} + +
+ + + {plan.title} + + +
+ +
+
+ {plan.territoryLabel ? ( +
+
+ ) : null} +
+ + + {plan.taskProgress.total > 0 ? ( + + {plan.taskProgress.done}/{plan.taskProgress.total} tarefas + + ) : null} +
+
+
+) diff --git a/src/components/campaign/ActionPlanFilters.tsx b/src/components/campaign/ActionPlanFilters.tsx new file mode 100644 index 00000000..96ae9848 --- /dev/null +++ b/src/components/campaign/ActionPlanFilters.tsx @@ -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 }) => ( + +) + +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 ( +
+ + +
+

+ {isPending ? 'Atualizando resultados…' : ''} +

+
+ +
+
+ + Tipo de ação + updateKind(event.target.value)} + className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" + > + Todos os tipos + {Object.entries(actionPlanKindLabels).map(([value, label]) => ( + + {label} + + ))} + + + {state.tab === 'todos' ? ( + + Status + updateStatus(event.target.value)} + className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" + > + Todos os status + {Object.entries(actionPlanStatusLabels).map(([value, label]) => ( + + {label} + + ))} + + + ) : null} + + Território de identidade + + + + Município + + +
+ {hasFilters ? ( +
+ +
+ ) : null} +
+
+
+
+ ) +} diff --git a/src/components/campaign/ActionPlanForm.tsx b/src/components/campaign/ActionPlanForm.tsx new file mode 100644 index 00000000..f2a2cea4 --- /dev/null +++ b/src/components/campaign/ActionPlanForm.tsx @@ -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 + existingHref?: string + submittedTitle?: string +} + +type ActionPlanFormAction = ( + state: ActionPlanFormState, + formData: FormData, +) => Promise + +export type { ActionPlanLeadershipOption } + +export type ActionPlanFormFieldsProps = { + coordinators?: NucleusCoordinatorOption[] + canManageCoordinators?: boolean + leadershipOptions?: ActionPlanLeadershipOption[] + plan?: ActionPlanFormViewModel + fieldErrors?: Record + submittedTitle?: string + searchContacts: (query: string) => Promise +} + +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( + plan?.responsible + ? { id: plan.responsible.id, name: plan.responsible.name, phone: plan.responsible.phone } + : null, + ) + + return ( + + {statusIsEditable ? ( + + ) : ( + + )} + + + + Informações básicas + + + + + Título * + + {errorFor('title') ? {errorFor('title')} : null} + +
+ + Tipo de ação * + + Selecione um tipo + {Object.entries(actionPlanKindLabels).map(([value, label]) => ( + + {label} + + ))} + + {errorFor('kind') ? {errorFor('kind')} : null} + + + Status * + {statusIsEditable ? ( + <> + value && setStatus(value as typeof status)} + variant="outline" + className="flex w-full flex-wrap justify-start" + aria-label="Status do plano" + > + Planejado + Rascunho + + + Rascunhos não exigem data. Planos planejados exigem data e horário de início. + + + ) : ( + + Status atual: {actionPlanStatusLabels[plan?.status ?? ''] ?? plan?.status}. Use as + ações do detalhe para marcar como realizado ou cancelado. + + )} + +
+ + Descrição +