From 7b93c232ff2cfc5bec7bb2dea791619cb223008f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 25 Jul 2026 20:47:55 -0300 Subject: [PATCH 1/2] 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 --- .cursor/rules/codebase-map.mdc | 8 +- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + AGENTS.md | 22 +- docs/ARCHITECTURE.md | 21 +- .../renomear-plano-acao-para-atividade.md | 15 +- docs/roadmap.md | 10 +- .../[slug]/ActivityOverviewTab.tsx} | 28 +- .../[slug]/editar/page.tsx | 36 +- .../atividades/[slug]/lifecycleFormActions.ts | 56 + .../{planos => atividades}/[slug]/page.tsx | 72 +- .../[slug]/resultFormActions.ts | 18 +- .../[slug]/taskActions.ts | 14 +- .../[slug]/updateFormActions.ts | 20 +- .../contactSearchActions.ts | 14 +- .../{planos => atividades}/formActions.ts | 58 +- .../{planos/novo => atividades/nova}/page.tsx | 28 +- .../(app)/{planos => atividades}/page.tsx | 56 +- .../campanha/(app)/demandas/[slug]/page.tsx | 8 +- .../(app)/demandas/nova/formActions.ts | 2 +- .../campanha/(app)/demandas/nova/page.tsx | 16 +- .../(app)/organizacoes/[slug]/page.tsx | 34 +- .../campanha/(app)/organizacoes/nova/page.tsx | 4 +- .../campanha/(app)/organizacoes/page.tsx | 4 +- .../planos/[slug]/lifecycleFormActions.ts | 59 - .../actions/{actionPlan.ts => activity.ts} | 164 +- .../{ActionPlan.ts => Activity.ts} | 165 +- src/collections/CampaignDemand.ts | 36 +- src/collections/Organization.ts | 2 +- .../campaign/actionPlan/ActionPlanList.tsx | 10 - .../actionPlan/ActionPlanStatusBadge.tsx | 19 - .../ActivityCard.tsx} | 34 +- .../ActivityDemandFields.tsx} | 8 +- .../ActivityFilters.tsx} | 62 +- .../ActivityForm.tsx} | 143 +- .../ActivityLifecycleDialog.tsx} | 28 +- .../campaign/activity/ActivityList.tsx | 10 + .../ActivityResultForm.tsx} | 18 +- .../campaign/activity/ActivityStatusBadge.tsx | 19 + .../ActivityTabNav.tsx} | 28 +- .../ActivityTaskChecklist.tsx} | 14 +- .../ActivityTaskFields.tsx} | 14 +- .../ActivityUpdateFeed.tsx} | 8 +- .../ActivityUpdateForm.tsx} | 8 +- src/components/campaign/demand/DemandForm.tsx | 60 +- .../municipality/MunicipalityDossier.tsx | 36 +- src/components/campaign/shell/nav.ts | 2 +- .../schemas/{actionPlan.ts => activity.ts} | 52 +- src/lib/schemas/campaignDemandInput.ts | 2 +- ...213000_rename_action_plan_to_activity.json | 11849 ++++++++++++++++ ...5_213000_rename_action_plan_to_activity.ts | 420 + src/migrations/index.ts | 6 + src/payload-types.ts | 26 +- src/payload.config.ts | 4 +- .../access/{actionPlans.ts => activities.ts} | 20 +- src/utilities/actionPlanDetailTabUi.ts | 26 - src/utilities/actionPlanPageData.ts | 110 - ...lPageData.ts => activityDetailPageData.ts} | 52 +- src/utilities/activityDetailTabUi.ts | 26 + ...ionPlanFormData.ts => activityFormData.ts} | 62 +- ...ptions.ts => activityLeadershipOptions.ts} | 10 +- src/utilities/activityPageData.ts | 110 + .../{actionPlanUi.ts => activityUi.ts} | 74 +- ...lanViewModels.ts => activityViewModels.ts} | 232 +- src/utilities/campaignAccess.ts | 18 +- src/utilities/campaignDemandData.ts | 8 +- src/utilities/campaignFormActionError.ts | 2 +- src/utilities/campaignRelationOptions.ts | 14 +- src/utilities/detailTabUi.ts | 4 +- src/utilities/municipalityDossierData.ts | 44 +- src/utilities/organizationData.ts | 20 +- ...e.spec.ts => campaignActivity.e2e.spec.ts} | 22 +- tests/e2e/fixtures/campaignE2EFixtures.ts | 15 +- tests/helpers/campaignFixtures.ts | 20 +- ...t.spec.ts => campaignActivity.int.spec.ts} | 170 +- tests/int/campaignLeadership.int.spec.ts | 8 +- .../campaignMunicipalityUpdate.int.spec.ts | 2 +- tests/int/municipalityDossierData.int.spec.ts | 14 +- .../municipalityZoneNeighborhoods.int.spec.ts | 2 +- tests/unit/actionPlanUi.unit.spec.ts | 136 - tests/unit/activityUi.unit.spec.ts | 132 + .../campaignCompositionCleanup.unit.spec.ts | 4 +- tests/unit/campaignListUrl.unit.spec.ts | 2 +- tests/unit/codebaseConventions.unit.spec.ts | 70 +- tests/unit/recentVisits.unit.spec.ts | 2 +- 84 files changed, 13813 insertions(+), 1469 deletions(-) rename src/app/(campaign)/campanha/(app)/{planos/[slug]/ActionPlanOverviewTab.tsx => atividades/[slug]/ActivityOverviewTab.tsx} (89%) rename src/app/(campaign)/campanha/(app)/{planos => atividades}/[slug]/editar/page.tsx (58%) create mode 100644 src/app/(campaign)/campanha/(app)/atividades/[slug]/lifecycleFormActions.ts rename src/app/(campaign)/campanha/(app)/{planos => atividades}/[slug]/page.tsx (57%) rename src/app/(campaign)/campanha/(app)/{planos => atividades}/[slug]/resultFormActions.ts (61%) rename src/app/(campaign)/campanha/(app)/{planos => atividades}/[slug]/taskActions.ts (56%) rename src/app/(campaign)/campanha/(app)/{planos => atividades}/[slug]/updateFormActions.ts (62%) rename src/app/(campaign)/campanha/(app)/{planos => atividades}/contactSearchActions.ts (79%) rename src/app/(campaign)/campanha/(app)/{planos => atividades}/formActions.ts (57%) rename src/app/(campaign)/campanha/(app)/{planos/novo => atividades/nova}/page.tsx (65%) rename src/app/(campaign)/campanha/(app)/{planos => atividades}/page.tsx (66%) delete mode 100644 src/app/(campaign)/campanha/(app)/planos/[slug]/lifecycleFormActions.ts rename src/app/(campaign)/campanha/actions/{actionPlan.ts => activity.ts} (62%) rename src/collections/{ActionPlan.ts => Activity.ts} (78%) delete mode 100644 src/components/campaign/actionPlan/ActionPlanList.tsx delete mode 100644 src/components/campaign/actionPlan/ActionPlanStatusBadge.tsx rename src/components/campaign/{actionPlan/ActionPlanCard.tsx => activity/ActivityCard.tsx} (55%) rename src/components/campaign/{actionPlan/ActionPlanDemandFields.tsx => activity/ActivityDemandFields.tsx} (93%) rename src/components/campaign/{actionPlan/ActionPlanFilters.tsx => activity/ActivityFilters.tsx} (80%) rename src/components/campaign/{actionPlan/ActionPlanForm.tsx => activity/ActivityForm.tsx} (78%) rename src/components/campaign/{actionPlan/ActionPlanLifecycleDialog.tsx => activity/ActivityLifecycleDialog.tsx} (77%) create mode 100644 src/components/campaign/activity/ActivityList.tsx rename src/components/campaign/{actionPlan/ActionPlanResultForm.tsx => activity/ActivityResultForm.tsx} (88%) create mode 100644 src/components/campaign/activity/ActivityStatusBadge.tsx rename src/components/campaign/{actionPlan/ActionPlanTabNav.tsx => activity/ActivityTabNav.tsx} (71%) rename src/components/campaign/{actionPlan/ActionPlanTaskChecklist.tsx => activity/ActivityTaskChecklist.tsx} (83%) rename src/components/campaign/{actionPlan/ActionPlanTaskFields.tsx => activity/ActivityTaskFields.tsx} (91%) rename src/components/campaign/{actionPlan/ActionPlanUpdateFeed.tsx => activity/ActivityUpdateFeed.tsx} (83%) rename src/components/campaign/{actionPlan/ActionPlanUpdateForm.tsx => activity/ActivityUpdateForm.tsx} (88%) rename src/lib/schemas/{actionPlan.ts => activity.ts} (70%) create mode 100644 src/migrations/20260725_213000_rename_action_plan_to_activity.json create mode 100644 src/migrations/20260725_213000_rename_action_plan_to_activity.ts rename src/utilities/access/{actionPlans.ts => activities.ts} (66%) delete mode 100644 src/utilities/actionPlanDetailTabUi.ts delete mode 100644 src/utilities/actionPlanPageData.ts rename src/utilities/{actionPlanDetailPageData.ts => activityDetailPageData.ts} (68%) create mode 100644 src/utilities/activityDetailTabUi.ts rename src/utilities/{actionPlanFormData.ts => activityFormData.ts} (75%) rename src/utilities/{actionPlanLeadershipOptions.ts => activityLeadershipOptions.ts} (85%) create mode 100644 src/utilities/activityPageData.ts rename src/utilities/{actionPlanUi.ts => activityUi.ts} (52%) rename src/utilities/{actionPlanViewModels.ts => activityViewModels.ts} (53%) rename tests/e2e/{campaignActionPlan.e2e.spec.ts => campaignActivity.e2e.spec.ts} (76%) rename tests/int/{campaignActionPlan.int.spec.ts => campaignActivity.int.spec.ts} (71%) delete mode 100644 tests/unit/actionPlanUi.unit.spec.ts create mode 100644 tests/unit/activityUi.unit.spec.ts diff --git a/.cursor/rules/codebase-map.mdc b/.cursor/rules/codebase-map.mdc index 491e678b..d41e0b34 100644 --- a/.cursor/rules/codebase-map.mdc +++ b/.cursor/rules/codebase-map.mdc @@ -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//` — 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//` — 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 diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index d96fb0e5..d3b4a296 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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). diff --git a/AGENTS.md b/AGENTS.md index fba1dab3..ebea4f58 100644 --- a/AGENTS.md +++ b/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// (municipality, actionPlan, supporter, …, shared, shell — Pass 2 W2), ui from shadcn +├── components/ # campaign// (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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3ede529b..c924a862 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/plans/renomear-plano-acao-para-atividade.md b/docs/plans/renomear-plano-acao-para-atividade.md index 925e652a..c05fba1c 100644 --- a/docs/plans/renomear-plano-acao-para-atividade.md +++ b/docs/plans/renomear-plano-acao-para-atividade.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index f9d0c155..873de9f0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-25 (**B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13** renomear "Plano de Ação" → **"Atividade"** em toda a vertical — pedido de produto (2026-07-25): a palavra que a mesa usa é "atividade"/"agenda" e "plano de ação" sempre precisa ser explicado (o próprio `PRODUCT.md` já chama a superfície de "agenda"); rename **completo** — entidade e labels, `slug: 'activity'`, tabelas/enums/colunas/índices, 61 arquivos (13 componentes, 7 utilities, schemas, actions, access), campo `campaignDemand.actionPlan` → `activity`, rota `/campanha/planos` → `/campanha/atividades` e copy fora da vertical (organizações, demandas, sidebar) — com **migration data-preserving escrita à mão** (`ALTER … RENAME` de tabela/tipo/índice/constraint, idempotente e logada; o diff do Payload geraria DROP+CREATE e apagaria a agenda já cadastrada — a janela de "sem dados reais" que justificou o drop na remodelagem fechou: atividade não tem trava jurídica e a mesa registra assim que o smoke passar); guard estende o describe "banned campaign terminology" de `tests/unit/codebaseConventions.unit.spec.ts` para tabela de termos varrendo `src`+`tests`+`scripts` (compostos + copy + rota; `plan`/`plano` isolado **não** entra — colidiria com os "planos" da Fase 2 white-label); fazer **antes** do onboarding e de **E13**, e obrigatoriamente antes do congelamento (~20/09) por carregar migration; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); **B27** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — a lista de checkboxes com botão "Salvar assessores" vira chips removíveis sobre um combobox com busca, gravando **por delta** (endpoint JSON espelho do `expected-votes/`, lock do B19 ✓), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); **B23** tooltip de conteúdo nas células das listas — `cellTooltip` em `CampaignTableColumn`, consumidores as colunas "Assessores" e "Tendência" (justificativa do E4R no hover; sem nota, sem tooltip) de `/campanha/municipios`, [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); **B22** explicação por coluna no header das listas — tooltip de hover/foco/tap promovido para o sistema de listas compartilhado, [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) +Atualizado em: 2026-07-25 (**B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); **B27** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — a lista de checkboxes com botão "Salvar assessores" vira chips removíveis sobre um combobox com busca, gravando **por delta** (endpoint JSON espelho do `expected-votes/`, lock do B19 ✓), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); **B23** tooltip de conteúdo nas células das listas — `cellTooltip` em `CampaignTableColumn`, consumidores as colunas "Assessores" e "Tendência" (justificativa do E4R no hover; sem nota, sem tooltip) de `/campanha/municipios`, [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); **B22** explicação por coluna no header das listas — tooltip de hover/foco/tap promovido para o sistema de listas compartilhado, [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -78,7 +78,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**A11** posição em votos do município (rank absoluto + % da própria votação) + ordenação da lista por votação~~ — **entregue 2026-07-24** (helper `municipalityVoteRank`; baseline + coluna lista; default `?sort=votos` desc; header=`2022`; consome contrato B15) · [plano](plans/ranking-votos-municipio.md) - ~~**E17** tabela comparativa dos Territórios de Identidade no Início (pedido do candidato) · somas/razões apenas, Metropolitano decomposto (salvaguardas MAUP) · ~1d · Janela 1–2 · primeira fatia de E12 · [plano](plans/tabela-ti-inicio.md)~~ — **entregue 2026-07-24** (`territoryOverview.ts` + `loadTerritoryOverview.ts` + `TerritoryOverviewTable.tsx`; rollup puro client-safe, loader `overrideAccess: true`, Metropolitano decomposto, ordenação client-side; sem migration) - ~~**C12** registro-fundação~~ — **entregue em código 2026-07-24** (versions, sinais, origem/custo derivado, decisões ex-ante; migration + testes) · [plano](plans/registro-fundacao.md) -- **C13** renomear "Plano de Ação" → "Atividade" (entidade, código, banco e rotas) — pedido de produto (2026-07-25): a mesa diz "atividade" (ou "agenda") e o nome atual **sempre precisa ser explicado**, custo de tradução recorrente justamente na janela de onboarding; `PRODUCT.md` (§Users, §Product Purpose) já descreve a superfície como "agenda", então a divergência é entre o produto escrito e o implementado. Rename **completo, sem meio-termo**: collection `actionPlan` → `activity` (labels "Atividade"/"Atividades"), tabelas `action_plan*` → `activity*` + 3 enums + `campaign_demand.action_plan_id` e `payload_locked_documents_rels.action_plan_id` + ~30 índices/constraints, 61 arquivos e ~820 ocorrências de identificador (13 componentes `ActionPlan*` → `Activity*`, 7 utilities `actionPlan*` → `activity*`, `access/actionPlans.ts`, `lib/schemas/actionPlan.ts`, `actions/actionPlan.ts`), rota `/campanha/planos` → `/campanha/atividades` (interna e autenticada — **sem** redirect legado, que seria uma rota fantasma a allowlistar) e a copy fora da vertical (`Organization`, `CampaignDemand`, `/campanha/organizacoes`, sidebar "Planos" → "Atividades"). **Migration data-preserving escrita à mão** (`ALTER … RENAME`, guardas `IF EXISTS`, contagem logada, `down()` simétrico; snapshot `.json` gerado é mantido e só o SQL é substituído — padrão de `rename_tag_visible_to_hidden` + reconcile da remodelagem): o diff do Payload geraria DROP+CREATE, e ao contrário de 2026-07-23 **já não vale supor banco vazio** — atividade não depende do lote jurídico, então a mesa a preenche assim que o smoke passar. **Guard** (pedido explícito): o describe "banned campaign terminology" de `tests/unit/codebaseConventions.unit.spec.ts` — que hoje mantém Praça/Núcleo fora de `src` — vira **tabela de termos** e passa a varrer `src`+`tests`+`scripts` (migrations continuam fora, história congelada; o próprio spec entra na allowlist porque cita os literais); banimento = compostos (`actionPlan|ActionPlan|ACTION_PLAN|action_plan|action-plan`), copy ("plano/planos de ação") e a rota antiga — **não** `plan`/`plano` isolado, que colidiria com os "planos" de assinatura da Fase 2 white-label. Zero mudança de comportamento, access, campo ou fluxo; PR mecânico e isolado (o polish visual da vertical continua no **R6**) · ~1–1,5d · Janela 1 (antes do onboarding; **antes de E13**, que constrói dentro desta vertical; e antes do congelamento ~20/09 por carregar migration) · Impeccable B · sem deps duras · cortável (mas cortar cedo ou nunca) · [plano](plans/renomear-plano-acao-para-atividade.md) +- ~~**C13** renomear "Plano de Ação" → "Atividade" (entidade, código, banco e rotas)~~ — **entregue 2026-07-25** (collection `activity`, 4 tabelas + 3 enums + 2 colunas + 39 índices + 14 FKs + 2 sequences renomeados por migration data-preserving escrita à mão `20260725_213000_rename_action_plan_to_activity`, que também alinhou os 8 índices fósseis `%plaza%` da remodelagem; rota `/campanha/atividades`, sidebar "Atividades", rótulos de campo "Tipo/Origem/Resultado da atividade"; guard de vocabulário table-driven em `codebaseConventions.unit.spec.ts` varrendo `src`+`tests`+`scripts`) · [plano](plans/renomear-plano-acao-para-atividade.md) - **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · camada de insight sobre o registro operacional `stateDeputy` (M4) · alimenta **E13** · [plano](plans/insight-dobradinha-2026.md) - **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md) - **B8 F2** polígonos dos Municípios-zona de Salvador (ZE 1–19; Camaçari saiu do escopo na M1 — município inteiro): F1 catálogo zona→bairros entregue (hoje Salvador-only); falta prep do catálogo (~½ dia) + dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável · [plano](plans/poligonos-pracas-zona.md) @@ -172,7 +172,7 @@ flowchart TD B37n["B37 Chips editáveis de município
na lista de dobradinhas"] B36n["B36 Chips de lideranças
na lista de dobradinhas"] B29n["B29 Sort + filtro no header
da lista de lideranças"] - C13n["C13 Plano de Ação → Atividade
(rename + guard de vocabulário)"] + C13n["C13 ✓ Plano de Ação → Atividade
(rename + guard de vocabulário)"] E4R["E4R ✓ Import planilha
(seed estratégia)"] A11n["A11 ✓ Posição em votos"] E17n["E17 ✓ Tabela TI no Início"] @@ -300,7 +300,7 @@ Paralelizáveis agora: **E10**/**E12** (E8 ✓ desbloqueou ambos), **E11** (E9 ### Sequência por janela (só pendentes) -**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16 ✓** filtros no header entregue 2026-07-25; **B20 ✓** Municípios prioritários no Início entregue 2026-07-25; **B21** (página dos Territórios de Identidade — a leitura regional sai do painel do Início e ganha tela própria com sort/filtro; útil já no onboarding para dividir carteiras) se folga — e, logo atrás dela, **B25** (link do território na célula da lista: quarto de dia, é o que faz a página nova ser encontrada de dentro do trabalho); **B22** (explicação por coluna no header — a tabela deixa de exigir o autor do E8/E9 ao lado para ser lida; quick win de onboarding, meio dia); **B23** (tooltip nas células — nomes dos assessores no hover da coluna, hoje só iniciais, e a justificativa da tendência, hoje invisível na lista; capacidade genérica da célula, meio dia, casa com B22 pelo mesmo primitivo); **B24** (auto-save + justificativa no popover de Tendência — a mesa corrige em massa as notas semeadas pelo E4R ✓ sem abrir 435 telas de edição; meio dia, casa com B23 pela mesma célula); **B26** (registrar sinal na própria célula "Último sinal" — fecha o laço do E9 ✓: hoje a fila diz onde está frio e obriga a sair dela para registrar qualquer coisa; ~0,75 dia, e é o item que mais aumenta a chance do gate de adoção abaixo, porque o registro passa a caber no gesto de varrer a lista, inclusive no celular); **B27** (combobox + chips + auto-save nos Assessores — é literalmente a tela em que o CG divide as carteiras no onboarding: hoje procura o nome numa coluna de checkboxes e confirma num botão; ~0,75 dia, casa com B24 pelo mesmo endpoint e com B23 pelo mesmo gatilho); **B28** (e-mail + celular na lista de lideranças — copy + WhatsApp como em assessores, sem abrir a ficha; ~0,5–0,75 dia, quick win de campo no onboarding da rede); **B30** (convidar para completar cadastro por WhatsApp na mesma lista — reusa o convite que já existe no detalhe, sem abrir a ficha; ~0,25–0,5 dia, o quick win mais barato desta leva porque não toca action/collection nenhuma; casa com B28 pela mesma linha, ícone distinto); **B31** (coluna de dobradinhas na mesma lista — é o preenchimento em lote que o onboarding exige: o E4R ✓ deixou dobradinha só no município e as fichas vazias, e completar uma a uma pelo detalhe é o gesto que trava a mesa; ~1 dia, casa com B27 pela mesma peça de célula — quem for primeiro define o padrão do chip+combobox in-list); **B32** (editar Status de apoio na célula da mesma lista — pedido de produto explícito para não repetir o toggle "Editar" do B19 ✓; fecha o adiado do próprio B29; ~0,4–0,5 dia, o quick win mais barato entre os popovers de célula desta leva, casa com B24 pelo guard CSRF compartilhado); **B34** (chips editáveis de municípios na mesma lista — a coluna "Municípios" ganha o mesmo tratamento da coluna homônima em Assessores (B19 ✓): chips com link + Popover de edição com combobox, em vez do texto morto atual; ~1–1,25 dia, casa com B27/B31 pela mesma peça de célula editável — quem entrar antes define o padrão do combobox+chip in-list que os outros herdam); **B37** (o mesmo tratamento de chips editáveis na coluna "Municípios" de `/campanha/dobradinhas` — hoje um número morto, `municipalityCount`; ~0,75–1 dia, herda a lib promovida pelo B34 se ele já tiver landado, senão promove ela mesma); **B36** (a coluna "Lideranças" da mesma lista ganha o mesmo tratamento — chips nomeados + Popover + busca + auto-save por delta, reusando sem alteração a mutação que o B31 desenha para o lado oposto da mesma relação; ~0,5–1,5 dia dependendo de quem entre B31/B36 chega primeiro na corrida de extração da célula compartilhada); **B33** (sort + filtro no header de `/campanha/dobradinhas` — pedido explícito de reuso: Nome/Partido no header e filtro por Partido, sem duplicar o chrome de `/campanha/municipios`; ~0,5–1,5 dia dependendo de quem chega primeiro na corrida de extração do chrome compartilhado com B29/B21; entra perto de B29 por reusar a mesma peça); **B29** (sort + filtro no header da mesma lista — é a tela onde o assessor recebe a carteira e precisa recortar "quem ainda está a abordar aqui" / "quem ainda não tem acesso ao app" antes de convidar; ~1,5–2 dias porque paga também a extração do head compartilhado, então entra depois dos quick wins — mas antes se B21 for adiado, para o head nascer compartilhado); **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; fill-in **renomear coluna Cobertura** (header curto na fila — quarto de dia; glossário/KPI intactos); fill-in **remover coluna Tipo** (densidade morta na fila — quarto/meio dia; Salvador via slugs cobre ZE); fill-in **selecionados no topo nos filtros do header** (desmarcar sem caçar checkbox; helper puro que B21 herda — quarto de dia); **C13** (renomear "Plano de Ação" → "Atividade" em toda a vertical — o time que entra agora aprende **uma** palavra, a que já usa em campo, em vez de decorar uma tradução; ~1–1,5 dia, PR mecânico e isolado, e é o único item desta janela que carrega migration, então quanto mais cedo melhor: cada item novo na vertical e cada semana de uso aumentam o custo, e depois do congelamento ~20/09 ele deixa de ser possível); gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa). +**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16 ✓** filtros no header entregue 2026-07-25; **B20 ✓** Municípios prioritários no Início entregue 2026-07-25; **B21** (página dos Territórios de Identidade — a leitura regional sai do painel do Início e ganha tela própria com sort/filtro; útil já no onboarding para dividir carteiras) se folga — e, logo atrás dela, **B25** (link do território na célula da lista: quarto de dia, é o que faz a página nova ser encontrada de dentro do trabalho); **B22** (explicação por coluna no header — a tabela deixa de exigir o autor do E8/E9 ao lado para ser lida; quick win de onboarding, meio dia); **B23** (tooltip nas células — nomes dos assessores no hover da coluna, hoje só iniciais, e a justificativa da tendência, hoje invisível na lista; capacidade genérica da célula, meio dia, casa com B22 pelo mesmo primitivo); **B24** (auto-save + justificativa no popover de Tendência — a mesa corrige em massa as notas semeadas pelo E4R ✓ sem abrir 435 telas de edição; meio dia, casa com B23 pela mesma célula); **B26** (registrar sinal na própria célula "Último sinal" — fecha o laço do E9 ✓: hoje a fila diz onde está frio e obriga a sair dela para registrar qualquer coisa; ~0,75 dia, e é o item que mais aumenta a chance do gate de adoção abaixo, porque o registro passa a caber no gesto de varrer a lista, inclusive no celular); **B27** (combobox + chips + auto-save nos Assessores — é literalmente a tela em que o CG divide as carteiras no onboarding: hoje procura o nome numa coluna de checkboxes e confirma num botão; ~0,75 dia, casa com B24 pelo mesmo endpoint e com B23 pelo mesmo gatilho); **B28** (e-mail + celular na lista de lideranças — copy + WhatsApp como em assessores, sem abrir a ficha; ~0,5–0,75 dia, quick win de campo no onboarding da rede); **B30** (convidar para completar cadastro por WhatsApp na mesma lista — reusa o convite que já existe no detalhe, sem abrir a ficha; ~0,25–0,5 dia, o quick win mais barato desta leva porque não toca action/collection nenhuma; casa com B28 pela mesma linha, ícone distinto); **B31** (coluna de dobradinhas na mesma lista — é o preenchimento em lote que o onboarding exige: o E4R ✓ deixou dobradinha só no município e as fichas vazias, e completar uma a uma pelo detalhe é o gesto que trava a mesa; ~1 dia, casa com B27 pela mesma peça de célula — quem for primeiro define o padrão do chip+combobox in-list); **B32** (editar Status de apoio na célula da mesma lista — pedido de produto explícito para não repetir o toggle "Editar" do B19 ✓; fecha o adiado do próprio B29; ~0,4–0,5 dia, o quick win mais barato entre os popovers de célula desta leva, casa com B24 pelo guard CSRF compartilhado); **B34** (chips editáveis de municípios na mesma lista — a coluna "Municípios" ganha o mesmo tratamento da coluna homônima em Assessores (B19 ✓): chips com link + Popover de edição com combobox, em vez do texto morto atual; ~1–1,25 dia, casa com B27/B31 pela mesma peça de célula editável — quem entrar antes define o padrão do combobox+chip in-list que os outros herdam); **B37** (o mesmo tratamento de chips editáveis na coluna "Municípios" de `/campanha/dobradinhas` — hoje um número morto, `municipalityCount`; ~0,75–1 dia, herda a lib promovida pelo B34 se ele já tiver landado, senão promove ela mesma); **B36** (a coluna "Lideranças" da mesma lista ganha o mesmo tratamento — chips nomeados + Popover + busca + auto-save por delta, reusando sem alteração a mutação que o B31 desenha para o lado oposto da mesma relação; ~0,5–1,5 dia dependendo de quem entre B31/B36 chega primeiro na corrida de extração da célula compartilhada); **B33** (sort + filtro no header de `/campanha/dobradinhas` — pedido explícito de reuso: Nome/Partido no header e filtro por Partido, sem duplicar o chrome de `/campanha/municipios`; ~0,5–1,5 dia dependendo de quem chega primeiro na corrida de extração do chrome compartilhado com B29/B21; entra perto de B29 por reusar a mesma peça); **B29** (sort + filtro no header da mesma lista — é a tela onde o assessor recebe a carteira e precisa recortar "quem ainda está a abordar aqui" / "quem ainda não tem acesso ao app" antes de convidar; ~1,5–2 dias porque paga também a extração do head compartilhado, então entra depois dos quick wins — mas antes se B21 for adiado, para o head nascer compartilhado); **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; fill-in **renomear coluna Cobertura** (header curto na fila — quarto de dia; glossário/KPI intactos); fill-in **remover coluna Tipo** (densidade morta na fila — quarto/meio dia; Salvador via slugs cobre ZE); fill-in **selecionados no topo nos filtros do header** (desmarcar sem caçar checkbox; helper puro que B21 herda — quarto de dia); **C13 ✓** (renomeado "Plano de Ação" → "Atividade" em toda a vertical, incluindo a migration data-preserving — entregue 2026-07-25); gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa). **Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga. @@ -312,7 +312,7 @@ Paralelizáveis agora: **E10**/**E12** (E8 ✓ desbloqueou ambos), **E11** (E9 **Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`). -**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); **B22** explicação no header (a página `/campanha/conceitos` do E18 ✓ continua explicando as métricas do E8 — cortar só custa o atalho em contexto, e o onboarding presencial cobre no curto prazo); **B23** tooltip nas células (as iniciais + o Popover de edição do coordenador continuam dando os nomes, e a justificativa da tendência continua no detalhe do município — cortar custa a leitura a olho na varredura; corte junto com B22, já que dividem o primitivo); **B24** auto-save + justificativa na Tendência (o botão "Salvar" continua funcionando e a justificativa continua editável em `/[slug]/editar` — cortar custa coerência com a célula vizinha e um desvio conhecido do princípio 4; é o corte mais barato desta lista, mas também o mais barato de fazer); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); **B30** convite de completar cadastro por WhatsApp na lista (o botão do detalhe continua gerando o convite; cortar custa só o atalho sem abrir a ficha — é o corte mais barato desta faixa, porque não toca nenhuma action/collection); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); **B32** editar Status de apoio na célula (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); **B36** coluna de lideranças na lista de dobradinhas (a contagem `leadershipCount` continua legível e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); **B33** sort + filtro no header das dobradinhas (a busca por nome e a ordem fixa por nome continuam funcionando — lista pequena hoje, cortar custa só o recorte por partido e a ordenação por partido; corta antes de B29 se o chrome compartilhado ainda não tiver sido pago por nenhum dos dois); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); **C13** renomear Plano de Ação → Atividade (a vertical continua funcionando com o nome antigo — cortar custa o atrito de tradução em cada conversa de onboarding e deixa o código, o banco e a URL divergindo do vocabulário do time; **cortar cedo ou nunca**: é o único item cortável desta faixa cujo custo cresce com o tempo — cada item novo na vertical multiplica as ocorrências, o uso real enche a tabela e o congelamento ~20/09 fecha a porta da migration, então cortar aqui significa cortar para o ciclo inteiro); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. +**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); **B22** explicação no header (a página `/campanha/conceitos` do E18 ✓ continua explicando as métricas do E8 — cortar só custa o atalho em contexto, e o onboarding presencial cobre no curto prazo); **B23** tooltip nas células (as iniciais + o Popover de edição do coordenador continuam dando os nomes, e a justificativa da tendência continua no detalhe do município — cortar custa a leitura a olho na varredura; corte junto com B22, já que dividem o primitivo); **B24** auto-save + justificativa na Tendência (o botão "Salvar" continua funcionando e a justificativa continua editável em `/[slug]/editar` — cortar custa coerência com a célula vizinha e um desvio conhecido do princípio 4; é o corte mais barato desta lista, mas também o mais barato de fazer); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); **B30** convite de completar cadastro por WhatsApp na lista (o botão do detalhe continua gerando o convite; cortar custa só o atalho sem abrir a ficha — é o corte mais barato desta faixa, porque não toca nenhuma action/collection); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); **B32** editar Status de apoio na célula (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); **B36** coluna de lideranças na lista de dobradinhas (a contagem `leadershipCount` continua legível e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); **B33** sort + filtro no header das dobradinhas (a busca por nome e a ordem fixa por nome continuam funcionando — lista pequena hoje, cortar custa só o recorte por partido e a ordenação por partido; corta antes de B29 se o chrome compartilhado ainda não tiver sido pago por nenhum dos dois); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. ## Bloqueadores atuais diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/ActionPlanOverviewTab.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx similarity index 89% rename from src/app/(campaign)/campanha/(app)/planos/[slug]/ActionPlanOverviewTab.tsx rename to src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx index ef864d91..faed4416 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/ActionPlanOverviewTab.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx @@ -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> +type ActivityDetailView = Awaited> /** 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 }) => (
@@ -74,8 +74,8 @@ export const ActionPlanOverviewTab = ({
{view.responsibleName ?? 'Não definido'}
-
Origem da ação
-
{actionPlanOriginLabels[view.origin]}
+
Origem da atividade
+
{activityOriginLabels[view.origin]}
@@ -133,7 +133,7 @@ export const ActionPlanOverviewTab = ({ {view.municipality ? ( ) : null} {canManageLifecycle ? ( <> - - + + ) : null} - + - {activeTab === 'overview' ? : null} + {activeTab === 'overview' ? : null} {activeTab === 'tasks' ? ( @@ -119,15 +119,15 @@ export default async function ActionPlanDetailPage({ Tarefas - + ) : null} {activeTab === 'updates' ? (
- - + +
) : null} diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/resultFormActions.ts b/src/app/(campaign)/campanha/(app)/atividades/[slug]/resultFormActions.ts similarity index 61% rename from src/app/(campaign)/campanha/(app)/planos/[slug]/resultFormActions.ts rename to src/app/(campaign)/campanha/(app)/atividades/[slug]/resultFormActions.ts index 56bcf7ab..fe5ffd8c 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/resultFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/resultFormActions.ts @@ -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 => { 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({ diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/taskActions.ts b/src/app/(campaign)/campanha/(app)/atividades/[slug]/taskActions.ts similarity index 56% rename from src/app/(campaign)/campanha/(app)/planos/[slug]/taskActions.ts rename to src/app/(campaign)/campanha/(app)/atividades/[slug]/taskActions.ts index 2beabf0e..579574ff 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/taskActions.ts +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/taskActions.ts @@ -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 => { +): Promise => { 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 { diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts b/src/app/(campaign)/campanha/(app)/atividades/[slug]/updateFormActions.ts similarity index 62% rename from src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts rename to src/app/(campaign)/campanha/(app)/atividades/[slug]/updateFormActions.ts index 90e46e84..56804e40 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/updateFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/updateFormActions.ts @@ -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 } -// 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 => { +): Promise => { 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({ diff --git a/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts b/src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts similarity index 79% rename from src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts rename to src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts index adf45f02..2005d3a5 100644 --- a/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts +++ b/src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts @@ -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 => { 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 => { +): Promise => { 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) } diff --git a/src/app/(campaign)/campanha/(app)/planos/formActions.ts b/src/app/(campaign)/campanha/(app)/atividades/formActions.ts similarity index 57% rename from src/app/(campaign)/campanha/(app)/planos/formActions.ts rename to src/app/(campaign)/campanha/(app)/atividades/formActions.ts index 6231d4a4..88945c4b 100644 --- a/src/app/(campaign)/campanha/(app)/planos/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/atividades/formActions.ts @@ -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 => { +const existingActivityState = async (formData: FormData): Promise => { const title = optionalFormText(formData, 'title') const slug = title ? slugify(title) : '' if (!slug) return null @@ -52,7 +52,7 @@ const existingActionPlanState = async (formData: FormData): Promise => { - let planSlug: string +): Promise => { + 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 => { - let planSlug: string +): Promise => { + 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}`) } diff --git a/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx similarity index 65% rename from src/app/(campaign)/campanha/(app)/planos/novo/page.tsx rename to src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx index 6c5f25b5..354aa59a 100644 --- a/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx @@ -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 (
-

Planos de ação

-

Novo plano

+

Atividades

+

Nova atividade

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

-
) diff --git a/src/app/(campaign)/campanha/(app)/planos/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/page.tsx similarity index 66% rename from src/app/(campaign)/campanha/(app)/planos/page.tsx rename to src/app/(campaign)/campanha/(app)/atividades/page.tsx index eddf0599..ce126151 100644 --- a/src/app/(campaign)/campanha/(app)/planos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/page.tsx @@ -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> } -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
-

Planos de ação

+

Atividades

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

{canCreate ? ( ) : null}
- @@ -78,26 +78,28 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis {result.docs.length ? ( <> - toActionPlanListViewModel(plan as ActionPlan))} + + toActivityListViewModel(activity as Activity), + )} /> buildActionPlanListHref(state, page)} + hrefForPage={(page) => buildActivityListHref(state, page)} /> ) : ( )} diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx index 7a3851fb..ac8e039a 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx @@ -79,15 +79,15 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps > {demand.municipalityName} - {demand.actionPlan ? ( + {demand.activity ? ( <> {' '} - · Plano:{' '} + · Atividade:{' '} - {demand.actionPlan.title} + {demand.activity.title} ) : null} diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts index 6d21b835..f55ee0f2 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts @@ -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}`, diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx index 69928bbf..c5248afa 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx @@ -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
diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx index 06c15d69..7c8c72da 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx @@ -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
- {organization.actionPlans.length ? ( + {organization.activities.length ? (
    - {organization.actionPlans.map((plan) => ( -
  • + {organization.activities.map((activity) => ( +
  • - {plan.title} + {activity.title} - {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' : ''}
  • @@ -126,7 +128,7 @@ export default async function OrganizationDetailPage({ params }: OrganizationDet
) : (

- Nenhum Plano de Ação vinculado a esta organização ainda. + Nenhuma atividade vinculada a esta organização ainda.

)}
diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx index ee979abe..794384ac 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx @@ -30,8 +30,8 @@ export default async function NewOrganizationPage() {

Nova organização

- 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.

diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx index e0fdd666..426891df 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx @@ -82,7 +82,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP

Organizações

Sindicatos, associações e movimentos que apoiam a campanha — com suas lideranças e - Planos de Ação. + atividades.

) @@ -69,11 +69,11 @@ const ActionPlanTabSwitch = ({ state }: { state: ActionPlanListState }) => ( ) -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 (
- +
setMobileFiltersOpen((open) => !open)} >