C13: renomeia "Plano de Acao" para "Atividade" em toda a vertical /campanha
Collection actionPlan -> activity (labels "Atividade"/"Atividades"), campo
campaignDemand.actionPlan -> activity, rota /campanha/planos ->
/campanha/atividades (criacao em atividades/nova), sidebar "Atividades" e
rotulos alinhados a entidade ("Tipo de atividade", "Origem da atividade",
"Resultado da atividade"). Valores de enum kind/status/origin inalterados.
Schema movido por migration data-preserving escrita a mao
(20260725_213000_rename_action_plan_to_activity): 4 tabelas, 3 enums, 2
colunas, 39 indices (4 pkeys inclusos), 14 FKs e 2 sequences, cada statement
guardado na existencia do objeto de origem, com down() simetrico. O gerador do
Payload emitiria DROP+CREATE e nao ve o indice parcial escrito a mao
action_plan_upcoming_start_at_idx, entao a lista veio de
pg_indexes/pg_constraint/pg_type/pg_sequences e o snapshot .json foi derivado
do anterior por transformacao textual -- validado por migrate:create __probe
sem mudanca de schema. A mesma migration alinha os 8 indices fosseis %plaza%
que a remodelagem de municipios deixou (renomeou as colunas plaza_id, nao os
indices).
Guard: o describe "banned campaign terminology" de
codebaseConventions.unit.spec.ts virou tabela de termos varrendo src+tests+
scripts (migrations fora -- historia congelada), banindo os compostos
actionPlan, a copy "plano de acao" e a rota antiga, mas nao plan/plano isolado
(colidiria com os planos de assinatura da Fase 2). Allowlist: o proprio spec,
scripts/generate-remodel-municipalities-migration.mjs e o comentario historico
em campaignMigrationReconciliation.int.spec.ts.
Plano: docs/plans/renomear-plano-acao-para-atividade.md
This commit is contained in:
parent
fac6792d9f
commit
7b93c232ff
84 changed files with 13813 additions and 1469 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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). 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).
|
||||
|
|
|
|||
22
AGENTS.md
22
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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.',
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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({
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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({
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { OrganizationForm } from '@/components/campaign/organization/Organizatio
|
|||
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 { activityStatusLabels, activityStatuses } from '@/lib/schemas/activity'
|
||||
import { organizationKindLabels } from '@/lib/schemas/organization'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
|
|
@ -23,8 +23,8 @@ 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]]
|
||||
activityStatuses.includes(status as (typeof activityStatuses)[number])
|
||||
? activityStatusLabels[status as (typeof activityStatuses)[number]]
|
||||
: status
|
||||
|
||||
export default async function OrganizationDetailPage({ params }: OrganizationDetailPageProps) {
|
||||
|
|
@ -94,31 +94,33 @@ 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">
|
||||
{organization.activities.map((activity) => (
|
||||
<li key={activity.id} className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<Link
|
||||
href={`/campanha/planos/${plan.slug}`}
|
||||
href={`/campanha/atividades/${activity.slug}`}
|
||||
className="truncate font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{plan.title}
|
||||
{activity.title}
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{statusLabel(plan.status)}
|
||||
{plan.startAt ? ` · ${dateFormatter.format(new Date(plan.startAt))}` : ''}
|
||||
{plan.deputyPresent ? ' · Deputado presente' : ''}
|
||||
{statusLabel(activity.status)}
|
||||
{activity.startAt
|
||||
? ` · ${dateFormatter.format(new Date(activity.startAt))}`
|
||||
: ''}
|
||||
{activity.deputyPresent ? ' · Deputado presente' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
|
|
@ -126,7 +128,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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,8 +608,8 @@ export const ActionPlan: CollectionConfig = {
|
|||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetActionPlanSystemField,
|
||||
update: canSetActionPlanSystemField,
|
||||
create: canSetActivitySystemField,
|
||||
update: canSetActivitySystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -623,8 +622,8 @@ export const ActionPlan: CollectionConfig = {
|
|||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetActionPlanSystemField,
|
||||
update: canSetActionPlanSystemField,
|
||||
create: canSetActivitySystemField,
|
||||
update: canSetActivitySystemField,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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]"
|
||||
|
|
@ -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: {activityStatusLabels[activity?.status ?? ''] ?? 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}
|
||||
|
|
@ -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
|
||||
10
src/components/campaign/activity/ActivityList.tsx
Normal file
10
src/components/campaign/activity/ActivityList.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
19
src/components/campaign/activity/ActivityStatusBadge.tsx
Normal file
19
src/components/campaign/activity/ActivityStatusBadge.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)))
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
11849
src/migrations/20260725_213000_rename_action_plan_to_activity.json
Normal file
11849
src/migrations/20260725_213000_rename_action_plan_to_activity.json
Normal file
File diff suppressed because it is too large
Load diff
420
src/migrations/20260725_213000_rename_action_plan_to_activity.ts
Normal file
420
src/migrations/20260725_213000_rename_action_plan_to_activity.ts
Normal 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`. 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 environments
|
||||
* that already carry either name converge.
|
||||
*/
|
||||
|
||||
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_municipality_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 = format('public.%I', r.table_name)::regclass
|
||||
) 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 = format('public.%I', r.table_name)::regclass
|
||||
) 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 $$;
|
||||
`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// Action plans
|
||||
// Action 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))
|
||||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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 a 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),
|
||||
}
|
||||
26
src/utilities/activityDetailTabUi.ts
Normal file
26
src/utilities/activityDetailTabUi.ts
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
@ -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 a 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'))
|
||||
}
|
||||
110
src/utilities/activityPageData.ts
Normal file
110
src/utilities/activityPageData.ts
Normal 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),
|
||||
)
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 }] : []
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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_PLAN_LIMIT, 'startAt'),
|
||||
findActivities('realizados', DOSSIER_RECENT_PLAN_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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ export type OrganizationDetailViewModel = {
|
|||
municipalityIDs: number[]
|
||||
municipalityNames: string[]
|
||||
leaderships: Array<{ id: number; name: string }>
|
||||
actionPlans: Array<{
|
||||
activities: Array<{
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
|
|
@ -188,7 +188,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 +199,7 @@ export const loadOrganizationDetail = async (
|
|||
overrideAccess: false,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'actionPlan',
|
||||
collection: 'activity',
|
||||
where: { organizations: { in: [organization.id] } },
|
||||
depth: 0,
|
||||
limit: 20,
|
||||
|
|
@ -235,13 +235,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: String(activity.status),
|
||||
startAt: activity.startAt ?? null,
|
||||
deputyPresent: Boolean(activity.deputyPresent),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
},
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 zona Município with a non-empty neighborhood list', () => {
|
||||
expect(municipalityZoneNeighborhoods).toHaveLength(zoneMunicipalities.length)
|
||||
expect(new Set(municipalityZoneNeighborhoods.map((entry) => entry.municipalitySlug)).size).toBe(
|
||||
zoneMunicipalities.length,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
132
tests/unit/activityUi.unit.spec.ts
Normal file
132
tests/unit/activityUi.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -61,30 +61,62 @@ 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',
|
||||
pattern:
|
||||
/actionPlan|ActionPlan|ACTION_PLAN|action_plan|action-plan|planos? de aç[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',
|
||||
// Asserts against the frozen migration chain by its historical names.
|
||||
'tests/int/campaignMigrationReconciliation.int.spec.ts',
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
it('keeps Praça/Núcleo out of src (data catalogs allowlisted)', () => {
|
||||
const offenders: string[] = []
|
||||
const searchRoots = ['src', 'tests', 'scripts'] as const
|
||||
|
||||
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
|
||||
it.each(bannedTerms.map((term) => [term.id, term] as const))(
|
||||
'keeps %s out of src, tests and scripts',
|
||||
(_id, term) => {
|
||||
// This spec quotes the banned literals itself.
|
||||
const allowlist = new Set<string>([...term.allowlist, repoPath(import.meta.filename)])
|
||||
const offenders: string[] = []
|
||||
|
||||
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}`)
|
||||
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 (term.pattern.test(line)) offenders.push(`${path}:${index + 1}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders, 'use "Município" (or allowlist genuine data)').toEqual([])
|
||||
})
|
||||
expect(offenders, term.remedy).toEqual([])
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('eslint legacy ignore lists', () => {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ describe('recentVisits storage', () => {
|
|||
recordRecentVisit(
|
||||
sampleEntry({
|
||||
href: `/campanha/municipios/praca-${index}`,
|
||||
label: `Praça ${index}`,
|
||||
label: `Município `,
|
||||
visitedAt: index,
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue