Merge branch 'cursor/6d2d332d'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 21:30:01 -03:00
commit ac671f4bc0
88 changed files with 13855 additions and 1488 deletions

View file

@ -18,7 +18,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
- `src/app/(campaign)/campanha` — ferramenta interna (auth `campaignUser`, cookie `campaign-token`; barreira no layout de `(app)`). Server actions em `campanha/actions/*`; `formActions.ts` por rota são cascas sobre `runCampaignFormAction` / `runCampaignRedirectFormAction` (`utilities/campaignFormActionError.ts`).
- `src/collections/` + `src/globals/` — modelo de dados Payload; TODA collection declara `access`; schema muda só por migration (`pnpm migrate:create`).
- `src/utilities/access/` — RBAC por domínio; `campaignAccess.ts` é o barrel de re-export; predicados de role client-safe em `src/lib/campaignRoles.ts`.
- `src/components/campaign/<domínio>/` — municipality, actionPlan, supporter, leadership, advisor, demand, organization, stateDeputy, votePledge, invite, map, dashboard, auth, shell (sidebar/nav/PWA), shared (sistema de listas + widgets genéricos).
- `src/components/campaign/<domínio>/` — municipality, activity, supporter, leadership, advisor, demand, organization, stateDeputy, votePledge, invite, map, dashboard, auth, shell (sidebar/nav/PWA), shared (sistema de listas + widgets genéricos).
- `src/components/ui/` — kit shadcn da casa (sem peças mortas; `shadcn add` re-adiciona).
- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`).
- `src/migrations/` — história congelada; nunca editar retroativamente.
@ -26,11 +26,11 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
## Sistema de listas (Pass 2 W1 — use, não reinvente)
- URL: `utilities/campaignListUrl.ts` (parse/canonicalize/`resolveListUrl`) + módulo por domínio (`municipalityListUrl.ts`, `supporterUi.ts`, `actionPlanUi.ts`, parsers nos `*Data.ts`). Contrato de URL congelado (B18).
- URL: `utilities/campaignListUrl.ts` (parse/canonicalize/`resolveListUrl`) + módulo por domínio (`municipalityListUrl.ts`, `supporterUi.ts`, `activityUi.ts`, parsers nos `*Data.ts`). Contrato de URL congelado (B18).
- Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `head`, `cell`, seams `mandatory`/`defaultVisible` p/ B17); heads ricos = ilhas (`municipality/MunicipalitySortableHead` + `MunicipalityHeaderFilter`).
- Shells: `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPagination`, pending compartilhado em `CampaignListPending.tsx` (dima o resultado, não o controle).
- Exceções documentadas: planos = cards; `TerritoryOverviewTable` = sort no cliente; comparação de candidatos, LeaderContacts e preview de import ficam fora.
- Detalhe por tabs: `utilities/detailTabUi.ts` (factory) + sections RSC colocadas na rota (`municipios/[slug]/MunicipalityDetailTabs.tsx`, `planos/[slug]/ActionPlanOverviewTab.tsx`).
- Exceções documentadas: atividades = cards; `TerritoryOverviewTable` = sort no cliente; comparação de candidatos, LeaderContacts e preview de import ficam fora.
- Detalhe por tabs: `utilities/detailTabUi.ts` (factory) + sections RSC colocadas na rota (`municipios/[slug]/MunicipalityDetailTabs.tsx`, `atividades/[slug]/ActivityOverviewTab.tsx`).
## Achar o dono de uma feature

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **C13 entregue em código (2026-07-25):** "Plano de Ação" → **"Atividade"** em toda a vertical. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades", hooks internos renomeados), campo `campaignDemand.actionPlan` → `activity`, 7 utilities `actionPlan*` → `activity*`, `access/actionPlans.ts` → `access/activities.ts`, `lib/schemas/actionPlan.ts` → `activity.ts`, `actions/actionPlan.ts` → `activity.ts`, `components/campaign/actionPlan/` → `activity/` (13 componentes), rota `(app)/planos/` → `(app)/atividades/` (criação em `atividades/nova`, feminino como `demandas/nova`) e sidebar "Atividades". **Migration data-preserving escrita à mão** `20260725_213000_rename_action_plan_to_activity`: 4 tabelas, 3 enums, 2 colunas, 39 índices (4 pkeys inclusos), 14 FKs, 2 sequences — lista canônica tirada de `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` porque o snapshot do drizzle não conhece o índice parcial `action_plan_upcoming_start_at_idx` (escrito à mão no C8) e porque o gerador do Payload emitiria DROP+CREATE. O `.json` foi derivado do snapshot anterior por transformação textual (o `migrate:create` é interativo para renames) e validado pelo teste que importa: `pnpm migrate:create __probe` responde "No schema changes detected". **Bônus aprovado pelo usuário nesta sessão:** os **8 índices fósseis `%plaza%`** da remodelagem de 23/07 (que renomeou as colunas `plaza_id` mas não os índices, deixando banco e snapshot em desacordo) foram alinhados na mesma migration; `down()` os restaura, para não inventar um estado que nunca existiu. Decisões de vocabulário: sidebar **"Atividades"** (uma palavra só, a da entidade) e rótulos de campo alinhados — **"Tipo de atividade"**, **"Origem da atividade"**, **"Resultado da atividade"** — porque "ação" ao lado de "atividade" reintroduzia a tradução mental que o item existe para matar; valores de enum `kind`/`status`/`origin` **inalterados**. **Guard:** o describe "banned campaign terminology" de `codebaseConventions.unit.spec.ts` virou tabela de termos varrendo `src`+`tests`+`scripts` (migrations fora — história congelada), banindo os compostos `actionPlan`, a copy "plano de ação" e a rota velha, mas **não** `plan`/`plano` isolado (colidiria com os planos de assinatura da Fase 2 white-label); allowlist: o próprio spec, `scripts/generate-remodel-municipalities-migration.mjs` e o comentário histórico em `campaignMigrationReconciliation.int.spec.ts`. Estendê-lo a `tests/` derrubou 8 fósseis "Praça" em fixtures, limpos no mesmo PR. Gate verde (tsc, lint, format, knip, check:cycles, 46 unit / 48 int / 7 e2e, build); Aikido sem achado. **Deploy:** o `pnpm build` da Vercel aplica a migration enquanto o deployment antigo ainda serve — deployar fora do horário de mesa (mesmo perfil da remodelagem de 23/07). **Pós-`/simplify`:** a varredura mecânica comeu uma interpolação — `tests/unit/recentVisits.unit.spec.ts` ficou com `label: \`Município \`` para todas as entradas, e passava porque o label não era asserido (restaurado, com assertion nova para fechar a brecha, e os slugs `praca-` do fixture alinhados). O guard ganhou tolerância a espaço (`action[\s_-]?plan`), que era o buraco por onde `access/shared.ts` mantinha "action plan" em prosa; com isso a allowlist encolheu — o comentário de `campaignMigrationReconciliation.int.spec.ts` passou a citar `activity` e o arquivo saiu da exceção (uma spec que cresce não fica fora do guard por causa de um comentário). Deliberadamente **não** ampliei o padrão de "Praça" para a forma sem cedilha: cobraria três entradas novas de allowlist (nomes reais de organização na planilha, o teste de entradas legadas `kind: 'nucleus'`) por um fóssil já corrigido. Também: três resíduos do find-and-replace (banner `// Action activities`, `DOSSIER_*_PLAN_LIMIT`, "a activity" em dois comentários), concordância em pt-BR ("Criada por", "atividades apoiadas" minúsculo), o `??` impossível em `ActivityForm`, e as duas buscas de FK da migration agora usam `to_regclass` em vez de `::regclass` (que lança em vez de retornar NULL, contradizendo a invariante do próprio cabeçalho). A entrada morta `action_plan_municipality_idx` saiu da lista de índices da migration (nenhuma migration jamais criou esse nome — só os snapshots o afirmam, e com `push: false` nenhum banco pode carregá-lo), deixando `up()` e `down()` com os mesmos 48 pares; e o detalhe de organização parou de derivar o rótulo de status à mão e passou a reusar `ActivityStatusBadge`, com `OrganizationDetailViewModel.activities[].status` tipado como `ActivityStatus` em vez de `string` (a origem que forçava o re-narrowing no consumidor). Plano: [`renomear-plano-acao-para-atividade.md`](../../../docs/plans/renomear-plano-acao-para-atividade.md).
- **B26 entregue em código (2026-07-25):** registrar sinal a partir da célula "Último sinal" de `/campanha/municipios` — Popover (desktop) / Drawer bottom sheet (mobile) cria `municipalityUpdate` `kind: 'sinal'` (C12) sem sair da fila; submit explícito "Registrar sinal" (exceção do princípio 4); `revalidateMunicipalityListPaths`; sem migration. Plano: [`registrar-sinal-lista-municipios.md`](../../../docs/plans/registrar-sinal-lista-municipios.md).
- **B20 entregue (2026-07-25):** Municípios prioritários no Início — `pickDashboardPriorityMunicipalities` (`src/utilities/dashboardPriorityMunicipalities.ts`) ordena `priority=alta` por déficit E9/`central`, amostra 8; `CampaignDashboard` exibe Badge com `highPriorityCount`, CTA “Ver todas” → `?priority=alta`, chips com `list-none`. Unit `dashboardPriorityMunicipalities.unit.spec.ts`. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.md).
- **B28 registrado (2026-07-25):** e-mail + celular na lista `/campanha/liderancas` — colunas com copy-on-click + WhatsApp (`wa.me`) no padrão de `/campanha/assessores` (B19 ✓); `email` no view model da lista; extrair primitivo compartilhado (2º call site); **sem** toggle Editar in-list (Contact update adiado). Appetite ~0,5–0,75d; Janela 1; cortável; Impeccable B. Plano: [`email-celular-lista-liderancas.md`](../../../docs/plans/email-celular-lista-liderancas.md).

View file

@ -44,7 +44,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 texts are 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. The C2 supporter registry (merged to `main`, production-blocked) additionally requires `Consent.key = 'apoiador-cadastro'` and `Consent.key = 'apoiador-intencao-voto'` (vote intention is sensitive data) — same fail-closed behavior. All four keys are bundled in the single legal batch of Onda 0 (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. Also the same day: **C2** Cadastro nominal de apoiadores (`supporter` join `Contact`↔campanha with optional `nucleus`, migration `20260718_222656_add_supporter` with `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent keys `apoiador-cadastro` / `apoiador-intencao-voto` resolved via a generalized `campaignConsent.ts` that fails closed, `/campanha/apoiadores` vertical with list+KPIs/detail/CSV-import wizard; phone required in v1, `lideranca` has no access to the area; production use with real data still waits on Consent keys + legal sign-off). Also the same day: C3 Planos de Ação (`actionPlan` + `/campanha/planos`, migration `20260718_222832_add_action_plan`, blocos "Próximos eventos"). Also the same day: **B2** map foundation — static TopoJSON for Bahia municipalities + identity territories, `bahiaMunicipalityCodes`, helpers in `bahiaGeometries`, re-runnable `pnpm build:geometries` (no migration, no UI; Leaflet is B3). Post-`/simplify` scale follow-ups for that delivery are registered as **B5** (`docs/plans/escala-dry-pos-b2.md`). 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: **C2** Cadastro nominal de apoiadores (`supporter` join `Contact`↔campanha with optional `nucleus`, migration `20260718_222656_add_supporter` with `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent keys `apoiador-cadastro` / `apoiador-intencao-voto` resolved via a generalized `campaignConsent.ts` that fails closed, `/campanha/apoiadores` vertical with list+KPIs/detail/CSV-import wizard; phone required in v1, `lideranca` has no access to the area; production use with real data still waits on Consent keys + legal sign-off). Also the same day: C3 Planos de Ação (`actionPlan` + `/campanha/planos`, migration `20260718_222832_add_action_plan`, blocos "Próximos eventos") — **renamed to `activity` / `/campanha/atividades` by C13 on 2026-07-25**; the old names survive only in frozen migration history. Also the same day: **B2** map foundation — static TopoJSON for Bahia municipalities + identity territories, `bahiaMunicipalityCodes`, helpers in `bahiaGeometries`, re-runnable `pnpm build:geometries` (no migration, no UI; Leaflet is B3). Post-`/simplify` scale follow-ups for that delivery are registered as **B5** (`docs/plans/escala-dry-pos-b2.md`). 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 evening / 2026-07-19):** **A4** Baseline no produto + Gap vs 2022 shipped and merged to `main` — `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` aggregate TSE 2022 by nucleus geography; `computeGapVs2022` + `NucleusInsights` on the overview tab; `NucleusElectoralBaseline` card (candidate / president / governor via `BASELINE_TICKET_2022`); "Baseline 2022" card on the filtered nuclei-list overview. No migration (reads existing A3 collections). A `/simplify` pass applied targeted cleanup (role-generic ticket config, per-city `zoneNumber in […]`, shared geography helpers). Scale/DRY debits larger than cleanup (aggregate federal ranking on detail instead of loading all nominal rows, filter by TSE `cityCode`, Alert `confirmed` + `Progress` reuse) are registered as **A7** (`docs/plans/escala-dry-pos-a4.md`).
@ -68,6 +68,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, planos-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActionPlanOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
**Recently resolved (2026-07-25):** **C13** "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades"), `campaignDemand.actionPlan` → `activity`, route `/campanha/planos` → `/campanha/atividades` (create at `/campanha/atividades/nova`), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum **values** (`kind`/`status`/`origin`) are unchanged. Schema moved by a **hand-written data-preserving migration** (`20260725_213000_rename_action_plan_to_activity`): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric `down()`. Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index `action_plan_upcoming_start_at_idx`, so the object list came from `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` and the `.json` snapshot was written by transforming its predecessor — validated by `pnpm migrate:create __probe` reporting no schema changes. The same migration aligned the **8 fossil `%plaza%` index names** the Município remodel left behind (it renamed the `plaza_id` columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the `codebaseConventions.unit.spec.ts` "banned campaign terminology" describe is now table-driven and sweeps `src` + `tests` + `scripts` (migrations stay out — frozen history), banning the `actionPlan` compounds, the pt-BR copy and the old route, but **not** bare `plan`/`plano` (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, `scripts/generate-remodel-municipalities-migration.mjs` and the historical comment in `campaignMigrationReconciliation.int.spec.ts`. Plano: [`docs/plans/renomear-plano-acao-para-atividade.md`](docs/plans/renomear-plano-acao-para-atividade.md).
**Recently resolved (2026-07-25):** **E16** Dossiê do município (pré-agenda, field request O6) — staff-only `dossie` tab on the municipality detail page (6th value in `municipalityDetailTabs`, same query-param pattern): `src/utilities/municipalityDossierData.ts` composes the existing loaders in one pass (TSE baseline + A11 rank via `municipalityElectionGeographyForSlug`, E8 goal account, pledges, leaderships sorted by a new `LeadershipRowViewModel.updatedAt` freshness field, updates feed, two scoped `actionPlan` reads with `overrideAccess: false`, and A8 demographics — the previously consumer-less `bahiaMunicipalityDemographics.ts` re-enters through the "Perfil" section, with an explicit whole-city caveat for Salvador zona municipalities). Section caps 8 lideranças / 5 sinais / 3 upcoming + 2 realizado plans, each with a "ver tudo" link to its tab. New staff-only `municipality.budgetNotes` textarea ("Emendas aportadas", G11 manual-first) via migration `20260725_022155_add_municipality_budget_notes` — its committed `.json` snapshot also heals the drizzle snapshot chain after the snapshot-less `campaign_goals` migration; strategy form/zod/action extended; the section renders only when filled. **Print view is CSS-only:** app chrome gets `print:hidden`, the `h-svh`/`overflow-hidden` shells get `print:h-auto print:overflow-visible` (otherwise only page 1 prints), and `styles.css` gains A4 print typography; the dossier cover is self-contained (name, TI, badges, "Gerado em") so the printed page keeps its identity. Int test mocks `loadMunicipalityElectoralBaseline` (its `unstable_cache` wrapper needs the Next runtime). Plano: [`docs/plans/dossie-municipio.md`](docs/plans/dossie-municipio.md).
**Recently resolved (2026-07-24):** **E8** Conta da cadeira (metas derivadas, potencial por município, cobertura) — committed TSE artifact extended to v2 (`pnpm build:election-aggregates` now writes `campoFederalVotesByYear`, `federalTallyByYear`, and 2022 `majoritarian2022` presidente/governador T1 per municipality, alongside the existing `votesByYear`/`validVotesByYear`; byte budget raised 400→700 KB in the unit test); `src/lib/campoParties.ts` (curated field-party siglas per election year, unit-tested against `electionPartySpectrum.ts`'s `esquerda` bucket — not a second party classifier); global `campaignGoals` (`src/globals/CampaignGoals.ts`, migration `20260724_180000_add_campaign_goals_global`, `stateGoal` default 150,000 + `margin`/`baseYear`/`note`, `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`, **no** `afterChange` hook — `/campanha` is dynamic with auth so nothing caches it); pure derived utilities `src/utilities/municipalityPotential.ts` (projected valid votes, field ceiling, capture rate, intra-field share, 2022-only roll-off, per-scenario suggested goal + TI sanity check) and `src/utilities/goalCoverage.ts` — **locked semantics:** `meta = expectedVotes[scenario] ?? suggestedGoal[scenario]`, `comprometido = aggregate.effectiveByScenario[scenario]` (pledges only — the mesa's own expectation never substitutes for the pledge sum in the coverage denominator, unlike `resolveMunicipalityStaffVoteTotal`); orchestration in `src/utilities/municipalityGoalAccount.ts` (`cache()`-deduplicated across dashboard/list/detail). UI (Impeccable class B): "Cobertura da meta" row on the dashboard and the list overview `CampaignMetricStrip`, a new list column (renaming the old "Cobertura" column to "Assessoria" to remove ambiguity) via a shared `MunicipalityListGoalCoverageCell` (compact/default layouts), and a new "Conta da cadeira" card on the municipality detail page (fixed to the `central` scenario, replacing the "Votos estimados" block that used to live in `MunicipalityStrategyCard`). Unblocks roadmap items **E9/E10/E12/E13**. Plano: [`docs/plans/conta-da-cadeira.md`](docs/plans/conta-da-cadeira.md). **As-built correction (same day, during E9):** the suggested goal originally split the state goal proportionally to the field ceiling alone, which ignored the candidate's own vote and produced absurd targets (2,911 for Vitória da Conquista, which gave him 5,005 votes in 2022; 813 for Campo Formoso, which gave him 47). `decomposeStateGoal` was deleted in favour of `deriveSuggestedGoalsByScenario`, anchored on the municipality's own 2022 vote — pessimistic = base × (1 − `margin`), central = base, optimistic = base × (`stateGoal` ÷ Σ base), clamped at `max(central, optimistic)` — so `stateGoal` now defines the optimistic scenario and `margin` the pessimistic haircut (10% fallback). Descriptions only, **no migration**.
@ -137,13 +139,13 @@ The internal `/campanha` area is gated by its own authentication, deliberately k
`/campanha` is organized around `municipality` ("Município") — the campaign's PREDEFINED operational territory. Deployed to production on 2026-07-23 (destructive remodel migrations applied via the Vercel build). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `docs/plans/remodelagem-pracas.md`).
- **Município definition:** 435 seeded rows — one per Bahia municipality (416), except Salvador (19 zone municipalities, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated). Canonical catalog: `src/lib/municipalityCatalog.ts` (`tests/fixtures/municipality-catalog.snapshot.json`). Seeded by migration `20260723_200000_remodel_municipalities` (+ reconcile `20260723_202000_reconcile_municipality_remodel`). Geography is admin-only; staff edit strategy fields and the coordinator assigns `advisors`.
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `allocationDecision`, `municipalityUpdate`, `campaignInvite`, `supporter`, `actionPlan`. **Global** (same admin group): `campaignGoals` (E8 "conta da cadeira" — `stateGoal` sets the optimistic suggested-goal scenario, `margin` the pessimistic haircut; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; no `afterChange` hook — nothing caches it).
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `allocationDecision`, `municipalityUpdate`, `campaignInvite`, `supporter`, `activity`. **Global** (same admin group): `campaignGoals` (E8 "conta da cadeira" — `stateGoal` sets the optimistic suggested-goal scenario, `margin` the pessimistic haircut; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; no `afterChange` hook — nothing caches it).
- **Roles:** `coordinator` ("Coordenador Geral") and `candidate` ("Candidato", unrestricted visibility via `isCampaignUnrestricted`) see/manage everything staff sees; `advisor` ("Assessor") is scoped to `municipality.advisors`; `leader` ("Liderança") is **lockdown** — home is the supporter contact tool only (`LeaderContactsPanel`, `source: lideranca`), reads only supporters they created (`createdBy`), no municipalities/leaderships/pledges/demands/plans/election data.
- **Vote pledge asymmetry:** staff declare `declaredVotes` and record `estimatedVotes` (pessimistic/central/optimistic); leaders never see estimates. Aggregates use `estimated[S] ?? declared`, default `central` (`src/utilities/votePledgeData.ts`).
- **Demandas:** staff-only create/read (`canCreateCampaignDemand`); optional `leadership` provenance selected by staff. Escalated decisions: coordinator **or** candidate.
- **Dobradinhas:** `stateDeputy` entity + `municipality.stateDeputies` / `leadership.stateDeputies` hasMany; UI at `/campanha/dobradinhas`.
- **Map:** staff dashboard (`/campanha`) loads `MunicipalityMapPanel`; `/campanha/municipios` is list/overview only.
- **URLs:** `/campanha/municipios[/slug]`, `/campanha/dobradinhas`, `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/planos`, `/campanha/apoiadores`, `/campanha/assessores` (coordinator/candidate), `/campanha/conceitos` (staff glossary), `/campanha/perfil`.
- **URLs:** `/campanha/municipios[/slug]`, `/campanha/dobradinhas`, `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/atividades[/slug]`, `/campanha/apoiadores`, `/campanha/assessores` (coordinator/candidate), `/campanha/conceitos` (staff glossary), `/campanha/perfil`.
## Campaign supporters (C2)
@ -157,13 +159,13 @@ Nominal supporter registry backing the campaign's base of declared supporters (r
- **C6 scale layer (merged to `main` 2026-07-19).** CSV import up to 5000 rows now completes in one transaction: `confirmSupporterImportRecord` acquires all phone advisory locks, then `bulkInsertSupporterImport` (`src/utilities/supporterImportBulk.ts`) inserts contacts + supporters via `payload.db.drizzle` on the Payload transaction session (`getPostgresTransactionDatabase`) in 500-row chunks with `ON CONFLICT DO NOTHING` on `(contact_id, municipality_id)`. `Contact.enforceUniqueContactPhone` honors `context.skipContactPhoneInvariant` **fail-closed** (only inside an active `req.transactionID`), so the bulk path skips the per-row re-check while the single-create path is unchanged. Preview no longer round-trips the full `ok` set: `previewSupporterImportText` stages it in `supporterImportBatch` (`src/collections/SupporterImportBatch.ts`, admin-hidden, `coordinator`/admin only, migration `20260719_011015_add_supporter_import_batch`) and returns an HMAC-SHA256 single-use `importToken` (10-min TTL, actor-bound, `src/utilities/supporterImportToken.ts`); `confirmSupporterImportRecord` verifies the token and consumes the batch (delete after commit). The list KPI is a single `COUNT(*) FILTER` aggregate (`src/utilities/supporterListOverviewAggregate.ts`) mirroring `buildSupporterListWhere` + the access constraint, replacing 3× `payload.count`. Shared shells — `campaignListUrl.ts`, `CampaignListPagination`, `campaignFormFields.ts`, `mapCampaignFormActionError.ts` — replace the three per-list pagination components and the duplicated form-error ladders. Scale/DRY follow-ups from the `/simplify` pass are registered as **C8** (`docs/plans/escala-dry-pos-c6.md`).
- **Mandatory production blocker:** do not import real supporter data or capture vote intention until electoral counsel documents the LGPD art. 11 basis (vote intention is sensitive data), approves the versioned consent texts, and an admin creates `Consent.key = 'apoiador-cadastro'` and `Consent.key = 'apoiador-intencao-voto'`. The app intentionally fails closed when these keys are absent.
## Campaign action plans (C3)
## Campaign activities (C3, renamed by C13)
Mobilization events / agenda backing the campaign's field operations (roadmap item C3; implemented and merged to `main` on 2026-07-18). One collection in admin group `Campanha`:
Mobilization events / agenda backing the campaign's field operations (roadmap item C3; implemented and merged to `main` on 2026-07-18). **Renamed "Plano de Ação" → "Atividade" on 2026-07-25 (C13)**: the collection slug is `activity`, the route is `/campanha/atividades`, and the vocabulary guard in `tests/unit/codebaseConventions.unit.spec.ts` now fails the build on `actionPlan` / `plano de ação` / `/campanha/planos` anywhere in `src`, `tests` or `scripts` (migrations excluded — frozen history). One collection in admin group `Campanha`:
- **`actionPlan`** (`src/collections/ActionPlan.ts`) — a calendarizable action/event (caminhada, comício, etc.) anchored on ONE `municipality`, with task checklist, update feed and result record. Fields: `title`, canonical immutable `slug`, `kind` / `status` enums in pt-BR, `startAt` (required beyond `rascunho`), `municipality` (required) + free-text `locality`, `organizations` (hasMany — supporting orgs), `advisors` (staff responsible; ex-`coordinators`), `deputyPresent` (checkbox "Deputado presente"), `responsible` → `Contact`, `leadership` optional, `tasks`/`updates` arrays (derived fields server-side), and the action result: `resultSummary` + `resultMedia` (upload → `media`) + derived `resultRecordedBy/At` — queryable history for future activities in the same município. No `Consent` (internal staff data).
- **Access.** `coordinator` everything; `advisor` where listed in `advisors` OR the plan's `municipality` is administered (may create, auto-included); `leader` has no access (lockdown).
- **UI** `/campanha/planos` — list with tabs Próximos (default) / Todos / Realizados / Rascunhos, filters (kind, municipality), cards with kind/status badge + date/time + município + "Resp:" + task progress + "Deputado presente"; detail with tabs Visão geral / Tarefas / Atualizações; result form appears for staff when the plan is `realizado`.
- **`activity`** (`src/collections/Activity.ts`) — a calendarizable action/event (caminhada, comício, etc.) anchored on ONE `municipality`, with task checklist, update feed and result record. Fields: `title`, canonical immutable `slug`, `kind` / `status` enums in pt-BR, `startAt` (required beyond `rascunho`), `municipality` (required) + free-text `locality`, `organizations` (hasMany — supporting orgs), `advisors` (staff responsible; ex-`coordinators`), `deputyPresent` (checkbox "Deputado presente"), `responsible` → `Contact`, `leadership` optional, `tasks`/`updates` arrays (derived fields server-side), and the action result: `resultSummary` + `resultMedia` (upload → `media`) + derived `resultRecordedBy/At` — queryable history for future activities in the same município. No `Consent` (internal staff data).
- **Access.** `coordinator` everything; `advisor` where listed in `advisors` OR the activity's `municipality` is administered (may create, auto-included); `leader` has no access (lockdown).
- **UI** `/campanha/atividades` — list with tabs Próximos (default) / Todos / Realizados / Rascunhos, filters (kind, municipality), cards with kind/status badge + date/time + município + "Resp:" + task progress + "Deputado presente"; detail with tabs Visão geral / Tarefas / Atualizações (`/campanha/atividades/[slug]`, create at `/campanha/atividades/nova`); result form appears for staff when the activity is `realizado`.
## Campaign map geometries (B2)
@ -218,9 +220,9 @@ src/
│ ├── (frontend)/ # Frontend routes + server actions (src/app/(frontend)/actions/*.ts)
│ ├── (payload)/ # Payload admin routes
│ └── (campaign)/ # Internal campaign-management area (auth barrier, see "Campaign auth")
├── collections/ # Users, CampaignUser, CampaignInvite, Municipality, Leadership, VotePledge, MunicipalityUpdate, Organization, StateDeputy, CampaignDemand, AllocationDecision, Supporter, SupporterImportBatch, ActionPlan, ElectionTally, ElectionCandidateVote, ElectionCandidate, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag
├── collections/ # Users, CampaignUser, CampaignInvite, Municipality, Leadership, VotePledge, MunicipalityUpdate, Organization, StateDeputy, CampaignDemand, AllocationDecision, Supporter, SupporterImportBatch, Activity, ElectionTally, ElectionCandidateVote, ElectionCandidate, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag
├── globals/ # Global configs (SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals)
├── components/ # campaign/<domínio>/ (municipality, actionPlan, supporter, …, shared, shell — Pass 2 W2), ui from shadcn
├── components/ # campaign/<domínio>/ (municipality, activity, supporter, …, shared, shell — Pass 2 W2), ui from shadcn
├── lib/ # cities.ts, bahiaTerritories.ts, bahiaTseZones.ts, bahiaMunicipalityCodes.ts, bahiaGeometries.ts, geometries/*.topo.json, municipalityCatalog.ts, electionAggregates/ (committed TSE artifact) + bahiaElectionAggregates.ts, formData.ts, zod schemas (lib/schemas/*)
├── utilities/ # campaign*, access/* (per-domain RBAC; campaignAccess.ts re-exports), municipality*, posts, documents, globals, locks, etc.
└── payload.config.ts # Main config

View file

@ -30,7 +30,7 @@ flowchart LR
- **`src/lib/`** — lógica pura e dados estáticos, importável de client e server: catálogos (`municipalityCatalog`, `bahiaTerritories`, geometrias TopoJSON), schemas zod (`lib/schemas/*`), helpers puros (`phone`, `slug`, `wordStartFilter`, `voteEstimate`, `electionFormat`, `voteTrend`), constantes client-safe (`campaignConsentKeys`, `campaignRoles`), artefato TSE (`electionAggregates/` + `bahiaElectionAggregates.ts`). `lib/` **nunca importa de `utilities/`** (as 9 inversões históricas foram zeradas no Pass 2 W2).
- **`src/utilities/`** — código acoplado a Payload/Next. Loaders que tocam banco ou `unstable_cache` são marcados **`import 'server-only'`** na primeira linha (21 marcados no Pass 2). Módulos de UI-helper client-safe (ex.: `municipalityListUrl`, `supporterUi`) ficam aqui sem a marca por importarem apenas TIPOS do Payload.
- **`src/collections/` + `src/globals/`** — o modelo de domínio É o Payload. Toda collection declara `access` explicitamente; hooks passam `req` (transação) — ver `AGENTS.md` e `.cursor/rules/security-critical.mdc`.
- **`src/components/campaign/`** — subpastas por domínio (Pass 2 W2): `municipality/ actionPlan/ supporter/ leadership/ advisor/ demand/ organization/ stateDeputy/ votePledge/ invite/ map/ dashboard/ auth/ shell/ shared/`. `shared/` é o sistema transversal (listas, tabela, comboboxes); `shell/` é o chrome do app (sidebar, nav, PWA).
- **`src/components/campaign/`** — subpastas por domínio (Pass 2 W2): `municipality/ activity/ supporter/ leadership/ advisor/ demand/ organization/ stateDeputy/ votePledge/ invite/ map/ dashboard/ auth/ shell/ shared/`. `shared/` é o sistema transversal (listas, tabela, comboboxes); `shell/` é o chrome do app (sidebar, nav, PWA).
- **Atravessando a fronteira client:** um client component nunca importa um módulo de dados de servidor por VALOR. Tipos/constantes compartilhados vivem num módulo de contrato client-safe — precedentes: `municipalityMapContract.ts` (mapa), `votePledgeViews.ts` (matemática de pledges; loaders em `votePledgeData.ts` são `server-only`), `lib/campaignRoles.ts` (predicados de role para a sidebar — a fonte da qual `utilities/access/shared.ts` deriva). `import type` é sempre seguro.
## Fluxo de dados de uma página `/campanha`
@ -48,7 +48,7 @@ flowchart LR
- **Leituras** passam `user` + `overrideAccess: false` (bypass admin só documentado, ex. resolução de nomes sobre linhas já checadas).
- **Escritas** saem de server actions em `src/app/(campaign)/campanha/actions/*`; multi-collection = transação Payload com `req: { transactionID }`. As `formActions.ts` por rota são cascas finas sobre os wrappers compartilhados: `runCampaignFormAction` (fica na página, estado de sucesso/erro) e `runCampaignRedirectFormAction` (cria e redireciona) em `campaignFormActionError.ts` — exceções documentadas nos próprios arquivos (planos, apoiadores/[id], convite login).
- **Listas** usam o sistema do Pass 2 W1: parse/canonicalização de URL em `campaignListUrl.ts` (+ módulo por domínio, ex. `municipalityListUrl.ts`), colunas como dado em `CampaignTable` (`shared/CampaignTable.tsx`, seams `mandatory`/`defaultVisible` para B17), shells `CampaignSearchForm`/`CampaignFilterChips`/`CampaignListFooter`/`CampaignListEmptyState`/`CampaignListPagination`, pending compartilhado via `CampaignListPendingBoundary` (dima o RESULTADO, não o controle — princípio "Feel the action"). Contrato de URL congelado (B18 depende).
- **Páginas de detalhe** roteiam tabs por querystring (`detailTabUi.ts` → `municipalityDetailTabUi`/`actionPlanDetailTabUi`); cada tab é uma section RSC própria colocada na rota (`MunicipalityDetailTabs.tsx`, `ActionPlanOverviewTab.tsx`), streamando atrás de `Suspense` quando a leitura é pesada.
- **Páginas de detalhe** roteiam tabs por querystring (`detailTabUi.ts` → `municipalityDetailTabUi`/`activityDetailTabUi`); cada tab é uma section RSC própria colocada na rota (`MunicipalityDetailTabs.tsx`, `ActionPlanOverviewTab.tsx`), streamando atrás de `Suspense` quando a leitura é pesada.
## Escada de caching (decidir nesta ordem)
@ -61,12 +61,12 @@ flowchart LR
Papéis em `campaignUser.role` — predicados client-safe em `lib/campaignRoles.ts`, checks de ator em `utilities/access/shared.ts`, regras por domínio em `utilities/access/*` (re-exportadas por `campaignAccess.ts`):
| Papel | Escopo |
| ------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `coordinator` | tudo (staff irrestrito); único que importa CSV de apoiadores e gerencia assessores (com `candidate`) |
| `candidate` | visibilidade irrestrita (staff); elegível como assessor responsável |
| `advisor` | staff restrito aos municípios em `municipality.advisors` |
| `leader` | **lockdown**: home = ferramenta de contatos; lê só apoiadores que criou; sem municípios/pledges/demandas/planos/eleições |
| Papel | Escopo |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `coordinator` | tudo (staff irrestrito); único que importa CSV de apoiadores e gerencia assessores (com `candidate`) |
| `candidate` | visibilidade irrestrita (staff); elegível como assessor responsável |
| `advisor` | staff restrito aos municípios em `municipality.advisors` |
| `leader` | **lockdown**: home = ferramenta de contatos; lê só apoiadores que criou; sem municípios/pledges/demandas/atividades/eleições |
Assimetria estrutural de pledge: staff registra `declaredVotes` + `estimatedVotes` (3 cenários); a liderança **nunca** vê estimativas (view models por papel; agregados usam `estimated[S] ?? declared`, default `central`).
@ -80,7 +80,7 @@ Dois contexts principais + um de suporte, espelhados nos prefixos de módulo e n
- **Liderança** — ficha única por pessoa (`contact` UNIQUE), atua em N municípios/organizações; pode ter conta `leader`.
- **Pledge (compromisso de votos)** — por liderança×município; `declaredVotes` (staff-entered, visível à liderança) vs `estimatedVotes` (staff-only, 3 cenários: `pessimistic|central|optimistic`).
- **Conta da cadeira (E8)** — vocabulário staff-only: _válidos projetados_, _teto do campo_, _captura_, _share intracampo_, _roll-off_, _meta_ (`expectedVotes[cenário] ?? meta sugerida`), _cobertura da meta_ (`comprometido ÷ meta`, comprometido = SÓ pledges). Glossário do usuário em `/campanha/conceitos` (`lib/campaignIntelligenceConcepts.ts`).
- **Demanda** — pedido de material/serviço, staff-only, escalada decidida por coordinator/candidate. **Plano de Ação** — evento/agenda ancorado em UM município. **Apoiador** — join `Contact`↔campanha (consent LGPD fail-closed por chave estável). **Dobradinha** — deputado estadual parceiro (`stateDeputy`). **Assessor responsável** — carteira em `municipality.advisors`.
- **Demanda** — pedido de material/serviço, staff-only, escalada decidida por coordinator/candidate. **Atividade** — evento/agenda ancorado em UM município (renomeada de "Plano de Ação" pelo C13 em 2026-07-25). **Apoiador** — join `Contact`↔campanha (consent LGPD fail-closed por chave estável). **Dobradinha** — deputado estadual parceiro (`stateDeputy`). **Assessor responsável** — carteira em `municipality.advisors`.
- **Contact** é o registro normalizado de PESSOA compartilhado com o site público — features novas criam JOINS para `contact`, nunca uma segunda tabela de pessoa.
**Site público (`(frontend)`)** — `post`/`tag` (taxonomia com `hidden` fail-closed para o período eleitoral), `petition`/`signature`, `subscription`, `consent` (versionado, resolvido por `key` estável). Cache ISR sob a tag `posts`.
@ -97,9 +97,10 @@ Dois contexts principais + um de suporte, espelhados nos prefixos de módulo e n
| 2026-07-23 | Agregados TSE imutáveis viram artefato commitado; build da Vercel não depende de conteúdo do banco |
| 2026-07-25 | **Pass 2 D1:** NO-GO em `src/domains/` e ports-and-adapters; GO em convenções + correções de fronteira + subpastas por domínio DENTRO de `components/campaign/`; subpastas em `utilities/` adiadas (gatilho: 3º módulo novo de um domínio num mês) — [decisao-arquitetura-dominios.md](plans/decisao-arquitetura-dominios.md) |
| 2026-07-25 | **Pass 2 D2:** matemática de insights da era núcleos DELETADA (`electionInsights.ts`); E10 nasce em módulo novo com classificação relativa |
| 2026-07-25 | **Pass 2 W1/D5:** listas de tabela no sistema compartilhado (colunas como dado); exceções documentadas: planos (cards), `TerritoryOverviewTable` (sort client, ≤27 linhas), comparação de candidatos, LeaderContacts, preview de import |
| 2026-07-25 | **Pass 2 W1/D5:** listas de tabela no sistema compartilhado (colunas como dado); exceções documentadas: atividades (cards), `TerritoryOverviewTable` (sort client, ≤27 linhas), comparação de candidatos, LeaderContacts, preview de import |
| 2026-07-25 | **Pass 2 W2:** dedup por POLÍTICA continua valendo (wrappers nomeados como `runCampaignFormAction`), nunca plumbing genérico de dados; aliases de access `canUpdateX = canReadX` são declarações de política deliberadas |
| 2026-07-25 | **Pass 2 W4b:** knip `exports`/`types`/`enumMembers` em ERROR no CI; tests fazem parte do grafo; kit `ui/` sem peças mortas (shadcn re-adiciona sob demanda) |
| 2026-07-25 | **C13:** vocabulário do produto é lei no código — "Plano de Ação" → **Atividade** (`activity`), rename completo (entidade, banco, rota, copy) por migration data-preserving escrita à mão; termo velho banido pelo guard de convenções em `src`+`tests`+`scripts` |
## Manutenção deste documento

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-C6 (apoiadores / import / listas)
Status: F4 (DRY de forms) **entregue** — twin praça 2026-07-21 + hardening 2026-07-23; **o resto do F4 (10 escadas hand-rolled escritas depois) fechou em 2026-07-25 no Pass 2 W4d** (`runCampaignFormAction`, exceções documentadas em comentário); **F3 (parsers das listas de entidade) fechou no Pass 2 W1-D2**; F1–F2 abertos com gatilho (volume real de import/base nominal pós-Onda 0)
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening)
Status: F4 (DRY de forms) **entregue** — twin praça 2026-07-21 + hardening 2026-07-23; **o resto do F4 (10 escadas hand-rolled escritas depois) fechou em 2026-07-25 no Pass 2 W4d** (`runCampaignFormAction`, exceções documentadas em comentário); **F3 (parsers das listas de entidade) fechou no Pass 2 W1-D2**; **F4b aberta** (o guard do F4 só inspeciona `formActions.ts`, deixando os arquivos de action irmãos sem verificação — achado do `/simplify` do C13); F1–F2 abertos com gatilho (volume real de import/base nominal pós-Onda 0)
Atualizado em: 2026-07-25 (F4b registrada pelo `capture-review-debts` pós-C13)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, item C8)
Responsável: —
@ -88,7 +88,15 @@ flowchart TD
- ~~Migrar `fieldError`/`firstError` local em forms legados~~ — `CampaignInviteForm.tsx` já importa de `campaignFormFields.ts`.
- **Nota pós-remodel:** paths `nucleos/*`/`pracas/*` foram superseded; não reabrir.
**Migration:** Fases 1, 3, 4 sem schema. Fase 2: `pnpm migrate:create add_contact_trgm_index` (extensão + 2 GIN indexes). Sem Consent novo.
### Fase 4b — o guard do F4 só vê `formActions.ts` — **ABERTA** (registrada 2026-07-25 pós-C13)
Appetite: **~2h eng, fill-in.** O `/simplify` do C13 mostrou que a invariante do F4 é menor do que o texto acima sugere: o guard `campaign formActions convention` (`tests/unit/codebaseConventions.unit.spec.ts`) filtra `file.endsWith('/formActions.ts')`, então os arquivos de action irmãos que cada rota escreveu depois — `lifecycleFormActions.ts`, `resultFormActions.ts`, `updateFormActions.ts`, `taskActions.ts` — **não são verificados em nenhuma rota**. A exceção documentada na Revisão 2026-07-24 ("`lifecycleFormActions.ts` só retorna safe-messages fixas — sem ladder para migrar") foi escrita quando o alvo era `mapCampaignFormActionError`; com `runCampaignRedirectFormAction` (Pass 2 W4d) a conta mudou, porque esse arquivo faz exatamente a forma que o wrapper padroniza: dois `catch` nus que descartam o erro + `redirect` no sucesso.
- Ampliar o filtro do guard para os irmãos (`/^\w*[Ff]ormActions\.ts$/` + `taskActions.ts`, ou um sufixo declarado) e allowlistar com racional escrito o que realmente não couber.
- Migrar o que o wrapper cobre; onde o `catch` nu ficar, decidir se o erro deve ser observável (hoje é descartado sem log em ambos os caminhos, então não é um débito diferencial — verificar no PR antes de inflar o escopo).
- Não escopo: mudar a semântica das safe-messages (copy revisada) e tocar `actions/*.ts` (as server actions em si já usam transação/lock).
**Migration:** Fases 1, 3, 4, 4b sem schema. Fase 2: `pnpm migrate:create add_contact_trgm_index` (extensão + 2 GIN indexes). Sem Consent novo.
## Dependências

View file

@ -1,6 +1,6 @@
# Renomear "Plano de Ação" para "Atividade" (entidade, código e rotas)
Status: rascunho
Status: entregue (2026-07-25)
Atualizado em: 2026-07-25
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C13)
Impeccable: B — encaixe em telas existentes (`/campanha/planos` → `/campanha/atividades`, sidebar, cards do Início/dossiê/organização/demanda); nenhuma tela nova
@ -21,6 +21,19 @@ Brief compacto:
- **Edit where you see:** N/A — este item não muda affordance de edição (a agenda continua com `/editar` para o formulário multi-campo, como hoje).
- **Anti-goals:** redesenhar a vertical no mesmo PR (o critique/polish visual dela é **R6**); introduzir um segundo sinônimo ("Agenda" em um lugar, "Atividade" em outro); manter nomes de banco divergentes do código "porque é só cosmético".
## As-built (2026-07-25)
Entregue como planejado, com quatro correções de inventário que a auditoria contra o banco real levantou:
- **4 tabelas, não 5.** `action_plan_texts` não existe mais — a remodelagem de 2026-07-23 já a havia removido. O rename cobre `activity{,_tasks,_updates,_rels}`.
- **O snapshot do drizzle não era a lista-alvo completa.** `action_plan_upcoming_start_at_idx` (índice parcial escrito à mão em `20260719_014906_action_plan_list_perf.ts`) não aparece no `.json`; a lista canônica veio de `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences`. Total renomeado: **4 tabelas, 3 enums, 2 colunas, 39 índices** (4 pkeys inclusos — `ALTER INDEX … RENAME` leva a constraint junto), **14 FKs** e **2 sequences**.
- **Fósseis `%plaza%` corrigidos no mesmo passo** (decisão do usuário nesta sessão): a remodelagem renomeou as colunas `plaza_id` → `municipality_id` mas não os índices, então o banco tinha `action_plan_plaza_idx`, `campaign_demand_plaza_idx`, `leadership_rels_plaza_id_idx`, `organization_rels_plaza_id_idx`, `supporter_plaza_idx`, `supporter_contact_plaza_nulls_not_distinct_idx`, `vote_pledge_plaza_idx` e `leadership_plaza_idx` enquanto o snapshot já dizia `*_municipality*`. Os 8 foram alinhados aqui; o `down()` os restaura, para não inventar um estado que nunca existiu.
- **Snapshot escrito à mão.** `pnpm migrate:create` é interativo para renames (pergunta enum por enum), então o `.json` foi derivado do snapshot anterior por transformação textual e validado pelo teste que importa: `pnpm migrate:create __probe` responde **"No schema changes detected"**.
Decisões de vocabulário fechadas na implementação: sidebar **"Atividades"**; rótulos de campo alinhados à entidade (**"Tipo de atividade"**, **"Origem da atividade"**, **"Resultado da atividade"**), porque "ação" ao lado de "atividade" reintroduzia a tradução mental que o item existe para eliminar. Rota nova é `/campanha/atividades/nova` (feminino, como `demandas/nova`). Valores de enum `kind`/`status` seguem inalterados, como o plano previa.
Efeito colateral do guard: estendê-lo a `tests/` e `scripts/` revelou fósseis "Praça" em fixtures e nomes de teste (8 ocorrências), limpos no mesmo PR; `scripts/generate-remodel-municipalities-migration.mjs` (gera SQL congelado) e o comentário histórico em `tests/int/campaignMigrationReconciliation.int.spec.ts` entraram na allowlist.
## Dados → decisão → apresentação
**Dados: N/A** — o item renomeia entidade, identificadores, rotas e copy. Nenhuma métrica, série, ranking ou mapa é criado, alterado ou removido; os contadores existentes (tarefas concluídas, próximos eventos) continuam idênticos, só mudam de rótulo.
@ -115,6 +128,25 @@ Componentes:
- **Rótulo "Agenda" no sidebar.** Revisitar se, no onboarding (ou no R6), ≥2 pessoas do time chamarem o destino de "agenda" ao navegar — aí o rail muda de rótulo mantendo a entidade "Atividade".
- **Terceira entrada no guard de vocabulário (extração para módulo próprio).** Revisitar quando existir um 3º rename de termo — antes disso, a tabela no spec basta.
## Simplify (2026-07-25 pós-C13)
Passagem `/simplify` (qualidade + perf + reuso) e `capture-review-debts`. O diff é rename mecânico: nenhuma forma de query mudou, o índice parcial `activity_upcoming_start_at_idx` sobreviveu com o predicado intacto (`EXPLAIN` confirma Index Scan sem Filter residual) e o app segue usando os shells compartilhados.
**Já resolvido (não reabrir):** interpolação `${index}` comida em `tests/unit/recentVisits.unit.spec.ts` (fixture com label único, agora com assertion que fecha a brecha, e slugs `praca-` alinhados); guard tolerante a espaço (`action[\s_-]?plan`), que era o buraco por onde `access/shared.ts` mantinha "action plan" em prosa; allowlist encolhida (o comentário de `campaignMigrationReconciliation.int.spec.ts` cita `activity` e o arquivo saiu da exceção); entrada morta `action_plan_municipality_idx` fora da migration (nenhuma migration jamais criou esse nome — só os snapshots o afirmam), deixando `up()`/`down()` com os mesmos 48 pares; buscas de FK com `to_regclass` em vez de `::regclass`; detalhe de organização reusando `ActivityStatusBadge` com `status` tipado como `ActivityStatus` no loader; resíduos de find-and-replace (banner `// Action activities`, `DOSSIER_*_PLAN_LIMIT`, "a activity", nome de teste "zona Município"); concordância pt-BR ("Criada por", "atividades apoiadas"); `??` impossível em `ActivityForm`.
**Absorvido em outro plano:** o guard de `formActions` só inspeciona arquivos chamados exatamente `formActions.ts`, deixando `lifecycleFormActions.ts`/`resultFormActions.ts`/`updateFormActions.ts`/`taskActions.ts` sem verificação → [escala-dry-pos-c6.md](escala-dry-pos-c6.md) **Fase 4b** (dono da invariante do C8 F4).
**Explicitamente fora (triage `capture-review-debts` 2026-07-25 pós-C13):**
- **Redirect de `/campanha/planos*` → `/campanha/atividades*`** — gatilho: primeiro relato de 404 por bookmark. A rota viveu 7 dias, a navegação é pelo rail, e nada persistido aponta para ela (`recentVisits` só grava páginas de município); um redirect reintroduziria a string que o guard bane.
- **Projeção de activity duplicada em `organizationData.ts`** (em vez de `activityListSelect` + `toActivityListViewModel`) — gatilho: a seção passar a mostrar localidade ou progresso de tarefas. Hoje a forma é subconjunto estrito e o view model completo faria over-fetch numa lista de resumo.
- **Painel de filtros mobile compartilhado entre `ActivityFilters` e `SupporterFilters`** — gatilho: 3º painel com a mesma mecânica de colapso (municípios usa header rico, não este shape).
- **Hoist das listas de rename da migration para constantes + helper local** — recusado: migration é história congelada, o consumidor é único, e a divergência que o helper preveniria está verificada como inexistente (conjuntos idênticos nos dois sentidos).
- **Contadores `renamed` + `RAISE NOTICE` do `up()`** — mantidos: são a única evidência, no log do build da Vercel, de que o rename tocou o número esperado de objetos; a assimetria com `down()` é aceitável porque `down()` só roda local.
- **`payload.create` sequencial dos rascunhos de demanda em `actions/activity.ts`** — pré-existente, roda sobre os rascunhos de um único submit dentro de uma transação; batchear perderia as semânticas de hook por linha.
- **`it.each` do guard re-varrer `src`+`tests`+`scripts` por termo** — 150 ms na suíte unitária; hoisting da leitura é throughput, não duplicação.
- **Padrão de "Praça" tolerante à falta de cedilha** — cobraria três allowlists novas (nomes reais de organização vindos da planilha, teste de entradas legadas `kind: 'nucleus'`) para pegar um fóssil já corrigido à mão.
## Referências
- `docs/roadmap.md` (Trilha C / "Demais itens abertos"; Janela 1; grafo)

File diff suppressed because one or more lines are too long

View file

@ -1,13 +1,13 @@
import { PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { ActionPlanResultForm } from '@/components/campaign/actionPlan/ActionPlanResultForm'
import { ActivityResultForm } from '@/components/campaign/activity/ActivityResultForm'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { actionPlanOriginLabels } from '@/lib/schemas/actionPlan'
import { activityOriginLabels } from '@/lib/schemas/activity'
import { campaignDemandKindLabels, campaignDemandStatusLabels } from '@/lib/schemas/campaignDemand'
import type { getActionPlanDetailPageData } from '@/utilities/actionPlanDetailPageData'
import type { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
const currencyFormatter = new Intl.NumberFormat('pt-BR', {
@ -15,14 +15,14 @@ const currencyFormatter = new Intl.NumberFormat('pt-BR', {
currency: 'BRL',
})
type ActionPlanDetailView = Awaited<ReturnType<typeof getActionPlanDetailPageData>>
type ActivityDetailView = Awaited<ReturnType<typeof getActivityDetailPageData>>
/** The "Visão geral" tab: details, team/orgs, linked demands and the result block. */
export const ActionPlanOverviewTab = ({
export const ActivityOverviewTab = ({
view,
isStaff,
}: {
view: ActionPlanDetailView
view: ActivityDetailView
isStaff: boolean
}) => (
<div className="flex flex-col gap-4">
@ -74,8 +74,8 @@ export const ActionPlanOverviewTab = ({
<dd>{view.responsibleName ?? 'Não definido'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Origem da ação</dt>
<dd>{actionPlanOriginLabels[view.origin]}</dd>
<dt className="text-muted-foreground">Origem da atividade</dt>
<dd>{activityOriginLabels[view.origin]}</dd>
</div>
</dl>
</CardContent>
@ -133,7 +133,7 @@ export const ActionPlanOverviewTab = ({
{view.municipality ? (
<Button asChild variant="outline" className="min-h-11 shrink-0">
<Link
href={`/campanha/demandas/nova?actionPlan=${view.id}&municipality=${view.municipality.id}`}
href={`/campanha/demandas/nova?activity=${view.id}&municipality=${view.municipality.id}`}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Adicionar demanda
@ -170,14 +170,16 @@ export const ActionPlanOverviewTab = ({
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhuma demanda vinculada a este plano.</p>
<p className="text-sm text-muted-foreground">
Nenhuma demanda vinculada a esta atividade.
</p>
)}
</CardContent>
</Card>
{view.status === 'realizado' && isStaff ? (
<ActionPlanResultForm
planId={view.id}
<ActivityResultForm
activityId={view.id}
initialSummary={view.result?.summary ?? null}
recordedByName={view.result?.recordedByName ?? null}
recordedAt={view.result?.recordedAt ?? null}
@ -185,7 +187,7 @@ export const ActionPlanOverviewTab = ({
) : view.result ? (
<Card>
<CardHeader>
<CardTitle>Resultado da ação</CardTitle>
<CardTitle>Resultado da atividade</CardTitle>
<p className="text-sm text-muted-foreground">
{[
view.result.recordedByName

View file

@ -3,12 +3,12 @@ import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import {
searchActionPlanContactOptions,
searchActionPlanLeadershipOptionsAction,
} from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
import { updateActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions'
import { ActionPlanForm } from '@/components/campaign/actionPlan/ActionPlanForm'
import { ActionPlanNotFoundError, getActionPlanEditPageData } from '@/utilities/actionPlanPageData'
searchActivityContactOptions,
searchActivityLeadershipOptionsAction,
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
import { updateActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
import { ActivityNotFoundError, getActivityEditPageData } from '@/utilities/activityPageData'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
@ -17,11 +17,11 @@ import {
} from '@/utilities/campaignRelationOptions'
import { getEligibleAdvisorOptions } from '@/utilities/municipalityViewModels'
type EditActionPlanPageProps = {
type EditActivityPageProps = {
params: Promise<{ slug: string }>
}
export default async function EditActionPlanPage({ params }: EditActionPlanPageProps) {
export default async function EditActivityPage({ params }: EditActivityPageProps) {
const [{ slug }, user, payload] = await Promise.all([
params,
getCampaignUser(),
@ -29,13 +29,13 @@ export default async function EditActionPlanPage({ params }: EditActionPlanPageP
])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha/planos')
if (!isCampaignStaff(user)) redirect('/campanha/atividades')
if (!slug) notFound()
const canManageAdvisors = user.role === 'coordinator'
const [view, municipalityOptions, organizationOptions, advisorOptions] = await Promise.all([
getActionPlanEditPageData(payload, user, slug).catch((error) => {
if (error instanceof ActionPlanNotFoundError) notFound()
getActivityEditPageData(payload, user, slug).catch((error) => {
if (error instanceof ActivityNotFoundError) notFound()
throw error
}),
loadMunicipalityOptions(payload, user),
@ -46,22 +46,22 @@ export default async function EditActionPlanPage({ params }: EditActionPlanPageP
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>
<p className="text-sm font-medium text-primary">Atividades</p>
<h1 className="text-2xl font-semibold tracking-tight">Editar {view.title}</h1>
<p className="text-muted-foreground">
Atualize os detalhes do plano sem alterar o título original.
Atualize os detalhes da atividade sem alterar o título original.
</p>
</header>
<ActionPlanForm
action={updateActionPlanFormAction}
plan={view}
<ActivityForm
action={updateActivityFormAction}
activity={view}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
advisorOptions={advisorOptions}
canManageAdvisors={canManageAdvisors}
submitLabel="Salvar alterações"
searchContacts={searchActionPlanContactOptions}
searchLeaderships={searchActionPlanLeadershipOptionsAction}
searchContacts={searchActivityContactOptions}
searchLeaderships={searchActivityLeadershipOptionsAction}
/>
</div>
)

View file

@ -0,0 +1,56 @@
'use server'
import { redirect } from 'next/navigation'
import { cancelActivity, markActivityRealized } from '@/app/(campaign)/campanha/actions/activity'
import { requiredRelationshipFormValue } from '@/lib/formData'
import type { Activity } from '@/payload-types'
export type ActivityLifecycleFormState = {
message?: string
}
const setActivityLifecycleFormAction = async (
formData: FormData,
mutate: (activityId: number) => Promise<Pick<Activity, 'slug'>>,
failureMessage: string,
): Promise<ActivityLifecycleFormState> => {
let activityId: number
try {
activityId = requiredRelationshipFormValue(formData, 'id')
} catch {
return {
message: 'Não foi possível identificar a atividade. Atualize a página e tente novamente.',
}
}
let activitySlug: string
try {
const activity = await mutate(activityId)
activitySlug = activity.slug
} catch {
return { message: failureMessage }
}
redirect(`/campanha/atividades/${activitySlug}`)
}
export const cancelActivityFormAction = async (
_state: ActivityLifecycleFormState,
formData: FormData,
): Promise<ActivityLifecycleFormState> =>
setActivityLifecycleFormAction(
formData,
cancelActivity,
'Não foi possível cancelar esta atividade. Verifique seu acesso ou atualize a página e tente novamente.',
)
export const markActivityRealizedFormAction = async (
_state: ActivityLifecycleFormState,
formData: FormData,
): Promise<ActivityLifecycleFormState> =>
setActivityLifecycleFormAction(
formData,
markActivityRealized,
'Não foi possível marcar esta atividade como realizada. Verifique seu acesso ou atualize a página e tente novamente.',
)

View file

@ -5,42 +5,42 @@ import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import {
CancelActionPlanDialog,
MarkActionPlanRealizedDialog,
} from '@/components/campaign/actionPlan/ActionPlanLifecycleDialog'
import { ActionPlanStatusBadge } from '@/components/campaign/actionPlan/ActionPlanStatusBadge'
import { ActionPlanTabNav } from '@/components/campaign/actionPlan/ActionPlanTabNav'
import { ActionPlanTaskChecklist } from '@/components/campaign/actionPlan/ActionPlanTaskChecklist'
import { ActionPlanUpdateFeed } from '@/components/campaign/actionPlan/ActionPlanUpdateFeed'
import { ActionPlanUpdateForm } from '@/components/campaign/actionPlan/ActionPlanUpdateForm'
CancelActivityDialog,
MarkActivityRealizedDialog,
} from '@/components/campaign/activity/ActivityLifecycleDialog'
import { ActivityStatusBadge } from '@/components/campaign/activity/ActivityStatusBadge'
import { ActivityTabNav } from '@/components/campaign/activity/ActivityTabNav'
import { ActivityTaskChecklist } from '@/components/campaign/activity/ActivityTaskChecklist'
import { ActivityUpdateFeed } from '@/components/campaign/activity/ActivityUpdateFeed'
import { ActivityUpdateForm } from '@/components/campaign/activity/ActivityUpdateForm'
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 { getActionPlanDetailPageData } from '@/utilities/actionPlanDetailPageData'
import { activityKindLabels } from '@/lib/schemas/activity'
import { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
import {
getActionPlanDetailTabRedirect,
resolveActionPlanDetailTab,
} from '@/utilities/actionPlanDetailTabUi'
getActivityDetailTabRedirect,
resolveActivityDetailTab,
} from '@/utilities/activityDetailTabUi'
import {
ActionPlanNotFoundError,
resolveAccessibleActionPlanContext,
} from '@/utilities/actionPlanPageData'
ActivityNotFoundError,
resolveAccessibleActivityContext,
} from '@/utilities/activityPageData'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import { ActionPlanOverviewTab } from './ActionPlanOverviewTab'
import { ActivityOverviewTab } from './ActivityOverviewTab'
type ActionPlanDetailPageProps = {
type ActivityDetailPageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function ActionPlanDetailPage({
export default async function ActivityDetailPage({
params,
searchParams,
}: ActionPlanDetailPageProps) {
}: ActivityDetailPageProps) {
const [{ slug }, query, user, payload] = await Promise.all([
params,
searchParams,
@ -52,15 +52,15 @@ export default async function ActionPlanDetailPage({
if (!isCampaignStaff(user)) redirect('/campanha')
if (!slug) notFound()
const activeTab = resolveActionPlanDetailTab(query)
const context = await resolveAccessibleActionPlanContext(payload, user, slug, activeTab).catch(
const activeTab = resolveActivityDetailTab(query)
const context = await resolveAccessibleActivityContext(payload, user, slug, activeTab).catch(
(error) => {
if (error instanceof ActionPlanNotFoundError) notFound()
if (error instanceof ActivityNotFoundError) notFound()
throw error
},
)
const view = await getActionPlanDetailPageData(payload, user, context, activeTab)
const canonicalTabRedirect = getActionPlanDetailTabRedirect(view.slug, query)
const view = await getActivityDetailPageData(payload, user, context, activeTab)
const canonicalTabRedirect = getActivityDetailTabRedirect(view.slug, query)
if (canonicalTabRedirect) redirect(canonicalTabRedirect)
const isStaff = isCampaignStaff(user)
@ -73,11 +73,11 @@ export default async function ActionPlanDetailPage({
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{view.title}</h1>
<ActionPlanStatusBadge status={view.status} />
<ActivityStatusBadge status={view.status} />
{view.deputyPresent ? <Badge>Deputado presente</Badge> : null}
</div>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground">
<Badge variant="secondary">{actionPlanKindLabels[view.kind]}</Badge>
<Badge variant="secondary">{activityKindLabels[view.kind]}</Badge>
<span>
{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'}
</span>
@ -87,31 +87,31 @@ export default async function ActionPlanDetailPage({
<div className="flex flex-wrap gap-2">
{isStaff ? (
<Button asChild variant="outline" className="min-h-11">
<Link href={`/campanha/planos/${view.slug}/editar`}>
<Link href={`/campanha/atividades/${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`}>
<Link href={`/campanha/atividades/${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} />
<MarkActivityRealizedDialog activityId={view.id} />
<CancelActivityDialog activityId={view.id} />
</>
) : null}
</div>
</div>
</header>
<ActionPlanTabNav activeTab={activeTab} planSlug={view.slug} searchParams={query} />
<ActivityTabNav activeTab={activeTab} activitySlug={view.slug} searchParams={query} />
{activeTab === 'overview' ? <ActionPlanOverviewTab view={view} isStaff={isStaff} /> : null}
{activeTab === 'overview' ? <ActivityOverviewTab view={view} isStaff={isStaff} /> : null}
{activeTab === 'tasks' ? (
<Card>
@ -119,15 +119,15 @@ export default async function ActionPlanDetailPage({
<CardTitle>Tarefas</CardTitle>
</CardHeader>
<CardContent>
<ActionPlanTaskChecklist planId={view.id} tasks={view.tasks} />
<ActivityTaskChecklist activityId={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} />
<ActivityUpdateForm activityId={view.id} />
<ActivityUpdateFeed updates={view.updates} />
</div>
) : null}
</div>

View file

@ -2,32 +2,32 @@
import { revalidatePath } from 'next/cache'
import { registerActionPlanResultAction } from '@/app/(campaign)/campanha/actions/actionPlan'
import { registerActivityResultAction } from '@/app/(campaign)/campanha/actions/activity'
import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
// Validation/transaction messages thrown by `registerActionPlanResult` —
// Validation/transaction messages thrown by `registerActivityResult` —
// safe to surface verbatim; anything else falls back to the generic message.
const safeMessages = [
'Informe o resultado da ação.',
'Informe o resultado da atividade.',
'Resultado muito longo. Reduza o texto e tente novamente.',
'Apenas a equipe da campanha pode registrar o resultado do plano.',
'Não foi possível registrar o resultado do plano.',
'Apenas a equipe da campanha pode registrar o resultado da atividade.',
'Não foi possível registrar o resultado da atividade.',
] as const
export const registerActionPlanResultFormAction = async (
export const registerActivityResultFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const planId = requiredRelationshipFormValue(formData, 'planId')
const activityId = requiredRelationshipFormValue(formData, 'activityId')
const resultSummary = requiredFormText(formData, 'resultSummary')
// Media upload is a follow-up: the v1 form submits text only.
await registerActionPlanResultAction(planId, resultSummary, [])
revalidatePath('/campanha/planos/[slug]', 'page')
await registerActivityResultAction(activityId, resultSummary, [])
revalidatePath('/campanha/atividades/[slug]', 'page')
return { status: 'success', message: 'Resultado registrado com sucesso.' }
} catch (error) {
return mapCampaignFormActionError({

View file

@ -2,20 +2,20 @@
import { revalidatePath } from 'next/cache'
import { toggleActionPlanTask } from '@/app/(campaign)/campanha/actions/actionPlan'
import { toggleActivityTask } from '@/app/(campaign)/campanha/actions/activity'
export type ToggleActionPlanTaskResult = { ok: true } | { ok: false; message: string }
export type ToggleActivityTaskResult = { ok: true } | { ok: false; message: string }
export const toggleActionPlanTaskAction = async (
planId: number,
export const toggleActivityTaskAction = async (
activityId: number,
taskId: string,
done: boolean,
): Promise<ToggleActionPlanTaskResult> => {
): Promise<ToggleActivityTaskResult> => {
try {
await toggleActionPlanTask(planId, taskId, done)
await toggleActivityTask(activityId, taskId, done)
// Refresh the RSC props behind the optimistic checkbox — without this the
// optimistic state reverts to stale server data when the transition ends.
revalidatePath('/campanha/planos/[slug]', 'page')
revalidatePath('/campanha/atividades/[slug]', 'page')
return { ok: true }
} catch (error) {
return {

View file

@ -2,34 +2,34 @@
import { revalidatePath } from 'next/cache'
import { appendActionPlanUpdate } from '@/app/(campaign)/campanha/actions/actionPlan'
import { appendActivityUpdate } from '@/app/(campaign)/campanha/actions/activity'
import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
export type ActionPlanUpdateFormState = {
export type ActivityUpdateFormState = {
status?: 'success'
message?: string
fieldErrors?: Record<string, string[]>
}
// The specific validation/transaction messages `appendActionPlanUpdate` throws —
// The specific validation/transaction messages `appendActivityUpdate` throws —
// safe to surface verbatim. Anything else (e.g. an unexpected Payload API error)
// now falls back to the generic message instead of leaking its raw text.
const safeMessages = [
'Informe o texto da atualização.',
'Atualização muito longa. Reduza o texto e tente novamente.',
'Não foi possível registrar a atualização do plano.',
'Não foi possível registrar a atualização da atividade.',
] as const
export const createActionPlanUpdateFormAction = async (
_state: ActionPlanUpdateFormState,
export const createActivityUpdateFormAction = async (
_state: ActivityUpdateFormState,
formData: FormData,
): Promise<ActionPlanUpdateFormState> => {
): Promise<ActivityUpdateFormState> => {
try {
const planId = requiredRelationshipFormValue(formData, 'planId')
const activityId = requiredRelationshipFormValue(formData, 'activityId')
const body = requiredFormText(formData, 'body')
await appendActionPlanUpdate(planId, body)
revalidatePath('/campanha/planos/[slug]', 'page')
await appendActivityUpdate(activityId, body)
revalidatePath('/campanha/atividades/[slug]', 'page')
return { status: 'success', message: 'Atualização registrada com sucesso.' }
} catch (error) {
return mapCampaignFormActionError({

View file

@ -6,14 +6,14 @@ import { getPayload } from 'payload'
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import {
searchActionPlanLeadershipOptions,
type ActionPlanLeadershipOption,
} from '@/utilities/actionPlanLeadershipOptions'
searchActivityLeadershipOptions,
type ActivityLeadershipOption,
} from '@/utilities/activityLeadershipOptions'
import { getCampaignUser } from '@/utilities/campaignAuth'
const CONTACT_OPTION_LIMIT = 20
export const searchActionPlanContactOptions = async (
export const searchActivityContactOptions = async (
query: string,
): Promise<ContactComboboxOption[]> => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
@ -41,11 +41,11 @@ export const searchActionPlanContactOptions = async (
return result.docs.map(({ id, name, phone }) => ({ id, name, phone: phone ?? null }))
}
export const searchActionPlanLeadershipOptionsAction = async (
export const searchActivityLeadershipOptionsAction = async (
query: string,
): Promise<ActionPlanLeadershipOption[]> => {
): Promise<ActivityLeadershipOption[]> => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) throw new Error('Autenticação necessária.')
return searchActionPlanLeadershipOptions(payload, user, query)
return searchActivityLeadershipOptions(payload, user, query)
}

View file

@ -3,25 +3,25 @@
// Documented exception to `runCampaignRedirectFormAction`/`runCampaignFormAction`
// (Pass 2 W4d): these ladders carry a custom error mapper (DB unique-violation
// regexes surfacing as field errors) plus an async duplicate-title fallback
// that links to the existing plan — policy the shared wrappers deliberately
// that links to the existing activity — policy the shared wrappers deliberately
// don't grow parameters for.
import config from '@payload-config'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { createActionPlan, updateActionPlan } from '@/app/(campaign)/campanha/actions/actionPlan'
import type { ActionPlanFormState } from '@/components/campaign/actionPlan/ActionPlanForm'
import { createActivity, updateActivity } from '@/app/(campaign)/campanha/actions/activity'
import type { ActivityFormState } from '@/components/campaign/activity/ActivityForm'
import { optionalFormText } from '@/lib/formData'
import { slugify } from '@/lib/slug'
import {
parseActionPlanCreateFormData,
parseActionPlanUpdateFormData,
} from '@/utilities/actionPlanFormData'
parseActivityCreateFormData,
parseActivityUpdateFormData,
} from '@/utilities/activityFormData'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
const getActionError = (error: unknown): ActionPlanFormState => {
const getActionError = (error: unknown): ActivityFormState => {
if (
error instanceof Error &&
/campaign_demand.*(?:slug|title)|campaignDemand.*(?:slug|title)/i.test(error.message)
@ -36,7 +36,7 @@ const getActionError = (error: unknown): ActionPlanFormState => {
// a `FormDataBoundaryError`/`ZodError`/known-safe string, so it needs its own
// branch to surface as a field error instead of falling to the generic message.
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 { fieldErrors: { title: ['Já existe uma atividade com este título.'] } }
}
return mapCampaignFormActionError({
error,
@ -44,7 +44,7 @@ const getActionError = (error: unknown): ActionPlanFormState => {
})
}
const existingActionPlanState = async (formData: FormData): Promise<ActionPlanFormState | null> => {
const existingActivityState = async (formData: FormData): Promise<ActivityFormState | null> => {
const title = optionalFormText(formData, 'title')
const slug = title ? slugify(title) : ''
if (!slug) return null
@ -52,7 +52,7 @@ const existingActionPlanState = async (formData: FormData): Promise<ActionPlanFo
if (!user) return null
const result = await payload.find({
collection: 'actionPlan',
collection: 'activity',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
@ -64,45 +64,45 @@ const existingActionPlanState = async (formData: FormData): Promise<ActionPlanFo
const existing = result.docs[0]
return existing
? {
fieldErrors: { title: ['Já existe um plano com este título.'] },
existingHref: `/campanha/planos/${existing.slug}`,
fieldErrors: { title: ['Já existe uma atividade com este título.'] },
existingHref: `/campanha/atividades/${existing.slug}`,
submittedTitle: title,
}
: null
}
export const createActionPlanFormAction = async (
_state: ActionPlanFormState,
export const createActivityFormAction = async (
_state: ActivityFormState,
formData: FormData,
): Promise<ActionPlanFormState> => {
let planSlug: string
): Promise<ActivityFormState> => {
let activitySlug: string
try {
const { demands, ...planInput } = parseActionPlanCreateFormData(formData)
const plan = await createActionPlan(planInput, demands)
planSlug = plan.slug
const { demands, ...activityInput } = parseActivityCreateFormData(formData)
const activity = await createActivity(activityInput, demands)
activitySlug = activity.slug
} catch (error) {
const existing = await existingActionPlanState(formData)
const existing = await existingActivityState(formData)
if (existing) return existing
return getActionError(error)
}
redirect(`/campanha/planos/${planSlug}`)
redirect(`/campanha/atividades/${activitySlug}`)
}
export const updateActionPlanFormAction = async (
_state: ActionPlanFormState,
export const updateActivityFormAction = async (
_state: ActivityFormState,
formData: FormData,
): Promise<ActionPlanFormState> => {
let planSlug: string
): Promise<ActivityFormState> => {
let activitySlug: string
try {
const { demands, ...planInput } = parseActionPlanUpdateFormData(formData)
const plan = await updateActionPlan(planInput, demands)
planSlug = plan.slug
const { demands, ...activityInput } = parseActivityUpdateFormData(formData)
const activity = await updateActivity(activityInput, demands)
activitySlug = activity.slug
} catch (error) {
return getActionError(error)
}
redirect(`/campanha/planos/${planSlug}`)
redirect(`/campanha/atividades/${activitySlug}`)
}

View file

@ -3,11 +3,11 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import {
searchActionPlanContactOptions,
searchActionPlanLeadershipOptionsAction,
} from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
import { createActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions'
import { ActionPlanForm } from '@/components/campaign/actionPlan/ActionPlanForm'
searchActivityContactOptions,
searchActivityLeadershipOptionsAction,
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
import { createActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
@ -16,11 +16,11 @@ import {
} from '@/utilities/campaignRelationOptions'
import { getEligibleAdvisorOptions } from '@/utilities/municipalityViewModels'
export default async function NewActionPlanPage() {
export default async function NewActivityPage() {
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha/planos')
if (!isCampaignStaff(user)) redirect('/campanha/atividades')
const canManageAdvisors = user.role === 'coordinator'
const [municipalityOptions, organizationOptions, advisorOptions] = await Promise.all([
@ -32,21 +32,21 @@ export default async function NewActionPlanPage() {
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-semibold tracking-tight">Novo plano</h1>
<p className="text-sm font-medium text-primary">Atividades</p>
<h1 className="text-2xl font-semibold tracking-tight">Nova atividade</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}
<ActivityForm
action={createActivityFormAction}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
advisorOptions={advisorOptions}
canManageAdvisors={canManageAdvisors}
submitLabel="Criar plano"
searchContacts={searchActionPlanContactOptions}
searchLeaderships={searchActionPlanLeadershipOptionsAction}
submitLabel="Criar atividade"
searchContacts={searchActivityContactOptions}
searchLeaderships={searchActivityLeadershipOptionsAction}
/>
</div>
)

View file

@ -8,31 +8,31 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { ActionPlanFilters } from '@/components/campaign/actionPlan/ActionPlanFilters'
import { ActionPlanList } from '@/components/campaign/actionPlan/ActionPlanList'
import { ActivityFilters } from '@/components/campaign/activity/ActivityFilters'
import { ActivityList } from '@/components/campaign/activity/ActivityList'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import type { ActionPlan } from '@/payload-types'
import { loadActionPlanListPageData } from '@/utilities/actionPlanPageData'
import type { Activity } from '@/payload-types'
import { loadActivityListPageData } from '@/utilities/activityPageData'
import {
buildActionPlanFiltersKey,
buildActionPlanListHref,
resolveActionPlanListUrl,
} from '@/utilities/actionPlanUi'
import { toActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
buildActivityFiltersKey,
buildActivityListHref,
resolveActivityListUrl,
} from '@/utilities/activityUi'
import { toActivityListViewModel } from '@/utilities/activityViewModels'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
type ActionPlanListPageProps = {
type ActivityListPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function ActionPlanListPage({ searchParams }: ActionPlanListPageProps) {
export default async function ActivityListPage({ searchParams }: ActivityListPageProps) {
const rawSearchParams = await searchParams
const canonicalUrl = resolveActionPlanListUrl(rawSearchParams)
const canonicalUrl = resolveActivityListUrl(rawSearchParams)
if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref)
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
@ -41,10 +41,10 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
const now = new Date()
const [{ result, state }, municipalityOptions] = await Promise.all([
loadActionPlanListPageData(payload, user, rawSearchParams, now),
loadActivityListPageData(payload, user, rawSearchParams, now),
loadMunicipalityOptions(payload, user),
])
const resolvedUrl = resolveActionPlanListUrl(rawSearchParams, result.totalPages)
const resolvedUrl = resolveActivityListUrl(rawSearchParams, result.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const canCreate = isCampaignStaff(user)
@ -53,24 +53,24 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
<CampaignPageShell>
<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-semibold tracking-tight">Planos de ação</h1>
<h1 className="text-2xl font-semibold tracking-tight">Atividades</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">
<Link href="/campanha/atividades/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo plano
Nova atividade
</Link>
</Button>
) : null}
</header>
<CampaignListPendingBoundary>
<ActionPlanFilters
key={buildActionPlanFiltersKey(state)}
<ActivityFilters
key={buildActivityFiltersKey(state)}
state={state}
municipalityOptions={municipalityOptions}
/>
@ -78,26 +78,28 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
<CampaignListResults>
{result.docs.length ? (
<>
<ActionPlanList
plans={result.docs.map((plan) => toActionPlanListViewModel(plan as ActionPlan))}
<ActivityList
activities={result.docs.map((activity) =>
toActivityListViewModel(activity as Activity),
)}
/>
<CampaignListFooter
totalDocs={result.totalDocs}
singular="plano encontrado"
plural="planos encontrados"
singular="atividade encontrada"
plural="atividades encontradas"
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildActionPlanListHref(state, page)}
hrefForPage={(page) => buildActivityListHref(state, page)}
/>
</>
) : (
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhum plano encontrado"
description="Ajuste os filtros ou a janela selecionada. Você só vê planos dentro do seu escopo."
title="Nenhuma atividade encontrada"
description="Ajuste os filtros ou a janela selecionada. Você só vê atividades dentro do seu escopo."
>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/planos">Limpar filtros</Link>
<Link href="/campanha/atividades">Limpar filtros</Link>
</Button>
</CampaignListEmptyState>
)}

View file

@ -79,15 +79,15 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps
>
{demand.municipalityName}
</Link>
{demand.actionPlan ? (
{demand.activity ? (
<>
{' '}
· Plano:{' '}
· Atividade:{' '}
<Link
href={`/campanha/planos/${demand.actionPlan.slug}`}
href={`/campanha/atividades/${demand.activity.slug}`}
className="text-primary underline-offset-4 hover:underline"
>
{demand.actionPlan.title}
{demand.activity.title}
</Link>
</>
) : null}

View file

@ -27,7 +27,7 @@ export const createDemandFormAction = async (
: 'outro',
description: optionalFormText(formData, 'description'),
municipality: requiredRelationshipFormValue(formData, 'municipalityId'),
actionPlan: nullableRelationshipFormValue(formData, 'actionPlanId') ?? undefined,
activity: nullableRelationshipFormValue(formData, 'activityId') ?? undefined,
})
},
redirectTo: (demand) => `/campanha/demandas/${demand.slug}`,

View file

@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl'
import { loadActionPlanOptions, loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { loadActivityOptions, loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { createDemandFormAction } from './formActions'
type NewDemandPageProps = {
@ -26,16 +26,16 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
const [municipalityOptions, actionPlanOptions] = await Promise.all([
const [municipalityOptions, activityOptions] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadActionPlanOptions(payload, user),
loadActivityOptions(payload, user),
])
const requestedActionPlan = actionPlanOptions.find(
(plan) => plan.id === strictDecimalInteger(firstValue(query.actionPlan)),
const requestedActivity = activityOptions.find(
(activity) => activity.id === strictDecimalInteger(firstValue(query.activity)),
)
const requestedMunicipalityId = strictDecimalInteger(firstValue(query.municipality))
const initialMunicipalityId =
requestedActionPlan?.municipalityId ??
requestedActivity?.municipalityId ??
municipalityOptions.find((option) => option.id === requestedMunicipalityId)?.id
return (
@ -56,9 +56,9 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps
<DemandForm
municipalityOptions={municipalityOptions}
actionPlanOptions={actionPlanOptions}
activityOptions={activityOptions}
initialMunicipalityId={initialMunicipalityId}
initialActionPlanId={requestedActionPlan?.id}
initialActivityId={requestedActivity?.id}
formAction={createDemandFormAction}
/>
</CampaignPageShell>

View file

@ -4,11 +4,11 @@ import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { ActivityStatusBadge } from '@/components/campaign/activity/ActivityStatusBadge'
import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { actionPlanStatusLabels, actionPlanStatuses } from '@/lib/schemas/actionPlan'
import { organizationKindLabels } from '@/lib/schemas/organization'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
@ -22,11 +22,6 @@ type OrganizationDetailPageProps = {
const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' })
const statusLabel = (status: string): string =>
actionPlanStatuses.includes(status as (typeof actionPlanStatuses)[number])
? actionPlanStatusLabels[status as (typeof actionPlanStatuses)[number]]
: status
export default async function OrganizationDetailPage({ params }: OrganizationDetailPageProps) {
const { slug } = await params
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
@ -94,31 +89,35 @@ export default async function OrganizationDetailPage({ params }: OrganizationDet
</section>
<section
aria-labelledby="organization-plans-title"
aria-labelledby="organization-activities-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex items-center gap-2">
<CalendarDaysIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<h2 id="organization-plans-title" className="text-base font-medium">
Planos de Ação apoiados
<h2 id="organization-activities-title" className="text-base font-medium">
Atividades apoiadas
</h2>
<Badge variant="outline">{organization.actionPlans.length}</Badge>
<Badge variant="outline">{organization.activities.length}</Badge>
</div>
{organization.actionPlans.length ? (
{organization.activities.length ? (
<ul className="flex flex-col gap-2">
{organization.actionPlans.map((plan) => (
<li key={plan.id} className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<Link
href={`/campanha/planos/${plan.slug}`}
className="truncate font-medium text-primary underline-offset-4 hover:underline"
>
{plan.title}
</Link>
{organization.activities.map((activity) => (
<li key={activity.id} className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/campanha/atividades/${activity.slug}`}
className="truncate font-medium text-primary underline-offset-4 hover:underline"
>
{activity.title}
</Link>
<ActivityStatusBadge status={activity.status} />
</div>
<span className="text-sm text-muted-foreground">
{statusLabel(plan.status)}
{plan.startAt ? ` · ${dateFormatter.format(new Date(plan.startAt))}` : ''}
{plan.deputyPresent ? ' · Deputado presente' : ''}
{activity.startAt
? dateFormatter.format(new Date(activity.startAt))
: 'Sem data definida'}
{activity.deputyPresent ? ' · Deputado presente' : ''}
</span>
</div>
</li>
@ -126,7 +125,7 @@ export default async function OrganizationDetailPage({ params }: OrganizationDet
</ul>
) : (
<p className="text-sm text-muted-foreground">
Nenhum Plano de Ação vinculado a esta organização ainda.
Nenhuma atividade vinculada a esta organização ainda.
</p>
)}
</section>

View file

@ -30,8 +30,8 @@ export default async function NewOrganizationPage() {
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Nova organização</h1>
<p className="text-muted-foreground">
Cadastre sindicatos, associações, movimentos e afins para vincular lideranças e Planos de
Ação.
Cadastre sindicatos, associações, movimentos e afins para vincular lideranças e
atividades.
</p>
</header>

View file

@ -82,7 +82,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
<h1 className="text-2xl font-semibold tracking-tight">Organizações</h1>
<p className="text-muted-foreground">
Sindicatos, associações e movimentos que apoiam a campanha — com suas lideranças e
Planos de Ação.
atividades.
</p>
</div>
<Button asChild className="min-h-11">
@ -118,7 +118,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhuma organização cadastrada"
description="Cadastre sindicatos, associações e movimentos para vincular lideranças e Planos de Ação."
description="Cadastre sindicatos, associações e movimentos para vincular lideranças e atividades."
>
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">

View file

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

View file

@ -3,13 +3,13 @@
import type { Payload } from 'payload'
import {
actionPlanCreateSchema,
actionPlanDemandDraftsSchema,
actionPlanUpdateSchema,
type ActionPlanCreateInput,
type ActionPlanDemandDraft,
type ActionPlanUpdateInput,
} from '@/lib/schemas/actionPlan'
activityCreateSchema,
activityDemandDraftsSchema,
activityUpdateSchema,
type ActivityCreateInput,
type ActivityDemandDraft,
type ActivityUpdateInput,
} from '@/lib/schemas/activity'
import type { CampaignUser } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
@ -17,25 +17,25 @@ import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
const MAX_ACTION_PLAN_UPDATE_BODY_LENGTH = 4000
const MAX_ACTION_PLAN_RESULT_SUMMARY_LENGTH = 6000
const MAX_ACTIVITY_UPDATE_BODY_LENGTH = 4000
const MAX_ACTIVITY_RESULT_SUMMARY_LENGTH = 6000
export const createActionPlanRecord = async (
export const createActivityRecord = async (
payload: Payload,
actor: CampaignUser,
input: ActionPlanCreateInput,
demandDrafts: ActionPlanDemandDraft[] = [],
input: ActivityCreateInput,
demandDrafts: ActivityDemandDraft[] = [],
) => {
const data = actionPlanCreateSchema.parse(input)
const demands = actionPlanDemandDraftsSchema.parse(demandDrafts)
const data = activityCreateSchema.parse(input)
const demands = activityDemandDraftsSchema.parse(demandDrafts)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
const plan = await payload.create({
collection: 'actionPlan',
data: hookFilledCreateData<'actionPlan'>(data),
const activity = await payload.create({
collection: 'activity',
data: hookFilledCreateData<'activity'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,
@ -48,7 +48,7 @@ export const createActionPlanRecord = async (
data: hookFilledCreateData<'campaignDemand'>({
...demand,
municipality: data.municipality,
actionPlan: plan.id,
activity: activity.id,
}),
depth: 0,
user: currentActor,
@ -57,28 +57,28 @@ export const createActionPlanRecord = async (
})
}
return plan
return activity
},
{ beginFailureMessage: 'Não foi possível iniciar a criação do plano.' },
{ beginFailureMessage: 'Não foi possível iniciar a criação da atividade.' },
)
}
const updateActionPlanRecord = async (
const updateActivityRecord = async (
payload: Payload,
actor: CampaignUser,
input: ActionPlanUpdateInput,
demandDrafts: ActionPlanDemandDraft[] = [],
input: ActivityUpdateInput,
demandDrafts: ActivityDemandDraft[] = [],
) => {
const { id, ...data } = actionPlanUpdateSchema.parse(input)
const demands = actionPlanDemandDraftsSchema.parse(demandDrafts)
const { id, ...data } = activityUpdateSchema.parse(input)
const demands = activityDemandDraftsSchema.parse(demandDrafts)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
const plan = await payload.update({
collection: 'actionPlan',
const activity = await payload.update({
collection: 'activity',
id,
data,
depth: 0,
@ -88,14 +88,14 @@ const updateActionPlanRecord = async (
})
const municipality =
typeof plan.municipality === 'number' ? plan.municipality : plan.municipality.id
typeof activity.municipality === 'number' ? activity.municipality : activity.municipality.id
for (const demand of demands) {
await payload.create({
collection: 'campaignDemand',
data: hookFilledCreateData<'campaignDemand'>({
...demand,
municipality,
actionPlan: plan.id,
activity: activity.id,
}),
depth: 0,
user: currentActor,
@ -104,13 +104,13 @@ const updateActionPlanRecord = async (
})
}
return plan
return activity
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização do plano.' },
{ beginFailureMessage: 'Não foi possível iniciar a atualização da atividade.' },
)
}
const setActionPlanLifecycleStatusRecord = async (
const setActivityLifecycleStatusRecord = async (
payload: Payload,
actor: CampaignUser,
id: number,
@ -122,7 +122,7 @@ const setActionPlanLifecycleStatusRecord = async (
const currentActor = await reloadCampaignActor(payload, actor, req)
return payload.update({
collection: 'actionPlan',
collection: 'activity',
id,
data: { status },
depth: 0,
@ -131,13 +131,13 @@ const setActionPlanLifecycleStatusRecord = async (
req,
})
},
{ beginFailureMessage: 'Não foi possível atualizar o status do plano.' },
{ beginFailureMessage: 'Não foi possível atualizar o status da atividade.' },
)
const toggleActionPlanTaskRecord = async (
const toggleActivityTaskRecord = async (
payload: Payload,
actor: CampaignUser,
planId: number,
activityId: number,
taskId: string,
done: boolean,
) =>
@ -145,10 +145,10 @@ const toggleActionPlanTaskRecord = async (
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, [`action-plan:${planId}`])
const plan = await payload.findByID({
collection: 'actionPlan',
id: planId,
await acquireTextAdvisoryLocks(payload, req, [`activity:${activityId}`])
const activity = await payload.findByID({
collection: 'activity',
id: activityId,
depth: 0,
select: { tasks: true },
user: currentActor,
@ -156,15 +156,15 @@ const toggleActionPlanTaskRecord = async (
req,
})
const tasks = plan.tasks ?? []
const tasks = activity.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,
collection: 'activity',
id: activityId,
data: { tasks: nextTasks },
depth: 0,
user: currentActor,
@ -173,18 +173,18 @@ const toggleActionPlanTaskRecord = async (
req,
})
},
{ beginFailureMessage: 'Não foi possível atualizar a tarefa do plano.' },
{ beginFailureMessage: 'Não foi possível atualizar a tarefa da atividade.' },
)
const appendActionPlanUpdateRecord = async (
const appendActivityUpdateRecord = async (
payload: Payload,
actor: CampaignUser,
planId: number,
activityId: 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) {
if (trimmedBody.length > MAX_ACTIVITY_UPDATE_BODY_LENGTH) {
throw new Error('Atualização muito longa. Reduza o texto e tente novamente.')
}
@ -192,10 +192,10 @@ const appendActionPlanUpdateRecord = async (
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, [`action-plan:${planId}`])
const plan = await payload.findByID({
collection: 'actionPlan',
id: planId,
await acquireTextAdvisoryLocks(payload, req, [`activity:${activityId}`])
const activity = await payload.findByID({
collection: 'activity',
id: activityId,
depth: 0,
select: { updates: true },
user: currentActor,
@ -203,12 +203,12 @@ const appendActionPlanUpdateRecord = async (
req,
})
const updates = plan.updates ?? []
const updates = activity.updates ?? []
const nextUpdates = [...updates, { body: trimmedBody }]
return payload.update({
collection: 'actionPlan',
id: planId,
collection: 'activity',
id: activityId,
data: { updates: nextUpdates },
depth: 0,
user: currentActor,
@ -217,20 +217,20 @@ const appendActionPlanUpdateRecord = async (
req,
})
},
{ beginFailureMessage: 'Não foi possível registrar a atualização do plano.' },
{ beginFailureMessage: 'Não foi possível registrar a atualização da atividade.' },
)
}
const registerActionPlanResult = async (
const registerActivityResult = async (
payload: Payload,
actor: CampaignUser,
planId: number,
activityId: number,
resultSummary: string,
mediaIDs: number[],
) => {
const trimmedSummary = resultSummary.trim()
if (!trimmedSummary) throw new Error('Informe o resultado da ação.')
if (trimmedSummary.length > MAX_ACTION_PLAN_RESULT_SUMMARY_LENGTH) {
if (!trimmedSummary) throw new Error('Informe o resultado da atividade.')
if (trimmedSummary.length > MAX_ACTIVITY_RESULT_SUMMARY_LENGTH) {
throw new Error('Resultado muito longo. Reduza o texto e tente novamente.')
}
@ -239,12 +239,12 @@ const registerActionPlanResult = async (
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (!isCampaignStaff(currentActor)) {
throw new Error('Apenas a equipe da campanha pode registrar o resultado do plano.')
throw new Error('Apenas a equipe da campanha pode registrar o resultado da atividade.')
}
return payload.update({
collection: 'actionPlan',
id: planId,
collection: 'activity',
id: activityId,
data: {
resultSummary: trimmedSummary,
// Media upload is a follow-up; an empty list must not wipe media
@ -257,51 +257,51 @@ const registerActionPlanResult = async (
req,
})
},
{ beginFailureMessage: 'Não foi possível registrar o resultado do plano.' },
{ beginFailureMessage: 'Não foi possível registrar o resultado da atividade.' },
)
}
export const createActionPlan = async (
input: ActionPlanCreateInput,
demandDrafts: ActionPlanDemandDraft[] = [],
export const createActivity = async (
input: ActivityCreateInput,
demandDrafts: ActivityDemandDraft[] = [],
) => {
const { payload, actor } = await getCampaignActionContext()
return createActionPlanRecord(payload, actor, input, demandDrafts)
return createActivityRecord(payload, actor, input, demandDrafts)
}
export const updateActionPlan = async (
input: ActionPlanUpdateInput,
demandDrafts: ActionPlanDemandDraft[] = [],
export const updateActivity = async (
input: ActivityUpdateInput,
demandDrafts: ActivityDemandDraft[] = [],
) => {
const { payload, actor } = await getCampaignActionContext()
return updateActionPlanRecord(payload, actor, input, demandDrafts)
return updateActivityRecord(payload, actor, input, demandDrafts)
}
export const cancelActionPlan = async (id: number) => {
export const cancelActivity = async (id: number) => {
const { payload, actor } = await getCampaignActionContext()
return setActionPlanLifecycleStatusRecord(payload, actor, id, 'cancelado')
return setActivityLifecycleStatusRecord(payload, actor, id, 'cancelado')
}
export const markActionPlanRealized = async (id: number) => {
export const markActivityRealized = async (id: number) => {
const { payload, actor } = await getCampaignActionContext()
return setActionPlanLifecycleStatusRecord(payload, actor, id, 'realizado')
return setActivityLifecycleStatusRecord(payload, actor, id, 'realizado')
}
export const toggleActionPlanTask = async (planId: number, taskId: string, done: boolean) => {
export const toggleActivityTask = async (activityId: number, taskId: string, done: boolean) => {
const { payload, actor } = await getCampaignActionContext()
return toggleActionPlanTaskRecord(payload, actor, planId, taskId, done)
return toggleActivityTaskRecord(payload, actor, activityId, taskId, done)
}
export const appendActionPlanUpdate = async (planId: number, body: string) => {
export const appendActivityUpdate = async (activityId: number, body: string) => {
const { payload, actor } = await getCampaignActionContext()
return appendActionPlanUpdateRecord(payload, actor, planId, body)
return appendActivityUpdateRecord(payload, actor, activityId, body)
}
export const registerActionPlanResultAction = async (
planId: number,
export const registerActivityResultAction = async (
activityId: number,
resultSummary: string,
mediaIDs: number[] = [],
) => {
const { payload, actor } = await getCampaignActionContext()
return registerActionPlanResult(payload, actor, planId, resultSummary, mediaIDs)
return registerActivityResult(payload, actor, activityId, resultSummary, mediaIDs)
}

View file

@ -6,53 +6,53 @@ import type {
import { APIError } from 'payload'
import {
actionPlanKindLabels,
actionPlanKinds,
actionPlanOriginLabels,
actionPlanOrigins,
actionPlanStatusLabels,
actionPlanStatuses,
} from '@/lib/schemas/actionPlan'
activityKindLabels,
activityKinds,
activityOriginLabels,
activityOrigins,
activityStatusLabels,
activityStatuses,
} from '@/lib/schemas/activity'
import { slugify } from '@/lib/slug'
import {
canCreateActionPlan,
canCreateActionPlanAdvisors,
canDeleteActionPlan,
canManageActionPlanAdvisors,
canCreateActivity,
canCreateActivityAdvisors,
canDeleteActivity,
canManageActivityAdvisors,
canManageCampaignStaffField,
canReadActionPlan,
canReadActivity,
canReadCampaignStaffField,
canSetActionPlanStatus,
canSetActionPlanSystemField,
canUpdateActionPlan,
canSetActivityStatus,
canSetActivitySystemField,
canUpdateActivity,
eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const isActionPlanMutationShortcut = (context: Record<string, unknown> | undefined) =>
const isActivityMutationShortcut = (context: Record<string, unknown> | undefined) =>
context?.mutationKind === 'taskToggle' || context?.mutationKind === 'appendUpdate'
const ACTION_PLAN_KIND_OPTIONS = actionPlanKinds.map((value) => ({
const ACTIVITY_KIND_OPTIONS = activityKinds.map((value) => ({
value,
label: actionPlanKindLabels[value],
label: activityKindLabels[value],
}))
const ACTION_PLAN_STATUS_OPTIONS = actionPlanStatuses.map((value) => ({
const ACTIVITY_STATUS_OPTIONS = activityStatuses.map((value) => ({
value,
label: actionPlanStatusLabels[value],
label: activityStatusLabels[value],
}))
const ACTION_PLAN_ORIGIN_OPTIONS = actionPlanOrigins.map((value) => ({
const ACTIVITY_ORIGIN_OPTIONS = activityOrigins.map((value) => ({
value,
label: actionPlanOriginLabels[value],
label: activityOriginLabels[value],
}))
const relationshipIds = (value: unknown): number[] =>
(Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null)
const actionPlanStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
const activityStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
title: trimmedText(doc.title),
slug: doc.slug ?? null,
kind: doc.kind ?? null,
@ -73,13 +73,13 @@ const actionPlanStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
origin: doc.origin ?? null,
})
const setCanonicalActionPlanSlug: CollectionBeforeValidateHook = ({
const setCanonicalActivitySlug: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
context,
}) => {
if (isActionPlanMutationShortcut(context)) return data
if (isActivityMutationShortcut(context)) return data
if (!data) return data
const title = trimmedText(data.title ?? originalDoc?.title)
const slug = slugify(title)
@ -87,20 +87,20 @@ const setCanonicalActionPlanSlug: CollectionBeforeValidateHook = ({
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)
throw new APIError('O título da atividade 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 = ({
const validateActivitySchedule: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
context,
}) => {
if (isActionPlanMutationShortcut(context)) return data
if (isActivityMutationShortcut(context)) return data
if (!data) return data
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
@ -109,7 +109,10 @@ const validateActionPlanSchedule: CollectionBeforeValidateHook = ({
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)
throw new APIError(
'Informe a data e horário de início ao planejar ou confirmar a atividade.',
400,
)
}
if (startAt && endAt) {
@ -123,8 +126,8 @@ const validateActionPlanSchedule: CollectionBeforeValidateHook = ({
return data
}
const validateActionPlanAdvisors: CollectionBeforeValidateHook = async ({ data, req, context }) => {
if (isActionPlanMutationShortcut(context)) return data
const validateActivityAdvisors: CollectionBeforeValidateHook = async ({ data, req, context }) => {
if (isActivityMutationShortcut(context)) return data
if (!data) return data
if (data.advisors === undefined) return data
@ -153,7 +156,7 @@ const validateActionPlanAdvisors: CollectionBeforeValidateHook = async ({ data,
return data
}
const deriveActionPlanFields: CollectionBeforeChangeHook = ({
const deriveActivityFields: CollectionBeforeChangeHook = ({
data,
operation,
originalDoc,
@ -223,15 +226,15 @@ const deriveActionPlanFields: CollectionBeforeChangeHook = ({
req.user?.collection === 'campaignUser' &&
req.user.role === 'leader'
) {
if (!isActionPlanMutationShortcut(req.context)) {
if (!isActivityMutationShortcut(req.context)) {
const previous = (originalDoc ?? {}) as Record<string, unknown>
const merged = { ...previous, ...(data as Record<string, unknown>) }
if (
JSON.stringify(actionPlanStaffFieldSnapshot(previous)) !==
JSON.stringify(actionPlanStaffFieldSnapshot(merged))
JSON.stringify(activityStaffFieldSnapshot(previous)) !==
JSON.stringify(activityStaffFieldSnapshot(merged))
) {
throw new APIError(
'Lideranças só podem marcar tarefas e registrar atualizações no plano.',
'Lideranças só podem marcar tarefas e registrar atualizações na atividade.',
403,
)
}
@ -261,11 +264,11 @@ const deriveActionPlanFields: CollectionBeforeChangeHook = ({
return data
}
export const ActionPlan: CollectionConfig = {
slug: 'actionPlan',
export const Activity: CollectionConfig = {
slug: 'activity',
labels: {
singular: 'Plano de ação',
plural: 'Planos de ação',
singular: 'Atividade',
plural: 'Atividades',
},
admin: {
group: 'Campanha',
@ -273,18 +276,14 @@ export const ActionPlan: CollectionConfig = {
defaultColumns: ['title', 'kind', 'status', 'municipality', 'startAt', 'updatedAt'],
},
access: {
create: canCreateActionPlan,
read: canReadActionPlan,
update: canUpdateActionPlan,
delete: canDeleteActionPlan,
create: canCreateActivity,
read: canReadActivity,
update: canUpdateActivity,
delete: canDeleteActivity,
},
hooks: {
beforeValidate: [
setCanonicalActionPlanSlug,
validateActionPlanSchedule,
validateActionPlanAdvisors,
],
beforeChange: [deriveActionPlanFields],
beforeValidate: [setCanonicalActivitySlug, validateActivitySchedule, validateActivityAdvisors],
beforeChange: [deriveActivityFields],
},
fields: [
{
@ -307,17 +306,17 @@ export const ActionPlan: CollectionConfig = {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
create: canSetActivitySystemField,
update: canSetActivitySystemField,
},
},
{
name: 'kind',
type: 'select',
label: 'Tipo de ação',
label: 'Tipo de atividade',
required: true,
index: true,
options: [...ACTION_PLAN_KIND_OPTIONS],
options: [...ACTIVITY_KIND_OPTIONS],
},
{
name: 'status',
@ -327,10 +326,10 @@ export const ActionPlan: CollectionConfig = {
defaultValue: 'rascunho',
index: true,
access: {
create: canSetActionPlanStatus,
update: canSetActionPlanStatus,
create: canSetActivityStatus,
update: canSetActivityStatus,
},
options: [...ACTION_PLAN_STATUS_OPTIONS],
options: [...ACTIVITY_STATUS_OPTIONS],
},
{
name: 'description',
@ -341,9 +340,9 @@ export const ActionPlan: CollectionConfig = {
{
name: 'origin',
type: 'select',
label: 'Origem da ação',
label: 'Origem da atividade',
defaultValue: 'dado',
options: ACTION_PLAN_ORIGIN_OPTIONS,
options: ACTIVITY_ORIGIN_OPTIONS,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
@ -357,7 +356,7 @@ export const ActionPlan: CollectionConfig = {
defaultValue: false,
index: true,
admin: {
description: 'Marque quando o deputado Jorge Solla estiver presente na ação.',
description: 'Marque quando o deputado Jorge Solla estiver presente na atividade.',
},
},
{
@ -421,8 +420,8 @@ export const ActionPlan: CollectionConfig = {
hasMany: true,
index: true,
access: {
create: canCreateActionPlanAdvisors,
update: canManageActionPlanAdvisors,
create: canCreateActivityAdvisors,
update: canManageActivityAdvisors,
},
filterOptions: eligibleCampaignStaffWhere,
},
@ -486,8 +485,8 @@ export const ActionPlan: CollectionConfig = {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
create: canSetActivitySystemField,
update: canSetActivitySystemField,
},
},
],
@ -502,8 +501,8 @@ export const ActionPlan: CollectionConfig = {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
create: canSetActivitySystemField,
update: canSetActivitySystemField,
},
},
{
@ -516,8 +515,8 @@ export const ActionPlan: CollectionConfig = {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
create: canSetActivitySystemField,
update: canSetActivitySystemField,
},
},
{
@ -545,8 +544,8 @@ export const ActionPlan: CollectionConfig = {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
create: canSetActivitySystemField,
update: canSetActivitySystemField,
},
},
{
@ -557,8 +556,8 @@ export const ActionPlan: CollectionConfig = {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
create: canSetActivitySystemField,
update: canSetActivitySystemField,
},
},
],
@ -566,11 +565,11 @@ export const ActionPlan: CollectionConfig = {
{
name: 'resultSummary',
type: 'textarea',
label: 'Resultado da ação',
label: 'Resultado da atividade',
maxLength: 6000,
access: {
create: canSetActionPlanStatus,
update: canSetActionPlanStatus,
create: canSetActivityStatus,
update: canSetActivityStatus,
},
admin: {
description:
@ -584,8 +583,8 @@ export const ActionPlan: CollectionConfig = {
label: 'Fotos e vídeos do resultado',
hasMany: true,
access: {
create: canSetActionPlanStatus,
update: canSetActionPlanStatus,
create: canSetActivityStatus,
update: canSetActivityStatus,
},
},
{
@ -597,8 +596,8 @@ export const ActionPlan: CollectionConfig = {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
create: canSetActivitySystemField,
update: canSetActivitySystemField,
},
},
{
@ -609,22 +608,22 @@ export const ActionPlan: CollectionConfig = {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
create: canSetActivitySystemField,
update: canSetActivitySystemField,
},
},
{
name: 'createdBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Criado por',
label: 'Criada por',
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
create: canSetActivitySystemField,
update: canSetActivitySystemField,
},
},
],

View file

@ -136,7 +136,7 @@ const enforceDemandWorkflow: CollectionBeforeChangeHook = async ({
return data
}
const validateDemandActionPlanMunicipality: CollectionBeforeValidateHook = async ({
const validateDemandActivityMunicipality: CollectionBeforeValidateHook = async ({
data,
operation,
originalDoc,
@ -145,34 +145,34 @@ const validateDemandActionPlanMunicipality: CollectionBeforeValidateHook = async
if (!data) return data
const municipalityID = relationshipId(data.municipality ?? originalDoc?.municipality)
const actionPlanID = relationshipId(data.actionPlan ?? originalDoc?.actionPlan)
if (!actionPlanID) return data
const activityID = relationshipId(data.activity ?? originalDoc?.activity)
if (!activityID) return data
if (!municipalityID) throw new APIError('Município da demanda inválido.', 400)
if (
operation === 'update' &&
municipalityID === relationshipId(originalDoc?.municipality) &&
actionPlanID === relationshipId(originalDoc?.actionPlan)
activityID === relationshipId(originalDoc?.activity)
) {
return data
}
const requestContext = req.context as Record<string, unknown>
const cacheKey = `campaignDemand:actionPlanMunicipality:${actionPlanID}`
let actionPlanMunicipalityID = requestContext[cacheKey]
if (typeof actionPlanMunicipalityID !== 'number') {
const actionPlan = await req.payload.findByID({
collection: 'actionPlan',
id: actionPlanID,
const cacheKey = `campaignDemand:activityMunicipality:${activityID}`
let activityMunicipalityID = requestContext[cacheKey]
if (typeof activityMunicipalityID !== 'number') {
const activity = await req.payload.findByID({
collection: 'activity',
id: activityID,
depth: 0,
select: { municipality: true },
overrideAccess: true,
req,
})
actionPlanMunicipalityID = relationshipId(actionPlan.municipality)
requestContext[cacheKey] = actionPlanMunicipalityID
activityMunicipalityID = relationshipId(activity.municipality)
requestContext[cacheKey] = activityMunicipalityID
}
if (actionPlanMunicipalityID !== municipalityID) {
throw new APIError('A demanda e o plano de ação devem pertencer ao mesmo município.', 409)
if (activityMunicipalityID !== municipalityID) {
throw new APIError('A demanda e a atividade devem pertencer ao mesmo município.', 409)
}
return data
@ -198,7 +198,7 @@ export const CampaignDemand: CollectionConfig = {
delete: canDeleteCampaignDemand,
},
hooks: {
beforeValidate: [setCanonicalDemandSlug, validateDemandActionPlanMunicipality],
beforeValidate: [setCanonicalDemandSlug, validateDemandActivityMunicipality],
beforeChange: [enforceDemandWorkflow],
},
fields: [
@ -249,10 +249,10 @@ export const CampaignDemand: CollectionConfig = {
index: true,
},
{
name: 'actionPlan',
name: 'activity',
type: 'relationship',
relationTo: 'actionPlan',
label: 'Plano de ação',
relationTo: 'activity',
label: 'Atividade',
index: true,
},
{

View file

@ -42,7 +42,7 @@ export const Organization: CollectionConfig = {
useAsTitle: 'name',
defaultColumns: ['name', 'kind', 'updatedAt'],
description:
'Sindicatos, associações, movimentos e afins. Concentra lideranças associadas e Planos de Ação apoiados.',
'Sindicatos, associações, movimentos e afins. Concentra lideranças associadas e atividades apoiadas.',
},
access: {
create: canCreateOrganization,

View file

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

View file

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

View file

@ -1,52 +1,52 @@
import { CalendarIcon, MapPinIcon, UserIcon } from 'lucide-react'
import Link from 'next/link'
import { ActionPlanStatusBadge } from '@/components/campaign/actionPlan/ActionPlanStatusBadge'
import { ActivityStatusBadge } from '@/components/campaign/activity/ActivityStatusBadge'
import { Badge } from '@/components/ui/Badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import type { ActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
import { activityKindLabels } from '@/lib/schemas/activity'
import type { ActivityListViewModel } from '@/utilities/activityViewModels'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
const formatWhen = (plan: ActionPlanListViewModel): string =>
plan.startAt ? formatBahiaDateTimeLabel(plan.startAt) : 'Data a definir'
const formatWhen = (activity: ActivityListViewModel): string =>
activity.startAt ? formatBahiaDateTimeLabel(activity.startAt) : 'Data a definir'
export const ActionPlanCard = ({ plan }: { plan: ActionPlanListViewModel }) => (
export const ActivityCard = ({ activity }: { activity: ActivityListViewModel }) => (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{actionPlanKindLabels[plan.kind]}</Badge>
<ActionPlanStatusBadge status={plan.status} />
{plan.deputyPresent ? <Badge>Deputado presente</Badge> : null}
<Badge variant="secondary">{activityKindLabels[activity.kind]}</Badge>
<ActivityStatusBadge status={activity.status} />
{activity.deputyPresent ? <Badge>Deputado presente</Badge> : null}
</div>
<CardTitle>
<Link
href={`/campanha/planos/${plan.slug}`}
href={`/campanha/atividades/${activity.slug}`}
className="text-foreground underline-offset-4 hover:underline"
>
{plan.title}
{activity.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>
<span>{formatWhen(activity)}</span>
</div>
{plan.locationLabel ? (
{activity.locationLabel ? (
<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.locationLabel}</Badge>
<Badge variant="outline">{activity.locationLabel}</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'}
Resp: {activity.responsibleName ?? 'Não definido'}
</span>
{plan.taskProgress.total > 0 ? (
{activity.taskProgress.total > 0 ? (
<span className="font-medium tabular-nums text-muted-foreground">
{plan.taskProgress.done}/{plan.taskProgress.total} tarefas
{activity.taskProgress.done}/{activity.taskProgress.total} tarefas
</span>
) : null}
</div>

View file

@ -8,7 +8,7 @@ import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Textarea } from '@/components/ui/textarea'
import { MAX_ACTION_PLAN_DEMAND_DRAFTS } from '@/lib/schemas/actionPlan'
import { MAX_ACTIVITY_DEMAND_DRAFTS } from '@/lib/schemas/activity'
import { campaignDemandKindLabels, campaignDemandKinds } from '@/lib/schemas/campaignDemand'
type DemandFieldValue = {
@ -35,7 +35,7 @@ const serializeDemands = (demands: DemandFieldValue[]) =>
})),
)
export const ActionPlanDemandFields = ({ error }: { error?: string }) => {
export const ActivityDemandFields = ({ error }: { error?: string }) => {
const fieldId = useId()
const [demands, setDemands] = useState<DemandFieldValue[]>([])
@ -60,7 +60,7 @@ export const ActionPlanDemandFields = ({ error }: { error?: string }) => {
<Field data-invalid={Boolean(error)}>
<FieldLabel htmlFor={fieldId}>Demandas vinculadas</FieldLabel>
<FieldDescription>
Registre necessidades que surgem deste plano. Município e plano serão vinculados
Registre necessidades que surgem desta atividade. Município e atividade serão vinculados
automaticamente.
</FieldDescription>
<input
@ -129,7 +129,7 @@ export const ActionPlanDemandFields = ({ error }: { error?: string }) => {
variant="outline"
className="min-h-11 w-fit"
onClick={addDemand}
disabled={demands.length >= MAX_ACTION_PLAN_DEMAND_DRAFTS}
disabled={demands.length >= MAX_ACTIVITY_DEMAND_DRAFTS}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Adicionar demanda

View file

@ -10,15 +10,15 @@ import type { RelationOption } from '@/components/campaign/shared/RelationMultiS
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
import { activityKindLabels, activityStatusLabels } from '@/lib/schemas/activity'
import { cn } from '@/lib/utils'
import {
actionPlanTabLabels,
actionPlanTabs,
buildActionPlanListSearchParams,
type ActionPlanListState,
type ActionPlanTab,
} from '@/utilities/actionPlanUi'
activityTabLabels,
activityTabs,
buildActivityListSearchParams,
type ActivityListState,
type ActivityTab,
} from '@/utilities/activityUi'
type FilterValues = {
kind: string
@ -26,7 +26,7 @@ type FilterValues = {
municipality: string
}
const valuesFromState = (state: ActionPlanListState): FilterValues => ({
const valuesFromState = (state: ActivityListState): FilterValues => ({
kind: state.kind ?? '',
status: state.status ?? '',
municipality: state.municipality ? String(state.municipality) : '',
@ -37,8 +37,8 @@ const municipalityIdFromValue = (value: string): number | undefined => {
return Number.isInteger(id) && id > 0 ? id : undefined
}
const buildTabHref = (state: ActionPlanListState, tab: ActionPlanTab): string => {
const params = buildActionPlanListSearchParams({
const buildTabHref = (state: ActivityListState, tab: ActivityTab): string => {
const params = buildActivityListSearchParams({
page: 1,
tab,
kind: state.kind,
@ -46,12 +46,12 @@ const buildTabHref = (state: ActionPlanListState, tab: ActionPlanTab): string =>
municipality: state.municipality,
})
const query = params.toString()
return query ? `/campanha/planos?${query}` : '/campanha/planos'
return query ? `/campanha/atividades?${query}` : '/campanha/atividades'
}
const ActionPlanTabSwitch = ({ state }: { state: ActionPlanListState }) => (
<nav aria-label="Janela de planos" className="flex flex-wrap gap-2">
{actionPlanTabs.map((tab) => {
const ActivityTabSwitch = ({ state }: { state: ActivityListState }) => (
<nav aria-label="Janela de atividades" className="flex flex-wrap gap-2">
{activityTabs.map((tab) => {
const active = tab === state.tab
return (
<Button
@ -61,7 +61,7 @@ const ActionPlanTabSwitch = ({ state }: { state: ActionPlanListState }) => (
className="min-h-11 rounded-[6px]"
>
<Link href={buildTabHref(state, tab)} aria-current={active ? 'page' : undefined}>
{actionPlanTabLabels[tab]}
{activityTabLabels[tab]}
</Link>
</Button>
)
@ -69,11 +69,11 @@ const ActionPlanTabSwitch = ({ state }: { state: ActionPlanListState }) => (
</nav>
)
export const ActionPlanFilters = ({
export const ActivityFilters = ({
state,
municipalityOptions,
}: {
state: ActionPlanListState
state: ActivityListState
municipalityOptions: RelationOption[]
}) => {
const router = useRouter()
@ -91,11 +91,11 @@ export const ActionPlanFilters = ({
const replaceValues = (nextValues: FilterValues) => {
valuesRef.current = nextValues
setValues(nextValues)
const params = buildActionPlanListSearchParams({
const params = buildActivityListSearchParams({
page: 1,
tab: state.tab,
kind: nextValues.kind as ActionPlanListState['kind'],
status: nextValues.status as ActionPlanListState['status'],
kind: nextValues.kind as ActivityListState['kind'],
status: nextValues.status as ActivityListState['status'],
municipality: municipalityIdFromValue(nextValues.municipality),
})
const query = params.toString()
@ -115,7 +115,7 @@ export const ActionPlanFilters = ({
return (
<div className="flex flex-col gap-3">
<ActionPlanTabSwitch state={state} />
<ActivityTabSwitch state={state} />
<div
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
@ -131,7 +131,7 @@ export const ActionPlanFilters = ({
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"
aria-controls="activity-filter-controls"
onClick={() => setMobileFiltersOpen((open) => !open)}
>
<FilterIcon data-icon="inline-start" aria-hidden="true" />
@ -139,7 +139,7 @@ export const ActionPlanFilters = ({
<ChevronDownIcon data-icon="inline-end" className="ml-auto" aria-hidden="true" />
</Button>
<div
id="action-plan-filter-controls"
id="activity-filter-controls"
className={cn(
mobileFiltersOpen ? 'block' : 'hidden',
'border-t p-4 lg:block lg:border-t-0',
@ -152,15 +152,15 @@ export const ActionPlanFilters = ({
)}
>
<Field>
<FieldLabel htmlFor="action-plan-kind">Tipo de ação</FieldLabel>
<FieldLabel htmlFor="activity-kind">Tipo de atividade</FieldLabel>
<NativeSelect
id="action-plan-kind"
id="activity-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]) => (
{Object.entries(activityKindLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
@ -169,15 +169,15 @@ export const ActionPlanFilters = ({
</Field>
{state.tab === 'todos' ? (
<Field>
<FieldLabel htmlFor="action-plan-status">Status</FieldLabel>
<FieldLabel htmlFor="activity-status">Status</FieldLabel>
<NativeSelect
id="action-plan-status"
id="activity-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]) => (
{Object.entries(activityStatusLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
@ -186,9 +186,9 @@ export const ActionPlanFilters = ({
</Field>
) : null}
<Field>
<FieldLabel htmlFor="action-plan-municipality">Município</FieldLabel>
<FieldLabel htmlFor="activity-municipality">Município</FieldLabel>
<NativeSelect
id="action-plan-municipality"
id="activity-municipality"
value={values.municipality}
onChange={(event) => updateMunicipality(event.target.value)}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"

View file

@ -3,8 +3,8 @@
import Link from 'next/link'
import { useActionState, useState } from 'react'
import { ActionPlanDemandFields } from '@/components/campaign/actionPlan/ActionPlanDemandFields'
import { ActionPlanTaskFields } from '@/components/campaign/actionPlan/ActionPlanTaskFields'
import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields'
import { ActivityTaskFields } from '@/components/campaign/activity/ActivityTaskFields'
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
import {
ContactCombobox,
@ -34,68 +34,72 @@ import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import {
actionPlanKindLabels,
actionPlanOriginLabels,
actionPlanStatusLabels,
} from '@/lib/schemas/actionPlan'
import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions'
import type { ActionPlanFormViewModel } from '@/utilities/actionPlanViewModels'
activityKindLabels,
activityOriginLabels,
activityStatusLabels,
} from '@/lib/schemas/activity'
import type { ActivityLeadershipOption } from '@/utilities/activityLeadershipOptions'
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
import { fieldError } from '@/utilities/campaignFormFields'
import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime'
import type { EligibleAdvisorOption } from '@/utilities/municipalityViewModels'
export type ActionPlanFormState = {
export type ActivityFormState = {
message?: string
fieldErrors?: Record<string, string[]>
existingHref?: string
submittedTitle?: string
}
type ActionPlanFormAction = (
state: ActionPlanFormState,
type ActivityFormAction = (
state: ActivityFormState,
formData: FormData,
) => Promise<ActionPlanFormState>
) => Promise<ActivityFormState>
export type ActionPlanFormFieldsProps = {
export type ActivityFormFieldsProps = {
municipalityOptions: RelationOption[]
organizationOptions: RelationOption[]
advisorOptions?: EligibleAdvisorOption[]
canManageAdvisors?: boolean
plan?: ActionPlanFormViewModel
activity?: ActivityFormViewModel
fieldErrors?: Record<string, string[]>
submittedTitle?: string
searchContacts: (query: string) => Promise<ContactComboboxOption[]>
searchLeaderships: (query: string) => Promise<ActionPlanLeadershipOption[]>
searchLeaderships: (query: string) => Promise<ActivityLeadershipOption[]>
}
const editableStatuses = ['rascunho', 'planejado'] as const
const ActionPlanFormFields = ({
const ActivityFormFields = ({
municipalityOptions,
organizationOptions,
advisorOptions = [],
canManageAdvisors = false,
plan,
activity,
fieldErrors = {},
submittedTitle,
searchContacts,
searchLeaderships,
}: ActionPlanFormFieldsProps) => {
}: ActivityFormFieldsProps) => {
const errorFor = (name: string) => fieldError(fieldErrors, name)
const statusIsEditable =
!plan || editableStatuses.includes(plan.status as (typeof editableStatuses)[number])
!activity || editableStatuses.includes(activity.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])
activity && editableStatuses.includes(activity.status as (typeof editableStatuses)[number])
? (activity.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 }
activity?.responsible
? {
id: activity.responsible.id,
name: activity.responsible.name,
phone: activity.responsible.phone,
}
: null,
)
const [leadership, setLeadership] = useState<ActionPlanLeadershipOption | null>(
plan?.leadership ? { id: plan.leadership.id, label: plan.leadership.label } : null,
const [leadership, setLeadership] = useState<ActivityLeadershipOption | null>(
activity?.leadership ? { id: activity.leadership.id, label: activity.leadership.label } : null,
)
return (
@ -103,7 +107,7 @@ const ActionPlanFormFields = ({
{statusIsEditable ? (
<input type="hidden" name="status" value={status} />
) : (
<input type="hidden" name="status" value={plan?.status ?? 'rascunho'} />
<input type="hidden" name="status" value={activity?.status ?? 'rascunho'} />
)}
<Card>
@ -117,12 +121,12 @@ const ActionPlanFormFields = ({
<Input
id="title"
name="title"
defaultValue={submittedTitle ?? plan?.title}
defaultValue={submittedTitle ?? activity?.title}
minLength={2}
maxLength={160}
className="min-h-11"
required
readOnly={Boolean(plan)}
readOnly={Boolean(activity)}
aria-invalid={Boolean(errorFor('title'))}
aria-describedby={errorFor('title') ? 'title-error' : undefined}
/>
@ -132,18 +136,18 @@ const ActionPlanFormFields = ({
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={Boolean(errorFor('kind'))}>
<FieldLabel htmlFor="kind">Tipo de ação *</FieldLabel>
<FieldLabel htmlFor="kind">Tipo de atividade *</FieldLabel>
<NativeSelect
id="kind"
name="kind"
defaultValue={plan?.kind}
defaultValue={activity?.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]) => (
{Object.entries(activityKindLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
@ -163,41 +167,42 @@ const ActionPlanFormFields = ({
onValueChange={(value) => value && setStatus(value as typeof status)}
variant="outline"
className="flex w-full flex-wrap justify-start"
aria-label="Status do plano"
aria-label="Status da atividade"
>
<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.
Rascunhos não exigem data. Atividades planejadas 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.
Status atual: {activity ? activityStatusLabels[activity.status] : ''}. Use as
ações do detalhe para marcar como realizado ou cancelado.
</FieldDescription>
)}
</Field>
</div>
<Field data-invalid={Boolean(errorFor('origin'))}>
<FieldLabel htmlFor="origin">Origem da ação</FieldLabel>
<FieldLabel htmlFor="origin">Origem da atividade</FieldLabel>
<NativeSelect
id="origin"
name="origin"
defaultValue={plan?.origin ?? 'dado'}
defaultValue={activity?.origin ?? 'dado'}
className="w-full **:data-[slot=native-select]:min-h-11 sm:max-w-sm"
aria-invalid={Boolean(errorFor('origin'))}
>
{Object.entries(actionPlanOriginLabels).map(([value, label]) => (
{Object.entries(activityOriginLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>
Registra se o plano nasceu da análise de dados, de um pedido de articulação ou de um
compromisso político.
Registra se a atividade nasceu da análise de dados, de um pedido de articulação ou
de um compromisso político.
</FieldDescription>
{errorFor('origin') ? <FieldError>{errorFor('origin')}</FieldError> : null}
</Field>
@ -206,7 +211,7 @@ const ActionPlanFormFields = ({
<Textarea
id="description"
name="description"
defaultValue={plan?.description ?? ''}
defaultValue={activity?.description ?? ''}
maxLength={4000}
className="min-h-24"
aria-invalid={Boolean(errorFor('description'))}
@ -218,7 +223,7 @@ const ActionPlanFormFields = ({
</Field>
<FieldLabel>
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
<Checkbox name="deputyPresent" defaultChecked={plan?.deputyPresent ?? false} />
<Checkbox name="deputyPresent" defaultChecked={activity?.deputyPresent ?? false} />
<span>Deputado presente</span>
</Field>
</FieldLabel>
@ -238,7 +243,9 @@ const ActionPlanFormFields = ({
id="startAt"
name="startAt"
type="datetime-local"
defaultValue={plan?.startAt ? formatIsoAsBahiaDateTimeInput(plan.startAt) : ''}
defaultValue={
activity?.startAt ? formatIsoAsBahiaDateTimeInput(activity.startAt) : ''
}
required={status !== 'rascunho'}
className="min-h-11"
aria-invalid={Boolean(errorFor('startAt'))}
@ -254,7 +261,7 @@ const ActionPlanFormFields = ({
id="endAt"
name="endAt"
type="datetime-local"
defaultValue={plan?.endAt ? formatIsoAsBahiaDateTimeInput(plan.endAt) : ''}
defaultValue={activity?.endAt ? formatIsoAsBahiaDateTimeInput(activity.endAt) : ''}
className="min-h-11"
aria-invalid={Boolean(errorFor('endAt'))}
aria-describedby={errorFor('endAt') ? 'endAt-error' : undefined}
@ -269,7 +276,9 @@ const ActionPlanFormFields = ({
id="deadline"
name="deadline"
type="datetime-local"
defaultValue={plan?.deadline ? formatIsoAsBahiaDateTimeInput(plan.deadline) : ''}
defaultValue={
activity?.deadline ? formatIsoAsBahiaDateTimeInput(activity.deadline) : ''
}
className="min-h-11"
aria-invalid={Boolean(errorFor('deadline'))}
aria-describedby={errorFor('deadline') ? 'deadline-error' : undefined}
@ -293,7 +302,7 @@ const ActionPlanFormFields = ({
<NativeSelect
id="municipality"
name="municipality"
defaultValue={plan?.municipalityId ? String(plan.municipalityId) : ''}
defaultValue={activity?.municipalityId ? String(activity.municipalityId) : ''}
required
aria-invalid={Boolean(errorFor('municipality'))}
aria-describedby={errorFor('municipality') ? 'municipality-error' : undefined}
@ -315,7 +324,7 @@ const ActionPlanFormFields = ({
<Input
id="locality"
name="locality"
defaultValue={plan?.locality ?? ''}
defaultValue={activity?.locality ?? ''}
maxLength={160}
className="min-h-11"
aria-invalid={Boolean(errorFor('locality'))}
@ -339,7 +348,7 @@ const ActionPlanFormFields = ({
<FieldLabel>Responsável</FieldLabel>
<ContactCombobox
name="responsible"
label="Responsável pelo plano"
label="Responsável pela atividade"
current={responsible}
search={searchContacts}
onChange={setResponsible}
@ -363,8 +372,8 @@ const ActionPlanFormFields = ({
onChange={setLeadership}
/>
<FieldDescription>
Quando vinculada, a liderança engajada vê o plano e pode marcar tarefas e registrar
atualizações.
Quando vinculada, a liderança engajada vê a atividade e pode marcar tarefas e
registrar atualizações.
</FieldDescription>
{errorFor('leadership') ? (
<FieldError id="leadership-error">{errorFor('leadership')}</FieldError>
@ -375,7 +384,7 @@ const ActionPlanFormFields = ({
name="organizations"
label="Organizações apoiadoras"
options={organizationOptions}
initialSelectedIDs={plan?.organizationIDs ?? []}
initialSelectedIDs={activity?.organizationIDs ?? []}
error={errorFor('organizations')}
placeholder="Adicionar organização…"
/>
@ -387,7 +396,7 @@ const ActionPlanFormFields = ({
>
<FieldLegend>Assessores responsáveis</FieldLegend>
<FieldDescription>
Assessores selecionados podem editar este plano e gerenciar suas tarefas.
Assessores selecionados podem editar esta atividade e gerenciar suas tarefas.
</FieldDescription>
<input type="hidden" name="advisorsSubmitted" value="1" />
{advisorOptions.length ? (
@ -399,7 +408,9 @@ const ActionPlanFormFields = ({
name="advisors"
value={String(advisor.id)}
defaultChecked={
plan ? plan.advisorIDs.includes(advisor.id) : advisor.isCurrent
activity
? activity.advisorIDs.includes(advisor.id)
: advisor.isCurrent
}
/>
<span>
@ -427,8 +438,8 @@ const ActionPlanFormFields = ({
<CardTitle>Tarefas</CardTitle>
</CardHeader>
<CardContent>
<ActionPlanTaskFields
initialTasks={plan?.tasks ?? []}
<ActivityTaskFields
initialTasks={activity?.tasks ?? []}
searchContacts={searchContacts}
error={errorFor('tasksJson')}
/>
@ -440,32 +451,32 @@ const ActionPlanFormFields = ({
<CardTitle>Demandas</CardTitle>
</CardHeader>
<CardContent>
<ActionPlanDemandFields error={errorFor('demandsJson')} />
<ActivityDemandFields error={errorFor('demandsJson')} />
</CardContent>
</Card>
</FieldGroup>
)
}
export const ActionPlanForm = ({
export const ActivityForm = ({
action,
municipalityOptions,
organizationOptions,
advisorOptions,
canManageAdvisors = false,
plan,
activity,
submitLabel,
searchContacts,
searchLeaderships,
}: ActionPlanFormFieldsProps & {
action: ActionPlanFormAction
}: ActivityFormFieldsProps & {
action: ActivityFormAction
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}
{activity ? <input type="hidden" name="id" value={activity.id} /> : null}
{state.message ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível salvar</AlertTitle>
@ -474,15 +485,15 @@ export const ActionPlanForm = ({
) : null}
{state.existingHref ? (
<Button asChild variant="outline" className="min-h-11 w-fit">
<Link href={state.existingHref}>Abrir plano existente</Link>
<Link href={state.existingHref}>Abrir atividade existente</Link>
</Button>
) : null}
<ActionPlanFormFields
<ActivityFormFields
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
advisorOptions={advisorOptions}
canManageAdvisors={canManageAdvisors}
plan={plan}
activity={activity}
fieldErrors={state.fieldErrors}
submittedTitle={state.submittedTitle}
searchContacts={searchContacts}
@ -490,7 +501,9 @@ export const ActionPlanForm = ({
/>
<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>
<Link href={activity ? `/campanha/atividades/${activity.slug}` : '/campanha/atividades'}>
Cancelar
</Link>
</Button>
<Button type="submit" className="min-h-11" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}

View file

@ -4,9 +4,9 @@ import { CheckCircle2Icon, XCircleIcon } from 'lucide-react'
import { useActionState } from 'react'
import {
cancelActionPlanFormAction,
markActionPlanRealizedFormAction,
} from '@/app/(campaign)/campanha/(app)/planos/[slug]/lifecycleFormActions'
cancelActivityFormAction,
markActivityRealizedFormAction,
} from '@/app/(campaign)/campanha/(app)/atividades/[slug]/lifecycleFormActions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import {
AlertDialog,
@ -22,25 +22,25 @@ import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
type LifecycleDialogProps = {
planId: number
activityId: number
}
export const CancelActionPlanDialog = ({ planId }: LifecycleDialogProps) => {
const [state, action, pending] = useActionState(cancelActionPlanFormAction, {})
export const CancelActivityDialog = ({ activityId }: LifecycleDialogProps) => {
const [state, action, pending] = useActionState(cancelActivityFormAction, {})
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" variant="destructive" className="min-h-11">
<XCircleIcon data-icon="inline-start" aria-hidden="true" />
Cancelar plano
Cancelar atividade
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancelar este plano?</AlertDialogTitle>
<AlertDialogTitle>Cancelar esta atividade?</AlertDialogTitle>
<AlertDialogDescription>
O plano será marcado como cancelado. Os dados permanecem preservados no sistema.
A atividade será marcada como cancelada. Os dados permanecem preservados no sistema.
</AlertDialogDescription>
</AlertDialogHeader>
{state.message ? (
@ -50,7 +50,7 @@ export const CancelActionPlanDialog = ({ planId }: LifecycleDialogProps) => {
</Alert>
) : null}
<form action={action}>
<input type="hidden" name="id" value={planId} />
<input type="hidden" name="id" value={activityId} />
<AlertDialogFooter>
<AlertDialogCancel type="button" className="min-h-11" disabled={pending}>
Voltar
@ -66,8 +66,8 @@ export const CancelActionPlanDialog = ({ planId }: LifecycleDialogProps) => {
)
}
export const MarkActionPlanRealizedDialog = ({ planId }: LifecycleDialogProps) => {
const [state, action, pending] = useActionState(markActionPlanRealizedFormAction, {})
export const MarkActivityRealizedDialog = ({ activityId }: LifecycleDialogProps) => {
const [state, action, pending] = useActionState(markActivityRealizedFormAction, {})
return (
<AlertDialog>
@ -79,7 +79,7 @@ export const MarkActionPlanRealizedDialog = ({ planId }: LifecycleDialogProps) =
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Marcar este plano como realizado?</AlertDialogTitle>
<AlertDialogTitle>Marcar esta atividade como realizada?</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.
@ -92,7 +92,7 @@ export const MarkActionPlanRealizedDialog = ({ planId }: LifecycleDialogProps) =
</Alert>
) : null}
<form action={action}>
<input type="hidden" name="id" value={planId} />
<input type="hidden" name="id" value={activityId} />
<AlertDialogFooter>
<AlertDialogCancel type="button" className="min-h-11" disabled={pending}>
Voltar

View file

@ -0,0 +1,10 @@
import { ActivityCard } from '@/components/campaign/activity/ActivityCard'
import type { ActivityListViewModel } from '@/utilities/activityViewModels'
export const ActivityList = ({ activities }: { activities: ActivityListViewModel[] }) => (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{activities.map((activity) => (
<ActivityCard key={activity.id} activity={activity} />
))}
</div>
)

View file

@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation'
import { useActionState, useEffect } from 'react'
import { toast } from 'sonner'
import { registerActionPlanResultFormAction } from '@/app/(campaign)/campanha/(app)/planos/[slug]/resultFormActions'
import { registerActivityResultFormAction } from '@/app/(campaign)/campanha/(app)/atividades/[slug]/resultFormActions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -14,21 +14,21 @@ import { Textarea } from '@/components/ui/textarea'
import { fieldError } from '@/utilities/campaignFormFields'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
type ActionPlanResultFormProps = {
planId: number
type ActivityResultFormProps = {
activityId: number
initialSummary: string | null
recordedByName: string | null
recordedAt: string | null
}
export const ActionPlanResultForm = ({
planId,
export const ActivityResultForm = ({
activityId,
initialSummary,
recordedByName,
recordedAt,
}: ActionPlanResultFormProps) => {
}: ActivityResultFormProps) => {
const router = useRouter()
const [state, formAction, pending] = useActionState(registerActionPlanResultFormAction, {})
const [state, formAction, pending] = useActionState(registerActivityResultFormAction, {})
useEffect(() => {
if (state.status !== 'success') return
@ -49,12 +49,12 @@ export const ActionPlanResultForm = ({
return (
<Card>
<CardHeader>
<CardTitle>Resultado da ação</CardTitle>
<CardTitle>Resultado da atividade</CardTitle>
{recordedLabel ? <p className="text-sm text-muted-foreground">{recordedLabel}</p> : null}
</CardHeader>
<CardContent>
<form action={formAction} className="flex flex-col gap-4">
<input type="hidden" name="planId" value={planId} />
<input type="hidden" name="activityId" value={activityId} />
{state.message && state.status !== 'success' ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível registrar</AlertTitle>

View file

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

View file

@ -2,41 +2,41 @@ import Link from 'next/link'
import { cn } from '@/lib/utils'
import {
actionPlanDetailTabs,
buildActionPlanDetailTabHref,
type ActionPlanDetailSearchParams,
type ActionPlanDetailTab,
} from '@/utilities/actionPlanDetailTabUi'
activityDetailTabs,
buildActivityDetailTabHref,
type ActivityDetailSearchParams,
type ActivityDetailTab,
} from '@/utilities/activityDetailTabUi'
const tabLabels: Record<ActionPlanDetailTab, string> = {
const tabLabels: Record<ActivityDetailTab, string> = {
overview: 'Visão geral',
tasks: 'Tarefas',
updates: 'Atualizações',
}
export const ActionPlanTabNav = ({
export const ActivityTabNav = ({
activeTab,
planSlug,
activitySlug,
searchParams,
}: {
activeTab: ActionPlanDetailTab
planSlug: string
searchParams: ActionPlanDetailSearchParams
activeTab: ActivityDetailTab
activitySlug: string
searchParams: ActivityDetailSearchParams
}) => (
<nav
aria-label="Seções do plano"
aria-label="Seções da atividade"
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) => {
{activityDetailTabs.map((tab) => {
const isActive = tab === activeTab
return (
<li key={tab} className="m-0 h-full p-0">
<Link
href={buildActionPlanDetailTabHref(planSlug, tab, searchParams)}
href={buildActivityDetailTabHref(activitySlug, tab, searchParams)}
scroll={false}
aria-current={isActive ? 'page' : undefined}
className={cn(

View file

@ -3,17 +3,17 @@
import { useOptimistic, useTransition } from 'react'
import { toast } from 'sonner'
import { toggleActionPlanTaskAction } from '@/app/(campaign)/campanha/(app)/planos/[slug]/taskActions'
import { toggleActivityTaskAction } from '@/app/(campaign)/campanha/(app)/atividades/[slug]/taskActions'
import { Checkbox } from '@/components/ui/Checkbox'
import type { ActionPlanTaskViewModel } from '@/utilities/actionPlanViewModels'
import type { ActivityTaskViewModel } from '@/utilities/activityViewModels'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
type ActionPlanTaskChecklistProps = {
planId: number
tasks: ActionPlanTaskViewModel[]
type ActivityTaskChecklistProps = {
activityId: number
tasks: ActivityTaskViewModel[]
}
export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskChecklistProps) => {
export const ActivityTaskChecklist = ({ activityId, tasks }: ActivityTaskChecklistProps) => {
const [isPending, startTransition] = useTransition()
const [optimisticTasks, setOptimisticDone] = useOptimistic(
tasks,
@ -26,7 +26,7 @@ export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskCheckli
startTransition(async () => {
setOptimisticDone({ taskId, done })
const result = await toggleActionPlanTaskAction(planId, taskId, done)
const result = await toggleActivityTaskAction(activityId, taskId, done)
// On failure the optimistic state rolls back automatically when the
// transition ends (the action does not revalidate on error).
if (!result.ok) toast.error(result.message)

View file

@ -12,7 +12,7 @@ import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime'
export type ActionPlanTaskFieldValue = {
export type ActivityTaskFieldValue = {
key: string
title: string
responsible: ContactComboboxOption | null
@ -20,7 +20,7 @@ export type ActionPlanTaskFieldValue = {
done: boolean
}
type ActionPlanTaskFieldsProps = {
type ActivityTaskFieldsProps = {
initialTasks: Array<{
id: string | null
title: string
@ -38,7 +38,7 @@ const nextTaskKey = (): string => {
return `new-${taskKeySequence}`
}
const serializeTasks = (tasks: ActionPlanTaskFieldValue[]) =>
const serializeTasks = (tasks: ActivityTaskFieldValue[]) =>
JSON.stringify(
tasks
.filter((task) => task.title.trim().length > 0)
@ -50,13 +50,13 @@ const serializeTasks = (tasks: ActionPlanTaskFieldValue[]) =>
})),
)
export const ActionPlanTaskFields = ({
export const ActivityTaskFields = ({
initialTasks,
searchContacts,
error,
}: ActionPlanTaskFieldsProps) => {
}: ActivityTaskFieldsProps) => {
const fieldId = useId()
const [tasks, setTasks] = useState<ActionPlanTaskFieldValue[]>(() =>
const [tasks, setTasks] = useState<ActivityTaskFieldValue[]>(() =>
initialTasks.map((task) => ({
key: task.id ?? nextTaskKey(),
title: task.title,
@ -66,7 +66,7 @@ export const ActionPlanTaskFields = ({
})),
)
const updateTask = (key: string, patch: Partial<ActionPlanTaskFieldValue>) => {
const updateTask = (key: string, patch: Partial<ActivityTaskFieldValue>) => {
setTasks((current) => current.map((task) => (task.key === key ? { ...task, ...patch } : task)))
}

View file

@ -2,10 +2,10 @@ 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 type { ActionPlanUpdateViewModel } from '@/utilities/actionPlanViewModels'
import type { ActivityUpdateViewModel } from '@/utilities/activityViewModels'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
const UpdateCard = ({ update }: { update: ActionPlanUpdateViewModel }) => (
const UpdateCard = ({ update }: { update: ActivityUpdateViewModel }) => (
<article>
<Card>
<CardHeader>
@ -21,7 +21,7 @@ const UpdateCard = ({ update }: { update: ActionPlanUpdateViewModel }) => (
</article>
)
export const ActionPlanUpdateFeed = ({ updates }: { updates: ActionPlanUpdateViewModel[] }) => {
export const ActivityUpdateFeed = ({ updates }: { updates: ActivityUpdateViewModel[] }) => {
if (!updates.length) {
return (
<Empty className="border">
@ -31,7 +31,7 @@ export const ActionPlanUpdateFeed = ({ updates }: { updates: ActionPlanUpdateVie
</EmptyMedia>
<EmptyTitle>Nenhuma atualização registrada</EmptyTitle>
<EmptyDescription>
Envie o primeiro reporte para iniciar o histórico cronológico deste plano.
Envie o primeiro reporte para iniciar o histórico cronológico desta atividade.
</EmptyDescription>
</EmptyHeader>
</Empty>

View file

@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation'
import { useActionState, useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { createActionPlanUpdateFormAction } from '@/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions'
import { createActivityUpdateFormAction } from '@/app/(campaign)/campanha/(app)/atividades/[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'
@ -13,10 +13,10 @@ import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { fieldError } from '@/utilities/campaignFormFields'
export const ActionPlanUpdateForm = ({ planId }: { planId: number }) => {
export const ActivityUpdateForm = ({ activityId }: { activityId: number }) => {
const router = useRouter()
const formRef = useRef<HTMLFormElement>(null)
const [state, formAction, pending] = useActionState(createActionPlanUpdateFormAction, {})
const [state, formAction, pending] = useActionState(createActivityUpdateFormAction, {})
useEffect(() => {
if (state.status !== 'success') return
@ -34,7 +34,7 @@ export const ActionPlanUpdateForm = ({ planId }: { planId: number }) => {
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="flex flex-col gap-4">
<input type="hidden" name="planId" value={planId} />
<input type="hidden" name="activityId" value={activityId} />
{state.message && state.status !== 'success' ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível enviar</AlertTitle>

View file

@ -13,13 +13,13 @@ import { Textarea } from '@/components/ui/textarea'
import { campaignDemandKindLabels, campaignDemandKinds } from '@/lib/schemas/campaignDemand'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
import type { ActionPlanRelationOption } from '@/utilities/campaignRelationOptions'
import type { ActivityRelationOption } from '@/utilities/campaignRelationOptions'
type DemandFormProps = {
municipalityOptions: RelationOption[]
actionPlanOptions: ActionPlanRelationOption[]
activityOptions: ActivityRelationOption[]
initialMunicipalityId?: number
initialActionPlanId?: number
initialActivityId?: number
formAction: (
state: CampaignFormActionState,
formData: FormData,
@ -28,21 +28,19 @@ type DemandFormProps = {
export const DemandForm = ({
municipalityOptions,
actionPlanOptions,
activityOptions,
initialMunicipalityId,
initialActionPlanId,
initialActivityId,
formAction,
}: DemandFormProps) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
const [municipalityId, setMunicipalityId] = useState(
initialMunicipalityId ? String(initialMunicipalityId) : '',
)
const [actionPlanId, setActionPlanId] = useState(
initialActionPlanId ? String(initialActionPlanId) : '',
)
const visibleActionPlans = municipalityId
? actionPlanOptions.filter((plan) => String(plan.municipalityId) === municipalityId)
: actionPlanOptions
const [activityId, setActivityId] = useState(initialActivityId ? String(initialActivityId) : '')
const visibleActivities = municipalityId
? activityOptions.filter((activity) => String(activity.municipalityId) === municipalityId)
: activityOptions
return (
<form action={submitAction} className="flex max-w-2xl flex-col gap-4">
@ -86,11 +84,14 @@ export const DemandForm = ({
onChange={(event) => {
const nextMunicipalityId = event.target.value
setMunicipalityId(nextMunicipalityId)
const selectedPlan = actionPlanOptions.find(
(plan) => String(plan.id) === actionPlanId,
const selectedActivity = activityOptions.find(
(activity) => String(activity.id) === activityId,
)
if (selectedPlan && String(selectedPlan.municipalityId) !== nextMunicipalityId) {
setActionPlanId('')
if (
selectedActivity &&
String(selectedActivity.municipalityId) !== nextMunicipalityId
) {
setActivityId('')
}
}}
className="min-h-11 w-full"
@ -110,31 +111,34 @@ export const DemandForm = ({
</Field>
</div>
<Field>
<FieldLabel htmlFor="demand-action-plan">Plano de ação relacionado</FieldLabel>
<FieldLabel htmlFor="demand-activity">Atividade relacionada</FieldLabel>
<NativeSelect
id="demand-action-plan"
name="actionPlanId"
value={actionPlanId}
id="demand-activity"
name="activityId"
value={activityId}
onChange={(event) => {
const nextPlanId = event.target.value
setActionPlanId(nextPlanId)
const selectedPlan = actionPlanOptions.find((plan) => String(plan.id) === nextPlanId)
if (selectedPlan) setMunicipalityId(String(selectedPlan.municipalityId))
const nextActivityId = event.target.value
setActivityId(nextActivityId)
const selectedActivity = activityOptions.find(
(activity) => String(activity.id) === nextActivityId,
)
if (selectedActivity) setMunicipalityId(String(selectedActivity.municipalityId))
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="">Nenhum plano</NativeSelectOption>
{visibleActionPlans.map((option) => (
<NativeSelectOption value="">Nenhuma atividade</NativeSelectOption>
{visibleActivities.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.name}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>
Opcional. Ao escolher um plano, o município correspondente é preenchido automaticamente.
Opcional. Ao escolher uma atividade, o município correspondente é preenchido
automaticamente.
</FieldDescription>
{fieldError(state.fieldErrors, 'actionPlan') ? (
<FieldError>{fieldError(state.fieldErrors, 'actionPlan')}</FieldError>
{fieldError(state.fieldErrors, 'activity') ? (
<FieldError>{fieldError(state.fieldErrors, 'activity')}</FieldError>
) : null}
</Field>
<Field>

View file

@ -1,6 +1,6 @@
import Link from 'next/link'
import { ActionPlanStatusBadge } from '@/components/campaign/actionPlan/ActionPlanStatusBadge'
import { ActivityStatusBadge } from '@/components/campaign/activity/ActivityStatusBadge'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { MunicipalityBaselineCard } from '@/components/campaign/municipality/MunicipalityBaselineCard'
import { MunicipalityStrategyCard } from '@/components/campaign/municipality/MunicipalityStrategyCard'
@ -8,8 +8,8 @@ import { MunicipalityUpdateFeed } from '@/components/campaign/municipality/Munic
import { Badge } from '@/components/ui/Badge'
import type { MunicipalityAgeBandKey } from '@/lib/bahiaMunicipalityDemographics'
import { formatElectionNumber } from '@/lib/electionFormat'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import type { ActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
import { activityKindLabels } from '@/lib/schemas/activity'
import type { ActivityListViewModel } from '@/utilities/activityViewModels'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import {
formatGoalCoverageDeficitLabel,
@ -65,22 +65,22 @@ const SectionHeader = ({
</div>
)
const PlanListItem = ({ plan }: { plan: ActionPlanListViewModel }) => (
const ActivityListItem = ({ activity }: { activity: ActivityListViewModel }) => (
<li className="flex flex-col gap-1 rounded-lg bg-muted/40 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/campanha/planos/${plan.slug}`}
href={`/campanha/atividades/${activity.slug}`}
className="text-sm font-medium underline-offset-4 hover:underline"
>
{plan.title}
{activity.title}
</Link>
<Badge variant="secondary">{actionPlanKindLabels[plan.kind]}</Badge>
<ActionPlanStatusBadge status={plan.status} />
{plan.deputyPresent ? <Badge>Deputado presente</Badge> : null}
<Badge variant="secondary">{activityKindLabels[activity.kind]}</Badge>
<ActivityStatusBadge status={activity.status} />
{activity.deputyPresent ? <Badge>Deputado presente</Badge> : null}
</div>
<p className="text-xs text-muted-foreground">
{plan.startAt ? formatBahiaDateTimeLabel(plan.startAt) : 'Sem data definida'}
{plan.locality ? ` · ${plan.locality}` : ''}
{activity.startAt ? formatBahiaDateTimeLabel(activity.startAt) : 'Sem data definida'}
{activity.locality ? ` · ${activity.locality}` : ''}
</p>
</li>
)
@ -274,27 +274,27 @@ export const MunicipalityDossier = ({
<SectionHeader
id="dossier-agenda-title"
title="Agenda no município"
seeAllHref={`/campanha/planos?municipality=${view.id}`}
seeAllHref={`/campanha/atividades?municipality=${view.id}`}
seeAllLabel="Ver agenda completa"
/>
{data.upcomingPlans.length ? (
{data.upcomingActivities.length ? (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Próximos eventos</h3>
<ul className="flex flex-col gap-2">
{data.upcomingPlans.map((plan) => (
<PlanListItem key={plan.id} plan={plan} />
{data.upcomingActivities.map((activity) => (
<ActivityListItem key={activity.id} activity={activity} />
))}
</ul>
</div>
) : (
<p className="text-sm text-muted-foreground">Nenhum evento futuro planejado.</p>
)}
{data.recentPlans.length ? (
{data.recentActivities.length ? (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Últimas ações realizadas</h3>
<ul className="flex flex-col gap-2">
{data.recentPlans.map((plan) => (
<PlanListItem key={plan.id} plan={plan} />
{data.recentActivities.map((activity) => (
<ActivityListItem key={activity.id} activity={activity} />
))}
</ul>
</div>

View file

@ -26,7 +26,7 @@ const staffNav: CampaignNavItem[] = [
{ title: 'Municípios', href: '/campanha/municipios', icon: MapPinIcon },
{ title: 'Lideranças', href: '/campanha/liderancas', icon: HandshakeIcon },
{ title: 'Dobradinhas', href: '/campanha/dobradinhas', icon: Users2Icon },
{ title: 'Planos', href: '/campanha/planos', icon: CalendarDaysIcon },
{ title: 'Atividades', href: '/campanha/atividades', icon: CalendarDaysIcon },
{ title: 'Demandas', href: '/campanha/demandas', icon: InboxIcon },
{ title: 'Apoiadores', href: '/campanha/apoiadores', icon: UsersIcon },
{ title: 'Assessores', href: '/campanha/assessores', icon: UserCogIcon },

View file

@ -7,7 +7,7 @@ import {
trimmedOptionalText,
} from '@/lib/schemas/primitives'
export const actionPlanKinds = [
export const activityKinds = [
'caminhada',
'comicio',
'carreata',
@ -23,7 +23,7 @@ export const actionPlanKinds = [
'outro',
] as const
export const actionPlanStatuses = [
export const activityStatuses = [
'rascunho',
'planejado',
'confirmado',
@ -31,16 +31,16 @@ export const actionPlanStatuses = [
'cancelado',
] as const
export const actionPlanOrigins = ['dado', 'pedido_broker', 'obrigacao_politica'] as const
export type ActionPlanOrigin = (typeof actionPlanOrigins)[number]
export const activityOrigins = ['dado', 'pedido_broker', 'obrigacao_politica'] as const
export type ActivityOrigin = (typeof activityOrigins)[number]
export const actionPlanOriginLabels: Record<ActionPlanOrigin, string> = {
export const activityOriginLabels: Record<ActivityOrigin, string> = {
dado: 'Baseado em dado',
pedido_broker: 'Pedido de broker',
obrigacao_politica: 'Obrigação política',
}
export const actionPlanKindLabels: Record<(typeof actionPlanKinds)[number], string> = {
export const activityKindLabels: Record<(typeof activityKinds)[number], string> = {
caminhada: 'Caminhada',
comicio: 'Comício',
carreata: 'Carreata',
@ -56,7 +56,7 @@ export const actionPlanKindLabels: Record<(typeof actionPlanKinds)[number], stri
outro: 'Outro',
}
export const actionPlanStatusLabels: Record<(typeof actionPlanStatuses)[number], string> = {
export const activityStatusLabels: Record<(typeof activityStatuses)[number], string> = {
rascunho: 'Rascunho',
planejado: 'Planejado',
confirmado: 'Confirmado',
@ -64,20 +64,20 @@ export const actionPlanStatusLabels: Record<(typeof actionPlanStatuses)[number],
cancelado: 'Cancelado',
}
const MAX_ACTION_PLAN_ORGANIZATIONS = 20
export const MAX_ACTION_PLAN_DEMAND_DRAFTS = 20
const MAX_ACTIVITY_ORGANIZATIONS = 20
export const MAX_ACTIVITY_DEMAND_DRAFTS = 20
const actionPlanDemandDraftSchema = campaignDemandCreateSchema.pick({
const activityDemandDraftSchema = campaignDemandCreateSchema.pick({
title: true,
kind: true,
description: true,
})
export const actionPlanDemandDraftsSchema = z
.array(actionPlanDemandDraftSchema)
.max(MAX_ACTION_PLAN_DEMAND_DRAFTS)
export const activityDemandDraftsSchema = z
.array(activityDemandDraftSchema)
.max(MAX_ACTIVITY_DEMAND_DRAFTS)
export type ActionPlanDemandDraft = z.input<typeof actionPlanDemandDraftSchema>
export type ActivityDemandDraft = z.input<typeof activityDemandDraftSchema>
const taskSchema = z.object({
title: z.string().trim().min(1).max(200),
@ -86,10 +86,10 @@ const taskSchema = z.object({
done: z.boolean().optional(),
})
const actionPlanFieldsSchema = z.object({
const activityFieldsSchema = z.object({
title: z.string().trim().min(2).max(160),
kind: z.enum(actionPlanKinds),
status: z.enum(actionPlanStatuses).optional(),
kind: z.enum(activityKinds),
status: z.enum(activityStatuses).optional(),
description: trimmedOptionalText(4000),
deputyPresent: z.boolean().optional(),
startAt: z.string().datetime().optional().nullable(),
@ -97,17 +97,17 @@ const actionPlanFieldsSchema = z.object({
deadline: z.string().datetime().optional().nullable(),
municipality: positiveRelationshipId,
locality: trimmedOptionalText(160),
organizations: z.array(positiveRelationshipId).max(MAX_ACTION_PLAN_ORGANIZATIONS).optional(),
organizations: z.array(positiveRelationshipId).max(MAX_ACTIVITY_ORGANIZATIONS).optional(),
advisors: z.array(positiveRelationshipId).optional(),
responsible: positiveRelationshipId.optional(),
leadership: positiveRelationshipId.optional(),
tasks: z.array(taskSchema).optional(),
origin: z.enum(actionPlanOrigins).optional(),
origin: z.enum(activityOrigins).optional(),
})
const validateSchedule = (
data: {
status?: (typeof actionPlanStatuses)[number] | null
status?: (typeof activityStatuses)[number] | null
startAt?: string | null
endAt?: string | null
},
@ -118,7 +118,7 @@ const validateSchedule = (
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.',
message: 'Informe a data e horário de início ao planejar ou confirmar a atividade.',
path: ['startAt'],
})
}
@ -136,7 +136,7 @@ const validateSchedule = (
}
}
export const actionPlanCreateSchema = actionPlanFieldsSchema
export const activityCreateSchema = activityFieldsSchema
.extend({
status: z.enum(['rascunho', 'planejado']).default('rascunho'),
})
@ -150,7 +150,7 @@ export const actionPlanCreateSchema = actionPlanFieldsSchema
deadline: data.deadline ?? null,
}))
export const actionPlanUpdateSchema = actionPlanFieldsSchema
export const activityUpdateSchema = activityFieldsSchema
.partial()
.extend({
id: positiveRelationshipId,
@ -161,11 +161,11 @@ export const actionPlanUpdateSchema = actionPlanFieldsSchema
deadline: z.string().datetime().nullable().optional(),
responsible: positiveRelationshipId.nullable().optional(),
leadership: positiveRelationshipId.nullable().optional(),
status: z.enum(actionPlanStatuses).optional(),
status: z.enum(activityStatuses).optional(),
})
.superRefine((data, context) => {
validateSchedule(data, context, 'patch')
})
export type ActionPlanCreateInput = z.input<typeof actionPlanCreateSchema>
export type ActionPlanUpdateInput = z.input<typeof actionPlanUpdateSchema>
export type ActivityCreateInput = z.input<typeof activityCreateSchema>
export type ActivityUpdateInput = z.input<typeof activityUpdateSchema>

View file

@ -12,7 +12,7 @@ export const campaignDemandCreateSchema = z.object({
kind: z.enum(campaignDemandKinds),
description: trimmedOptionalText(4000),
municipality: positiveRelationshipId,
actionPlan: positiveRelationshipId.optional(),
activity: positiveRelationshipId.optional(),
/** Staff may record on behalf of a leadership; leaders get their own. */
leadership: positiveRelationshipId.optional(),
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,420 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Renames the `actionPlan` collection to `activity` ("Plano de Ação" → "Atividade")
* WITHOUT losing data: every schema object is renamed in place instead of being
* dropped and recreated. Written by hand because drizzle's generator would emit
* DROP/CREATE for the tables and cannot see the hand-written partial index
* `action_plan_upcoming_start_at_idx`.
*
* Renaming a table carries its row/array types along, so no `ALTER TYPE` is
* emitted for `action_plan*` / `_action_plan*`; indexes, constraints and
* sequences do NOT follow, hence the explicit lists below.
*
* Two index names in the lists are fossils from the Município remodel, which
* renamed the `plaza_id` columns but not their indexes: the database holds
* `action_plan_plaza_idx` and `campaign_demand_plaza_idx` while the drizzle
* snapshot expects `*_municipality_idx` — no migration ever created the aligned
* name, so the fossil is the only name any database can hold. The six remaining
* `%plaza%` fossils on other tables are aligned here too, so the database matches
* the snapshot in one pass. Every rename is guarded on the source object existing,
* so a partially converged environment still lands on the same state.
*/
const renameTables = sql`
DO $$
DECLARE
r record;
renamed int := 0;
BEGIN
FOR r IN SELECT * FROM (VALUES
('action_plan', 'activity'),
('action_plan_tasks', 'activity_tasks'),
('action_plan_updates', 'activity_updates'),
('action_plan_rels', 'activity_rels')
) AS t(old_name, new_name)
LOOP
IF EXISTS (
SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = r.old_name
) THEN
EXECUTE format('ALTER TABLE public.%I RENAME TO %I', r.old_name, r.new_name);
renamed := renamed + 1;
END IF;
END LOOP;
RAISE NOTICE 'activity rename: % tables', renamed;
END $$;
`
const renameEnums = sql`
DO $$
DECLARE
r record;
renamed int := 0;
BEGIN
FOR r IN SELECT * FROM (VALUES
('enum_action_plan_kind', 'enum_activity_kind'),
('enum_action_plan_status', 'enum_activity_status'),
('enum_action_plan_origin', 'enum_activity_origin')
) AS t(old_name, new_name)
LOOP
IF EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public' AND t.typname = r.old_name
) THEN
EXECUTE format('ALTER TYPE public.%I RENAME TO %I', r.old_name, r.new_name);
renamed := renamed + 1;
END IF;
END LOOP;
RAISE NOTICE 'activity rename: % enums', renamed;
END $$;
`
const renameColumns = sql`
DO $$
DECLARE
r record;
renamed int := 0;
BEGIN
FOR r IN SELECT * FROM (VALUES
('campaign_demand', 'action_plan_id', 'activity_id'),
('payload_locked_documents_rels', 'action_plan_id', 'activity_id')
) AS t(table_name, old_name, new_name)
LOOP
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = r.table_name
AND column_name = r.old_name
) THEN
EXECUTE format(
'ALTER TABLE public.%I RENAME COLUMN %I TO %I',
r.table_name, r.old_name, r.new_name
);
renamed := renamed + 1;
END IF;
END LOOP;
RAISE NOTICE 'activity rename: % columns', renamed;
END $$;
`
/**
* Includes the four primary keys — renaming a pkey index renames its constraint
* with it — and the `%plaza%` fossils described in the file header.
*/
const renameIndexes = sql`
DO $$
DECLARE
r record;
renamed int := 0;
BEGIN
FOR r IN SELECT * FROM (VALUES
('action_plan_pkey', 'activity_pkey'),
('action_plan_title_idx', 'activity_title_idx'),
('action_plan_slug_idx', 'activity_slug_idx'),
('action_plan_kind_idx', 'activity_kind_idx'),
('action_plan_status_idx', 'activity_status_idx'),
('action_plan_deputy_present_idx', 'activity_deputy_present_idx'),
('action_plan_start_at_idx', 'activity_start_at_idx'),
('action_plan_upcoming_start_at_idx', 'activity_upcoming_start_at_idx'),
('action_plan_plaza_idx', 'activity_municipality_idx'),
('action_plan_responsible_idx', 'activity_responsible_idx'),
('action_plan_leadership_idx', 'activity_leadership_idx'),
('action_plan_result_recorded_by_idx', 'activity_result_recorded_by_idx'),
('action_plan_created_by_idx', 'activity_created_by_idx'),
('action_plan_updated_at_idx', 'activity_updated_at_idx'),
('action_plan_created_at_idx', 'activity_created_at_idx'),
('action_plan_tasks_pkey', 'activity_tasks_pkey'),
('action_plan_tasks_order_idx', 'activity_tasks_order_idx'),
('action_plan_tasks_parent_id_idx', 'activity_tasks_parent_id_idx'),
('action_plan_tasks_responsible_idx', 'activity_tasks_responsible_idx'),
('action_plan_updates_pkey', 'activity_updates_pkey'),
('action_plan_updates_order_idx', 'activity_updates_order_idx'),
('action_plan_updates_parent_id_idx', 'activity_updates_parent_id_idx'),
('action_plan_updates_author_idx', 'activity_updates_author_idx'),
('action_plan_rels_pkey', 'activity_rels_pkey'),
('action_plan_rels_order_idx', 'activity_rels_order_idx'),
('action_plan_rels_parent_idx', 'activity_rels_parent_idx'),
('action_plan_rels_path_idx', 'activity_rels_path_idx'),
('action_plan_rels_organization_id_idx', 'activity_rels_organization_id_idx'),
('action_plan_rels_campaign_user_id_idx', 'activity_rels_campaign_user_id_idx'),
('action_plan_rels_media_id_idx', 'activity_rels_media_id_idx'),
('campaign_demand_action_plan_idx', 'campaign_demand_activity_idx'),
('payload_locked_documents_rels_action_plan_id_idx', 'payload_locked_documents_rels_activity_id_idx'),
('campaign_demand_plaza_idx', 'campaign_demand_municipality_idx'),
('leadership_rels_plaza_id_idx', 'leadership_rels_municipality_id_idx'),
('organization_rels_plaza_id_idx', 'organization_rels_municipality_id_idx'),
('supporter_plaza_idx', 'supporter_municipality_idx'),
('supporter_contact_plaza_nulls_not_distinct_idx', 'supporter_contact_municipality_nulls_not_distinct_idx'),
('vote_pledge_plaza_idx', 'vote_pledge_municipality_idx'),
('leadership_plaza_idx', 'leadership_municipality_idx')
) AS t(old_name, new_name)
LOOP
IF EXISTS (
SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = r.old_name
) AND NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = r.new_name
) THEN
EXECUTE format('ALTER INDEX public.%I RENAME TO %I', r.old_name, r.new_name);
renamed := renamed + 1;
END IF;
END LOOP;
RAISE NOTICE 'activity rename: % indexes', renamed;
END $$;
`
const renameForeignKeys = sql`
DO $$
DECLARE
r record;
renamed int := 0;
BEGIN
FOR r IN SELECT * FROM (VALUES
('activity', 'action_plan_responsible_id_contact_id_fk', 'activity_responsible_id_contact_id_fk'),
('activity', 'action_plan_leadership_id_leadership_id_fk', 'activity_leadership_id_leadership_id_fk'),
('activity', 'action_plan_result_recorded_by_id_campaign_user_id_fk', 'activity_result_recorded_by_id_campaign_user_id_fk'),
('activity', 'action_plan_created_by_id_campaign_user_id_fk', 'activity_created_by_id_campaign_user_id_fk'),
('activity_tasks', 'action_plan_tasks_responsible_id_contact_id_fk', 'activity_tasks_responsible_id_contact_id_fk'),
('activity_tasks', 'action_plan_tasks_parent_id_fk', 'activity_tasks_parent_id_fk'),
('activity_updates', 'action_plan_updates_author_id_campaign_user_id_fk', 'activity_updates_author_id_campaign_user_id_fk'),
('activity_updates', 'action_plan_updates_parent_id_fk', 'activity_updates_parent_id_fk'),
('activity_rels', 'action_plan_rels_parent_fk', 'activity_rels_parent_fk'),
('activity_rels', 'action_plan_rels_organization_fk', 'activity_rels_organization_fk'),
('activity_rels', 'action_plan_rels_campaign_user_fk', 'activity_rels_campaign_user_fk'),
('activity_rels', 'action_plan_rels_media_fk', 'activity_rels_media_fk'),
('campaign_demand', 'campaign_demand_action_plan_id_action_plan_id_fk', 'campaign_demand_activity_id_activity_id_fk'),
('payload_locked_documents_rels', 'payload_locked_documents_rels_action_plan_fk', 'payload_locked_documents_rels_activity_fk')
) AS t(table_name, old_name, new_name)
LOOP
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = r.old_name AND conrelid = to_regclass('public.' || quote_ident(r.table_name))
) THEN
EXECUTE format(
'ALTER TABLE public.%I RENAME CONSTRAINT %I TO %I',
r.table_name, r.old_name, r.new_name
);
renamed := renamed + 1;
END IF;
END LOOP;
RAISE NOTICE 'activity rename: % foreign keys', renamed;
END $$;
`
const renameSequences = sql`
DO $$
DECLARE
r record;
renamed int := 0;
BEGIN
FOR r IN SELECT * FROM (VALUES
('action_plan_id_seq', 'activity_id_seq'),
('action_plan_rels_id_seq', 'activity_rels_id_seq')
) AS t(old_name, new_name)
LOOP
IF EXISTS (
SELECT 1 FROM pg_sequences WHERE schemaname = 'public' AND sequencename = r.old_name
) THEN
EXECUTE format('ALTER SEQUENCE public.%I RENAME TO %I', r.old_name, r.new_name);
renamed := renamed + 1;
END IF;
END LOOP;
RAISE NOTICE 'activity rename: % sequences', renamed;
END $$;
`
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(renameTables)
await db.execute(renameEnums)
await db.execute(renameColumns)
await db.execute(renameIndexes)
await db.execute(renameForeignKeys)
await db.execute(renameSequences)
}
/**
* Symmetric, in reverse order — and deliberately restores the `%plaza%` index
* names, so `down()` lands on the state that actually existed instead of
* inventing a half-aligned one.
*/
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DO $$
DECLARE
r record;
BEGIN
FOR r IN SELECT * FROM (VALUES
('action_plan_id_seq', 'activity_id_seq'),
('action_plan_rels_id_seq', 'activity_rels_id_seq')
) AS t(old_name, new_name)
LOOP
IF EXISTS (
SELECT 1 FROM pg_sequences WHERE schemaname = 'public' AND sequencename = r.new_name
) THEN
EXECUTE format('ALTER SEQUENCE public.%I RENAME TO %I', r.new_name, r.old_name);
END IF;
END LOOP;
END $$;
`)
await db.execute(sql`
DO $$
DECLARE
r record;
BEGIN
FOR r IN SELECT * FROM (VALUES
('activity', 'action_plan_responsible_id_contact_id_fk', 'activity_responsible_id_contact_id_fk'),
('activity', 'action_plan_leadership_id_leadership_id_fk', 'activity_leadership_id_leadership_id_fk'),
('activity', 'action_plan_result_recorded_by_id_campaign_user_id_fk', 'activity_result_recorded_by_id_campaign_user_id_fk'),
('activity', 'action_plan_created_by_id_campaign_user_id_fk', 'activity_created_by_id_campaign_user_id_fk'),
('activity_tasks', 'action_plan_tasks_responsible_id_contact_id_fk', 'activity_tasks_responsible_id_contact_id_fk'),
('activity_tasks', 'action_plan_tasks_parent_id_fk', 'activity_tasks_parent_id_fk'),
('activity_updates', 'action_plan_updates_author_id_campaign_user_id_fk', 'activity_updates_author_id_campaign_user_id_fk'),
('activity_updates', 'action_plan_updates_parent_id_fk', 'activity_updates_parent_id_fk'),
('activity_rels', 'action_plan_rels_parent_fk', 'activity_rels_parent_fk'),
('activity_rels', 'action_plan_rels_organization_fk', 'activity_rels_organization_fk'),
('activity_rels', 'action_plan_rels_campaign_user_fk', 'activity_rels_campaign_user_fk'),
('activity_rels', 'action_plan_rels_media_fk', 'activity_rels_media_fk'),
('campaign_demand', 'campaign_demand_action_plan_id_action_plan_id_fk', 'campaign_demand_activity_id_activity_id_fk'),
('payload_locked_documents_rels', 'payload_locked_documents_rels_action_plan_fk', 'payload_locked_documents_rels_activity_fk')
) AS t(table_name, old_name, new_name)
LOOP
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = r.new_name AND conrelid = to_regclass('public.' || quote_ident(r.table_name))
) THEN
EXECUTE format(
'ALTER TABLE public.%I RENAME CONSTRAINT %I TO %I',
r.table_name, r.new_name, r.old_name
);
END IF;
END LOOP;
END $$;
`)
await db.execute(sql`
DO $$
DECLARE
r record;
BEGIN
FOR r IN SELECT * FROM (VALUES
('action_plan_pkey', 'activity_pkey'),
('action_plan_title_idx', 'activity_title_idx'),
('action_plan_slug_idx', 'activity_slug_idx'),
('action_plan_kind_idx', 'activity_kind_idx'),
('action_plan_status_idx', 'activity_status_idx'),
('action_plan_deputy_present_idx', 'activity_deputy_present_idx'),
('action_plan_start_at_idx', 'activity_start_at_idx'),
('action_plan_upcoming_start_at_idx', 'activity_upcoming_start_at_idx'),
('action_plan_plaza_idx', 'activity_municipality_idx'),
('action_plan_responsible_idx', 'activity_responsible_idx'),
('action_plan_leadership_idx', 'activity_leadership_idx'),
('action_plan_result_recorded_by_idx', 'activity_result_recorded_by_idx'),
('action_plan_created_by_idx', 'activity_created_by_idx'),
('action_plan_updated_at_idx', 'activity_updated_at_idx'),
('action_plan_created_at_idx', 'activity_created_at_idx'),
('action_plan_tasks_pkey', 'activity_tasks_pkey'),
('action_plan_tasks_order_idx', 'activity_tasks_order_idx'),
('action_plan_tasks_parent_id_idx', 'activity_tasks_parent_id_idx'),
('action_plan_tasks_responsible_idx', 'activity_tasks_responsible_idx'),
('action_plan_updates_pkey', 'activity_updates_pkey'),
('action_plan_updates_order_idx', 'activity_updates_order_idx'),
('action_plan_updates_parent_id_idx', 'activity_updates_parent_id_idx'),
('action_plan_updates_author_idx', 'activity_updates_author_idx'),
('action_plan_rels_pkey', 'activity_rels_pkey'),
('action_plan_rels_order_idx', 'activity_rels_order_idx'),
('action_plan_rels_parent_idx', 'activity_rels_parent_idx'),
('action_plan_rels_path_idx', 'activity_rels_path_idx'),
('action_plan_rels_organization_id_idx', 'activity_rels_organization_id_idx'),
('action_plan_rels_campaign_user_id_idx', 'activity_rels_campaign_user_id_idx'),
('action_plan_rels_media_id_idx', 'activity_rels_media_id_idx'),
('campaign_demand_action_plan_idx', 'campaign_demand_activity_idx'),
('payload_locked_documents_rels_action_plan_id_idx', 'payload_locked_documents_rels_activity_id_idx'),
('campaign_demand_plaza_idx', 'campaign_demand_municipality_idx'),
('leadership_rels_plaza_id_idx', 'leadership_rels_municipality_id_idx'),
('organization_rels_plaza_id_idx', 'organization_rels_municipality_id_idx'),
('supporter_plaza_idx', 'supporter_municipality_idx'),
('supporter_contact_plaza_nulls_not_distinct_idx', 'supporter_contact_municipality_nulls_not_distinct_idx'),
('vote_pledge_plaza_idx', 'vote_pledge_municipality_idx'),
('leadership_plaza_idx', 'leadership_municipality_idx')
) AS t(old_name, new_name)
LOOP
IF EXISTS (
SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = r.new_name
) AND NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = r.old_name
) THEN
EXECUTE format('ALTER INDEX public.%I RENAME TO %I', r.new_name, r.old_name);
END IF;
END LOOP;
END $$;
`)
await db.execute(sql`
DO $$
DECLARE
r record;
BEGIN
FOR r IN SELECT * FROM (VALUES
('campaign_demand', 'action_plan_id', 'activity_id'),
('payload_locked_documents_rels', 'action_plan_id', 'activity_id')
) AS t(table_name, old_name, new_name)
LOOP
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = r.table_name
AND column_name = r.new_name
) THEN
EXECUTE format(
'ALTER TABLE public.%I RENAME COLUMN %I TO %I',
r.table_name, r.new_name, r.old_name
);
END IF;
END LOOP;
END $$;
`)
await db.execute(sql`
DO $$
DECLARE
r record;
BEGIN
FOR r IN SELECT * FROM (VALUES
('enum_action_plan_kind', 'enum_activity_kind'),
('enum_action_plan_status', 'enum_activity_status'),
('enum_action_plan_origin', 'enum_activity_origin')
) AS t(old_name, new_name)
LOOP
IF EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public' AND t.typname = r.new_name
) THEN
EXECUTE format('ALTER TYPE public.%I RENAME TO %I', r.new_name, r.old_name);
END IF;
END LOOP;
END $$;
`)
await db.execute(sql`
DO $$
DECLARE
r record;
BEGIN
FOR r IN SELECT * FROM (VALUES
('action_plan', 'activity'),
('action_plan_tasks', 'activity_tasks'),
('action_plan_updates', 'activity_updates'),
('action_plan_rels', 'activity_rels')
) AS t(old_name, new_name)
LOOP
IF EXISTS (
SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = r.new_name
) THEN
EXECUTE format('ALTER TABLE public.%I RENAME TO %I', r.new_name, r.old_name);
END IF;
END LOOP;
END $$;
`)
}

View file

@ -29,6 +29,7 @@ import * as migration_20260724_180000_add_campaign_foundation_records from './20
import * as migration_20260724_180000_add_campaign_goals_global from './20260724_180000_add_campaign_goals_global'
import * as migration_20260725_022155_add_municipality_budget_notes from './20260725_022155_add_municipality_budget_notes'
import * as migration_20260725_170000_whatsapp_subscription_consent_key from './20260725_170000_whatsapp_subscription_consent_key'
import * as migration_20260725_213000_rename_action_plan_to_activity from './20260725_213000_rename_action_plan_to_activity'
export const migrations = [
{
@ -186,4 +187,9 @@ export const migrations = [
down: migration_20260725_170000_whatsapp_subscription_consent_key.down,
name: '20260725_170000_whatsapp_subscription_consent_key',
},
{
up: migration_20260725_213000_rename_action_plan_to_activity.up,
down: migration_20260725_213000_rename_action_plan_to_activity.down,
name: '20260725_213000_rename_action_plan_to_activity',
},
]

View file

@ -81,7 +81,7 @@ export interface Config {
supporter: Supporter;
supporterImportBatch: SupporterImportBatch;
municipalityUpdate: MunicipalityUpdate;
actionPlan: ActionPlan;
activity: Activity;
electionTally: ElectionTally;
electionCandidateVote: ElectionCandidateVote;
electionCandidate: ElectionCandidate;
@ -116,7 +116,7 @@ export interface Config {
supporter: SupporterSelect<false> | SupporterSelect<true>;
supporterImportBatch: SupporterImportBatchSelect<false> | SupporterImportBatchSelect<true>;
municipalityUpdate: MunicipalityUpdateSelect<false> | MunicipalityUpdateSelect<true>;
actionPlan: ActionPlanSelect<false> | ActionPlanSelect<true>;
activity: ActivitySelect<false> | ActivitySelect<true>;
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
electionCandidateVote: ElectionCandidateVoteSelect<false> | ElectionCandidateVoteSelect<true>;
electionCandidate: ElectionCandidateSelect<false> | ElectionCandidateSelect<true>;
@ -479,7 +479,7 @@ export interface StateDeputy {
createdAt: string;
}
/**
* Sindicatos, associações, movimentos e afins. Concentra lideranças associadas e Planos de Ação apoiados.
* Sindicatos, associações, movimentos e afins. Concentra lideranças associadas e atividades apoiadas.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "organization".
@ -597,7 +597,7 @@ export interface CampaignDemand {
| 'outro';
description?: string | null;
municipality: number | Municipality;
actionPlan?: (number | null) | ActionPlan;
activity?: (number | null) | Activity;
leadership?: (number | null) | Leadership;
status: 'aberta' | 'em_analise' | 'escalada' | 'aprovada' | 'rejeitada';
decisionNote?: string | null;
@ -626,9 +626,9 @@ export interface CampaignDemand {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "actionPlan".
* via the `definition` "activity".
*/
export interface ActionPlan {
export interface Activity {
id: number;
title: string;
slug: string;
@ -650,7 +650,7 @@ export interface ActionPlan {
description?: string | null;
origin?: ('dado' | 'pedido_broker' | 'obrigacao_politica') | null;
/**
* Marque quando o deputado Jorge Solla estiver presente na ação.
* Marque quando o deputado Jorge Solla estiver presente na atividade.
*/
deputyPresent?: boolean | null;
startAt?: string | null;
@ -1206,8 +1206,8 @@ export interface PayloadLockedDocument {
value: number | MunicipalityUpdate;
} | null)
| ({
relationTo: 'actionPlan';
value: number | ActionPlan;
relationTo: 'activity';
value: number | Activity;
} | null)
| ({
relationTo: 'electionTally';
@ -1516,7 +1516,7 @@ export interface CampaignDemandSelect<T extends boolean = true> {
kind?: T;
description?: T;
municipality?: T;
actionPlan?: T;
activity?: T;
leadership?: T;
status?: T;
decisionNote?: T;
@ -1592,9 +1592,9 @@ export interface MunicipalityUpdateSelect<T extends boolean = true> {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "actionPlan_select".
* via the `definition` "activity_select".
*/
export interface ActionPlanSelect<T extends boolean = true> {
export interface ActivitySelect<T extends boolean = true> {
title?: T;
slug?: T;
kind?: T;
@ -2193,7 +2193,7 @@ export interface TaskCreateCollectionExport {
| 'supporter'
| 'supporterImportBatch'
| 'municipalityUpdate'
| 'actionPlan'
| 'activity'
| 'electionTally'
| 'electionCandidateVote'
| 'electionCandidate'

View file

@ -9,7 +9,7 @@ import { resendAdapter } from '@payloadcms/email-resend'
import { importExportPlugin } from '@payloadcms/plugin-import-export'
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
import { pt } from 'payload/i18n/pt'
import { ActionPlan } from './collections/ActionPlan'
import { Activity } from './collections/Activity'
import { AllocationDecision } from './collections/AllocationDecision'
import { CampaignDemand } from './collections/CampaignDemand'
import { CampaignInvite } from './collections/CampaignInvite'
@ -96,7 +96,7 @@ export default buildConfig({
Supporter,
SupporterImportBatch,
MunicipalityUpdate,
ActionPlan,
Activity,
ElectionTally,
ElectionCandidateVote,
ElectionCandidate,

View file

@ -1,5 +1,5 @@
// ---------------------------------------------------------------------------
// Action plans
// Activities
// ---------------------------------------------------------------------------
import type { Access, FieldAccess, Where } from 'payload'
@ -14,15 +14,15 @@ import {
isPayloadAdmin,
} from '@/utilities/access/shared'
const canStaffCreateActionPlan: FieldAccess = async ({ req }) => {
const canStaffCreateActivity: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canCreateActionPlan: Access = canStaffCreateActionPlan
export const canCreateActivity: Access = canStaffCreateActivity
export const canReadActionPlan: Access = async ({ req }): Promise<boolean | Where> => {
export const canReadActivity: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
@ -52,17 +52,17 @@ export const canReadActionPlan: Access = async ({ req }): Promise<boolean | Wher
}
/** Same row scope as read — leader write limits are enforced in `beforeChange`. */
export const canUpdateActionPlan: Access = canReadActionPlan
export const canUpdateActivity: Access = canReadActivity
export const canDeleteActionPlan: Access = ({ req }) => isPayloadAdmin(req.user)
export const canDeleteActivity: Access = ({ req }) => isPayloadAdmin(req.user)
export const canSetActionPlanSystemField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
export const canSetActivitySystemField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
export const canSetActionPlanStatus: FieldAccess = canStaffCreateActionPlan
export const canSetActivityStatus: FieldAccess = canStaffCreateActivity
export const canCreateActionPlanAdvisors: FieldAccess = canStaffCreateActionPlan
export const canCreateActivityAdvisors: FieldAccess = canStaffCreateActivity
export const canManageActionPlanAdvisors: FieldAccess = async ({ req }) => {
export const canManageActivityAdvisors: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignCoordinator(await getFreshCampaignUser(req))

View file

@ -61,7 +61,7 @@ export const isCampaignStaff = (user: CampaignActor): boolean =>
isCampaignUser(user) && isStaffCampaignRole(user.role)
/**
* Eligible relationship targets for advisor assignments (municipality / action plan).
* Eligible relationship targets for advisor assignments (municipality / activity).
* The candidate is included: the projection sheet lists him as the responsible contact
* for some municipalities (decision 2026-07-24).
*/

View file

@ -1,26 +0,0 @@
import { createDetailTabHelpers, type DetailTabSearchParams } from '@/utilities/detailTabUi'
export const actionPlanDetailTabs = ['overview', 'tasks', 'updates'] as const
export type ActionPlanDetailTab = (typeof actionPlanDetailTabs)[number]
export type ActionPlanDetailSearchParams = DetailTabSearchParams
const helpers = createDetailTabHelpers<ActionPlanDetailTab>({
tabs: actionPlanDetailTabs,
defaultTab: 'overview',
tabQueryKeys: {
overview: [],
tasks: [],
updates: ['newUpdate'],
},
basePath: (planSlug) => `/campanha/planos/${planSlug}`,
forcedTab: (searchParams) =>
(Array.isArray(searchParams.newUpdate) ? searchParams.newUpdate[0] : searchParams.newUpdate) ===
'1'
? 'updates'
: null,
})
export const resolveActionPlanDetailTab = helpers.resolveTab
export const buildActionPlanDetailTabHref = helpers.buildTabHref
export const getActionPlanDetailTabRedirect = helpers.getTabRedirect

View file

@ -1,110 +0,0 @@
import 'server-only'
import type { Payload } from 'payload'
import type { ActionPlan, CampaignUser } from '@/payload-types'
import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi'
import {
actionPlanPageSize,
buildActionPlanListWhere,
parseActionPlanListParams,
} from '@/utilities/actionPlanUi'
import {
actionPlanFormSelect,
actionPlanListSelect,
getActionPlanDetailSelect,
toActionPlanFormViewModel,
} from '@/utilities/actionPlanViewModels'
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 getActionPlanDetailQueryDepth = (activeTab: ActionPlanDetailTab): number =>
activeTab === 'updates' ? 0 : 1
const loadAccessibleActionPlanBySlug = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
planSlug: string,
select: ReturnType<typeof getActionPlanDetailSelect> | typeof actionPlanFormSelect,
depth: number,
): Promise<ActionPlan> => {
const result = await payload.find({
collection: 'actionPlan',
where: { slug: { equals: planSlug } },
depth,
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,
activeTab: ActionPlanDetailTab = 'overview',
): Promise<AccessibleActionPlanContext> => {
const document = await loadAccessibleActionPlanBySlug(
payload,
user,
planSlug,
getActionPlanDetailSelect(activeTab),
getActionPlanDetailQueryDepth(activeTab),
)
return {
id: document.id,
slug: document.slug,
document,
}
}
export const getActionPlanEditPageData = async (
payload: Payload,
user: CampaignUser,
planSlug: string,
) =>
toActionPlanFormViewModel(
await loadAccessibleActionPlanBySlug(payload, user, planSlug, actionPlanFormSelect, 1),
)

View file

@ -2,18 +2,18 @@ import 'server-only'
import type { Payload } from 'payload'
import type { ActionPlan, CampaignDemand, CampaignUser } from '@/payload-types'
import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi'
import type { AccessibleActionPlanContext } from '@/utilities/actionPlanPageData'
import type { Activity, CampaignDemand, CampaignUser } from '@/payload-types'
import type { ActivityDetailTab } from '@/utilities/activityDetailTabUi'
import type { AccessibleActivityContext } from '@/utilities/activityPageData'
import {
actionPlanMunicipalitySummary,
toActionPlanDetailViewModel,
type ActionPlanDetailViewModel,
type ActionPlanMunicipalitySummary,
} from '@/utilities/actionPlanViewModels'
activityMunicipalitySummary,
toActivityDetailViewModel,
type ActivityDetailViewModel,
type ActivityMunicipalitySummary,
} from '@/utilities/activityViewModels'
import { relationshipId } from '@/utilities/relationship'
type ActionPlanDemandSummary = {
type ActivityDemandSummary = {
id: number
title: string
slug: string
@ -22,10 +22,10 @@ type ActionPlanDemandSummary = {
cost: number | null
}
const loadActionPlanUpdateAuthorNames = async (
const loadActivityUpdateAuthorNames = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
updates: NonNullable<ActionPlan['updates']>,
updates: NonNullable<Activity['updates']>,
): Promise<Map<number, string>> => {
const authorIds = [
...new Set(
@ -51,15 +51,15 @@ const loadActionPlanUpdateAuthorNames = async (
}
/**
* Display-name lookup for a plan's municipality when the document was loaded at
* depth 0 (updates tab). The actor already passed row access on the plan
* Display-name lookup for an activity's municipality when the document was loaded at
* depth 0 (updates tab). The actor already passed row access on the activity
* itself, so reading the municipality's name/slug privileged avoids a second
* per-role access round-trip (established display-name pattern).
*/
const loadActionPlanMunicipalitySummaryById = async (
const loadActivityMunicipalitySummaryById = async (
payload: Pick<Payload, 'find'>,
municipalityId: number,
): Promise<ActionPlanMunicipalitySummary | null> => {
): Promise<ActivityMunicipalitySummary | null> => {
const result = await payload.find({
collection: 'municipality',
where: { id: { equals: municipalityId } },
@ -75,35 +75,35 @@ const loadActionPlanMunicipalitySummaryById = async (
: null
}
export const getActionPlanDetailPageData = async (
export const getActivityDetailPageData = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
context: AccessibleActionPlanContext,
activeTab: ActionPlanDetailTab,
context: AccessibleActivityContext,
activeTab: ActivityDetailTab,
): Promise<
ActionPlanDetailViewModel & {
demands: ActionPlanDemandSummary[]
ActivityDetailViewModel & {
demands: ActivityDemandSummary[]
demandCostTotal: number
}
> => {
const municipalityId = relationshipId(context.document.municipality)
const populatedMunicipalitySummary =
actionPlanMunicipalitySummary(context.document.municipality) ?? null
activityMunicipalitySummary(context.document.municipality) ?? null
const municipalitySummaryPromise = populatedMunicipalitySummary
? Promise.resolve(populatedMunicipalitySummary)
: municipalityId
? loadActionPlanMunicipalitySummaryById(payload, municipalityId)
? loadActivityMunicipalitySummaryById(payload, municipalityId)
: Promise.resolve(null)
const authorNamesPromise =
activeTab === 'updates' && context.document.updates?.length
? loadActionPlanUpdateAuthorNames(payload, user, context.document.updates)
? loadActivityUpdateAuthorNames(payload, user, context.document.updates)
: Promise.resolve(new Map<number, string>())
const demandSummariesPromise: Promise<ActionPlanDemandSummary[]> =
const demandSummariesPromise: Promise<ActivityDemandSummary[]> =
activeTab === 'overview'
? payload
.find({
collection: 'campaignDemand',
where: { actionPlan: { equals: context.id } },
where: { activity: { equals: context.id } },
depth: 0,
pagination: false,
sort: 'createdAt',
@ -129,7 +129,7 @@ export const getActionPlanDetailPageData = async (
])
return {
...toActionPlanDetailViewModel(context.document, activeTab, authorNames, municipalitySummary),
...toActivityDetailViewModel(context.document, activeTab, authorNames, municipalitySummary),
demands,
demandCostTotal: demands.reduce((total, demand) => total + (demand.cost ?? 0), 0),
}

View file

@ -0,0 +1,26 @@
import { createDetailTabHelpers, type DetailTabSearchParams } from '@/utilities/detailTabUi'
export const activityDetailTabs = ['overview', 'tasks', 'updates'] as const
export type ActivityDetailTab = (typeof activityDetailTabs)[number]
export type ActivityDetailSearchParams = DetailTabSearchParams
const helpers = createDetailTabHelpers<ActivityDetailTab>({
tabs: activityDetailTabs,
defaultTab: 'overview',
tabQueryKeys: {
overview: [],
tasks: [],
updates: ['newUpdate'],
},
basePath: (activitySlug) => `/campanha/atividades/${activitySlug}`,
forcedTab: (searchParams) =>
(Array.isArray(searchParams.newUpdate) ? searchParams.newUpdate[0] : searchParams.newUpdate) ===
'1'
? 'updates'
: null,
})
export const resolveActivityDetailTab = helpers.resolveTab
export const buildActivityDetailTabHref = helpers.buildTabHref
export const getActivityDetailTabRedirect = helpers.getTabRedirect

View file

@ -10,18 +10,18 @@ import {
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
actionPlanDemandDraftsSchema,
actionPlanKinds,
actionPlanOrigins,
actionPlanStatuses,
MAX_ACTION_PLAN_DEMAND_DRAFTS,
type ActionPlanCreateInput,
type ActionPlanDemandDraft,
type ActionPlanUpdateInput,
} from '@/lib/schemas/actionPlan'
activityDemandDraftsSchema,
activityKinds,
activityOrigins,
activityStatuses,
MAX_ACTIVITY_DEMAND_DRAFTS,
type ActivityCreateInput,
type ActivityDemandDraft,
type ActivityUpdateInput,
} from '@/lib/schemas/activity'
import { parseBahiaDateTimeInput } from '@/utilities/campaignTime'
type ParsedActionPlanTask = {
type ParsedActivityTask = {
title: string
responsible?: number
due?: string
@ -36,7 +36,7 @@ const parseDateTimeFormField = (formData: FormData, field: string): string | und
return iso
}
const parseTasksFormData = (formData: FormData): ParsedActionPlanTask[] => {
const parseTasksFormData = (formData: FormData): ParsedActivityTask[] => {
const raw = boundedJsonFormValue(formData, 'tasksJson', 20_000)
if (raw === undefined) return []
if (!Array.isArray(raw)) {
@ -77,34 +77,34 @@ const parseTasksFormData = (formData: FormData): ParsedActionPlanTask[] => {
})
}
const parseDemandDraftsFormData = (formData: FormData): ActionPlanDemandDraft[] => {
const parseDemandDraftsFormData = (formData: FormData): ActivityDemandDraft[] => {
const raw = boundedJsonFormValue(formData, 'demandsJson', 80_000)
if (raw === undefined) return []
const parsed = actionPlanDemandDraftsSchema.safeParse(raw)
const parsed = activityDemandDraftsSchema.safeParse(raw)
if (parsed.success) return parsed.data
const demandIndex = parsed.error.issues[0]?.path[0]
if (typeof demandIndex === 'number') {
throw new FormDataBoundaryError('demandsJson', `Demanda ${demandIndex + 1} inválida.`)
}
if (!Array.isArray(raw) || raw.length > MAX_ACTION_PLAN_DEMAND_DRAFTS) {
if (!Array.isArray(raw) || raw.length > MAX_ACTIVITY_DEMAND_DRAFTS) {
throw new FormDataBoundaryError(
'demandsJson',
`Informe no máximo ${MAX_ACTION_PLAN_DEMAND_DRAFTS} demandas válidas.`,
`Informe no máximo ${MAX_ACTIVITY_DEMAND_DRAFTS} demandas válidas.`,
)
}
throw new FormDataBoundaryError('demandsJson', 'Lista de demandas inválida.')
}
const parseSharedActionPlanFormData = (formData: FormData) => {
const parseSharedActivityFormData = (formData: FormData) => {
const responsible = nullableRelationshipFormValue(formData, 'responsible')
const leadership = nullableRelationshipFormValue(formData, 'leadership')
return {
title: optionalFormText(formData, 'title') ?? '',
kind: optionalFormText(formData, 'kind') as (typeof actionPlanKinds)[number],
origin: optionalFormText(formData, 'origin') as (typeof actionPlanOrigins)[number] | undefined,
status: optionalFormText(formData, 'status') as (typeof actionPlanStatuses)[number] | undefined,
kind: optionalFormText(formData, 'kind') as (typeof activityKinds)[number],
origin: optionalFormText(formData, 'origin') as (typeof activityOrigins)[number] | undefined,
status: optionalFormText(formData, 'status') as (typeof activityStatuses)[number] | undefined,
description: optionalFormText(formData, 'description'),
deputyPresent: checkboxFormValue(formData, 'deputyPresent'),
startAt: parseDateTimeFormField(formData, 'startAt'),
@ -120,19 +120,17 @@ const parseSharedActionPlanFormData = (formData: FormData) => {
}
}
export type ParsedActionPlanCreateFormData = ActionPlanCreateInput & {
demands: ActionPlanDemandDraft[]
export type ParsedActivityCreateFormData = ActivityCreateInput & {
demands: ActivityDemandDraft[]
}
export const parseActionPlanCreateFormData = (
formData: FormData,
): ParsedActionPlanCreateFormData => {
const shared = parseSharedActionPlanFormData(formData)
export const parseActivityCreateFormData = (formData: FormData): ParsedActivityCreateFormData => {
const shared = parseSharedActivityFormData(formData)
const advisorIds = repeatedRelationshipFormValues(formData, 'advisors')
return {
...shared,
status: (shared.status ?? 'rascunho') as ActionPlanCreateInput['status'],
status: (shared.status ?? 'rascunho') as ActivityCreateInput['status'],
responsible: shared.responsible ?? undefined,
leadership: shared.leadership ?? undefined,
organizations: shared.organizations.length ? shared.organizations : undefined,
@ -140,14 +138,12 @@ export const parseActionPlanCreateFormData = (
}
}
export type ParsedActionPlanUpdateFormData = ActionPlanUpdateInput & {
demands: ActionPlanDemandDraft[]
export type ParsedActivityUpdateFormData = ActivityUpdateInput & {
demands: ActivityDemandDraft[]
}
export const parseActionPlanUpdateFormData = (
formData: FormData,
): ParsedActionPlanUpdateFormData => {
const shared = parseSharedActionPlanFormData(formData)
export const parseActivityUpdateFormData = (formData: FormData): ParsedActivityUpdateFormData => {
const shared = parseSharedActivityFormData(formData)
const advisorIds = repeatedRelationshipFormValues(formData, 'advisors')
// Checkboxes only submit when checked, so a hidden marker distinguishes
// "advisors section rendered but all unchecked" from "not editable" (advisor view).
@ -156,7 +152,7 @@ export const parseActionPlanUpdateFormData = (
return {
...shared,
id: requiredRelationshipFormValue(formData, 'id'),
status: shared.status as ActionPlanUpdateInput['status'],
status: shared.status as ActivityUpdateInput['status'],
locality: shared.locality ?? null,
description: shared.description ?? null,
startAt: shared.startAt ?? null,

View file

@ -6,7 +6,7 @@ import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/co
import type { CampaignUser, Contact, Leadership } from '@/payload-types'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
export type ActionPlanLeadershipOption = {
export type ActivityLeadershipOption = {
id: number
label: string
}
@ -21,14 +21,14 @@ const leadershipLabel = (leadership: Leadership): string | null => {
}
/**
* Engaged leaderships visible to the actor (for linking a plan to a leadership).
* Engaged leaderships visible to the actor (for linking an activity to a leadership).
* Uses overrideAccess: false so Payload row-level access applies.
*/
export const searchActionPlanLeadershipOptions = async (
export const searchActivityLeadershipOptions = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
query: string,
): Promise<ActionPlanLeadershipOption[]> => {
): Promise<ActivityLeadershipOption[]> => {
const normalizedQuery = typeof query === 'string' ? query : ''
if (!isContactSearchQueryReady(normalizedQuery)) return []
@ -65,6 +65,6 @@ export const searchActionPlanLeadershipOptions = async (
if (!id || !label) return null
return { id, label }
})
.filter((option): option is ActionPlanLeadershipOption => option !== null)
.filter((option): option is ActivityLeadershipOption => option !== null)
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
}

View file

@ -0,0 +1,110 @@
import 'server-only'
import type { Payload } from 'payload'
import type { Activity, CampaignUser } from '@/payload-types'
import type { ActivityDetailTab } from '@/utilities/activityDetailTabUi'
import {
activityPageSize,
buildActivityListWhere,
parseActivityListParams,
} from '@/utilities/activityUi'
import {
activityFormSelect,
activityListSelect,
getActivityDetailSelect,
toActivityFormViewModel,
} from '@/utilities/activityViewModels'
type ActivityListSearchParams = Record<string, string | string[] | undefined>
export class ActivityNotFoundError extends Error {
override name = 'ActivityNotFoundError'
constructor() {
super('Atividade não encontrada.')
}
}
export type AccessibleActivityContext = {
id: number
slug: string
document: Activity
}
export const loadActivityListPageData = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
searchParams: Promise<ActivityListSearchParams> | ActivityListSearchParams,
now = new Date(),
) => {
const state = parseActivityListParams(await searchParams)
const result = await payload.find({
collection: 'activity',
depth: 1,
limit: activityPageSize,
page: state.page,
sort: 'startAt',
where: buildActivityListWhere(state, now),
select: activityListSelect,
user,
overrideAccess: false,
})
return { result, state }
}
const getActivityDetailQueryDepth = (activeTab: ActivityDetailTab): number =>
activeTab === 'updates' ? 0 : 1
const loadAccessibleActivityBySlug = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
activitySlug: string,
select: ReturnType<typeof getActivityDetailSelect> | typeof activityFormSelect,
depth: number,
): Promise<Activity> => {
const result = await payload.find({
collection: 'activity',
where: { slug: { equals: activitySlug } },
depth,
limit: 1,
pagination: false,
select,
user,
overrideAccess: false,
})
const activity = result.docs[0]
if (!activity) throw new ActivityNotFoundError()
return activity as Activity
}
export const resolveAccessibleActivityContext = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
activitySlug: string,
activeTab: ActivityDetailTab = 'overview',
): Promise<AccessibleActivityContext> => {
const document = await loadAccessibleActivityBySlug(
payload,
user,
activitySlug,
getActivityDetailSelect(activeTab),
getActivityDetailQueryDepth(activeTab),
)
return {
id: document.id,
slug: document.slug,
document,
}
}
export const getActivityEditPageData = async (
payload: Payload,
user: CampaignUser,
activitySlug: string,
) =>
toActivityFormViewModel(
await loadAccessibleActivityBySlug(payload, user, activitySlug, activityFormSelect, 1),
)

View file

@ -1,6 +1,6 @@
import type { Where } from 'payload'
import { actionPlanKinds, actionPlanStatuses } from '@/lib/schemas/actionPlan'
import { activityKinds, activityStatuses } from '@/lib/schemas/activity'
import {
buildListHref,
firstValue,
@ -9,52 +9,52 @@ import {
type RawSearchParams as CampaignListRawSearchParams,
} from '@/utilities/campaignListUrl'
export const actionPlanPageSize = 25
export const activityPageSize = 25
export const actionPlanTabs = ['proximos', 'todos', 'realizados', 'rascunhos'] as const
export type ActionPlanTab = (typeof actionPlanTabs)[number]
export const activityTabs = ['proximos', 'todos', 'realizados', 'rascunhos'] as const
export type ActivityTab = (typeof activityTabs)[number]
type ActionPlanKind = (typeof actionPlanKinds)[number]
export type ActionPlanStatus = (typeof actionPlanStatuses)[number]
type ActivityKind = (typeof activityKinds)[number]
export type ActivityStatus = (typeof activityStatuses)[number]
export const actionPlanTabLabels: Record<ActionPlanTab, string> = {
export const activityTabLabels: Record<ActivityTab, string> = {
proximos: 'Próximos',
todos: 'Todos',
realizados: 'Realizados',
rascunhos: 'Rascunhos',
}
export type ActionPlanListState = {
export type ActivityListState = {
page: number
tab: ActionPlanTab
kind?: ActionPlanKind
status?: ActionPlanStatus
tab: ActivityTab
kind?: ActivityKind
status?: ActivityStatus
municipality?: number
}
type RawSearchParams = CampaignListRawSearchParams
const actionPlanListParamNames = ['tab', 'kind', 'status', 'municipality', 'page'] as const
const activityListParamNames = ['tab', 'kind', 'status', 'municipality', 'page'] as const
const actionPlanListParamNameSet = new Set<string>(actionPlanListParamNames)
const activityListParamNameSet = new Set<string>(activityListParamNames)
const isActionPlanTab = (value: string | undefined): value is ActionPlanTab =>
actionPlanTabs.includes(value as ActionPlanTab)
const isActivityTab = (value: string | undefined): value is ActivityTab =>
activityTabs.includes(value as ActivityTab)
const isActionPlanKind = (value: string | undefined): value is ActionPlanKind =>
actionPlanKinds.includes(value as ActionPlanKind)
const isActivityKind = (value: string | undefined): value is ActivityKind =>
activityKinds.includes(value as ActivityKind)
const isActionPlanStatus = (value: string | undefined): value is ActionPlanStatus =>
actionPlanStatuses.includes(value as ActionPlanStatus)
const isActivityStatus = (value: string | undefined): value is ActivityStatus =>
activityStatuses.includes(value as ActivityStatus)
export const parseActionPlanListParams = (params: RawSearchParams): ActionPlanListState => {
export const parseActivityListParams = (params: RawSearchParams): ActivityListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const rawTab = firstValue(params.tab)
const tab = isActionPlanTab(rawTab) ? rawTab : 'proximos'
const tab = isActivityTab(rawTab) ? rawTab : 'proximos'
const rawKind = firstValue(params.kind)
const kind = isActionPlanKind(rawKind) ? rawKind : undefined
const kind = isActivityKind(rawKind) ? rawKind : undefined
const rawStatus = tab === 'todos' ? firstValue(params.status) : undefined
const status = isActionPlanStatus(rawStatus) ? rawStatus : undefined
const status = isActivityStatus(rawStatus) ? rawStatus : undefined
const municipality = strictDecimalInteger(firstValue(params.municipality))
return {
@ -66,7 +66,7 @@ export const parseActionPlanListParams = (params: RawSearchParams): ActionPlanLi
}
}
export const buildActionPlanListWhere = (state: ActionPlanListState, now: Date): Where => {
export const buildActivityListWhere = (state: ActivityListState, now: Date): Where => {
const filters: Where[] = []
if (state.kind) filters.push({ kind: { equals: state.kind } })
@ -86,11 +86,11 @@ export const buildActionPlanListWhere = (state: ActionPlanListState, now: Date):
return filters.length ? { and: filters } : {}
}
export const buildActionPlanListSearchParams = (
state: ActionPlanListState,
export const buildActivityListSearchParams = (
state: ActivityListState,
page = state.page,
): URLSearchParams => {
const canonicalState = parseActionPlanListParams({
const canonicalState = parseActivityListParams({
page: String(page),
tab: state.tab,
kind: state.kind,
@ -108,25 +108,25 @@ export const buildActionPlanListSearchParams = (
return params
}
export const buildActionPlanFiltersKey = (state: ActionPlanListState): string =>
buildActionPlanListSearchParams(state).toString()
export const buildActivityFiltersKey = (state: ActivityListState): string =>
buildActivityListSearchParams(state).toString()
export const buildActionPlanListHref = (state: ActionPlanListState, page: number): string =>
buildListHref(state, buildActionPlanListSearchParams, '/campanha/planos', page)
export const buildActivityListHref = (state: ActivityListState, page: number): string =>
buildListHref(state, buildActivityListSearchParams, '/campanha/atividades', page)
export const resolveActionPlanListUrl = (
export const resolveActivityListUrl = (
params: RawSearchParams,
totalPages?: number,
): {
state: ActionPlanListState
state: ActivityListState
href: string
redirectHref?: string
} =>
resolveListUrl({
params,
paramNameSet: actionPlanListParamNameSet,
parse: parseActionPlanListParams,
buildSearchParams: buildActionPlanListSearchParams,
basePath: '/campanha/planos',
paramNameSet: activityListParamNameSet,
parse: parseActivityListParams,
buildSearchParams: buildActivityListSearchParams,
basePath: '/campanha/atividades',
totalPages,
})

View file

@ -1,23 +1,23 @@
import type { ActionPlanOrigin } from '@/lib/schemas/actionPlan'
import type { ActionPlan, CampaignUser, Contact, Municipality, Organization } from '@/payload-types'
import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi'
import type { ActivityOrigin } from '@/lib/schemas/activity'
import type { Activity, CampaignUser, Contact, Municipality, Organization } from '@/payload-types'
import type { ActivityDetailTab } from '@/utilities/activityDetailTabUi'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
export type ActionPlanMunicipalitySummary = {
export type ActivityMunicipalitySummary = {
id: number
name: string
slug: string
}
export const actionPlanMunicipalitySummary = (
municipality: ActionPlan['municipality'] | null | undefined,
): ActionPlanMunicipalitySummary | null =>
export const activityMunicipalitySummary = (
municipality: Activity['municipality'] | null | undefined,
): ActivityMunicipalitySummary | null =>
isPopulatedRelationship<Municipality>(municipality)
? { id: municipality.id, name: municipality.name, slug: municipality.slug }
: null
/** Short "where" line, e.g. "Itabuna · Feira do Malhado". */
const formatActionPlanLocationLabel = ({
const formatActivityLocationLabel = ({
municipalityName,
locality,
}: {
@ -25,7 +25,7 @@ const formatActionPlanLocationLabel = ({
locality?: string | null
}): string => [municipalityName, locality].filter(Boolean).join(' · ')
export const actionPlanListSelect = {
export const activityListSelect = {
title: true,
slug: true,
kind: true,
@ -40,12 +40,12 @@ export const actionPlanListSelect = {
taskTotal: true,
} as const
export type ActionPlanListViewModel = {
export type ActivityListViewModel = {
id: number
title: string
slug: string
kind: ActionPlan['kind']
status: ActionPlan['status']
kind: Activity['kind']
status: Activity['status']
deputyPresent: boolean
startAt: string | null
endAt: string | null
@ -61,29 +61,29 @@ const relationshipName = (
): string | null =>
isPopulatedRelationship<Contact | CampaignUser>(relationship) ? relationship.name : null
export const toActionPlanListViewModel = (plan: ActionPlan): ActionPlanListViewModel => {
const municipalityName = actionPlanMunicipalitySummary(plan.municipality)?.name ?? null
export const toActivityListViewModel = (activity: Activity): ActivityListViewModel => {
const municipalityName = activityMunicipalitySummary(activity.municipality)?.name ?? null
return {
id: plan.id,
title: plan.title,
slug: plan.slug,
kind: plan.kind,
status: plan.status,
deputyPresent: Boolean(plan.deputyPresent),
startAt: plan.startAt ?? null,
endAt: plan.endAt ?? null,
id: activity.id,
title: activity.title,
slug: activity.slug,
kind: activity.kind,
status: activity.status,
deputyPresent: Boolean(activity.deputyPresent),
startAt: activity.startAt ?? null,
endAt: activity.endAt ?? null,
municipalityName,
locality: plan.locality ?? null,
locationLabel: formatActionPlanLocationLabel({ municipalityName, locality: plan.locality }),
responsibleName: relationshipName(plan.responsible),
locality: activity.locality ?? null,
locationLabel: formatActivityLocationLabel({ municipalityName, locality: activity.locality }),
responsibleName: relationshipName(activity.responsible),
taskProgress: {
done: plan.taskDoneCount ?? 0,
total: plan.taskTotal ?? 0,
done: activity.taskDoneCount ?? 0,
total: activity.taskTotal ?? 0,
},
}
}
export const actionPlanFormSelect = {
export const activityFormSelect = {
title: true,
slug: true,
kind: true,
@ -103,7 +103,7 @@ export const actionPlanFormSelect = {
tasks: true,
} as const
type ActionPlanFormTaskViewModel = {
type ActivityFormTaskViewModel = {
id: string | null
title: string
responsible: { id: number; name: string; phone: string | null } | null
@ -111,13 +111,13 @@ type ActionPlanFormTaskViewModel = {
done: boolean
}
export type ActionPlanFormViewModel = {
export type ActivityFormViewModel = {
id: number
title: string
slug: string
kind: ActionPlan['kind']
origin: ActionPlanOrigin
status: ActionPlan['status']
kind: Activity['kind']
origin: ActivityOrigin
status: Activity['status']
description: string | null
deputyPresent: boolean
startAt: string | null
@ -129,40 +129,40 @@ export type ActionPlanFormViewModel = {
advisorIDs: number[]
responsible: { id: number; name: string; phone: string | null } | null
leadership: { id: number; label: string } | null
tasks: ActionPlanFormTaskViewModel[]
tasks: ActivityFormTaskViewModel[]
}
const relationshipIds = (value: unknown): number[] =>
(Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null)
export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewModel => ({
id: plan.id,
title: plan.title,
slug: plan.slug,
kind: plan.kind,
origin: plan.origin ?? 'dado',
status: plan.status,
description: plan.description ?? null,
deputyPresent: Boolean(plan.deputyPresent),
startAt: plan.startAt ?? null,
endAt: plan.endAt ?? null,
deadline: plan.deadline ?? null,
municipalityId: relationshipId(plan.municipality),
locality: plan.locality ?? null,
organizationIDs: relationshipIds(plan.organizations),
advisorIDs: relationshipIds(plan.advisors),
responsible: isPopulatedRelationship<Contact>(plan.responsible)
export const toActivityFormViewModel = (activity: Activity): ActivityFormViewModel => ({
id: activity.id,
title: activity.title,
slug: activity.slug,
kind: activity.kind,
origin: activity.origin ?? 'dado',
status: activity.status,
description: activity.description ?? null,
deputyPresent: Boolean(activity.deputyPresent),
startAt: activity.startAt ?? null,
endAt: activity.endAt ?? null,
deadline: activity.deadline ?? null,
municipalityId: relationshipId(activity.municipality),
locality: activity.locality ?? null,
organizationIDs: relationshipIds(activity.organizations),
advisorIDs: relationshipIds(activity.advisors),
responsible: isPopulatedRelationship<Contact>(activity.responsible)
? {
id: plan.responsible.id,
name: plan.responsible.name,
phone: plan.responsible.phone ?? null,
id: activity.responsible.id,
name: activity.responsible.name,
phone: activity.responsible.phone ?? null,
}
: null,
leadership: (() => {
const leadershipId = relationshipId(plan.leadership)
const leadershipId = relationshipId(activity.leadership)
if (!leadershipId) return null
if (isPopulatedRelationship(plan.leadership)) {
const contact = plan.leadership.contact
if (isPopulatedRelationship(activity.leadership)) {
const contact = activity.leadership.contact
const contactName = isPopulatedRelationship<Contact>(contact)
? contact.name
: `Liderança #${leadershipId}`
@ -170,7 +170,7 @@ export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewM
}
return { id: leadershipId, label: `Liderança #${leadershipId}` }
})(),
tasks: (plan.tasks ?? []).map((task) => ({
tasks: (activity.tasks ?? []).map((task) => ({
id: task.id ?? null,
title: task.title,
responsible: isPopulatedRelationship<Contact>(task.responsible)
@ -185,7 +185,7 @@ export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewM
})),
})
const actionPlanDetailContextSelect = {
const activityDetailContextSelect = {
title: true,
slug: true,
kind: true,
@ -206,10 +206,10 @@ const actionPlanDetailContextSelect = {
createdAt: true,
} as const
export const getActionPlanDetailSelect = (activeTab: ActionPlanDetailTab) => {
export const getActivityDetailSelect = (activeTab: ActivityDetailTab) => {
if (activeTab === 'overview') {
return {
...actionPlanDetailContextSelect,
...activityDetailContextSelect,
taskDoneCount: true,
taskTotal: true,
resultSummary: true,
@ -219,17 +219,17 @@ export const getActionPlanDetailSelect = (activeTab: ActionPlanDetailTab) => {
}
if (activeTab === 'tasks') {
return {
...actionPlanDetailContextSelect,
...activityDetailContextSelect,
tasks: true,
} as const
}
return {
...actionPlanDetailContextSelect,
...activityDetailContextSelect,
updates: true,
} as const
}
export type ActionPlanTaskViewModel = {
export type ActivityTaskViewModel = {
id: string | null
title: string
responsibleId: number | null
@ -239,32 +239,32 @@ export type ActionPlanTaskViewModel = {
doneAt: string | null
}
export type ActionPlanUpdateViewModel = {
export type ActivityUpdateViewModel = {
id: string | null
body: string
authorName: string | null
createdAt: string | null
}
type ActionPlanResultViewModel = {
type ActivityResultViewModel = {
summary: string
recordedByName: string | null
recordedAt: string | null
}
export type ActionPlanDetailViewModel = {
export type ActivityDetailViewModel = {
id: number
title: string
slug: string
kind: ActionPlan['kind']
origin: ActionPlanOrigin
status: ActionPlan['status']
kind: Activity['kind']
origin: ActivityOrigin
status: Activity['status']
description: string | null
deputyPresent: boolean
startAt: string | null
endAt: string | null
deadline: string | null
municipality: ActionPlanMunicipalitySummary | null
municipality: ActivityMunicipalitySummary | null
locality: string | null
locationLabel: string
organizations: Array<{ id: number; name: string }>
@ -272,16 +272,16 @@ export type ActionPlanDetailViewModel = {
responsibleId: number | null
responsibleName: string | null
taskProgress: { done: number; total: number }
tasks: ActionPlanTaskViewModel[]
updates: ActionPlanUpdateViewModel[]
result: ActionPlanResultViewModel | null
tasks: ActivityTaskViewModel[]
updates: ActivityUpdateViewModel[]
result: ActivityResultViewModel | null
createdByName: string | null
updatedAt: string
createdAt: string
}
const mapActionPlanTasks = (plan: ActionPlan): ActionPlanTaskViewModel[] =>
(plan.tasks ?? []).map((task) => ({
const mapActivityTasks = (activity: Activity): ActivityTaskViewModel[] =>
(activity.tasks ?? []).map((task) => ({
id: task.id ?? null,
title: task.title,
responsibleId: relationshipId(task.responsible),
@ -291,11 +291,11 @@ const mapActionPlanTasks = (plan: ActionPlan): ActionPlanTaskViewModel[] =>
doneAt: task.doneAt ?? null,
}))
const mapActionPlanUpdates = (
plan: ActionPlan,
const mapActivityUpdates = (
activity: Activity,
authorNamesById: ReadonlyMap<number, string> = new Map(),
): ActionPlanUpdateViewModel[] =>
(plan.updates ?? []).map((update) => {
): ActivityUpdateViewModel[] =>
(activity.updates ?? []).map((update) => {
const authorId = relationshipId(update.author)
const populatedName = relationshipName(update.author)
return {
@ -306,64 +306,64 @@ const mapActionPlanUpdates = (
}
})
const mapActionPlanResult = (plan: ActionPlan): ActionPlanResultViewModel | null => {
const summary = plan.resultSummary?.trim()
const mapActivityResult = (activity: Activity): ActivityResultViewModel | null => {
const summary = activity.resultSummary?.trim()
if (!summary) return null
return {
summary,
recordedByName: relationshipName(plan.resultRecordedBy),
recordedAt: plan.resultRecordedAt ?? null,
recordedByName: relationshipName(activity.resultRecordedBy),
recordedAt: activity.resultRecordedAt ?? null,
}
}
export const toActionPlanDetailViewModel = (
plan: ActionPlan,
activeTab: ActionPlanDetailTab = 'overview',
export const toActivityDetailViewModel = (
activity: Activity,
activeTab: ActivityDetailTab = 'overview',
authorNamesById: ReadonlyMap<number, string> = new Map(),
municipalitySummary: ActionPlanMunicipalitySummary | null = null,
): ActionPlanDetailViewModel => {
const municipality = municipalitySummary ?? actionPlanMunicipalitySummary(plan.municipality)
municipalitySummary: ActivityMunicipalitySummary | null = null,
): ActivityDetailViewModel => {
const municipality = municipalitySummary ?? activityMunicipalitySummary(activity.municipality)
return {
id: plan.id,
title: plan.title,
slug: plan.slug,
kind: plan.kind,
origin: plan.origin ?? 'dado',
status: plan.status,
description: plan.description ?? null,
deputyPresent: Boolean(plan.deputyPresent),
startAt: plan.startAt ?? null,
endAt: plan.endAt ?? null,
deadline: plan.deadline ?? null,
id: activity.id,
title: activity.title,
slug: activity.slug,
kind: activity.kind,
origin: activity.origin ?? 'dado',
status: activity.status,
description: activity.description ?? null,
deputyPresent: Boolean(activity.deputyPresent),
startAt: activity.startAt ?? null,
endAt: activity.endAt ?? null,
deadline: activity.deadline ?? null,
municipality,
locality: plan.locality ?? null,
locationLabel: formatActionPlanLocationLabel({
locality: activity.locality ?? null,
locationLabel: formatActivityLocationLabel({
municipalityName: municipality?.name ?? null,
locality: plan.locality,
locality: activity.locality,
}),
organizations:
plan.organizations
activity.organizations
?.filter((organization): organization is Organization =>
isPopulatedRelationship<Organization>(organization),
)
.map(({ id, name }) => ({ id, name })) ?? [],
advisors:
plan.advisors
activity.advisors
?.filter((advisor): advisor is CampaignUser =>
isPopulatedRelationship<CampaignUser>(advisor),
)
.map(({ id, name }) => ({ id, name })) ?? [],
responsibleId: relationshipId(plan.responsible),
responsibleName: relationshipName(plan.responsible),
responsibleId: relationshipId(activity.responsible),
responsibleName: relationshipName(activity.responsible),
taskProgress: {
done: plan.taskDoneCount ?? 0,
total: plan.taskTotal ?? 0,
done: activity.taskDoneCount ?? 0,
total: activity.taskTotal ?? 0,
},
tasks: activeTab === 'tasks' ? mapActionPlanTasks(plan) : [],
updates: activeTab === 'updates' ? mapActionPlanUpdates(plan, authorNamesById) : [],
result: mapActionPlanResult(plan),
createdByName: relationshipName(plan.createdBy),
updatedAt: plan.updatedAt,
createdAt: plan.createdAt,
tasks: activeTab === 'tasks' ? mapActivityTasks(activity) : [],
updates: activeTab === 'updates' ? mapActivityUpdates(activity, authorNamesById) : [],
result: mapActivityResult(activity),
createdByName: relationshipName(activity.createdBy),
updatedAt: activity.updatedAt,
createdAt: activity.createdAt,
}
}

View file

@ -102,15 +102,15 @@ export {
} from '@/utilities/access/demands'
export {
canCreateActionPlan,
canCreateActionPlanAdvisors,
canDeleteActionPlan,
canManageActionPlanAdvisors,
canReadActionPlan,
canSetActionPlanStatus,
canSetActionPlanSystemField,
canUpdateActionPlan,
} from '@/utilities/access/actionPlans'
canCreateActivity,
canCreateActivityAdvisors,
canDeleteActivity,
canManageActivityAdvisors,
canReadActivity,
canSetActivityStatus,
canSetActivitySystemField,
canUpdateActivity,
} from '@/utilities/access/activities'
export {
assertCanReadElectionData,

View file

@ -4,7 +4,7 @@ import type { Payload } from 'payload'
import type { CampaignDemandKind, CampaignDemandStatus } from '@/lib/schemas/campaignDemand'
import { campaignDemandKinds, campaignDemandStatuses } from '@/lib/schemas/campaignDemand'
import type { ActionPlan, CampaignDemand, CampaignUser } from '@/payload-types'
import type { Activity, CampaignDemand, CampaignUser } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import {
buildListHref,
@ -214,7 +214,7 @@ export type DemandDetailViewModel = DemandRowViewModel & {
createdAt: string | null
}>
canLeaderEdit: boolean
actionPlan: { title: string; slug: string } | null
activity: { title: string; slug: string } | null
}
export const loadDemandDetail = async (
@ -287,8 +287,8 @@ export const loadDemandDetail = async (
}))
: [],
canLeaderEdit: user.role === 'leader' && demand.status === 'aberta',
actionPlan: isPopulatedRelationship<ActionPlan>(demand.actionPlan)
? { title: demand.actionPlan.title, slug: demand.actionPlan.slug }
activity: isPopulatedRelationship<Activity>(demand.activity)
? { title: demand.activity.title, slug: demand.activity.slug }
: null,
}
}

View file

@ -57,7 +57,7 @@ export const mapCampaignFormActionError = <Values>(
* success state (`message` plus extras like created ids). Pre-parsed `values`
* and `revision` are echoed on failure so forms can repopulate.
*
* Documented exceptions that deliberately stay hand-rolled: the planos
* Documented exceptions that deliberately stay hand-rolled: the atividades
* ladders (custom unique-violation mapping + async duplicate-title fallback)
* and apoiadores/[id] (flattens field errors into message-only states for
* its inline controls).

View file

@ -6,7 +6,7 @@ import type { RelationOption } from '@/components/campaign/shared/RelationMultiS
import type { CampaignUser } from '@/payload-types'
import { relationshipId } from '@/utilities/relationship'
export type ActionPlanRelationOption = RelationOption & {
export type ActivityRelationOption = RelationOption & {
municipalityId: number
}
@ -47,12 +47,12 @@ export const loadOrganizationOptions = async (
return result.docs.map((organization) => ({ id: organization.id, name: organization.name }))
}
export const loadActionPlanOptions = async (
export const loadActivityOptions = async (
payload: Payload,
user: CampaignUser,
): Promise<ActionPlanRelationOption[]> => {
): Promise<ActivityRelationOption[]> => {
const result = await payload.find({
collection: 'actionPlan',
collection: 'activity',
depth: 0,
limit: 0,
pagination: false,
@ -63,9 +63,9 @@ export const loadActionPlanOptions = async (
overrideAccess: false,
})
return result.docs.flatMap((plan) => {
const municipalityId = relationshipId(plan.municipality)
return municipalityId ? [{ id: plan.id, name: plan.title, municipalityId }] : []
return result.docs.flatMap((activity) => {
const municipalityId = relationshipId(activity.municipality)
return municipalityId ? [{ id: activity.id, name: activity.title, municipalityId }] : []
})
}

View file

@ -1,6 +1,6 @@
/**
* Shared factory behind the detail-page tab helpers (município, plano de
* ação): querystring-tab resolution with per-tab preserved params and
* Shared factory behind the detail-page tab helpers (município, atividade):
* querystring-tab resolution with per-tab preserved params and
* canonical hrefs. Each detail page declares its config in its own
* `*DetailTabUi.ts` module and re-exports the generated helpers.
*/

View file

@ -6,13 +6,13 @@ import {
demographicsForCode,
type MunicipalityDemographics,
} from '@/lib/bahiaMunicipalityDemographics'
import type { ActionPlan, CampaignUser } from '@/payload-types'
import { buildActionPlanListWhere } from '@/utilities/actionPlanUi'
import type { Activity, CampaignUser } from '@/payload-types'
import { buildActivityListWhere } from '@/utilities/activityUi'
import {
actionPlanListSelect,
toActionPlanListViewModel,
type ActionPlanListViewModel,
} from '@/utilities/actionPlanViewModels'
activityListSelect,
toActivityListViewModel,
type ActivityListViewModel,
} from '@/utilities/activityViewModels'
import {
loadFreshestMunicipalityLeaderships,
type LeadershipRowViewModel,
@ -43,8 +43,8 @@ import {
*/
export const DOSSIER_LEADERSHIP_LIMIT = 8
export const DOSSIER_SIGNAL_LIMIT = 5
const DOSSIER_UPCOMING_PLAN_LIMIT = 3
const DOSSIER_RECENT_PLAN_LIMIT = 2
const DOSSIER_UPCOMING_ACTIVITY_LIMIT = 3
const DOSSIER_RECENT_ACTIVITY_LIMIT = 2
export type MunicipalityDossierData = {
baseline: MunicipalityElectoralBaseline | null
@ -52,38 +52,38 @@ export type MunicipalityDossierData = {
pledgeAggregate: MunicipalityPledgeAggregate
leaderships: { rows: LeadershipRowViewModel[]; totalCount: number }
signals: { rows: MunicipalityUpdateViewModel[]; totalCount: number }
upcomingPlans: ActionPlanListViewModel[]
recentPlans: ActionPlanListViewModel[]
upcomingActivities: ActivityListViewModel[]
recentActivities: ActivityListViewModel[]
demographics: MunicipalityDemographics | null
}
const loadDossierPlans = async (
const loadDossierActivities = async (
payload: Payload,
user: CampaignUser,
municipalityID: number,
): Promise<{ upcoming: ActionPlanListViewModel[]; recent: ActionPlanListViewModel[] }> => {
): Promise<{ upcoming: ActivityListViewModel[]; recent: ActivityListViewModel[] }> => {
const now = new Date()
const findPlans = (tab: 'proximos' | 'realizados', limit: number, sort: string) =>
const findActivities = (tab: 'proximos' | 'realizados', limit: number, sort: string) =>
payload.find({
collection: 'actionPlan',
where: buildActionPlanListWhere({ page: 1, tab, municipality: municipalityID }, now),
collection: 'activity',
where: buildActivityListWhere({ page: 1, tab, municipality: municipalityID }, now),
depth: 0,
limit,
pagination: false,
sort,
select: actionPlanListSelect,
select: activityListSelect,
user,
overrideAccess: false,
})
const [upcoming, recent] = await Promise.all([
findPlans('proximos', DOSSIER_UPCOMING_PLAN_LIMIT, 'startAt'),
findPlans('realizados', DOSSIER_RECENT_PLAN_LIMIT, '-startAt'),
findActivities('proximos', DOSSIER_UPCOMING_ACTIVITY_LIMIT, 'startAt'),
findActivities('realizados', DOSSIER_RECENT_ACTIVITY_LIMIT, '-startAt'),
])
return {
upcoming: upcoming.docs.map((plan) => toActionPlanListViewModel(plan as ActionPlan)),
recent: recent.docs.map((plan) => toActionPlanListViewModel(plan as ActionPlan)),
upcoming: upcoming.docs.map((activity) => toActivityListViewModel(activity as Activity)),
recent: recent.docs.map((activity) => toActivityListViewModel(activity as Activity)),
}
}
@ -98,7 +98,7 @@ export const loadMunicipalityDossierData = async (
): Promise<MunicipalityDossierData> => {
const geography = municipalityElectionGeographyForSlug(view.slug)
const [baseline, pledgesAndGoal, leaderships, feed, plans] = await Promise.all([
const [baseline, pledgesAndGoal, leaderships, feed, activities] = await Promise.all([
geography ? loadMunicipalityElectoralBaseline(user, geography) : null,
loadMunicipalityPledges(payload, user, view.id).then(async (pledges) => {
const pledgeAggregate = aggregateMunicipalityPledgesFromRows(pledges)
@ -114,7 +114,7 @@ export const loadMunicipalityDossierData = async (
}),
loadFreshestMunicipalityLeaderships(payload, user, view.id, DOSSIER_LEADERSHIP_LIMIT),
loadMunicipalityUpdatesFeed(payload, user, view.id, { page: 1 }),
loadDossierPlans(payload, user, view.id),
loadDossierActivities(payload, user, view.id),
])
return {
@ -123,8 +123,8 @@ export const loadMunicipalityDossierData = async (
pledgeAggregate: pledgesAndGoal.pledgeAggregate,
leaderships,
signals: { rows: feed.updates.slice(0, DOSSIER_SIGNAL_LIMIT), totalCount: feed.totalDocs },
upcomingPlans: plans.upcoming,
recentPlans: plans.recent,
upcomingActivities: activities.upcoming,
recentActivities: activities.recent,
demographics: demographicsForCode(view.ibgeCode) ?? null,
}
}

View file

@ -5,6 +5,7 @@ import type { Payload } from 'payload'
import type { OrganizationKind } from '@/lib/schemas/organization'
import { organizationKinds } from '@/lib/schemas/organization'
import type { CampaignUser, Organization } from '@/payload-types'
import type { ActivityStatus } from '@/utilities/activityUi'
import {
buildListHref,
firstValue,
@ -156,11 +157,11 @@ export type OrganizationDetailViewModel = {
municipalityIDs: number[]
municipalityNames: string[]
leaderships: Array<{ id: number; name: string }>
actionPlans: Array<{
activities: Array<{
id: number
title: string
slug: string
status: string
status: ActivityStatus
startAt: string | null
deputyPresent: boolean
}>
@ -188,7 +189,7 @@ export const loadOrganizationDetail = async (
.filter((id): id is number => id !== null)
const municipalityNames = await municipalityNamesByIds(payload, municipalityIDs)
const [leaderships, actionPlans] = await Promise.all([
const [leaderships, activities] = await Promise.all([
payload.find({
collection: 'leadership',
where: { organizations: { in: [organization.id] } },
@ -199,7 +200,7 @@ export const loadOrganizationDetail = async (
overrideAccess: false,
}),
payload.find({
collection: 'actionPlan',
collection: 'activity',
where: { organizations: { in: [organization.id] } },
depth: 0,
limit: 20,
@ -235,13 +236,13 @@ export const loadOrganizationDetail = async (
: 'Contato',
}
}),
actionPlans: actionPlans.docs.map((plan) => ({
id: plan.id,
title: plan.title,
slug: plan.slug,
status: String(plan.status),
startAt: plan.startAt ?? null,
deputyPresent: Boolean(plan.deputyPresent),
activities: activities.docs.map((activity) => ({
id: activity.id,
title: activity.title,
slug: activity.slug,
status: activity.status,
startAt: activity.startAt ?? null,
deputyPresent: Boolean(activity.deputyPresent),
})),
}
}

View file

@ -1,6 +1,6 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
test.describe('Planos de ação — registro-fundação', () => {
test.describe('Atividades — registro-fundação', () => {
test.setTimeout(90_000)
test('registra origem, cria demandas vinculadas e exibe a explicação dos sinais', async ({
@ -20,16 +20,16 @@ test.describe('Planos de ação — registro-fundação', () => {
depth: 0,
})
const municipality = await fixtures.claimMunicipality()
const planTitle = fixtures.value('Giro territorial')
const activityTitle = fixtures.value('Giro territorial')
const transportDemand = fixtures.value('Transporte para equipe')
const materialDemand = fixtures.value('Material de rua')
await campaign.login(page, coordinator.email!, password)
await page.goto(`${campaign.baseURL}/campanha/planos/novo`)
await page.goto(`${campaign.baseURL}/campanha/atividades/nova`)
await page.getByLabel('Título *').fill(planTitle)
await page.getByLabel('Tipo de ação *').selectOption('caminhada')
await page.getByLabel('Origem da ação').selectOption('obrigacao_politica')
await page.getByLabel('Título *').fill(activityTitle)
await page.getByLabel('Tipo de atividade *').selectOption('caminhada')
await page.getByLabel('Origem da atividade').selectOption('obrigacao_politica')
await page.getByLabel('Município *').selectOption(String(municipality.id))
await page.getByRole('button', { name: 'Adicionar demanda' }).click()
@ -39,15 +39,17 @@ test.describe('Planos de ação — registro-fundação', () => {
await page.getByLabel('Título da demanda 2').fill(materialDemand)
await page.getByLabel('Tipo da demanda 2').selectOption('material')
await page.getByRole('button', { name: 'Criar plano' }).click()
await expect(page).toHaveURL(/\/campanha\/planos\/[^/?]+$/)
await expect(page.getByRole('heading', { name: planTitle })).toBeVisible({ timeout: 15_000 })
await page.getByRole('button', { name: 'Criar atividade' }).click()
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/)
await expect(page.getByRole('heading', { name: activityTitle })).toBeVisible({
timeout: 15_000,
})
await expect(page.getByText('Obrigação política', { exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: transportDemand })).toBeVisible()
await expect(page.getByRole('link', { name: materialDemand })).toBeVisible()
await page.getByRole('link', { name: 'Adicionar demanda' }).click()
await expect(page.getByLabel('Plano de ação relacionado')).toHaveValue(/\d+/)
await expect(page.getByLabel('Atividade relacionada')).toHaveValue(/\d+/)
await expect(page.getByLabel('Município')).toHaveValue(String(municipality.id))
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=updates`)

View file

@ -23,7 +23,7 @@ type OwnedCollection =
| 'campaignDemand'
| 'allocationDecision'
| 'municipalityUpdate'
| 'actionPlan'
| 'activity'
| 'campaignInvite'
| 'consent'
| 'supporter'
@ -34,7 +34,7 @@ const deletionOrder: OwnedCollection[] = [
'campaignDemand',
'allocationDecision',
'municipalityUpdate',
'actionPlan',
'activity',
'leadership',
'supporter',
'organization',
@ -133,8 +133,8 @@ class CampaignE2EOwnership {
}
private async discoverOwnedRows(): Promise<void> {
const [users, campaignUsers, contacts, organizations, actionPlans, consents] =
await Promise.all([
const [users, campaignUsers, contacts, organizations, activities, consents] = await Promise.all(
[
this.rootPayload.find({
collection: 'users',
where: { email: { contains: this.runID } },
@ -170,7 +170,7 @@ class CampaignE2EOwnership {
pagination: false,
}),
this.rootPayload.find({
collection: 'actionPlan',
collection: 'activity',
where: {
or: [{ title: { contains: this.runID } }, { slug: { contains: this.runID } }],
},
@ -183,12 +183,13 @@ class CampaignE2EOwnership {
depth: 0,
pagination: false,
}),
])
],
)
for (const user of users.docs) this.own('users', user.id)
for (const user of campaignUsers.docs) this.own('campaignUser', user.id)
for (const contact of contacts.docs) this.own('contact', contact.id)
for (const organization of organizations.docs) this.own('organization', organization.id)
for (const actionPlan of actionPlans.docs) this.own('actionPlan', actionPlan.id)
for (const activity of activities.docs) this.own('activity', activity.id)
for (const consent of consents.docs) this.own('consent', consent.id)
const userIDs = this.ids('campaignUser')

View file

@ -35,7 +35,7 @@ type CampaignCollection =
| 'votePledge'
| 'campaignDemand'
| 'allocationDecision'
| 'actionPlan'
| 'activity'
| 'leadership'
| 'supporter'
| 'organization'
@ -110,7 +110,7 @@ const emptyOwnedIDs = (): OwnedIDs => ({
votePledge: new Set(),
campaignDemand: new Set(),
allocationDecision: new Set(),
actionPlan: new Set(),
activity: new Set(),
leadership: new Set(),
supporter: new Set(),
organization: new Set(),
@ -209,7 +209,7 @@ const purgeMunicipalityResidue = async (
payload: Payload,
municipalityID: number,
): Promise<void> => {
const [pledges, updates, demands, leaderships, supporters, plans] = await Promise.all([
const [pledges, updates, demands, leaderships, supporters, activities] = await Promise.all([
payload.find({
collection: 'votePledge',
where: { municipality: { equals: municipalityID } },
@ -246,7 +246,7 @@ const purgeMunicipalityResidue = async (
select: {},
}),
payload.find({
collection: 'actionPlan',
collection: 'activity',
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
@ -277,14 +277,14 @@ const purgeMunicipalityResidue = async (
| 'campaignDemand'
| 'leadership'
| 'supporter'
| 'actionPlan'
| 'activity'
ids: number[]
}> = [
{ collection: 'campaignInvite', ids: invites.docs.map((doc) => doc.id) },
{ collection: 'votePledge', ids: pledges.docs.map((doc) => doc.id) },
{ collection: 'municipalityUpdate', ids: updates.docs.map((doc) => doc.id) },
{ collection: 'campaignDemand', ids: demands.docs.map((doc) => doc.id) },
{ collection: 'actionPlan', ids: plans.docs.map((doc) => doc.id) },
{ collection: 'activity', ids: activities.docs.map((doc) => doc.id) },
{ collection: 'leadership', ids: leadershipIDs },
{ collection: 'supporter', ids: supporters.docs.map((doc) => doc.id) },
]
@ -507,7 +507,7 @@ export class CampaignFixtures {
pagination: false,
}),
this.rootPayload.find({
collection: 'actionPlan',
collection: 'activity',
where: {
or: [{ title: { contains: this.runID } }, { slug: { contains: this.runID } }],
},
@ -534,7 +534,7 @@ 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 organization of roots[3].docs) this.own('organization', organization)
for (const plan of roots[4].docs) this.own('actionPlan', plan)
for (const activity of roots[4].docs) this.own('activity', activity)
for (const demand of roots[5].docs) this.own('campaignDemand', demand)
for (const consent of roots[6].docs) this.own('consent', consent)
}
@ -907,7 +907,7 @@ export class CampaignFixtures {
}
const lockedDocumentConditions = [
['action_plan_id', this.owned.actionPlan],
['activity_id', this.owned.activity],
['allocation_decision_id', this.owned.allocationDecision],
['campaign_invite_id', this.owned.campaignInvite],
['campaign_user_id', this.owned.campaignUser],
@ -1004,7 +1004,7 @@ export class CampaignFixtures {
'campaignDemand',
'allocationDecision',
'municipalityUpdate',
'actionPlan',
'activity',
'leadership',
'supporter',
'organization',

View file

@ -8,14 +8,14 @@ import {
} from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { createActionPlanRecord } from '@/app/(campaign)/campanha/actions/actionPlan'
import { actionPlanCreateSchema, actionPlanUpdateSchema } from '@/lib/schemas/actionPlan'
import { createActivityRecord } from '@/app/(campaign)/campanha/actions/activity'
import { activityCreateSchema, activityUpdateSchema } from '@/lib/schemas/activity'
import config from '@/payload.config'
import { parseActionPlanCreateFormData } from '@/utilities/actionPlanFormData'
import { parseActivityCreateFormData } from '@/utilities/activityFormData'
import {
canCreateActionPlan,
canReadActionPlan,
canUpdateActionPlan,
canCreateActivity,
canReadActivity,
canUpdateActivity,
getAccessibleLeadershipIds,
} from '@/utilities/campaignAccess'
@ -23,7 +23,7 @@ import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
/** Create data with server-derived fields (e.g. `slug`) intentionally omitted. */
type ActionPlanCreateData = RequiredDataFromCollectionSlug<'actionPlan'>
type ActivityCreateData = RequiredDataFromCollectionSlug<'activity'>
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -33,7 +33,7 @@ const campaignFixtures = installCampaignFixtures({
},
})
const validPlanInput = (municipalityId: number) => ({
const validActivityInput = (municipalityId: number) => ({
title: campaignFixtures().value('Caminhada Centro'),
kind: 'caminhada' as const,
status: 'planejado' as const,
@ -42,30 +42,30 @@ const validPlanInput = (municipalityId: number) => ({
locality: 'Centro',
})
describe('action plan domain', () => {
describe('activity domain', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('requires startAt outside rascunho and allows drafts without it', () => {
expect(
actionPlanCreateSchema.safeParse({
...validPlanInput(1),
activityCreateSchema.safeParse({
...validActivityInput(1),
status: 'rascunho',
startAt: undefined,
}).success,
).toBe(true)
expect(
actionPlanCreateSchema.safeParse({
...validPlanInput(1),
activityCreateSchema.safeParse({
...validActivityInput(1),
status: 'planejado',
startAt: undefined,
}).success,
).toBe(false)
expect(
actionPlanUpdateSchema.safeParse({
activityUpdateSchema.safeParse({
id: 1,
status: 'confirmado',
startAt: null,
@ -74,24 +74,24 @@ describe('action plan domain', () => {
})
it('requires a municipality on create', () => {
const result = actionPlanCreateSchema.safeParse({
title: campaignFixtures().value('Plano sem Praça'),
const result = activityCreateSchema.safeParse({
title: campaignFixtures().value('Atividade sem município'),
kind: 'caminhada',
status: 'rascunho',
})
expect(result.success).toBe(false)
})
it('keeps the recorded origin of a new action plan', () => {
const parsed = actionPlanCreateSchema.parse({
...validPlanInput(1),
it('keeps the recorded origin of a new activity', () => {
const parsed = activityCreateSchema.parse({
...validActivityInput(1),
origin: 'pedido_broker',
})
expect(parsed.origin).toBe('pedido_broker')
})
it('parses the origin selected in the action plan form', () => {
it('parses the origin selected in the activity form', () => {
const formData = new FormData()
formData.set('title', 'Caminhada no centro')
formData.set('kind', 'caminhada')
@ -100,10 +100,10 @@ describe('action plan domain', () => {
formData.set('origin', 'obrigacao_politica')
formData.set('tasksJson', '[]')
expect(parseActionPlanCreateFormData(formData).origin).toBe('obrigacao_politica')
expect(parseActivityCreateFormData(formData).origin).toBe('obrigacao_politica')
})
it('parses several demand drafts from the action plan form', () => {
it('parses several demand drafts from the activity form', () => {
const formData = new FormData()
formData.set('title', 'Caminhada no centro')
formData.set('kind', 'caminhada')
@ -118,23 +118,23 @@ describe('action plan domain', () => {
]),
)
expect(parseActionPlanCreateFormData(formData).demands).toEqual([
expect(parseActivityCreateFormData(formData).demands).toEqual([
{ title: 'Panfletos', kind: 'material' },
{ title: 'Van', kind: 'transporte', description: 'Levar a equipe.' },
])
})
it('creates several demands with the action plan in one workflow', async () => {
it('creates several demands with the activity in one workflow', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const plan = await createActionPlanRecord(
const activity = await createActivityRecord(
payload,
coordinator,
{
...validPlanInput(municipality.id),
title: fixtures.value('Plano com demandas'),
...validActivityInput(municipality.id),
title: fixtures.value('Atividade com demandas'),
origin: 'dado',
},
[
@ -146,11 +146,11 @@ describe('action plan domain', () => {
},
],
)
fixtures.own('actionPlan', plan.id)
fixtures.own('activity', activity.id)
const demands = await payload.find({
collection: 'campaignDemand',
where: { actionPlan: { equals: plan.id } },
where: { activity: { equals: activity.id } },
depth: 0,
pagination: false,
overrideAccess: true,
@ -173,7 +173,7 @@ describe('action plan domain', () => {
).toBe(true)
})
it('rolls back the plan and every nested demand when one demand conflicts', async () => {
it('rolls back the activity and every nested demand when one demand conflicts', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
@ -183,15 +183,15 @@ describe('action plan domain', () => {
municipality: municipality.id,
createdBy: coordinator.id,
})
const planTitle = fixtures.value('Plano que deve reverter')
const activityTitle = fixtures.value('Atividade que deve reverter')
await expect(
createActionPlanRecord(
createActivityRecord(
payload,
coordinator,
{
...validPlanInput(municipality.id),
title: planTitle,
...validActivityInput(municipality.id),
title: activityTitle,
},
[
{ title: fixtures.value('Primeira demanda temporária'), kind: 'material' },
@ -200,10 +200,10 @@ describe('action plan domain', () => {
),
).rejects.toThrow()
const [plans, nestedDemands] = await Promise.all([
const [activities, nestedDemands] = await Promise.all([
payload.find({
collection: 'actionPlan',
where: { title: { equals: planTitle } },
collection: 'activity',
where: { title: { equals: activityTitle } },
depth: 0,
pagination: false,
overrideAccess: true,
@ -216,7 +216,7 @@ describe('action plan domain', () => {
overrideAccess: true,
}),
])
expect(plans.docs).toHaveLength(0)
expect(activities.docs).toHaveLength(0)
expect(nestedDemands.docs).toHaveLength(0)
})
@ -236,23 +236,23 @@ describe('action plan domain', () => {
supportStatus: 'engajado',
})
const createAsCoordinator = await canCreateActionPlan({
const createAsCoordinator = await canCreateActivity({
req: stub<PayloadRequest>({ user: coordinator, payload, context: {} }),
})
const createAsAdvisor = await canCreateActionPlan({
const createAsAdvisor = await canCreateActivity({
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
})
const createAsLeader = await canCreateActionPlan({
const createAsLeader = await canCreateActivity({
req: stub<PayloadRequest>({ user: leaderAccount, payload, context: {} }),
})
expect(createAsCoordinator).toBe(true)
expect(createAsAdvisor).toBe(true)
expect(createAsLeader).toBe(false)
const plan = await payload.create({
collection: 'actionPlan',
data: stub<ActionPlanCreateData>({
...validPlanInput(municipality.id),
const activity = await payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(municipality.id),
advisors: [advisor.id],
leadership: leadership.id,
responsible: contact.id,
@ -260,28 +260,28 @@ describe('action plan domain', () => {
}),
overrideAccess: true,
})
fixtures.own('actionPlan', plan.id)
fixtures.own('activity', activity.id)
const coordinatorRead = await canReadActionPlan({
const coordinatorRead = await canReadActivity({
req: stub<PayloadRequest>({ user: coordinator, payload, context: {} }),
})
expect(coordinatorRead).toBe(true)
const advisorRead = await canReadActionPlan({
const advisorRead = await canReadActivity({
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
})
expect(advisorRead).toEqual({
or: [{ advisors: { contains: advisor.id } }, { municipality: { in: [municipality.id] } }],
})
const otherAdvisorRead = await canReadActionPlan({
const otherAdvisorRead = await canReadActivity({
req: stub<PayloadRequest>({ user: otherAdvisor, payload, context: {} }),
})
expect(otherAdvisorRead).toEqual({
or: [{ advisors: { contains: otherAdvisor.id } }, { municipality: { in: [] } }],
})
const leaderRead = await canReadActionPlan({
const leaderRead = await canReadActivity({
req: stub<PayloadRequest>({ user: leaderAccount, payload, context: {} }),
})
expect(leaderRead).toBe(false)
@ -292,7 +292,7 @@ describe('action plan domain', () => {
)
expect(leadershipIds).toEqual([])
const advisorUpdate = await canUpdateActionPlan({
const advisorUpdate = await canUpdateActivity({
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
})
expect(advisorUpdate).toEqual({
@ -300,8 +300,8 @@ describe('action plan domain', () => {
})
const visibleToAdvisor = await payload.find({
collection: 'actionPlan',
where: { id: { equals: plan.id } },
collection: 'activity',
where: { id: { equals: activity.id } },
user: advisor,
overrideAccess: false,
depth: 0,
@ -309,8 +309,8 @@ describe('action plan domain', () => {
expect(visibleToAdvisor.totalDocs).toBe(1)
const hiddenFromOther = await payload.find({
collection: 'actionPlan',
where: { id: { equals: plan.id } },
collection: 'activity',
where: { id: { equals: activity.id } },
user: otherAdvisor,
overrideAccess: false,
depth: 0,
@ -319,8 +319,8 @@ describe('action plan domain', () => {
await expect(
payload.find({
collection: 'actionPlan',
where: { id: { equals: plan.id } },
collection: 'activity',
where: { id: { equals: activity.id } },
user: leaderAccount,
overrideAccess: false,
depth: 0,
@ -334,24 +334,24 @@ describe('action plan domain', () => {
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const plan = await payload.create({
collection: 'actionPlan',
data: stub<ActionPlanCreateData>({
...validPlanInput(municipality.id),
title: fixtures.value('Plano do assessor'),
const activity = await payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(municipality.id),
title: fixtures.value('Atividade do assessor'),
}),
user: advisor,
overrideAccess: false,
})
fixtures.own('actionPlan', plan.id)
fixtures.own('activity', activity.id)
const advisorIds = (plan.advisors ?? []).map((value) =>
const advisorIds = (activity.advisors ?? []).map((value) =>
typeof value === 'number' ? value : value.id,
)
expect(advisorIds).toEqual([advisor.id])
expect(typeof plan.createdBy === 'number' ? plan.createdBy : plan.createdBy?.id).toBe(
advisor.id,
)
expect(
typeof activity.createdBy === 'number' ? activity.createdBy : activity.createdBy?.id,
).toBe(advisor.id)
})
it('enforces schedule validation in the collection hook', async () => {
@ -361,24 +361,24 @@ describe('action plan domain', () => {
await expect(
payload.create({
collection: 'actionPlan',
data: stub<ActionPlanCreateData>({
...validPlanInput(municipality.id),
title: fixtures.value('Plano sem data'),
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(municipality.id),
title: fixtures.value('Atividade sem data'),
startAt: null,
createdBy: coordinator.id,
}),
overrideAccess: true,
}),
).rejects.toThrow('Informe a data e horário de início ao planejar ou confirmar o plano.')
).rejects.toThrow('Informe a data e horário de início ao planejar ou confirmar a atividade.')
const start = new Date(Date.now() + 86_400_000)
await expect(
payload.create({
collection: 'actionPlan',
data: stub<ActionPlanCreateData>({
...validPlanInput(municipality.id),
title: fixtures.value('Plano com término invertido'),
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(municipality.id),
title: fixtures.value('Atividade com término invertido'),
startAt: start.toISOString(),
endAt: new Date(start.getTime() - 3_600_000).toISOString(),
createdBy: coordinator.id,
@ -388,7 +388,7 @@ describe('action plan domain', () => {
).rejects.toThrow('O horário de término deve ser posterior ao de início.')
})
it('denies leaders from updating action plans', async () => {
it('denies leaders from updating activities', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const leaderAccount = await fixtures.createCampaignUser('leader')
@ -402,23 +402,23 @@ describe('action plan domain', () => {
supportStatus: 'engajado',
})
const plan = await payload.create({
collection: 'actionPlan',
data: stub<ActionPlanCreateData>({
...validPlanInput(municipality.id),
title: fixtures.value('Plano Tarefas'),
const activity = await payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(municipality.id),
title: fixtures.value('Atividade Tarefas'),
advisors: [advisor.id],
leadership: leadership.id,
tasks: [{ title: 'Levar faixas', done: false }],
}),
overrideAccess: true,
})
fixtures.own('actionPlan', plan.id)
fixtures.own('activity', activity.id)
await expect(
payload.update({
collection: 'actionPlan',
id: plan.id,
collection: 'activity',
id: activity.id,
data: {
tasks: [{ title: 'Levar faixas', done: true }],
},

View file

@ -75,7 +75,11 @@ describe('campaign leadership domain', () => {
expect(Object.hasOwn(parsed, 'user')).toBe(false)
expect(Object.hasOwn(parsed, 'consent')).toBe(false)
expect(() =>
leadershipCreateSchema.parse({ municipalities: [], name: 'Sem Praça', phone: '71999990000' }),
leadershipCreateSchema.parse({
municipalities: [],
name: 'Sem município',
phone: '71999990000',
}),
).toThrow()
const update = leadershipInternalUpdateSchema.parse({
id: 1,
@ -340,7 +344,7 @@ describe('campaign leadership domain', () => {
const ownLeadership = await createLeadershipRecord(payload, advisor, {
municipalities: [assigned.id],
name: 'Liderança da Praça',
name: 'Liderança do município',
phone: campaignFixtures().phone(),
supportStatus: 'engajado',
notes: 'Avaliação interna',

View file

@ -160,7 +160,7 @@ describe('campaign migration existing-schema reconciliation', () => {
// NOTE: the pre-remodel "empty path" rollback test (municipalityRemodel.down →
// consolidated.down → original.down) is intentionally absent: the municipality
// remodel down is currently broken — `DROP TABLE "municipality" CASCADE` already
// cascades the supporter/action_plan/locked-documents FKs that the script
// cascades the supporter/activity/locked-documents FKs that the script
// then tries to DROP CONSTRAINT explicitly, so the rollback always aborts.
// Restore that coverage once the migration down uses IF EXISTS (or drops
// constraints before the cascaded tables).

View file

@ -474,7 +474,7 @@ describe('campaign municipality update domain', () => {
const oldRemaining = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: oldMunicipality.id,
kind: 'nota',
body: 'Permanece na Praça original',
body: 'Permanece no município original',
})
const moved = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: oldMunicipality.id,

View file

@ -192,7 +192,7 @@ describe('ESLint conventions', () => {
`'use server'\n\nexport type Result = { ok: true }\nexport const save = async () => ({ ok: true as const })\nexport async function load() { return 1 }\n`,
)
await expectValid(
'src/app/(campaign)/campanha/(app)/pracas/municipalityStaffEditMessages.ts',
'src/app/(campaign)/campanha/(app)/municipios/municipalityStaffEditMessages.ts',
`export const municipalityStaffEditSafeMessages = ['x'] as const\n`,
)
})

View file

@ -11,7 +11,7 @@ vi.mock('@/utilities/municipalityElectoralBaseline', () => ({
loadMunicipalityElectoralBaseline: vi.fn().mockResolvedValue(null),
}))
import { createActionPlanRecord } from '@/app/(campaign)/campanha/actions/actionPlan'
import { createActivityRecord } from '@/app/(campaign)/campanha/actions/activity'
import { updateMunicipalityStrategyRecord } from '@/app/(campaign)/campanha/actions/municipality'
import config from '@/payload.config'
import {
@ -39,7 +39,7 @@ describe('municipality dossier data (E16)', () => {
payload = await getPayload({ config: await config })
})
// The fixture builds ~20 rows sequentially (leaderships, updates, plans),
// The fixture builds ~20 rows sequentially (leaderships, updates, activities),
// so this test legitimately outlives the 5s default under parallel load.
it(
'composes the pre-visit dossier with section caps and full totals',
@ -64,7 +64,7 @@ describe('municipality dossier data (E16)', () => {
})
}
const upcomingPlan = await createActionPlanRecord(payload, coordinator, {
const upcomingActivity = await createActivityRecord(payload, coordinator, {
title: fixtures.value('Caminhada dossiê'),
kind: 'caminhada',
status: 'planejado',
@ -72,7 +72,7 @@ describe('municipality dossier data (E16)', () => {
municipality: municipality.id,
locality: 'Centro',
})
fixtures.own('actionPlan', upcomingPlan.id)
fixtures.own('activity', upcomingActivity.id)
await updateMunicipalityStrategyRecord(payload, coordinator, {
municipality: municipality.id,
@ -96,8 +96,10 @@ describe('municipality dossier data (E16)', () => {
expect(dossier.signals.rows).toHaveLength(DOSSIER_SIGNAL_LIMIT)
expect(dossier.signals.totalCount).toBe(DOSSIER_SIGNAL_LIMIT + 1)
expect(dossier.upcomingPlans.map((plan) => plan.id)).toContain(upcomingPlan.id)
expect(dossier.recentPlans).toEqual([])
expect(dossier.upcomingActivities.map((activity) => activity.id)).toContain(
upcomingActivity.id,
)
expect(dossier.recentActivities).toEqual([])
// Staff view model exists, so the E8 goal account block is present.
expect(dossier.goalAccount).not.toBeNull()

View file

@ -45,7 +45,7 @@ const canonicalEvidenceRows = (assignments: OfficialEvidence['assignments']): st
describe('Municipality zone neighborhood catalog (Salvador only)', () => {
const zoneMunicipalities = municipalityCatalog.filter((entry) => entry.kind === 'zona')
it('covers every zona Praça with a non-empty neighborhood list', () => {
it('covers every zone municipality with a non-empty neighborhood list', () => {
expect(municipalityZoneNeighborhoods).toHaveLength(zoneMunicipalities.length)
expect(new Set(municipalityZoneNeighborhoods.map((entry) => entry.municipalitySlug)).size).toBe(
zoneMunicipalities.length,

View file

@ -1,136 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
actionPlanTabs,
buildActionPlanFiltersKey,
buildActionPlanListHref,
buildActionPlanListSearchParams,
buildActionPlanListWhere,
parseActionPlanListParams,
resolveActionPlanListUrl,
} from '@/utilities/actionPlanUi'
const NOW = new Date('2026-07-25T12:00:00.000Z')
describe('parseActionPlanListParams', () => {
it('defaults to the proximos tab on page 1', () => {
expect(parseActionPlanListParams({})).toEqual({ page: 1, tab: 'proximos' })
})
it('falls back to proximos on an unknown tab', () => {
expect(parseActionPlanListParams({ tab: 'passados' }).tab).toBe('proximos')
})
it('accepts every declared tab', () => {
for (const tab of actionPlanTabs) {
expect(parseActionPlanListParams({ tab }).tab).toBe(tab)
}
})
it('honors status only on the todos tab', () => {
expect(parseActionPlanListParams({ status: 'realizado' }).status).toBeUndefined()
expect(parseActionPlanListParams({ tab: 'todos', status: 'realizado' }).status).toBe(
'realizado',
)
expect(
parseActionPlanListParams({ tab: 'todos', status: 'inexistente' }).status,
).toBeUndefined()
})
it('validates kind and municipality', () => {
expect(parseActionPlanListParams({ kind: 'caminhada' }).kind).toBe('caminhada')
expect(parseActionPlanListParams({ kind: 'festa' }).kind).toBeUndefined()
expect(parseActionPlanListParams({ municipality: '12' }).municipality).toBe(12)
expect(parseActionPlanListParams({ municipality: '0' }).municipality).toBeUndefined()
})
})
describe('buildActionPlanListWhere (tab → where matrix)', () => {
it('proximos = planejado/confirmado starting from now', () => {
const where = buildActionPlanListWhere({ page: 1, tab: 'proximos' }, NOW)
expect(where).toEqual({
and: [
{ status: { in: ['planejado', 'confirmado'] } },
{ startAt: { greater_than_equal: NOW.toISOString() } },
],
})
})
it('realizados and rascunhos filter by their status', () => {
expect(buildActionPlanListWhere({ page: 1, tab: 'realizados' }, NOW)).toEqual({
and: [{ status: { equals: 'realizado' } }],
})
expect(buildActionPlanListWhere({ page: 1, tab: 'rascunhos' }, NOW)).toEqual({
and: [{ status: { equals: 'rascunho' } }],
})
})
it('todos is unfiltered unless a status is chosen', () => {
expect(buildActionPlanListWhere({ page: 1, tab: 'todos' }, NOW)).toEqual({})
expect(buildActionPlanListWhere({ page: 1, tab: 'todos', status: 'cancelado' }, NOW)).toEqual({
and: [{ status: { equals: 'cancelado' } }],
})
})
it('prepends kind and municipality filters on any tab', () => {
const where = buildActionPlanListWhere(
{ page: 1, tab: 'realizados', kind: 'comicio', municipality: 3 },
NOW,
)
expect(where).toEqual({
and: [
{ kind: { equals: 'comicio' } },
{ municipality: { equals: 3 } },
{ status: { equals: 'realizado' } },
],
})
})
})
describe('buildActionPlanListSearchParams', () => {
it('omits the default tab and page 1', () => {
expect(buildActionPlanListSearchParams({ page: 1, tab: 'proximos' }).toString()).toBe('')
expect(buildActionPlanListSearchParams({ page: 2, tab: 'todos' }).toString()).toBe(
'tab=todos&page=2',
)
})
it('serializes filters in canonical order', () => {
const params = buildActionPlanListSearchParams({
page: 1,
tab: 'todos',
kind: 'caminhada',
status: 'cancelado',
municipality: 9,
})
expect(params.toString()).toBe('tab=todos&kind=caminhada&status=cancelado&municipality=9')
})
})
describe('hrefs and canonical URL resolution', () => {
it('builds hrefs against the planos base path', () => {
expect(buildActionPlanListHref({ page: 1, tab: 'proximos' }, 1)).toBe('/campanha/planos')
expect(buildActionPlanListHref({ page: 1, tab: 'realizados' }, 2)).toBe(
'/campanha/planos?tab=realizados&page=2',
)
})
it('derives the filters key from the canonical serialization', () => {
expect(buildActionPlanFiltersKey({ page: 1, tab: 'todos', kind: 'comicio' })).toBe(
'tab=todos&kind=comicio',
)
})
it('redirects an explicit default tab out of the URL', () => {
const result = resolveActionPlanListUrl({ tab: 'proximos' })
expect(result.href).toBe('/campanha/planos')
expect(result.redirectHref).toBe('/campanha/planos')
})
it('redirects on unsupported params and clamps the page', () => {
expect(resolveActionPlanListUrl({ foo: 'bar' }).redirectHref).toBe('/campanha/planos')
const clamped = resolveActionPlanListUrl({ tab: 'todos', page: '50' }, 2)
expect(clamped.state.page).toBe(2)
expect(clamped.redirectHref).toBe('/campanha/planos?tab=todos&page=2')
})
})

View file

@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest'
import {
activityTabs,
buildActivityFiltersKey,
buildActivityListHref,
buildActivityListSearchParams,
buildActivityListWhere,
parseActivityListParams,
resolveActivityListUrl,
} from '@/utilities/activityUi'
const NOW = new Date('2026-07-25T12:00:00.000Z')
describe('parseActivityListParams', () => {
it('defaults to the proximos tab on page 1', () => {
expect(parseActivityListParams({})).toEqual({ page: 1, tab: 'proximos' })
})
it('falls back to proximos on an unknown tab', () => {
expect(parseActivityListParams({ tab: 'passados' }).tab).toBe('proximos')
})
it('accepts every declared tab', () => {
for (const tab of activityTabs) {
expect(parseActivityListParams({ tab }).tab).toBe(tab)
}
})
it('honors status only on the todos tab', () => {
expect(parseActivityListParams({ status: 'realizado' }).status).toBeUndefined()
expect(parseActivityListParams({ tab: 'todos', status: 'realizado' }).status).toBe('realizado')
expect(parseActivityListParams({ tab: 'todos', status: 'inexistente' }).status).toBeUndefined()
})
it('validates kind and municipality', () => {
expect(parseActivityListParams({ kind: 'caminhada' }).kind).toBe('caminhada')
expect(parseActivityListParams({ kind: 'festa' }).kind).toBeUndefined()
expect(parseActivityListParams({ municipality: '12' }).municipality).toBe(12)
expect(parseActivityListParams({ municipality: '0' }).municipality).toBeUndefined()
})
})
describe('buildActivityListWhere (tab → where matrix)', () => {
it('proximos = planejado/confirmado starting from now', () => {
const where = buildActivityListWhere({ page: 1, tab: 'proximos' }, NOW)
expect(where).toEqual({
and: [
{ status: { in: ['planejado', 'confirmado'] } },
{ startAt: { greater_than_equal: NOW.toISOString() } },
],
})
})
it('realizados and rascunhos filter by their status', () => {
expect(buildActivityListWhere({ page: 1, tab: 'realizados' }, NOW)).toEqual({
and: [{ status: { equals: 'realizado' } }],
})
expect(buildActivityListWhere({ page: 1, tab: 'rascunhos' }, NOW)).toEqual({
and: [{ status: { equals: 'rascunho' } }],
})
})
it('todos is unfiltered unless a status is chosen', () => {
expect(buildActivityListWhere({ page: 1, tab: 'todos' }, NOW)).toEqual({})
expect(buildActivityListWhere({ page: 1, tab: 'todos', status: 'cancelado' }, NOW)).toEqual({
and: [{ status: { equals: 'cancelado' } }],
})
})
it('prepends kind and municipality filters on any tab', () => {
const where = buildActivityListWhere(
{ page: 1, tab: 'realizados', kind: 'comicio', municipality: 3 },
NOW,
)
expect(where).toEqual({
and: [
{ kind: { equals: 'comicio' } },
{ municipality: { equals: 3 } },
{ status: { equals: 'realizado' } },
],
})
})
})
describe('buildActivityListSearchParams', () => {
it('omits the default tab and page 1', () => {
expect(buildActivityListSearchParams({ page: 1, tab: 'proximos' }).toString()).toBe('')
expect(buildActivityListSearchParams({ page: 2, tab: 'todos' }).toString()).toBe(
'tab=todos&page=2',
)
})
it('serializes filters in canonical order', () => {
const params = buildActivityListSearchParams({
page: 1,
tab: 'todos',
kind: 'caminhada',
status: 'cancelado',
municipality: 9,
})
expect(params.toString()).toBe('tab=todos&kind=caminhada&status=cancelado&municipality=9')
})
})
describe('hrefs and canonical URL resolution', () => {
it('builds hrefs against the atividades base path', () => {
expect(buildActivityListHref({ page: 1, tab: 'proximos' }, 1)).toBe('/campanha/atividades')
expect(buildActivityListHref({ page: 1, tab: 'realizados' }, 2)).toBe(
'/campanha/atividades?tab=realizados&page=2',
)
})
it('derives the filters key from the canonical serialization', () => {
expect(buildActivityFiltersKey({ page: 1, tab: 'todos', kind: 'comicio' })).toBe(
'tab=todos&kind=comicio',
)
})
it('redirects an explicit default tab out of the URL', () => {
const result = resolveActivityListUrl({ tab: 'proximos' })
expect(result.href).toBe('/campanha/atividades')
expect(result.redirectHref).toBe('/campanha/atividades')
})
it('redirects on unsupported params and clamps the page', () => {
expect(resolveActivityListUrl({ foo: 'bar' }).redirectHref).toBe('/campanha/atividades')
const clamped = resolveActivityListUrl({ tab: 'todos', page: '50' }, 2)
expect(clamped.state.page).toBe(2)
expect(clamped.redirectHref).toBe('/campanha/atividades?tab=todos&page=2')
})
})

View file

@ -217,7 +217,7 @@ describe('static table composition', () => {
createElement(
TableBody,
null,
createElement(TableRow, null, createElement(TableCell, null, 'Praça Centro')),
createElement(TableRow, null, createElement(TableCell, null, 'Município Centro')),
),
)
@ -241,7 +241,7 @@ describe('static table composition', () => {
})
expect(container.querySelector('table')).not.toBeNull()
expect(container.textContent).toContain('Praça Centro')
expect(container.textContent).toContain('Município Centro')
expect(consoleError).not.toHaveBeenCalled()
await act(async () => root?.unmount())

View file

@ -70,7 +70,7 @@ describe('inspectRawListParams', () => {
})
})
// A minimal list-state fixture mirroring how supporter/actionPlan lists wire
// A minimal list-state fixture mirroring how supporter/activity lists wire
// resolveListUrl: q + page, canonical order q-then-page, page omitted at 1.
type FixtureState = { page: number; q?: string }

View file

@ -61,29 +61,57 @@ describe('server-only boundary in src/utilities', () => {
})
describe('banned campaign terminology', () => {
// "Praça"/"Núcleo" were replaced by "Município" (2026-07-23 remodel; W4f
// swept the copy). The manual sweep still missed 4 consent drafts — this
// keeps the invariant self-enforcing. Migrations are frozen history and
// stay out of scope. Allowlisted files carry legitimate data, not copy:
const allowlist = new Set<string>([
'src/lib/cities.ts', // real locality names (e.g. "Núcleo Bandeirante" — DF)
])
// Every rename of an operational concept leaves prose behind: the 2026-07-23
// remodel ("Praça"/"Núcleo" → "Município") needed a manual W4f sweep that
// still missed 4 consent drafts, and C13 ("Plano de Ação" → "Atividade")
// touched 62 files. Each retired term gets a row here so it cannot come back.
// Migrations are frozen history and stay out of scope; allowlisted files
// carry legitimate data or frozen SQL, not live copy.
const bannedTerms = [
{
id: 'Praça/Núcleo',
pattern: /praç|núcleo/i,
remedy: 'use "Município" (or allowlist genuine data)',
allowlist: [
'src/lib/cities.ts', // real locality names (e.g. "Núcleo Bandeirante" — DF)
// Emits the frozen remodel migration, which names the term it retired.
'scripts/generate-remodel-municipalities-migration.mjs',
],
},
{
id: 'Plano de Ação',
// Accent-tolerant (agents routinely type "plano de acao") and space-tolerant,
// so English prose ("action plan") cannot slip past the identifier forms.
pattern: /action[\s_-]?plan|planos? de a[çc][aã]o|\/campanha\/planos/i,
remedy: 'use "Atividade" / activity (or allowlist frozen history)',
allowlist: [
// Emits the frozen remodel migration, whose SQL predates the rename.
'scripts/generate-remodel-municipalities-migration.mjs',
],
},
] as const
it('keeps Praça/Núcleo out of src (data catalogs allowlisted)', () => {
const searchRoots = ['src', 'tests', 'scripts'] as const
it.each(bannedTerms)('keeps $id out of src, tests and scripts', (term) => {
// This spec quotes the banned literals itself.
const allowlist = new Set<string>([...term.allowlist, repoPath(import.meta.filename)])
const offenders: string[] = []
for (const file of walkSourceFiles(resolve(repoRoot, 'src'), ['.ts', '.tsx'])) {
const path = repoPath(file)
if (path.startsWith('src/migrations/')) continue
if (allowlist.has(path)) continue
for (const root of searchRoots) {
for (const file of walkSourceFiles(resolve(repoRoot, root), ['.ts', '.tsx', '.mjs'])) {
const path = repoPath(file)
if (path.startsWith('src/migrations/')) continue
if (allowlist.has(path)) continue
const lines = readFileSync(file, 'utf8').split('\n')
for (const [index, line] of lines.entries()) {
if (/praç|núcleo/i.test(line)) offenders.push(`${path}:${index + 1}`)
const lines = readFileSync(file, 'utf8').split('\n')
for (const [index, line] of lines.entries()) {
if (term.pattern.test(line)) offenders.push(`${path}:${index + 1}`)
}
}
}
expect(offenders, 'use "Município" (or allowlist genuine data)').toEqual([])
expect(offenders, term.remedy).toEqual([])
})
})

View file

@ -63,8 +63,8 @@ describe('recentVisits storage', () => {
for (let index = 0; index < MAX_ENTRIES + 2; index += 1) {
recordRecentVisit(
sampleEntry({
href: `/campanha/municipios/praca-${index}`,
label: `Praça ${index}`,
href: `/campanha/municipios/municipio-${index}`,
label: `Município ${index}`,
visitedAt: index,
}),
)
@ -72,8 +72,9 @@ describe('recentVisits storage', () => {
const visits = listRecentVisits()
expect(visits).toHaveLength(MAX_ENTRIES)
expect(visits[0]?.href).toBe(`/campanha/municipios/praca-${MAX_ENTRIES + 1}`)
expect(visits.at(-1)?.href).toBe('/campanha/municipios/praca-2')
expect(visits[0]?.href).toBe(`/campanha/municipios/municipio-${MAX_ENTRIES + 1}`)
expect(visits[0]?.label).toBe(`Município ${MAX_ENTRIES + 1}`)
expect(visits.at(-1)?.href).toBe('/campanha/municipios/municipio-2')
})
it('clears stored visits', () => {