Remodel /campanha around predefined Praças replacing electoral nuclei.

436 seeded Praças (municipality-based; per-TSE-zone in Salvador/Camaçari) become
the operational unit; roles renamed to coordinator/advisor/leader with advisors
scoped to administered Praças. Leadership is one record per person across many
Praças/organizations; vote pledges split leader-declared vs staff-estimated
(estimates never serialized to leaders). Adds organizations, demand workflow
with staff-only cost/receipts, action-plan results with deputy presence, manual
political trend, multi-candidate comparison and the diverging red/white/blue
map mode. Migration 20260721_020109_remodel_plazas deliberately resets the
campaign vertical in production (public site untouched); test suite ported to
the new model (unit 187 / int 306 / e2e 9 green).
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-21 02:28:00 -03:00
parent bbd9f627f5
commit cf13c441aa
335 changed files with 32081 additions and 29106 deletions

View file

@ -3,10 +3,11 @@ description: Estado do projeto Núcleos Eleitorais
alwaysApply: false
---
# Núcleos Eleitorais
# Núcleos Eleitorais → Praças
## Status
- **REMODELAGEM ENTREGUE EM CÓDIGO (2026-07-21; deploy pendente — migração `20260721_020109_remodel_plazas` é destrutiva para a vertical campanha, revisar SQL antes do build de produção). Suítes verdes: unit 187, int 306 (incl. assimetria declarado×estimado, access por papel, workflow de demandas), e2e 9. Critique/polish visual fino registrado como R6 no roadmap.** Feedback da coordenação invalidou o modelo de núcleo abstrato. Decisões travadas: 436 Praças pré-definidas (415 municípios + 19 zonas de Salvador + 2 de Camaçari; zonas compartilhadas cortadas na divisa municipal); código `plaza`; roles renomeados para `coordinator`/`advisor`/`leader` (labels "Coordenador Geral"/"Assessor"/"Liderança"); assessor vê só as Praças que administra; liderança = 1 registro por pessoa com N Praças e N organizações; votos por liderança×Praça (`declaredVotes` visível à liderança, `estimatedVotes` staff-only); demandas com workflow assessor→coordenador geral + comprovantes staff-only; tendência política manual (staff); comparativo multi-candidato/multi-ano por Praça; mapa divergente vermelho↔branco↔azul. Reset dos dados de campanha em produção (site público intocado). Plano-mestre: [`docs/plans/remodelagem-pracas.md`](../../../docs/plans/remodelagem-pracas.md). As entradas abaixo descrevem a era Núcleos — padrões de infra (locks, transações, consent por chave, shells, mapa, dados eleitorais) continuam válidos; superfícies e modelo de Núcleo foram substituídos.
- **MVP Ciclo 1 entregue e enviado em `main` (2026-07-18):** ondas 1–8 (auth/RBAC, domínio+UI de Núcleo/Liderança/Inteligência/Atualizações/Convites, dashboard, hardening) + refactors de composição Next.js, primitivos compartilhados, locks PostgreSQL, fixtures E2E e migration consolidada `20260718_010733_consolidate_campaign_schema`.
- **Ciclo 2 entregue, mesclado e deployado (2026-07-18):** A1 território multi-município/bairro, A3 baseline TSE 2022 (modelo + import), B1 overview da lista, C1 compartilhar página e D1 PWA — detalhes no item datado abaixo.
- **A2 entregue e mesclado em `main` (2026-07-18):** cadastro estático município↔ZE (`bahiaTseZones`, TSE 2024) + chips opt-in no formulário (`NucleusTerritoryAndZonesFields` / `territorySuggestions`); sem migration. Plano: [`docs/plans/zonas-por-municipio.md`](../../../docs/plans/zonas-por-municipio.md).

17
.impeccable/config.json Normal file
View file

@ -0,0 +1,17 @@
{
"detector": {
"ignoreRules": [],
"ignoreFiles": [],
"ignoreValues": [
{
"rule": "design-system-color",
"value": "#111827",
"files": [
"src/collections/CampaignUser.ts"
],
"createdAt": "2026-07-21T01:54:11.928Z",
"reason": "Pre-existing inline hex in the password-reset EMAIL HTML template; transactional email cannot use CSS tokens"
}
]
}
}

View file

@ -8,7 +8,8 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si
**Locked decisions — do not relitigate without a new concrete reason:**
- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `geral`, `coordenador`, and `lideranca`, all on the separate auth collection `campaignUser` (see "Campaign auth" below).
- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), and `leader` ("Liderança"), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the Praças they administer.
- **2026-07-20 remodel (in progress): "Praça" replaces "Núcleo Eleitoral" as the operational unit.** Praças are PREDEFINED (seeded, geography read-only): one per Bahia municipality, except Salvador (19 zone Praças, ZE 1–19) and Camaçari (2 zone Praças, ZE 170/171); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `plaza`. Leaderships are one record per person (`contact` UNIQUE) linked to N plazas and N organizations; vote pledges are per leadership×plaza with `declaredVotes` (leader-visible) vs `estimatedVotes` (staff-only, hidden from the leader). Campaign production data was reset by the remodel migration (public-site data untouched). Master plan: `docs/plans/remodelagem-pracas.md`. Sections below describing "nucleus" surfaces are being rewritten as the remodel lands.
- Hosting stays on Vercel for now. No self-host/Coolify migration in progress.
- Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL.
@ -98,41 +99,39 @@ The internal `/campanha` area is gated by its own authentication, deliberately k
- **Route layout.** The route group's root layout `src/app/(campaign)/layout.tsx` renders the `<html>`/`<body>` with `data-theme="campaign"`. The public login lives at `src/app/(campaign)/campanha/login/` (`page.tsx` + client `LoginForm.tsx`). Everything gated sits in the `(app)` group, whose layout (`src/app/(campaign)/campanha/(app)/layout.tsx`) calls `getCampaignUser()` and `redirect('/campanha/login')` when there is no session — that layout is the barrier.
- **Migrations.** `20260716_010420_add_campaign_user` created the auth collection; the unpublished 2026-07-17 campaign chain was consolidated into `20260718_010733_consolidate_campaign_schema`, which adds role/username auth, contact phone, and the remaining final campaign schema.
## Campaign nuclei MVP
## Campaign Praças model (2026-07-20 remodel)
The first `/campanha` operational cycle is centered on `electoralNucleus`; a Núcleo is the campaign's operating unit and is not a TSE electoral zone.
`/campanha` is organized around `plaza` ("Praça") — the campaign's PREDEFINED operational territory. Master plan: `docs/plans/remodelagem-pracas.md`.
- **Collections:** `electoralNucleus` (territory, coordinators, TSE zone references, intelligence and vote estimates), `leadership` (unique `Contact`↔nucleus join with support status), `nucleusUpdate` (immutable field reports), and `campaignInvite` (single-use WhatsApp invite hashes). All are in admin group `Campanha`.
- **Canonical nucleus URLs:** nuclei use a unique canonical `slug` generated from `name` and route through `/campanha/nucleos/[slug]`. IDs stay internal. Names are immutable after creation so existing shared links remain durable without an alias table.
- **Territory:** Bahia is implicit. Since 2026-07-18 nuclei use `hasMany` arrays `regions` / `cities` / `neighborhoods` (plus `locality` / `territoryNotes`), validated against official Bahia identity territories (`src/lib/bahiaTerritories.ts`): `regions` is derived server-side from the selected cities, and neighborhoods require exactly one city. List filters stay single-select and query array membership with `{ equals }`. Migration `20260718_190559_territorio_multi_municipio_bairro` backfilled the old scalar fields (`docs/plans/territorio-multi-municipio-bairro.md`). **A2 (2026-07-18):** static municipality↔TSE zone map in `src/lib/bahiaTseZones.ts` (`tseZonesForCity` / `tseZonesForTerritory` / `citiesForTseZone`); the create/edit form coordinator `NucleusTerritoryAndZonesFields` offers opt-in `{label} +` chips from `buildTerritorySuggestions` (city/TI → missing zones; TI siblings and zone cities → municipalities). Manual `TseZoneInput` stays free (1–999); server validation does not require `tseZones` to match the official set; an amber client advisory may warn when a typed zone is outside the selected geography. Details: `docs/plans/zonas-por-municipio.md`. **B2 (2026-07-18):** static map geometries — see "Campaign map geometries (B2)" below (Leaflet surfaces are B3).
- **Role scope:** `geral` sees/manages all nuclei, assigns coordinators, and confirms estimates; `coordenador` sees assigned nuclei and manages their leaderships, updates, invites, and estimates; `lideranca` sees only nuclei linked through an engaged leadership record, creates own updates, and suggests estimates.
- **Sensitive fields:** `leadership.supportStatus`, internal notes, consent notes, and strategic nucleus intelligence are staff-only and are excluded from leadership view models. Revoking `engajado` revokes nucleus access immediately. Dashboard aggregates omit Payload-redacted missing statuses and fail closed on invalid enum values.
- **People and consent:** leaderships always reuse `Contact`; multi-collection writes are transactional. Self-service updates accept a strict whitelist derived from the invite token. Campaign consent is looked up only by the stable key `lideranca-autopreenchimento`.
- **Invites:** only SHA-256 token hashes are persisted. Absolute invite URLs require a valid `NEXT_PUBLIC_SITE_URL` (exact HTTPS DNS origin in production). Public invite pages are dynamic, `no-store`, `noindex,nofollow`, `no-referrer`, and have no third-party application scripts. Login invites are allowed only for engaged leaderships.
- **Vote estimates:** proposals never overwrite the confirmed value; each proposal carries a server-generated UUID version compared under advisory lock on confirm. Only a coordinator assigned to the nucleus or `geral` may confirm.
- **List overview (B1, 2026-07-18):** `/campanha/nucleos` renders an aggregate panel between the filters and the list (`NucleusListOverview` + `loadNucleusListOverviewData`): confirmed vote estimate total/%, coordinator coverage, and a 3-update preview, computed over the entire filtered set (`buildNucleusListWhere`, `pagination: false`, `overrideAccess: false`). Hidden when the filtered set is empty; `lideranca` gets a reduced select without pending-suggestion counts.
- **Share dialog (C1, 2026-07-18):** the nucleus detail header has a "Compartilhar" dialog (WhatsApp `wa.me` per recipient + copy link) that sends only the URL — it never creates invites or grants access. Recipients (gerais, assigned coordinators, `engajado` leaderships) are loaded on open via a read-only Server Action; the loader gates on nucleus access (`overrideAccess: false`) then reads name+phone privileged (`overrideAccess: true`) to avoid N+1 field-access checks. `canReadCampaignUserPhone` allows any campaign user to read `geral` phones.
- **PWA (D1, 2026-07-18):** `/campanha` is installable — manifest and service worker are served by route handlers under `/campanha` (script built in `src/utilities/campaignPwa.ts`), scoped to the vertical only (public site and `/admin` have no SW). Cache is named `campanha-<buildId>` and swapped on deploy (`skipWaiting`/`claim`); navigation is network-first with an `/campanha/offline` fallback; RSC/Flight responses and `/campanha/convite/*` are never cached. `InstallPwaToast` prompts install on mobile (Android `beforeinstallprompt`, iOS step-by-step drawer, `sessionStorage` dismissal), and logout wipes the Cache API client-side before the server action runs. Push handlers are placeholders for D2.
- **Praça definition:** 436 seeded rows — one per Bahia municipality (415), except Salvador (19 zone Praças, ZE 1–19) and Camaçari (2 zone Praças, ZE 170/171). TSE zones shared across municipalities (141, 162) or leaking outside the RMS (128, 185, 200) are cut at the municipal boundary, so every Praça maps to exact `cityCode × zones` election cells. The canonical catalog is `src/lib/plazaCatalog.ts` (slug/name identity frozen by `tests/fixtures/plaza-catalog.snapshot.json`); the migration `20260721_020109_remodel_plazas` seeds it. Nobody creates/edits plaza geography in the app — identity fields are admin-only; staff edit only strategy fields (goals, priority, intel, political trend) and the coordinator assigns `advisors`.
- **Collections (admin group `Campanha`):** `plaza` (strategy + advisors + manual `politicalTrend`), `leadership` (ONE per person: `contact` UNIQUE + `plazas` hasMany + `organizations` hasMany + staff-only `supportStatus`), `votePledge` (leadership×plaza UNIQUE: `declaredVotes` leader-visible vs `estimatedVotes` staff-only), `organization` (sindicatos/associações/movimentos with reverse leadership + actionPlan views), `campaignDemand` (leader-opened workflow `aberta → em_analise → escalada → aprovada|rejeitada` with staff-only `cost`/`receipts`), `plazaUpdate` (immutable field reports maintaining `plaza.lastUpdateAt` under advisory lock), `campaignInvite` (unchanged: single-use SHA-256 hashes hanging off `leadership`), plus adapted `supporter` (optional `plaza`) and `actionPlan` (single `plaza`, `advisors`, `organizations`, `deputyPresent`, `resultSummary`/`resultMedia`).
- **Roles:** `coordinator` ("Coordenador Geral") sees/manages everything and is the only one who assigns advisors and decides escalated demands; `advisor` ("Assessor") sees and manages ONLY plazas listed in `plaza.advisors` (list, map, dashboard, leaderships, pledges, demands are all scoped); `leader` ("Liderança") sees only plazas linked through their engaged leadership, declares own votes, opens demands, and writes own updates.
- **Vote pledge asymmetry (product-critical):** the leader INFORMS `declaredVotes` per linked plaza and may update it; staff record `estimatedVotes`/`estimateNote`, which field access denies to leaders (same technique as `supportStatus`). Leader-facing view models never aggregate estimates; staff aggregates use `estimatedVotes ?? declaredVotes` (`src/utilities/votePledgeData.ts`). Guarded by `tests/int/campaignVotePledge.int.spec.ts`.
- **Political trend:** manual conjuncture reading on the plaza (staff-only group `politicalTrend`: `favoravel|neutra|desfavoravel` + note + derived author/date). The numeric series is deliberately labeled "Evolução de votos" in the UI to keep "tendência" for the political reading.
- **Elections intelligence:** plaza geography → election cells via `src/utilities/plazaElectionGeography.ts`; baseline + Solla series 2014/2018/2022 (`plazaElectoralBaseline.ts`); multi-candidate comparison table on the plaza detail (`plazaCandidateComparison.ts`, up to 5 candidates via `?compare=`); map (`plazaMapData.ts` + `PlazaMapPanel`) colors municipalities by year (2026 = pledge estimates) and has a diverging compare mode (campaign red `#c51414` where Solla leads ↔ white tie ↔ blue where the compared candidate leads). Zone Praças aggregate into the municipality polygon with a per-zone breakdown list (zone polygons remain a rabbit hole).
- **URLs:** `/campanha/pracas[/slug]` (seeded slugs from the catalog), `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/planos`, `/campanha/apoiadores`. Route params stay English keys; slugs/enums stay pt data values.
- **Invites:** unchanged mechanics (hash-only persistence, `NEXT_PUBLIC_SITE_URL` origin, `no-store` public pages, login invites only for `engajado`); advisor scope now follows leadership↔plaza intersection. Consent still resolves only by stable key `lideranca-autopreenchimento` (fail closed).
- **PWA (D1):** unchanged — installable `/campanha`, versioned cache, offline fallback; push is still D2.
## Campaign supporters (C2)
Nominal supporter registry backing the campaign's base of declared supporters (roadmap item C2; engineering ready and merged to `main` on 2026-07-18, production-blocked on the legal batch). One collection in admin group `Campanha`:
- **`supporter`** (`src/collections/Supporter.ts`) — join `Contact`↔campaign with **optional `nucleus`** (transversal base; aggregates by territory when linked). Fields: `contact` (required, indexed), `nucleus` (optional, indexed), `voteIntention` (select `certo | tende_a_certo | indeciso | outro`, indexed — **sensitive data**, field access restricted to `geral`/`coordenador`), paired consent fields for registration and vote intention (`consent`/`consentContentHash`/`consentedAt` and `voteIntentionConsent`/`voteIntentionConsentContentHash`/`voteIntentionConsentedAt`), `source` (`import_csv | manual | convite | evento`; v1 writes only the first two), `consentNote`, `notes` (staff-only), `createdBy` (readOnly). Uniqueness: `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)` (Postgres ≥15) via migration `20260718_222656_add_supporter`. A `beforeChange` hook sets `createdBy` and rejects a `contact` that is already a `leadership` of the same `nucleus`.
- **`supporter`** (`src/collections/Supporter.ts`) — join `Contact`↔campaign with **optional `plaza`** (transversal base; aggregates by Praça when linked). Fields: `contact` (required, indexed), `plaza` (optional, indexed), `voteIntention` (select `certo | tende_a_certo | indeciso | outro`, indexed — **sensitive data**, field access restricted to `coordinator`/`advisor`), paired consent fields for registration and vote intention (`consent`/`consentContentHash`/`consentedAt` and `voteIntentionConsent`/`voteIntentionConsentContentHash`/`voteIntentionConsentedAt`), `source` (`import_csv | manual | convite | evento`; v1 writes only the first two), `consentNote`, `notes` (staff-only), `createdBy` (readOnly). Uniqueness: `UNIQUE NULLS NOT DISTINCT (contact_id, plaza_id)` (Postgres ≥15) via migration `20260721_020109_remodel_plazas`. A `beforeChange` hook sets `createdBy` and rejects a `contact` whose leadership is already linked to the same `plaza`.
- **Consent by stable key.** `campaignConsent.ts` was generalized to `getConsentByKey` / `requireConsentByKey` (fail-closed). The two keys are `apoiador-cadastro` (declared support) and `apoiador-intencao-voto` (vote intention — highlighted, separate consent). The app fails closed if either key is missing — same pattern as `lideranca-autopreenchimento`.
- **Access.** `geral` sees/manages all; `coordenador` sees supporters of assigned nuclei (`nucleus in getAccessibleNucleusIds`); supporters without a nucleus are `geral`-only; `lideranca` has no access to the area. Delete is admin-only. Access helpers live in `campaignAccess.ts` (`canReadSupporter` / `canManageSupporter` / `canCreateSupporter`).
- **Server actions** (`src/app/(campaign)/campanha/actions/supporter.ts`): `createSupporter`, `setSupporterVoteIntention`, `previewSupporterImport` / `confirmSupporterImport` (CSV, `geral`-only, transactional with `req: { transactionID }`, dedup by phone via `contactPhoneInvariant`), and `removeSupporterData` (deletes the `supporter`; anonymizes/clears `Contact` PII only if no other join — `leadership`/`signature`/`subscription`/other `supporter` — references it).
- **Access.** `coordinator` sees/manages all; `advisor` sees supporters of administered plazas (`plaza in getAccessiblePlazaIds`); supporters without a plaza are `coordinator`-only; `leader` has no access to the area. Delete is admin-only. Access helpers live in `campaignAccess.ts` (`canReadSupporter` / `canManageSupporter` / `canCreateSupporter`).
- **Server actions** (`src/app/(campaign)/campanha/actions/supporter.ts`): `createSupporter`, `setSupporterVoteIntention`, `previewSupporterImport` / `confirmSupporterImport` (CSV, `coordinator`-only, transactional with `req: { transactionID }`, dedup by phone via `contactPhoneInvariant`), and `removeSupporterData` (deletes the `supporter`; anonymizes/clears `Contact` PII only if no other join — `leadership`/`signature`/`subscription`/other `supporter` — references it).
- **UI** `/campanha/apoiadores` — list with KPIs (total / "Certo + Tende" / "Indecisos"), filters (vote intention, territory), detail with LGPD consent block + vote-intention segmented control (disabled until consent) + minimal share kit (`wa.me` + copy text), and a 3-step CSV import wizard (Upload → Preview → Confirm). Phone is required in v1.
- **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, nucleus_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, `geral`/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`).
- **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, plaza_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)
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`:
- **`actionPlan`** (`src/collections/ActionPlan.ts`) — a calendarizable action/event (caminhada, comício, etc.) with its own territory (no link to `electoralNucleus`), task checklist, and update feed. Fields: `title`, canonical immutable `slug` (from `title`), `kind` / `status` enums in pt-BR (data values), `startAt` (optional only while `rascunho`; required to move to `planejado`/`confirmado`/`realizado`/`cancelado` — card shows "Data a definir" when absent), territory arrays `regions`/`cities`/`neighborhoods` (Bahia validation shared via `campaignTerritoryValidation.ts`; list/overview filters use `{ equals }` on arrays, A1 semantics), people (`coordinators` → `campaignUser`, `responsible` → `Contact`, `leadership` → `leadership` optional), `tasks` and `updates` as arrays in the MVP (append-only for updates; `doneAt`/`author`/`createdAt` derived server-side). No `Consent` (internal staff data). Migration `20260718_222832_add_action_plan`.
- **Access.** `geral` everything; `coordenador` where listed in `coordinators` (may create, auto-included); `lideranca` where `leadership` ∈ engaged — write limited to toggling `tasks.done` and appending `updates`. Domain writes go through `withPayloadTransaction` (`src/utilities/payloadTransaction.ts`).
- **UI** `/campanha/planos` — list with tabs Próximos (default) / Todos / Realizados / Rascunhos, filters (kind, territory), cards with kind/status badge + date/time + municipality + TI chip + "Resp:" + task progress; detail with tabs Visão geral / Tarefas / Atualizações (pattern `NucleusTabNav`); new/edit forms. "Próximos eventos" blocks surface on the nuclei list overview and the dashboard.
- **`actionPlan`** (`src/collections/ActionPlan.ts`) — a calendarizable action/event (caminhada, comício, etc.) anchored on ONE `plaza`, with task checklist, update feed and result record. Fields: `title`, canonical immutable `slug`, `kind` / `status` enums in pt-BR, `startAt` (required beyond `rascunho`), `plaza` (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 Praça. No `Consent` (internal staff data).
- **Access.** `coordinator` everything; `advisor` where listed in `advisors` OR the plan's `plaza` is administered (may create, auto-included); `leader` where `leadership` ∈ engaged — write limited to toggling `tasks.done` and appending `updates`. Domain writes go through `withPayloadTransaction` (`src/utilities/payloadTransaction.ts`).
- **UI** `/campanha/planos` — list with tabs Próximos (default) / Todos / Realizados / Rascunhos, filters (kind, plaza), cards with kind/status badge + date/time + Praça + "Resp:" + task progress + "Deputado presente"; detail with tabs Visão geral / Tarefas / Atualizações; result form appears for staff when the plan is `realizado`.
## Campaign map geometries (B2)
@ -146,7 +145,7 @@ Static Bahia map foundation for future Leaflet surfaces (roadmap B2; UI is B3).
## Election baseline data (TSE 2022)
Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Three collections in admin group `Dados Eleitorais`: `electionTally` (per city×zone totals: eligible voters, turnout, valid/blank/null votes), `electionCandidateVote` (nominal votes per candidate×city×zone), and `electionCandidate` (candidate registry with a cross-year `identityKey` hash — no CPF or voter ID is ever persisted). Read access requires an authenticated `campaignUser` or `users` account (`canReadElectionData`); writes are Payload-admin-only, and the import runs via CLI with `overrideAccess: true`. **A4 (merged to `main` 2026-07-18):** `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` / `computeGapVs2022` surface the baseline on the nucleus overview tab (`NucleusElectoralBaseline` + `NucleusInsights` Gap vs 2022) and a "Baseline 2022" card on the filtered nuclei list overview; ticket roles live in `BASELINE_TICKET_2022`. Scale follow-ups from the A4 `/simplify` pass are **A7** (`docs/plans/escala-dry-pos-a4.md`).
Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Three collections in admin group `Dados Eleitorais`: `electionTally` (per city×zone totals: eligible voters, turnout, valid/blank/null votes), `electionCandidateVote` (nominal votes per candidate×city×zone), and `electionCandidate` (candidate registry with a cross-year `identityKey` hash — no CPF or voter ID is ever persisted). Read access requires an authenticated `campaignUser` or `users` account (`canReadElectionData`); writes are Payload-admin-only, and the import runs via CLI with `overrideAccess: true`. Since the Praça remodel, the consumers are `plazaElectoralBaseline.ts` (per-plaza series/turnout), `plazaCandidateComparison.ts` (multi-candidate table) and `plazaMapData.ts` (map, incl. the diverging compare mode); ticket roles live in `BASELINE_TICKET_2022`. Seeds cover 2014/2018/2022 (E2 extended the original 2022-only A3 scope).
`pnpm db:seed:tse` (`scripts/seed-tse-results.mjs`) downloads the TSE open-data zips (provenance URLs + SHA-256 in the script header), parses the Bahia scope, and imports via `payload.db.drizzle` in a transaction — idempotent by replace-per-scope `(year, office, turn)`, only `voteType: 'nominal'` rows in v1. It refuses a non-local `DATABASE_URL` (same guard family as the other seeds; override with `ALLOW_REMOTE_DB=true`) and caches downloads under `data/tse/` (gitignored). No revalidate step is needed: campaign pages are dynamic with auth, not ISR-cached.
@ -158,6 +157,7 @@ Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Thr
4. Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change.
5. Review Vercel environment variables and migration SQL before deploy; `pnpm build` applies pending migrations to production automatically.
6. **Mandatory production blocker:** do not insert real leadership/support data or enable invites until electoral counsel documents the LGPD art. 11 basis, approves the specific versioned consent text, and an admin creates it with exactly `Consent.key = 'lideranca-autopreenchimento'`. The app intentionally fails closed when this key is absent.
7. **Praça remodel deploy note:** migration `20260721_020109_remodel_plazas` is DESTRUCTIVE for the campaign vertical (drops nucleus-era tables, wipes campaign rows, converts `campaignUser.role` values, seeds 436 plazas). Review its SQL before the first production build that includes it; public-site data is untouched.
## Core Principles

View file

@ -12,13 +12,13 @@ web
Two surfaces, one product.
**`/campanha` (primary for design defaults):** campaign coordination (`geral`, `coordenador`) and leaderships (`lideranca`) as first-class peers — staff need depth for territory, people, estimates, and agenda; leaderships need a simpler, phone-first path into their nucleus. Used in the field and on the go, often under time pressure through the 2026 calendar.
**`/campanha` (primary for design defaults):** campaign coordination (`coordinator` "Coordenador Geral", `advisor` "Assessor") and leaderships (`leader` "Liderança") as first-class peers — staff need depth for Praças (predefined territories: municipality, or TSE zone in Salvador/Camaçari), people, declared-vs-estimated votes, and agenda; leaderships need a simpler, phone-first path into their Praças. Advisors see only the Praças they administer. Used in the field and on the go, often under time pressure through the 2026 calendar.
**Public site:** citizens and the base in Bahia first (news, petitions, donate, stay close to the mandate); institutional visitors second (press, partners, official voice).
## Product Purpose
**`/campanha`:** field operations and electoral intelligence as one job — nuclei, people, estimates, agenda, and share kits, plus a live picture of territory and vote gap so the team can prioritize action. Nuclei, people, and estimates live here as the system of record, even when outreach uses pragmatic channels.
**`/campanha`:** field operations and electoral intelligence as one job — Praças, people, vote pledges, organizations, demands, agenda, and share kits, plus a live picture of territory (map scaled by votes, year-over-year candidate comparison) so the team can prioritize action. Praças, people, and pledges live here as the system of record, even when outreach uses pragmatic channels.
**Public site:** own the relationship with the base first (engagement channel the campaign controls); project the mandate’s official voice and credibility second.

View file

@ -0,0 +1,135 @@
# Remodelagem da /campanha: Núcleo Eleitoral → Praça
Status: entregue (código; deploy pendente — revisar SQL destrutivo antes do build de produção)
Atualizado em: 2026-07-21
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Remodelagem Praças", itens R0–R5)
Impeccable: C — vertical `/campanha` remodelada (rotas novas `/campanha/pracas`, `/campanha/organizacoes`, `/campanha/demandas`; adaptação de planos/apoiadores/dashboard)
Appetite: ~2,5–3 semanas eng (fases R0–R5), funcional antes de 16/08 (início da propaganda)
Responsável: —
## Design (Impeccable)
Âncoras: `PRODUCT.md` / `DESIGN.md` (register product — "Field Desk") · tema `data-theme='campaign'` · shells existentes (`CampaignPageShell`, `CampaignListPagination`, `campaignListUrl`, `campaignFormFields`, shadcn `src/components/ui`).
Na implementação: shape compacto por superfície → craft → critique → polish (Fase R5).
Brief compacto:
- **Persona / contexto:** Alex (coordenador geral) aloca esforço entre 436 Praças; o Assessor opera só as Praças que administra, no celular, sob pressão; Lia (liderança) declara votos e abre demandas pelo PWA.
- **Job principal:** enxergar o mesmo quadro operacional por Praça (votos, lideranças, agenda, demandas) e agir no território certo.
- **Estratégia de cor:** Restrained (Field Desk). Exceção justificada: escala divergente do mapa comparativo (vermelho `#c51414` ↔ branco ↔ azul novo token).
- **Anti-goals:** não virar dashboard SaaS genérico; não expor à liderança números de staff (estimativas do assessor, tendência política, custos).
## Contexto
O MVP de Núcleos (ciclos 1–2, Trilhas A–E) modelou a unidade operacional como `electoralNucleus`: uma entidade criada manualmente, com geografia livre (arrays `regions`/`cities`/`neighborhoods`), natureza organizativa (`organizationKind`) e estimativa de votos única por núcleo (proposta→confirmada). Em 2026-07-20, feedback da coordenação de campanha invalidou três premissas:
1. **A coordenação não se organiza em torno de núcleos abstratos, e sim de territórios pré-definidos** — municípios, exceto na RMS onde o delimitador é a zona eleitoral. Criar/editar geografia manualmente é atrito sem valor.
2. **O jargão real é "Assessor"**, não "coordenador" (que colide com "Coordenador Geral"). E "tendência" na campanha significa mudança de conjuntura política (prefeito aliado que virou adversário), não evolução de série numérica.
3. **Estimativa de votos é por liderança×território**, com assimetria: a liderança informa quantos votos traz; o assessor estima o valor real e a liderança nunca vê o ajuste.
A decisão de produto (2026-07-20, confirmada com o usuário) é remodelar a vertical em torno de **"Praça"** (código: `plaza`), com reset dos dados de campanha em produção (não há dados reais na vertical; o site público permanece intocado).
## Objetivos
- As 436 Praças existem pré-definidas (seed via migration a partir de catálogo estático versionado); ninguém cria/edita geografia de Praça no app.
- Roles `coordinator` ("Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"); assessor vê e gerencia **somente** as Praças que administra.
- Liderança é 1 registro por pessoa (`contact` UNIQUE), vinculada a N Praças e N Organizações; declara votos por Praça (`votePledge.declaredVotes`); o assessor registra `estimatedVotes` invisível à liderança (field access nega leitura).
- Organizações (sindicatos, associações, etc.) com página própria mostrando lideranças associadas e Planos de Ação apoiados.
- Planos de Ação com Praça, organizações apoiadoras, "Deputado presente" e registro de resultado (texto + fotos/vídeos) consultável no histórico da Praça.
- Demandas abertas pela liderança (ou staff), revisadas pelo assessor, escaláveis ao coordenador geral, com custo e comprovantes staff-only.
- Tendência política manual (staff) por Praça; a série numérica E2 é renomeada para "Evolução de votos" na UI.
- Comparativo multi-candidato/multi-ano por Praça (tabela) e no mapa (escala divergente vermelho↔branco↔azul).
- Guardrails: access control por papel em toda leitura (`overrideAccess: false` com `user`); escrita multi-collection transacional com `req`; sem chave `Consent` nova (Onda 0 inalterada); migrations commitadas (`push: false`).
## Decisões travadas
- **Entidade principal "Praça", código `plaza`.** Tradução direta minimiza carga cognitiva; "Praça" evita colisão com Territórios de Identidade na UI. (Usuário, 2026-07-20.) **Rejeitado:** `territory` (não traduz "praça"; colide com identificadores TI existentes — `bahiaTerritories`, `regions`, `territoryForCity` permanecem intocados); manter "Núcleo" (não representa a organização real da coordenação).
- **436 Praças pré-definidas:** 415 municípios (todos menos Salvador e Camaçari) + 19 zonas de Salvador (ZE 1–19) + 2 de Camaçari (ZE 170/171). Zonas compartilhadas (141, 162) e que vazam da RMS (128, 185, 200) são **cortadas na divisa municipal** — cada município é sua própria Praça; dados TSE são município×zona, recorte exato. (Usuário, 2026-07-20.) **Rejeitado:** zona "pura" multi-município (unidades cruzando divisa municipal e vazando da RMS); zonas em todos os 13 municípios da RMS (11 têm zona única — viraria 1:1 com o município); manter geografia livre por núcleo.
- **Roles em inglês no valor do enum:** `coordinator` | `advisor` | `leader` (labels pt "Coordenador Geral" / "Assessor" / "Liderança"). Aproveita o reset; sem segundo "coordenador" não há ambiguidade. Migração converte `geral`→`coordinator`, `coordenador`→`advisor`, `lideranca`→`leader` preservando contas. (Usuário, 2026-07-20.) **Rejeitado:** manter valores pt (`assessor` etc.) — inconsistente com a decisão de reduzir carga cognitiva no código.
- **Reset dos dados de campanha em produção.** Migração destrutiva dropa e recria as collections da vertical campanha (núcleos, lideranças, atualizações, convites, apoiadores, planos); `campaignUser` e `Contact` preservados; site público intocado. (Usuário, 2026-07-20 — "apenas na vertical campanha; as publicações do site público são reais".) **Rejeitado:** migração núcleo→praça com preservação (não há dados reais que justifiquem o custo); coexistência `/nucleos` + `/pracas` (duas verticais em paralelo confundem o time).
- **Assimetria de votos: `declaredVotes` (liderança) vs `estimatedVotes` (staff-only).** A liderança informa; o assessor estima; a liderança lê apenas o próprio declarado. Field access nega leitura de `estimatedVotes`/`estimateNote` a `leader` (mesma técnica de `supportStatus`); view models de liderança nunca somam estimados. Agregados staff usam `estimatedVotes ?? declaredVotes`. (Usuário, 2026-07-20.) **Rejeitado:** fluxo sugerir→confirmar por Praça (não é o processo real); mostrar o ajuste à liderança (quebra a relação de campo).
- **Assessor vê somente as Praças que administra** (`plaza.advisors`), incluindo lista, mapa, agregados e dashboard. (Usuário, 2026-07-20.) **Rejeitado:** leitura global com gestão restrita (vazaria inteligência entre assessores).
- **Demandas com eixo financeiro na v1:** `cost` + `receipts[]` (upload → `media`), field access staff-only, disclaimer de controle interno (não substitui SPCE/TSE — base legal e limites já pesquisados em [demandas-campanha.md](demandas-campanha.md)). (Usuário, 2026-07-20.) **Rejeitado:** cortar comprovantes da v1.
- **Mapa comparativo divergente:** vermelho da plataforma (`#c51414`) onde Solla lidera, centro branco, azul onde o comparado lidera. (Usuário, 2026-07-20.)
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`plaza`, `Plaza*`, `votePledge`, `campaignDemand`, `plazaUpdate`, `organization`), URLs/segmentos em pt (`/campanha/pracas`, `/campanha/organizacoes`, `/campanha/demandas`), labels/admin em pt-BR; enums de dados permanecem em pt (`municipio|zona`, `aberta|em_analise|...`).
## Questões em aberto
- **Valores do enum de tendência política?** **Opções:** `favoravel|neutra|desfavoravel` | escala 5 pontos | texto livre. **Recomendação:** 3 valores + nota obrigatória (validar jargão com a coordenação na âncora de quarta).
- **Contas `leader` órfãs pós-reset?** **Opções:** manter logins | desativar. **Recomendação:** manter contas (username/senha preservados) e revincular quando a nova liderança for cadastrada com o mesmo telefone.
- **Tom do azul do modo comparação?** **Recomendação:** novo token semântico no tema `campaign` com contraste AA sobre branco; definir no polish (R5).
## Abordagem proposta
```mermaid
flowchart TD
catalog["src/lib/plazaCatalog.ts<br/>436 entradas estáticas"] --> seed["migration consolidada<br/>drop campanha + seed plazas + roles"]
seed --> plaza["plaza (collection)"]
plaza --> leadership["leadership (contact UNIQUE,<br/>plazas[], organizations[])"]
leadership --> pledge["votePledge (leadership x plaza,<br/>declared vs estimated)"]
plaza --> update["plazaUpdate (feed imutável)"]
plaza --> demand["campaignDemand (workflow +<br/>cost/receipts staff-only)"]
plaza --> plan["actionPlan (plaza, orgs,<br/>deputyPresent, result)"]
org["organization"] --> leadership
org --> plan
election["electionTally/CandidateVote/Candidate<br/>2014/2018/2022 (preservados)"] -.baseline, comparativo, mapa.-> plaza
```
Componentes principais (depth check: reusar shells/utilities existentes):
- **`src/lib/plazaCatalog.ts`** — catálogo estático das 436 Praças (slug, nome, kind `municipio|zona`, city, region TI, ibgeCode, tseCityCode, zoneNumber?, tseZones[]), derivado de `bahiaTseZones`/`bahiaMunicipalityCodes`/`bahiaTerritories` + fixture `tests/fixtures/` + teste int de contagem/bijeção/consistência.
- **Collections** (`src/collections/`): `Plaza.ts`, `Leadership.ts` (reescrita), `VotePledge.ts`, `Organization.ts`, `CampaignDemand.ts`, `PlazaUpdate.ts` (ex-`NucleusUpdate`), `ActionPlan.ts` (alterada: `plaza`, `organizations`, `deputyPresent`, `result*`), `Supporter.ts` (nucleus→plaza), `CampaignUser.ts` (roles), `CampaignInvite.ts` (escopo por plazas).
- **`src/utilities/campaignAccess.ts`** — reescrita: `getAdministeredPlazaIds` (advisor), `getLeaderPlazaIds`, access por collection seguindo o padrão atual (Where por papel, cache em `req.context`).
- **Migração** `pnpm migrate:create remodel_plazas` — converte roles com `USING`, dropa tabelas da vertical, cria o novo schema e faz seed das 436 Praças (import do catálogo no migration, precedente Onda 0).
- **Geografia eleitoral** — `plazaElectionGeography` substitui `nucleusElectionGeography` (praça-município: cityCode + todas as zonas; praça-zona: cityCode + [zoneNumber]); baseline/série/gap/insights adaptados.
- **Superfícies** (`src/app/(campaign)/campanha/(app)/`): `pracas/` (lista + mapa com seletor de ano), `pracas/[slug]/` (tabs Visão geral / Eleições / Lideranças / Atualizações / Demandas), `organizacoes/`, `demandas/`, `planos/` adaptado, `apoiadores/` adaptado, dashboard reancorado. Server actions em `campanha/actions/` seguem o padrão transacional existente.
- **Mapa** — reusa `BahiaMap`/`ChoroplethMapPanel` (B3) com métrica por ano (2014/2018/2022 TSE; 2026 = `estimatedVotes ?? declaredVotes` agregado) e modo comparativo divergente (Fase R4).
## Fases
- **R0 Documentação** — este plano; roadmap reescrito; AGENTS/notebook/PRODUCT/CUSTOMER.
- **R1 Domínio** — catálogo + collections + access + migração + types verdes.
- **R2 Superfícies core** — pracas lista/detalhe/mapa v1, CRM liderança multi-Praça, votos declarados×estimados, dashboard, convites.
- **R3 Organizações, Planos, Demandas** — verticais novas + adaptação de planos.
- **R4 Inteligência** — comparativo multi-candidato, mapa divergente, tendência manual, rename série E2.
- **R5 Hardening** — testes por papel, responsividade 360/390/768/1440, critique/polish, Aikido, checklist de deploy (migração destrutiva revisada antes do build de produção).
## Dependências
- Reusa: `bahiaTseZones`/`bahiaMunicipalityCodes`/`bahiaTerritories`/`bahiaGeometries` (A2/B2), collections eleitorais + seeds 2014/2018/2022 (A3/E2), `BahiaMap`/choropleth (B3), locks transacionais (`postgresTransactionLocks`), `withPayloadTransaction`, `campaignConsent` (chaves existentes), shells de lista/form (C6/C9), PWA D1.
- Nenhum item externo bloqueia; Onda 0 (lote jurídico) segue paralelo e inalterado.
## Não escopo
- Polígonos por zona eleitoral no mapa (permanece como evolução futura do B4; v1 agrega zonas no polígono municipal com painel de detalhe).
- Push/notificações (D2, [notifications.md](notifications.md)); GOTV (C5); dobradinha automática 2026 (A6, reenquadrar pós-remodel); perfis IBGE (A8, reenquadrar).
- Exportação fiscal SPCE (documentado em [demandas-campanha.md](demandas-campanha.md)).
- Import CSV de lideranças/pledges (apoiadores mantém o wizard existente adaptado).
## Rabbit holes
- **Polígonos de zona (geocodificar seções).** Se alguém "só completar" o mapa de Salvador por zona: semanas de geoprocessamento frágil. **Mitigação:** agregação municipal + painel por zona; polígono de zona só com decisão explícita.
- **Cargo da liderança por organização.** Join com atributos (presidente, diretor…) explode o CRM. **Mitigação:** v1 é `hasMany` simples; atributo por vínculo só com pedido real.
- **Histórico auditável de tendência política.** v1 = valor atual + nota + autor/data; collection de histórico só se o coordenador geral pedir.
- **Renomear identificadores TI existentes.** `bahiaTerritories`/`regions`/etc. permanecem; renomear em massa é churn sem valor.
- **Vídeo grande no resultado do plano.** Upload via `media`/Blob tem limites de tamanho; documentar limite na UI, não construir pipeline de vídeo.
- **Paridade 1:1 da suíte de testes de núcleos.** Reescrever 488 testes int nucleus-based seria a cauda longa; portar cobertura por papel/fluxo crítico e apagar specs obsoletos, documentando o corte.
## Adiado com gatilho
- **Lista global de lideranças com filtros avançados.** Revisitar quando o CRM por Praça não bastar (pedido do coordenador geral ou >200 lideranças).
- **`demandUpdate` como collection separada.** Revisitar se o array `statusHistory` crescer além do razoável (>20 entradas por demanda).
- **Import em massa de pledges.** Revisitar quando houver planilha real de votos declarados por liderança.
## Referências
- `docs/roadmap.md` (seção "Remodelagem Praças")
- `docs/plans/demandas-campanha.md` — pesquisa legal/LGPD do eixo financeiro (reaproveitada)
- `docs/plans/mapa-bahia-geometrias.md` — decisões de geometria e limitação de zonas
- `docs/plans/baseline-eleitoral-tse.md` — modelo eleitoral por município×zona
- `src/collections/ElectoralNucleus.ts`, `Leadership.ts`, `NucleusUpdate.ts`, `ActionPlan.ts`, `Supporter.ts` — padrões (hooks de slug, locks, access) a portar
- `src/utilities/campaignAccess.ts`, `voteEstimate.ts`, `nucleusElectionGeography.ts`, `nucleusElectoralBaseline.ts` — base da reescrita
- `src/lib/bahiaTseZones.ts`, `bahiaMunicipalityCodes.ts`, `bahiaTerritories.ts`, `electionResults.ts`, `electionInsights.ts`
- AGENTS.md — Campaign auth, naming, transações, migrations (`push: false`), LGPD/Consent fail-closed
- `PRODUCT.md` / `DESIGN.md` — register product (Field Desk), Signal Red Rule, tokens `data-theme='campaign'`

View file

@ -1,226 +1,133 @@
# Roadmap — Teqo
Atualizado em: 2026-07-20 (A8+ fill-in pós-`/simplify`; A8 em implementação)
Atualizado em: 2026-07-20 (Remodelagem Praças R0–R5 registrada como caminho crítico; itens núcleo-cêntricos supersedidos/reenquadrados)
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)
| Data | Marco | Consequência para o produto |
| ----------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------- |
| 20/07–05/08 | Convenções partidárias | Núcleos/coordenadores operando **agora** |
| 15/08 | Prazo final de registro de candidaturas | TSE publica candidaturas 2026 → destrava A6 (dobradinha) |
| 16/08 | Início da propaganda eleitoral | Base nominal, baseline e agenda em produção **antes** desta data |
| 09–23/10 | Propaganda gratuita rádio/TV | Congelar mudanças arriscadas |
| 04/10 | 1º turno | GOTV (C5) — confirmação de comparecimento da base nominal |
| 25/10 | Eventual 2º turno | Chapa majoritária; Solla decidido no 1º turno |
| Data | Marco | Consequência para o produto |
| ----------- | --------------------------------------- | --------------------------------------------------------------------------------------------- |
| 20/07–05/08 | Convenções partidárias | Remodelagem Praças em curso; assessores operando no modelo novo o quanto antes |
| 15/08 | Prazo final de registro de candidaturas | TSE publica candidaturas 2026 → destrava A6 (dobradinha) |
| 16/08 | Início da propaganda eleitoral | Vertical remodelada (Praças, demandas, agenda, base nominal) em produção **antes** desta data |
| 09–23/10 | Propaganda gratuita rádio/TV | Congelar mudanças arriscadas (~20/09) |
| 04/10 | 1º turno | GOTV (C5) — confirmação de comparecimento da base nominal |
| 25/10 | Eventual 2º turno | Chapa majoritária; Solla decidido no 1º turno |
## Princípios e decisões travadas
- Ownership de audiência e portabilidade de dados; módulos reutilizáveis em outros contextos políticos.
- Um único app Next.js: site `(frontend)`, admin `(payload)`, `/campanha` `(campaign)`. Sem Rust separado. Vercel por enquanto.
- Doações só via CTA QueroApoiar (`apoiar.me/jorgesolla`). Pessoa = `Contact` + joins — nunca cadastro paralelo.
- Núcleo = unidade operacional; Zona TSE = referência oficial distinta. Sem disparo em massa (Res. TSE 23.610 art. 33 / Meta).
- **Praça = unidade operacional pré-definida** (2026-07-20): município, exceto Salvador (19 Praças-zona) e Camaçari (2 Praças-zona); zonas compartilhadas cortadas na divisa municipal. Ninguém cria/edita geografia no app. Substitui o Núcleo Eleitoral — plano [remodelagem-pracas.md](plans/remodelagem-pracas.md).
- Roles: `coordinator` ("Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"); assessor vê só as Praças que administra. Sem disparo em massa (Res. TSE 23.610 art. 33 / Meta).
## Onda 0 — caminho crítico para dados reais
Engenharia de núcleos / C2 / C3 / baseline / PWA / Trilha E parcial já em `main`. Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](plans/onda-0.md)); **hold de PII real** até o lote jurídico final.
Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](plans/onda-0.md)); **hold de PII real** até o lote jurídico final. Inalterada pela remodelagem (mesmas chaves `Consent`).
1. **Lote jurídico único** _(externo)_ — textos finais (substituem provisórios) + base LGPD art. 11: `lideranca-autopreenchimento`, `apoiador-cadastro`, `apoiador-intencao-voto`, `campanha-notificacoes-push`, Aviso de Privacidade, avaliação RIPD.
2. **Smoke pós-deploy** — `NEXT_PUBLIC_SITE_URL` HTTPS, login `/campanha`, núcleo de teste; checklist no AGENTS.md.
2. **Smoke pós-deploy** — `NEXT_PUBLIC_SITE_URL` HTTPS, login `/campanha`, Praça de teste; checklist no AGENTS.md.
3. **Ativação com dados reais** assim que (1) liberar — lideranças/apoiadores reais e import em massa.
4. **Onboarding do time** — usuários `geral`/`coordenador`, primeiros núcleos, treino de campo.
4. **Onboarding do time** — usuários `coordinator`/`advisor`, primeiras Praças assumidas, treino de campo.
**O0+** (escala/DRY pós-Onda 0) não bloqueia jurídico nem smoke fictício — [plano](plans/escala-dry-pos-onda0.md).
## Já entregue (resumo)
## Remodelagem Praças (R0–R5) — caminho crítico de produto ✅ (código pronto; deploy pendente)
- **Núcleos MVP + Ciclo 2** — auth/`campaignUser`, território A1/A2, baseline A3/A4, overview B1, share C1, PWA D1, geometrias B2, Leaflet B3.
- **Operação** — C2 (eng. pronta; prod. ↔ Onda 0), C3 agenda, C6–C9 escala apoiadores/planos.
- **Trilha A (parcial)** — A5 conversão + classificação territorial + alavancagem/virada + mobilização (brancos/nulos); A7 F1 agregação federal no detalhe.
- **Trilha E (parcial)** — E1 metas/prioridade + E3 estratégia manual; E2 série TSE 2014/2018 + tendência; E7 F2 int loader; **E4 import planilha cortado** (decisão de produto: dados via UI manual, sem `pnpm db:seed:mapa`).
- **Fill-ins** — visitados recentemente, reset senha + perfil, Field Desk polish (Impeccable); B5 F1 lazy geometrias (com B3).
Feedback da coordenação (2026-07-20) invalidou o modelo de Núcleo: a campanha se organiza por territórios pré-definidos ("Praças"), jargão é "Assessor", votos são declarados por liderança×Praça e estimados pelo assessor (assimetria), demandas nascem da liderança com workflow de aprovação, tendência é conjuntura política manual, e a análise-chave é comparar candidatos por Praça através dos anos. Reset dos dados de campanha em produção (sem dados reais na vertical). Plano-mestre: [remodelagem-pracas.md](plans/remodelagem-pracas.md).
## Próximos — Campanha (`/campanha`)
### Por trilha (só abertos)
- **A** — A5 restante: inteligência competitiva ainda sem insight no produto · [competitiva](plans/insight-inteligencia-competitiva.md)
- **A** — A6 dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · [plano](plans/insight-dobradinha-2026.md)
- **A** — A7 F2–F5: `cityCode` TSE, Alert/Progress DRY, fetch 2022 único lista+mapa, batch flip na lista · gatilho F4: pós-B3 ✓; F5: pós-A5 · [plano](plans/escala-dry-pos-a4.md)
- **A** — A8 perfil médio do eleitorado (IBGE) na aba Eleitorado sem sobrescrever manuais · [plano](plans/perfil-eleitorado-ibge.md)
- **A** — A8+ escala pós-A8: lazy demographics, cobertura parcial, size budget · gatilho: merge A8 · [plano](plans/escala-dry-pos-a8.md)
- **B** — B4 camada de zonas TSE no mapa coroplético · [mapa](plans/mapa-bahia-geometrias.md)
- **B** — B5 F2–F3: cache CLI compartilhado + factory mun/TI · [plano](plans/escala-dry-pos-b2.md)
- **B** — B6 `BahiaMap` setStyle incremental ao trocar métrica · gatilho: troca frequente no overview · [plano](plans/escala-dry-pos-b3.md)
- **C** — C4 demandas de campanha (CRM de pedidos + custo) · [plano](plans/demandas-campanha.md)
- **C** — C10 lista de apoiadores sem N+1 de access / forms com `errorProps` · gatilho: base nominal crescendo · [plano](plans/escala-dry-pos-c9.md)
- **C** — C11 feed/loaders de planos sem O(n) no detalhe · gatilho: agenda com uso real / volume medido · [plano](plans/escala-dry-pos-c7.md)
- **D** — D2 push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md)
- **E** — E6 aggregate metas/prioridade + DRY geo/UI na lista · gatilho: muitos núcleos / lista lenta · [plano](plans/escala-dry-pos-e1.md)
- **E** — E7 F1/F3/F4 + extensões F2 pós-A5 (leverage/flip/classification/**mobilization** int, trend VM único, tipos, helpers) · [plano](plans/escala-dry-pos-e2.md)
- **Débitos / fill-ins** — O0+, VR+, RS+, FD+, FD2, listas globais, higiene PascalCase (ver abaixo)
### A validar (assumptions)
- **C5** — operação dia D / GOTV (check-off da base em 04/10) — validar com produto; design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png); sem plano ainda
- **E5** — Salvador por bairro (`votacao_secao` + DE-PARA) — valor pós-eleição?
- **FD2** — prioridades de UX pós-critique (bulk fila, glossário, …) — validar com produto · [plano](plans/field-desk-ux-pos-critique.md)
- **Listas globais** — lideranças/atualizações/territórios no raiz — sem plano detalhado
### Referências de design (só itens abertos com ref)
| Item | Design | Plano |
| ---------------------------- | ------------------------------ | --------------------------------------------------------------------- |
| A5 competitiva | `Baseline-Eleitoral-2022` | [competitiva](plans/insight-inteligencia-competitiva.md) |
| A6 Dobradinha 2026 | — (encomendar) | [insight-dobradinha-2026.md](plans/insight-dobradinha-2026.md) |
| A8 Perfis eleitorado IBGE | — (reusa aba Eleitorado) | [perfil-eleitorado-ibge.md](plans/perfil-eleitorado-ibge.md) |
| C4 Demandas | — (encomendar) | [demandas-campanha.md](plans/demandas-campanha.md) |
| C5 GOTV _(validar)_ | `Dia-D-GOTV` | sem plano ainda |
| D2 Notificações | `Notificacoes-PWA` | [notifications.md](plans/notifications.md) |
Paleta dos PNGs UX Pilot é legado — implementação usa tokens `data-theme='campaign'`.
### Grafo de dependências (abertos + predecessores mínimos)
| Fase | Escopo | Status |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| R0 | Documentação (plano-mestre, roadmap, AGENTS/notebook/PRODUCT/CUSTOMER) | entregue 2026-07-21 |
| R1 | Domínio: catálogo 436 Praças, collections (`plaza`, `leadership`, `votePledge`, `organization`, `campaignDemand`, `plazaUpdate`), access, migração consolidada `20260721_020109_remodel_plazas` | entregue 2026-07-21 |
| R2 | Superfícies core: `/campanha/pracas` lista+detalhe+mapa (seletor de ano), CRM `/campanha/liderancas` multi-Praça, votos declarados×estimados, dashboard, convites | entregue 2026-07-21 |
| R3 | `/campanha/organizacoes`, planos com Praça/orgs/presença/resultado, `/campanha/demandas` com workflow e comprovantes | entregue 2026-07-21 |
| R4 | Inteligência: comparativo multi-candidato, mapa divergente (vermelho↔branco↔azul), tendência política manual, rename série E2 → "Evolução" | entregue 2026-07-21 |
| R5 | Hardening: testes por papel (unit 187 / int 306 / e2e 9 verdes), Aikido, checklist de deploy (migração destrutiva revisada) | entregue 2026-07-21 — critique/polish visual fino registrado como R6 |
```mermaid
flowchart TD
subgraph Onda0["Onda 0 — produção"]
JUR["Lote jurídico único<br/>(Consent finais + privacidade)"]
O0DRY["O0+ Escala/DRY pós-Onda 0"]
end
subgraph TrilhaA["Trilha A"]
A4["A4 Baseline + Gap ✓"]
A5done["A5 conversão/classif./alavancagem/mobilização ✓"]
A5open["A5 competitiva"]
A7["A7 F2–F5 escala pós-A4"]
A6["A6 Dobradinha 2026"]
A8["A8 Perfis IBGE"]
A8DRY["A8+ escala pós-A8"]
subgraph Remodel["Remodelagem Praças"]
R1["R1 Domínio"]
R2["R2 Superfícies core"]
R3["R3 Orgs + Planos + Demandas"]
R4["R4 Inteligência"]
R5["R5 Hardening"]
end
subgraph TrilhaB["Trilha B"]
B3["B3 Leaflet ✓"]
B5["B5 F2–F3 cache/factory"]
B6["B6 setStyle Leaflet"]
B4["B4 Zonas TSE no mapa"]
end
subgraph TrilhaC["Trilha C"]
C2["C2 Cadastro nominal ✓"]
C3["C3 Agenda ✓"]
C10["C10 Escala pós-C9"]
C11["C11 Escala pós-C7"]
C4["C4 Demandas"]
C5["C5 GOTV _(validar)_"]
end
subgraph TrilhaD["Trilha D"]
D1["D1 PWA ✓"]
D2["D2 Push + sino"]
end
subgraph TrilhaE["Trilha E"]
E1["E1+E3 Metas/estratégia ✓"]
E2["E2 Tendência ✓"]
E6["E6 Escala pós-E1"]
E7["E7 Escala pós-E2"]
E5["E5 Salvador bairro _(validar)_"]
end
A4 --> A7
A4 --> A5open
A5done -.mesmo loader.-> A7
A4 --> A6
A8 -.após merge.-> A8DRY
TSE2026["TSE candidaturas 2026<br/>(após 15/08)"] --> A6
B3 --> B6
B3 --> B4
A7 -.fetch 2022.-> B3
JUR --> C2prod["C2 dados reais"]
C2 --> C2prod
C2 --> C5
C3 --> C11
C3 -.relação opcional.-> C4
C10 -.forms DRY.-> C11
D1 --> D2
R1 --> R2 --> R3 --> R5
R2 --> R4 --> R5
JUR --> C2prod["C2 dados reais<br/>(base nominal)"]
R2 --> C2prod
TSE2026["TSE candidaturas 2026<br/>(após 15/08)"] --> A6["A6 Dobradinha 2026<br/>(reenquadrada p/ Praça)"]
R4 -.baseline por Praça.-> A6
R2 --> D2["D2 Push + sino"]
JUR -.chave push.-> D2
E1 --> E6
E2 --> E7
E2 -.dataset.-> E5
C2prod --> C5["C5 GOTV (validar)"]
```
### Sequência por janela (só pendentes)
### Sequência por janela
**Janela 1 — agora → 05/08 (convenções)**
**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; R6 critique/polish; Onda 0 jurídica em paralelo.
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | ----------------------------------------- | -------------------------------------------------- | ----------------------- | ------------------- |
| 1 | Onda 0 (lote jurídico + smoke + onboarding) | [onda-0.md](plans/onda-0.md) | engenharia ✓ | tudo |
| 2 | C2 dados reais (após Consent finais) | [cadastro-nominal-apoiadores.md](plans/cadastro-nominal-apoiadores.md) | Onda 0 jurídico | A7, E6, C10, C11 |
| 3 | C10 / C11 se base ou agenda crescerem | [C10](plans/escala-dry-pos-c9.md) · [C11](plans/escala-dry-pos-c7.md) | C9 ✓ / C7 ✓ | E6, A7, O0+ |
| 4 | E6 F1 se lista de núcleos ficar lenta | [escala-dry-pos-e1.md](plans/escala-dry-pos-e1.md) | E1+E3 ✓ | E7, A7, C10 |
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; D2 se sobrar folga.
**Janela 2 — 05/08 → 16/08 (pré-propaganda)**
**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08), B6 `setStyle` incremental (mapa com troca de ano/métrica frequente), débitos sobreviventes (abaixo).
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | ----------------------------------------- | -------------------------------------------------- | ----------------------- | ------------------- |
| 5 | A5 inteligência competitiva | [competitiva](plans/insight-inteligencia-competitiva.md) | A4 ✓ | A7, A8, C4, D2 |
| 6 | A7 F2–F5 | [escala-dry-pos-a4.md](plans/escala-dry-pos-a4.md) | A4 ✓ | A5, A8, C4 |
| 7 | A8 perfis IBGE | [perfil-eleitorado-ibge.md](plans/perfil-eleitorado-ibge.md) | A1 ✓ | A5, A7 |
| 8 | E7 F1/F3/F4 | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) | E2 ✓ | E6, A7 |
| 9 | C4 Demandas | [demandas-campanha.md](plans/demandas-campanha.md) | A1 ✓ (suave: C3) | A5, A7, D2 |
| 10 | D2 Notificações | [notifications.md](plans/notifications.md) | D1 ✓ + chave push | A5, A7, C4 |
| 11 | B5 F2–F3 / B6 | [B5](plans/escala-dry-pos-b2.md) · [B6](plans/escala-dry-pos-b3.md) | B2 ✓ / B3 ✓ | A7 |
**Janela 3 — 16/08 → set**
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | ------------------- | ------------------------------------------------------------- | ----------------- | ----------------- |
| 13 | B4 Zonas no mapa | [mapa-bahia-geometrias.md](plans/mapa-bahia-geometrias.md) | A2 ✓ + B3 ✓ | A6 prep, C5 prep |
**Janela 4 — set → 04/10**
| Ordem | Item | Plano / design | Depende de |
| ----- | ----------------------------------------- | ------------------------------------------------- | ----------------------------------- |
| 14 | A6 Dobradinha 2026 | [insight-dobradinha-2026.md](plans/insight-dobradinha-2026.md) | A4 ✓ + TSE pós-15/08 |
| 15 | C5 GOTV _(validar)_ | design `Dia-D-GOTV` | C2 dados reais |
| 16 | Congelamento ~20/09 | — | só bugfix / dados |
### Fill-ins abertos
- **O0+** — revalidate globals, módulo de chaves Consent, testes SQL, DRY Lexical/layout · [plano](plans/escala-dry-pos-onda0.md)
- **A8+** — lazy `bahiaMunicipalityDemographics`, cobertura parcial explícita, size budget int · gatilho: A8 ✓ · [plano](plans/escala-dry-pos-a8.md)
- **VR+** — refresh bfcache/storage do painel recentes + DRY de lista · gatilho F1: painel stale · [plano](plans/escala-dry-pos-visitados-recentemente.md)
- **RS+** — auth read leve + shells/campos de senha compartilhados · gatilho F1: `/campanha` lento sob carga · [plano](plans/escala-dry-pos-reset-senha-perfil.md)
- **FD+** — shell size / heading / strips Field Desk · gatilho strips: 3ª strip no home ou Fase 3 VoteGoals · [plano](plans/escala-dry-pos-field-desk.md)
- **FD2** _(validar)_ — bulk fila Sem coordenador, glossário, outline, empty coordenador · [plano](plans/field-desk-ux-pos-critique.md)
- **Listas globais** _(validar)_ — lideranças / atualizações / territórios no nível raiz
- **Higiene PascalCase** — varredura de componentes legados
### Itens cortados
| Item | Cortado em | Motivo | Alternativa |
| ---- | ---------- | ------ | ----------- |
| **E4** import único da planilha (`pnpm db:seed:mapa`) | 2026-07-19 | Decisão de produto: não importar automaticamente da planilha de exemplo; preferência por recolocar dados manualmente | Preencher metas, prioridade, dobradinhas e encaminhamentos via UI E1+E3 em `/campanha/nucleos` |
Plano histórico: [mapa-projecao-municipios.md](plans/mapa-projecao-municipios.md) (seção E4 marcada como cortada).
**Janela 4 — set → 04/10:** C5 GOTV _(validar)_, congelamento ~20/09 (só bugfix/dados).
### Cortes seguros / não cortáveis
**Não cortáveis:** Onda 0 (jurídico/Consent), C2 dados reais, C3 agenda (já ✓), A4 baseline (já ✓), E1+E3 paridade mínima com a planilha (já ✓) — risco legal, base nominal, operação e instrumento de alocação.
**Não cortáveis:** Onda 0 (jurídico/Consent); R1–R2 (sem eles a vertical não reflete a operação real); C2 dados reais; assimetria declarado×estimado (relação de campo).
**Cortes seguros** (se o prazo apertar): E5; B4; B6; B5 F2–F3; A8; **A8+**; E7 F1/F3/F4; E6 F2–F4 (preferir F1 se lista lenta); A7 F2–F5 (F4 vale mais com mapa na lista; F5 se filtros ≤10 núcleos); A5 competitiva; C4; D2 push (manter sino); C10/C11 se base/agenda pequenas; O0+/VR+/RS+/FD+ fases cosméticas; FD2 Fase 5 motion; fill-ins.
**Cortes seguros** (se o prazo apertar, nesta ordem): R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; B6; débitos/fill-ins.
## Já entregue (resumo)
- **Era Núcleos (2026-07-15 → 2026-07-20)** — MVP + Ciclo 2 (auth `campaignUser`, território A1/A2, baseline TSE A3/A4, overview B1, share C1, PWA D1, geometrias B2, Leaflet B3), C2 apoiadores (eng.), C3 agenda, C6–C11 escala, E1+E3 metas/estratégia, E2 série TSE 2014/2018/2022, A5 conversão/classificação/alavancagem/mobilização, A7 F1–F2, A8 perfis IBGE, fill-ins (reset senha/perfil, visitados recentes, Field Desk polish). Infra e padrões (locks, transações, consent por chave, shells, mapa, dados eleitorais) **são reaproveitados pela remodelagem**; as superfícies e o modelo de Núcleo são substituídos.
- **Plataforma** — local Postgres + guards, migrations baselined, posts/tags do site público com cache `posts`, Onda 0 textos provisórios + `/privacidade`.
## Supersedidos pela remodelagem (2026-07-20)
| Item antigo | Destino |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| A5 competitiva (parte aberta) | Absorvido por **R4** (comparativo multi-candidato por Praça é a versão pedida pela coordenação) |
| A7 F3–F5 (escala pós-A4) | Morre com os loaders de núcleo; fetch único lista+mapa vira requisito nativo do mapa de Praças (R2) |
| A8+ (escala pós-A8) | Reavaliar pós-R2 se o Eleitorado IBGE for portado ao detalhe da Praça |
| B4 zonas TSE no mapa | Rabbit hole documentado no plano-mestre (agregação municipal + painel por zona; polígono de zona só com decisão explícita) |
| C4 demandas | Absorvido por **R3** (agora nascem da liderança, com workflow assessor→coordenador geral e comprovantes staff-only) |
| C10 / C11 (escala apoiadores/planos) | Reavaliar pós-R2/R3 nas superfícies adaptadas; gatilhos originais (base nominal crescendo / volume medido) continuam valendo |
| E5 Salvador por bairro | Praças-zona de Salvador cobrem a granularidade; bairro só com pedido real pós-eleição |
| E6 (escala pós-E1) / E7 (escala pós-E2) | Overview de núcleos e trend VM morrem; débitos int reavaliar pós-R4 |
| FD+ / FD2 (Field Desk pós-critique) | Superfícies redesenhadas em R2; princípios (glossário inline, filas, bulk) entram no critique de R5 |
| VR+ (visitados recentemente) | Painel portado para Praças em R2; débito de refresh mantém gatilho original |
| Listas globais (lideranças/atualizações) | CRM de liderança já nasce global em R2 (liderança é 1 registro por pessoa) |
## Próximos — sobreviventes e pós-remodelagem
- **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · reenquadrar para Praça · [plano](plans/insight-dobradinha-2026.md)
- **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md)
- **B6** `BahiaMap` setStyle incremental ao trocar métrica/ano · gatilho: troca frequente no mapa de Praças · [plano](plans/escala-dry-pos-b3.md)
- **C5** operação dia D / GOTV _(validar com produto)_ · design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) · depende de C2 dados reais
- **D2** push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md)
- **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · gatilho: antes de 16/08
- **Fill-ins:** O0+ ([plano](plans/escala-dry-pos-onda0.md)) · RS+ auth read leve + shells de senha ([plano](plans/escala-dry-pos-reset-senha-perfil.md)) · higiene PascalCase
## Bloqueadores atuais
| Item | Status | Fonte |
| -------------------------------------------------------------------------------------------- | ------------------------------------ | ------------------------------ |
| Lote jurídico final LGPD (Consent + privacidade + RIPD) — PII real em hold | **caminho crítico para dados reais** | notebook; onda-0; AGENTS |
| RBAC em `users` (admin Payload) antes de abrir `/admin` a equipe maior | pendente | AGENTS Known Gap #1 |
| Consent público ainda por ID numérico (`submitWhatsapp.ts` etc.) | pendente | AGENTS Known Gap #2 |
| Collection `Pages` + hero/copy da home editáveis | pendente (não bloqueia `/campanha`) | AGENTS Known Gap #3 |
| Item | Status | Fonte |
| ------------------------------------------------------------------------------------------------ | ------------------------------------ | ---------------------------------------------------- |
| Lote jurídico final LGPD (Consent + privacidade + RIPD) — PII real em hold | **caminho crítico para dados reais** | notebook; onda-0; AGENTS |
| Deploy da remodelagem Praças (migração destrutiva `remodel_plazas` — revisar SQL antes do build) | **pronto para deploy** | [remodelagem-pracas.md](plans/remodelagem-pracas.md) |
| RBAC em `users` (admin Payload) antes de abrir `/admin` a equipe maior | pendente | AGENTS Known Gap #1 |
| Consent público ainda por ID numérico (`submitWhatsapp.ts` etc.) | pendente | AGENTS Known Gap #2 |
| Collection `Pages` + hero/copy da home editáveis | pendente (não bloqueia `/campanha`) | AGENTS Known Gap #3 |
## Site público
@ -245,14 +152,15 @@ Plano histórico: [mapa-projecao-municipios.md](plans/mapa-projecao-municipios.m
## Fora de escopo (por enquanto)
- Serviço Rust separado; self-host/Coolify enquanto Vercel atender; doações in-app
- Núcleo = Zona TSE; PWA do site/`/admin`; PostGIS sem query espacial real
- Polígonos por zona eleitoral (geocodificação de seções); Praça = seção eleitoral
- PWA do site/`/admin`; PostGIS sem query espacial real
- WhatsApp Business API / disparo em massa; previsão estatística de votos neste ciclo
- Import automático de planilhas de projeção (decisão E4 mantida: dados estratégicos via UI)
## Fontes
- [`AGENTS.md`](../AGENTS.md) — decisões travadas, Known Gaps, checklist
- [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc) — status operacional Núcleos
- [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc) — status operacional (era Núcleos → Praças)
- [`README.md`](../README.md) — missão
- [`docs/plans/*.md`](plans/) — planos por item
- `docs/sheets/*.xlsx` — planilhas Trilha E (estrutura; dados eleitorais vêm do TSE)
- [`docs/plans/*.md`](plans/) — planos por item ([remodelagem-pracas.md](plans/remodelagem-pracas.md) é o plano-mestre vigente)
- Res. TSE 23.760/2026 · Res. TSE 23.610/2019 art. 33 · Politipédia AVM

View file

@ -31,7 +31,7 @@ export type SupporterRemoveFormState = {
const safeVoteIntentionMessages = [
'Consentimento de intenção de voto ainda não configurado.',
'Somente a coordenação pode gerenciar apoiadores.',
'Somente a coordenação e a assessoria podem gerenciar apoiadores.',
] as const
const toMessageOnlyState = (

View file

@ -13,7 +13,7 @@ import { VoteIntentionControl } from '@/components/campaign/VoteIntentionControl
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { isCampaignGeneral } from '@/utilities/campaignAccess'
import { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
loadSupporterDetailConsentData,
@ -70,7 +70,7 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
</Link>
</Button>
<CampaignScopeBadge>
{isCampaignGeneral(user) ? 'Coordenação geral' : 'Coordenação de núcleo'}
{isCampaignCoordinator(user) ? 'Coordenador Geral' : 'Assessor'}
</CampaignScopeBadge>
<h1 className="text-2xl font-semibold tracking-tight">{supporter.name}</h1>
<p className="text-muted-foreground">
@ -109,18 +109,18 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
<p className="tabular-nums">{supporter.phoneDisplay || '—'}</p>
{supporter.email ? <p>{supporter.email}</p> : null}
<p>
{supporter.nucleusName ? (
{supporter.plazaName ? (
<>
Núcleo:{' '}
Praça:{' '}
<Link
href={`/campanha/nucleos/${supporter.nucleusSlug}`}
href={`/campanha/pracas/${supporter.plazaSlug}`}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{supporter.nucleusName}
{supporter.plazaName}
</Link>
</>
) : (
<span className="text-muted-foreground">Sem núcleo vinculado</span>
<span className="text-muted-foreground">Sem Praça vinculada</span>
)}
</p>
</CardContent>

View file

@ -5,13 +5,13 @@ import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { SupporterImportWizard } from '@/components/campaign/SupporterImportWizard'
import { Button } from '@/components/ui/button'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { isCampaignGeneral } from '@/utilities/campaignAccess'
import { isCampaignCoordinator } from '@/utilities/campaignAccess'
export default async function ImportSupportersPage() {
const user = await getCampaignUser()
if (!user) redirect('/campanha/login')
if (!isCampaignGeneral(user)) redirect('/campanha/apoiadores')
if (!isCampaignCoordinator(user)) redirect('/campanha/apoiadores')
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
@ -19,7 +19,7 @@ export default async function ImportSupportersPage() {
<Button asChild variant="ghost" className="w-fit px-0">
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
</Button>
<CampaignScopeBadge>Importação · Coordenação geral</CampaignScopeBadge>
<CampaignScopeBadge>Importação · Coordenador Geral</CampaignScopeBadge>
<h1 className="text-2xl font-semibold tracking-tight">Importar apoiadores via CSV</h1>
<p className="text-muted-foreground">
Envie uma planilha, confira a prévia e confirme a importação em lote.

View file

@ -26,15 +26,15 @@ export type SupporterFormValues = {
phone?: string
email?: string
city?: string
nucleus?: string
plaza?: string
voteIntention?: string
}
const safeActionMessages = [
'Esta pessoa já está cadastrada como apoiador neste núcleo.',
'Esta pessoa já está cadastrada como apoiador nesta Praça.',
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.',
'Somente a coordenação pode gerenciar apoiadores.',
'Somente a coordenação geral pode cadastrar apoiadores sem núcleo.',
'Somente a coordenação e a assessoria podem gerenciar apoiadores.',
'Somente o Coordenador Geral pode cadastrar apoiadores sem Praça.',
'Consentimento de cadastro de apoiador ainda não configurado.',
'Consentimento de intenção de voto ainda não configurado.',
] as const
@ -65,20 +65,20 @@ export const createSupporterFormAction = async (
phone: optionalFormText(formData, 'phone'),
email: optionalFormText(formData, 'email'),
city: optionalFormText(formData, 'city'),
nucleus: optionalFormText(formData, 'nucleus'),
plaza: optionalFormText(formData, 'plaza'),
voteIntention: optionalFormText(formData, 'voteIntention'),
}
const phone = sanitizeBrazilianPhoneInput(values.phone ?? '')
const voteIntention = values.voteIntention?.trim() || undefined
const nucleus = nullableRelationshipFormValue(formData, 'nucleus')
const plaza = nullableRelationshipFormValue(formData, 'plaza')
const input = supporterCreateSchema.parse({
name: values.name ?? '',
phone,
email: values.email,
city: values.city,
...(nucleus ? { nucleus } : {}),
...(plaza ? { plaza } : {}),
...(voteIntention ? { voteIntention } : {}),
consentAccepted: checkboxFormValue(formData, 'consentAccepted') ? true : undefined,
voteIntentionConsentAccepted: voteIntention

View file

@ -7,7 +7,7 @@ import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoia
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { SupporterForm } from '@/components/campaign/SupporterForm'
import { Button } from '@/components/ui/button'
import { isCampaignGeneral } from '@/utilities/campaignAccess'
import { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadSupporterCreatePageData } from '@/utilities/supporterPageData'
import { canAccessSupporterArea } from '@/utilities/supporterUi'
@ -27,7 +27,7 @@ export default async function NewSupporterPage() {
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
</Button>
<CampaignScopeBadge>
{isCampaignGeneral(user) ? 'Coordenação geral' : 'Coordenação de núcleo'}
{isCampaignCoordinator(user) ? 'Coordenador Geral' : 'Assessor'}
</CampaignScopeBadge>
<h1 className="text-2xl font-semibold tracking-tight">Novo apoiador</h1>
<p className="text-muted-foreground">
@ -37,10 +37,10 @@ export default async function NewSupporterPage() {
<SupporterForm
action={createSupporterFormAction}
nucleusOptions={pageData.nucleusOptions}
plazaOptions={pageData.plazaOptions}
registrationConsentConfigured={pageData.registrationConsentConfigured}
voteIntentionConsentConfigured={pageData.voteIntentionConsentConfigured}
requireNucleus={pageData.requireNucleus}
requirePlaza={pageData.requirePlaza}
/>
</div>
)

View file

@ -19,7 +19,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { isCampaignGeneral } from '@/utilities/campaignAccess'
import { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadSupportersPageData } from '@/utilities/supporterPageData'
import {
@ -42,7 +42,7 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
const rawSearchParams = await searchParams
const now = new Date()
const { result, state, redirectHref, nucleusOptions, overview } = await loadSupportersPageData(
const { result, state, redirectHref, plazaOptions, overview } = await loadSupportersPageData(
payload,
user,
rawSearchParams,
@ -55,16 +55,16 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Apoiadores</h1>
<p className="text-muted-foreground">
Base nominal de apoio com intenção de voto e vínculo opcional a núcleos.
Base nominal de apoio com intenção de voto e vínculo opcional a Praças.
</p>
<CampaignScopeBadge>
{isCampaignGeneral(user)
? 'Coordenação geral · todos os apoiadores'
{isCampaignCoordinator(user)
? 'Coordenador Geral · todos os apoiadores'
: getSupporterScopeLabel(result.totalDocs)}
</CampaignScopeBadge>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{isCampaignGeneral(user) ? (
{isCampaignCoordinator(user) ? (
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores/importar">
<FileUpIcon data-icon="inline-start" aria-hidden="true" />
@ -84,7 +84,7 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
<SupporterFilters
key={buildSupporterFiltersKey(state)}
state={state}
nucleusOptions={nucleusOptions}
plazaOptions={plazaOptions}
/>
{result.docs.length ? (

View file

@ -0,0 +1,106 @@
'use server'
import { revalidatePath } from 'next/cache'
import {
attachCampaignDemandReceiptRecord,
setCampaignDemandCost,
transitionCampaignDemand,
} from '@/app/(campaign)/campanha/actions/demand'
import { nullableFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import { campaignDemandStatuses, type CampaignDemandStatus } from '@/lib/schemas/campaignDemand'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
const transitionSafeMessages = [
'Somente a coordenação e a assessoria movem demandas.',
'Demandas escaladas são decididas pelo Coordenador Geral.',
] as const
export const transitionDemandFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const rawStatus = requiredFormText(formData, 'status')
if (!campaignDemandStatuses.includes(rawStatus as CampaignDemandStatus)) {
return { message: 'Status de demanda inválido.' }
}
await transitionCampaignDemand({
id: requiredRelationshipFormValue(formData, 'demandId'),
status: rawStatus as CampaignDemandStatus,
decisionNote: nullableFormText(formData, 'decisionNote'),
})
revalidatePath('/campanha/demandas/[slug]', 'page')
return { status: 'success', message: 'Demanda atualizada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: transitionSafeMessages,
genericMessage: 'Não foi possível mover a demanda. Verifique seu acesso e tente novamente.',
})
}
}
export const setDemandCostFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const rawCost = formData.get('cost')
let cost: number | null = null
if (typeof rawCost === 'string' && rawCost.trim()) {
const parsed = Number(rawCost.replace(',', '.'))
if (!Number.isFinite(parsed) || parsed < 0) {
return { fieldErrors: { cost: ['Informe um valor válido.'] } }
}
cost = Math.round(parsed * 100) / 100
}
await setCampaignDemandCost({
id: requiredRelationshipFormValue(formData, 'demandId'),
cost,
})
revalidatePath('/campanha/demandas/[slug]', 'page')
return { status: 'success', message: 'Custo registrado.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria registram custos.'],
genericMessage: 'Não foi possível registrar o custo. Tente novamente.',
})
}
}
export const attachDemandReceiptFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const demandID = requiredRelationshipFormValue(formData, 'demandId')
const file = formData.get('receipt')
if (!(file instanceof File)) {
return { fieldErrors: { receipt: ['Selecione um arquivo.'] } }
}
const { payload, actor } = await getCampaignActionContext()
await attachCampaignDemandReceiptRecord(payload, actor, demandID, file)
revalidatePath('/campanha/demandas/[slug]', 'page')
return { status: 'success', message: 'Comprovante anexado.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: [
'Envie uma imagem (JPEG, PNG, WebP) ou PDF.',
'O arquivo enviado está vazio.',
'O comprovante deve ter no máximo 10 MB.',
'Somente a coordenação e a assessoria anexam comprovantes.',
],
genericMessage: 'Não foi possível anexar o comprovante. Tente novamente.',
})
}
}

View file

@ -0,0 +1,186 @@
import config from '@payload-config'
import { ArrowLeftIcon, PaperclipIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { DemandWorkflowCard } from '@/components/campaign/DemandWorkflowCard'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
campaignDemandKindLabels,
campaignDemandStatusLabels,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadDemandDetail } from '@/utilities/campaignDemandData'
import {
attachDemandReceiptFormAction,
setDemandCostFormAction,
transitionDemandFormAction,
} from './formActions'
type DemandDetailPageProps = {
params: Promise<{ slug: string }>
}
const statusVariant: Record<
CampaignDemandStatus,
'secondary' | 'estimate-pending' | 'destructive' | 'estimate-confirmed'
> = {
aberta: 'estimate-pending',
em_analise: 'secondary',
escalada: 'estimate-pending',
aprovada: 'estimate-confirmed',
rejeitada: 'destructive',
}
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
timeStyle: 'short',
})
const currencyFormatter = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
})
export default async function DemandDetailPage({ params }: DemandDetailPageProps) {
const { slug } = await params
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
const demand = await loadDemandDetail(payload, user, slug)
if (!demand) notFound()
const isStaffView = isCampaignStaff(user)
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/demandas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para demandas
</Link>
</Button>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{demand.title}</h1>
<Badge variant={statusVariant[demand.status]}>
{campaignDemandStatusLabels[demand.status]}
</Badge>
</div>
<p className="text-muted-foreground">
{campaignDemandKindLabels[demand.kind]} ·{' '}
<Link
href={`/campanha/pracas/${demand.plazaSlug}`}
className="text-primary underline-offset-4 hover:underline"
>
{demand.plazaName}
</Link>
{demand.requesterName ? ` · Solicitada por ${demand.requesterName}` : ''} · Aberta em{' '}
{dateTimeFormatter.format(new Date(demand.createdAt))}
</p>
</header>
{demand.description ? (
<section aria-label="Descrição da demanda" className="rounded-xl border p-4">
<p className="whitespace-pre-wrap text-sm">{demand.description}</p>
</section>
) : null}
{demand.decisionNote && (demand.status === 'aprovada' || demand.status === 'rejeitada') ? (
<section aria-label="Decisão" className="rounded-xl border p-4">
<h2 className="text-sm font-medium">Nota da decisão</h2>
<p className="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
{demand.decisionNote}
</p>
{demand.decidedAt ? (
<p className="mt-1 text-xs text-muted-foreground">
Decidida em {dateTimeFormatter.format(new Date(demand.decidedAt))}
</p>
) : null}
</section>
) : null}
{isStaffView ? (
<>
<DemandWorkflowCard
demandID={demand.id}
status={demand.status}
isCoordinator={user.role === 'coordinator'}
currentCost={demand.cost}
transitionFormAction={transitionDemandFormAction}
costFormAction={setDemandCostFormAction}
receiptFormAction={attachDemandReceiptFormAction}
/>
<section
aria-labelledby="demand-internal-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<h2 id="demand-internal-title" className="text-base font-medium">
Controle interno
</h2>
<p className="text-sm text-muted-foreground">
Custo:{' '}
{demand.cost != null ? currencyFormatter.format(demand.cost) : 'não registrado'}
</p>
{demand.receipts.length ? (
<ul className="flex flex-col gap-1">
{demand.receipts.map((receipt) => (
<li key={receipt.id} className="flex items-center gap-2 text-sm">
<PaperclipIcon className="size-4 text-muted-foreground" aria-hidden="true" />
{receipt.url ? (
<a
href={receipt.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{receipt.filename ?? `Comprovante ${receipt.id}`}
</a>
) : (
<span>{receipt.filename ?? `Comprovante ${receipt.id}`}</span>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhum comprovante anexado.</p>
)}
{demand.statusHistory.length ? (
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">Histórico</h3>
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{demand.statusHistory.map((entry, index) => (
<li key={`${entry.status}-${index}`}>
{campaignDemandStatusLabels[entry.status]}
{entry.authorName ? ` — ${entry.authorName}` : ''}
{entry.createdAt
? ` · ${dateTimeFormatter.format(new Date(entry.createdAt))}`
: ''}
{entry.note ? ` · ${entry.note}` : ''}
</li>
))}
</ul>
</div>
) : null}
</section>
</>
) : (
<section aria-label="Acompanhamento" className="rounded-xl border px-4 py-6">
<p className="text-sm text-muted-foreground">
{demand.status === 'aberta'
? 'Sua demanda foi registrada e aguarda análise da assessoria.'
: demand.status === 'em_analise' || demand.status === 'escalada'
? 'Sua demanda está em análise pela coordenação.'
: 'Sua demanda foi decidida — veja a nota acima.'}
</p>
</section>
)}
</CampaignPageShell>
)
}

View file

@ -0,0 +1,38 @@
'use server'
import { redirect } from 'next/navigation'
import { createCampaignDemand } from '@/app/(campaign)/campanha/actions/demand'
import { optionalFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import { campaignDemandKinds, type CampaignDemandKind } from '@/lib/schemas/campaignDemand'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const createDemandFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
let createdSlug: string | null = null
try {
const rawKind = requiredFormText(formData, 'kind')
const demand = await createCampaignDemand({
title: requiredFormText(formData, 'title'),
kind: campaignDemandKinds.includes(rawKind as CampaignDemandKind)
? (rawKind as CampaignDemandKind)
: 'outro',
description: optionalFormText(formData, 'description'),
plaza: requiredRelationshipFormValue(formData, 'plazaId'),
})
createdSlug = demand.slug
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente lideranças engajadas podem abrir demandas.'],
genericMessage: 'Não foi possível abrir a demanda. Verifique os dados e tente novamente.',
})
}
redirect(`/campanha/demandas/${createdSlug}`)
}

View file

@ -0,0 +1,39 @@
import config from '@payload-config'
import { ArrowLeftIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { DemandForm } from '@/components/campaign/DemandForm'
import { Button } from '@/components/ui/button'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadPlazaOptions } from '@/utilities/campaignRelationOptions'
import { createDemandFormAction } from './formActions'
export default async function NewDemandPage() {
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
const plazaOptions = await loadPlazaOptions(payload, user)
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/demandas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para demandas
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Nova demanda</h1>
<p className="text-muted-foreground">
Descreva a necessidade (material, transporte, espaço, apoio…) e a Praça. A assessoria
revisa e responde por aqui.
</p>
</header>
<DemandForm plazaOptions={plazaOptions} formAction={createDemandFormAction} />
</CampaignPageShell>
)
}

View file

@ -0,0 +1,191 @@
import config from '@payload-config'
import { InboxIcon, PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import {
campaignDemandKindLabels,
campaignDemandStatusLabels,
campaignDemandStatuses,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadDemandListPageData, parseDemandListParams } from '@/utilities/campaignDemandData'
type DemandsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
const statusVariant: Record<
CampaignDemandStatus,
'secondary' | 'estimate-pending' | 'destructive' | 'estimate-confirmed'
> = {
aberta: 'estimate-pending',
em_analise: 'secondary',
escalada: 'estimate-pending',
aprovada: 'estimate-confirmed',
rejeitada: 'destructive',
}
const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' })
export default async function DemandsPage({ searchParams }: DemandsPageProps) {
const rawSearchParams = await searchParams
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
const state = parseDemandListParams(rawSearchParams)
const { rows, totalDocs, totalPages, openCount } = await loadDemandListPageData(
payload,
user,
state,
)
const hrefFor = (status?: CampaignDemandStatus, page = 1) => {
const params = new URLSearchParams()
if (status) params.set('status', status)
if (state.kind) params.set('kind', state.kind)
if (page > 1) params.set('page', String(page))
const query = params.toString()
return query ? `/campanha/demandas?${query}` : '/campanha/demandas'
}
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Demandas</h1>
<p className="text-muted-foreground">
Necessidades da campanha abertas pelas lideranças, revisadas pela assessoria e — quando
preciso — decididas pelo Coordenador Geral.
</p>
<Badge variant="estimate-pending" className="w-fit">
{openCount} {openCount === 1 ? 'demanda em aberto' : 'demandas em aberto'}
</Badge>
</div>
<Button asChild className="min-h-11">
<Link href="/campanha/demandas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova demanda
</Link>
</Button>
</header>
<nav aria-label="Filtrar por status" className="flex flex-wrap gap-2">
<Button
asChild
variant={state.status === undefined ? 'default' : 'outline'}
className="min-h-11"
>
<Link href={hrefFor(undefined)}>Todas</Link>
</Button>
{campaignDemandStatuses.map((status) => (
<Button
key={status}
asChild
variant={state.status === status ? 'default' : 'outline'}
className="min-h-11"
>
<Link href={hrefFor(status)}>{campaignDemandStatusLabels[status]}</Link>
</Button>
))}
</nav>
{rows.length ? (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Demanda</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Praça</TableHead>
<TableHead>Solicitante</TableHead>
<TableHead>Status</TableHead>
<TableHead>Aberta em</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="max-w-64 whitespace-normal">
<Link
href={`/campanha/demandas/${row.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.title}
</Link>
</TableCell>
<TableCell>{campaignDemandKindLabels[row.kind]}</TableCell>
<TableCell className="text-muted-foreground">{row.plazaName}</TableCell>
<TableCell className="text-muted-foreground">
{row.requesterName ?? '—'}
</TableCell>
<TableCell>
<Badge variant={statusVariant[row.status]}>
{campaignDemandStatusLabels[row.status]}
</Badge>
</TableCell>
<TableCell>{dateFormatter.format(new Date(row.createdAt))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'demanda' : 'demandas'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => hrefFor(state.status, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<InboxIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma demanda por aqui</EmptyTitle>
<EmptyDescription>
Abra uma demanda quando precisar de material, transporte, espaço ou apoio para uma
ação.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/demandas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova demanda
</Link>
</Button>
</EmptyContent>
</Empty>
)}
</CampaignPageShell>
)
}

View file

@ -0,0 +1,55 @@
'use server'
import { revalidatePath } from 'next/cache'
import { updateLeadershipInternal } from '@/app/(campaign)/campanha/actions/leadership'
import {
nullableFormText,
optionalFormText,
repeatedRelationshipFormValues,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
const safeMessages = [
'Você só pode vincular lideranças às Praças que assessora.',
'Somente a coordenação e a assessoria podem gerenciar lideranças.',
] as const
export const updateLeadershipInternalFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const sector = optionalFormText(formData, 'sector')
const supportStatus = optionalFormText(formData, 'supportStatus')
await updateLeadershipInternal({
id: requiredRelationshipFormValue(formData, 'leadershipId'),
plazas: repeatedRelationshipFormValues(formData, 'plazas'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
sector: leadershipSectors.includes(sector as (typeof leadershipSectors)[number])
? (sector as (typeof leadershipSectors)[number])
: null,
supportStatus: leadershipSupportStatuses.includes(
supportStatus as (typeof leadershipSupportStatuses)[number],
)
? (supportStatus as (typeof leadershipSupportStatuses)[number])
: undefined,
notes: nullableFormText(formData, 'notes'),
consentNote: nullableFormText(formData, 'consentNote'),
})
revalidatePath('/campanha/liderancas/[id]', 'page')
return { status: 'success', message: 'Ficha da liderança atualizada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages,
genericMessage: 'Não foi possível salvar a ficha. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -0,0 +1,94 @@
import config from '@payload-config'
import { ArrowLeftIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { LeadershipInternalForm } from '@/components/campaign/LeadershipInternalForm'
import { LeadershipInviteButtons } from '@/components/campaign/LeadershipInviteButtons'
import { SupportStatusBadge } from '@/components/campaign/SupportStatusBadge'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadOrganizationOptions, loadPlazaOptions } from '@/utilities/campaignRelationOptions'
import { loadLeadershipDetail } from '@/utilities/leadershipData'
import { updateLeadershipInternalFormAction } from './formActions'
type LeadershipDetailPageProps = {
params: Promise<{ id: string }>
}
export default async function LeadershipDetailPage({ params }: LeadershipDetailPageProps) {
const { id } = await params
if (!/^[1-9]\d*$/.test(id)) notFound()
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
const leadership = await loadLeadershipDetail(payload, user, Number(id))
if (!leadership) notFound()
const [plazaOptions, organizationOptions] = await Promise.all([
loadPlazaOptions(payload, user),
loadOrganizationOptions(payload, user),
])
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/liderancas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para lideranças
</Link>
</Button>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{leadership.name}</h1>
{leadership.supportStatus ? (
<SupportStatusBadge status={leadership.supportStatus} />
) : null}
<Badge variant={leadership.hasAppAccess ? 'estimate-confirmed' : 'outline'}>
{leadership.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app'}
</Badge>
</div>
<p className="text-muted-foreground">
{leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'}
{leadership.email ? ` · ${leadership.email}` : ''}
</p>
</header>
<section
aria-labelledby="leadership-invites-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="leadership-invites-title" className="text-base font-medium">
Convites pelo WhatsApp
</h2>
<p className="text-sm text-muted-foreground">
O convite de acesso ao app exige status engajado. Links são de uso único.
</p>
</div>
<LeadershipInviteButtons
leadershipID={leadership.id}
canInviteLogin={leadership.supportStatus === 'engajado'}
/>
</section>
<section aria-labelledby="leadership-internal-title" className="flex flex-col gap-3">
<h2 id="leadership-internal-title" className="text-base font-medium">
Ficha interna
</h2>
<LeadershipInternalForm
leadership={leadership}
plazaOptions={plazaOptions}
organizationOptions={organizationOptions}
formAction={updateLeadershipInternalFormAction}
/>
</section>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,57 @@
'use server'
import { redirect } from 'next/navigation'
import { createLeadership } from '@/app/(campaign)/campanha/actions/leadership'
import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData'
import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
const safeMessages = [
'Esta pessoa já está cadastrada como liderança. Edite a ficha existente para vincular novas Praças.',
'Você só pode vincular lideranças às Praças que assessora.',
'Somente a coordenação e a assessoria podem gerenciar lideranças.',
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.',
] as const
export const createLeadershipFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
let createdId: number | null = null
try {
const sector = optionalFormText(formData, 'sector')
const supportStatus = optionalFormText(formData, 'supportStatus')
const leadership = await createLeadership({
name: requiredFormText(formData, 'name'),
phone: requiredFormText(formData, 'phone'),
email: optionalFormText(formData, 'email'),
plazas: repeatedRelationshipFormValues(formData, 'plazas'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
sector: leadershipSectors.includes(sector as (typeof leadershipSectors)[number])
? (sector as (typeof leadershipSectors)[number])
: undefined,
supportStatus: leadershipSupportStatuses.includes(
supportStatus as (typeof leadershipSupportStatuses)[number],
)
? (supportStatus as (typeof leadershipSupportStatuses)[number])
: 'a_abordar',
notes: optionalFormText(formData, 'notes'),
consentNote: optionalFormText(formData, 'consentNote'),
})
createdId = leadership.id
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages,
genericMessage:
'Não foi possível cadastrar a liderança. Verifique os dados e tente novamente.',
})
}
redirect(`/campanha/liderancas/${createdId}`)
}

View file

@ -0,0 +1,64 @@
import config from '@payload-config'
import { ArrowLeftIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { LeadershipForm } from '@/components/campaign/LeadershipForm'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadOrganizationOptions, loadPlazaOptions } from '@/utilities/campaignRelationOptions'
import { createLeadershipFormAction } from './formActions'
type NewLeadershipPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function NewLeadershipPage({ searchParams }: NewLeadershipPageProps) {
const rawSearchParams = await searchParams
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
const [plazaOptions, organizationOptions] = await Promise.all([
loadPlazaOptions(payload, user),
loadOrganizationOptions(payload, user),
])
const rawInitialPlaza = Array.isArray(rawSearchParams.plaza)
? rawSearchParams.plaza[0]
: rawSearchParams.plaza
const initialPlazaID =
rawInitialPlaza && /^[1-9]\d*$/.test(rawInitialPlaza) ? Number(rawInitialPlaza) : null
const initialPlazaIDs =
initialPlazaID && plazaOptions.some((option) => option.id === initialPlazaID)
? [initialPlazaID]
: []
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/liderancas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para lideranças
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Nova liderança</h1>
<p className="text-muted-foreground">
O contato é reaproveitado pelo celular quando já existe. Registre o consentimento antes de
inserir dados reais.
</p>
</header>
<LeadershipForm
plazaOptions={plazaOptions}
organizationOptions={organizationOptions}
initialPlazaIDs={initialPlazaIDs}
formAction={createLeadershipFormAction}
/>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,162 @@
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { SupportStatusBadge } from '@/components/campaign/SupportStatusBadge'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadLeadershipListPageData, parseLeadershipListParams } from '@/utilities/leadershipData'
type LeadershipsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function LeadershipsPage({ searchParams }: LeadershipsPageProps) {
const rawSearchParams = await searchParams
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
const state = parseLeadershipListParams(rawSearchParams)
const { rows, totalDocs, totalPages } = await loadLeadershipListPageData(payload, user, state)
const hrefForPage = (page: number) => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (page > 1) params.set('page', String(page))
const query = params.toString()
return query ? `/campanha/liderancas?${query}` : '/campanha/liderancas'
}
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Lideranças</h1>
<p className="text-muted-foreground">
Uma ficha por pessoa — cada liderança pode atuar em várias Praças e organizações.
</p>
</div>
<Button asChild className="min-h-11">
<Link href="/campanha/liderancas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
</header>
<form role="search" className="flex gap-2">
<input
type="search"
name="q"
defaultValue={state.q ?? ''}
placeholder="Buscar por nome…"
aria-label="Buscar liderança por nome"
className="min-h-11 w-full max-w-md rounded-[6px] border border-input bg-background px-3 text-sm outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/30"
/>
<Button type="submit" variant="secondary" className="min-h-11">
Buscar
</Button>
</form>
{rows.length ? (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Status</TableHead>
<TableHead>Praças</TableHead>
<TableHead>Organizações</TableHead>
<TableHead>Acesso ao app</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/campanha/liderancas/${row.id}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
</TableCell>
<TableCell>
{row.supportStatus ? <SupportStatusBadge status={row.supportStatus} /> : '—'}
</TableCell>
<TableCell className="max-w-64 whitespace-normal text-muted-foreground">
{row.plazaNames.join(', ') || '—'}
</TableCell>
<TableCell className="max-w-56 whitespace-normal text-muted-foreground">
{row.organizationNames.join(', ') || '—'}
</TableCell>
<TableCell>
<Badge variant={row.hasAppAccess ? 'estimate-confirmed' : 'outline'}>
{row.hasAppAccess ? 'Com acesso' : 'Sem acesso'}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'liderança' : 'lideranças'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={hrefForPage}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma liderança encontrada</EmptyTitle>
<EmptyDescription>
Cadastre a primeira liderança ou ajuste a busca. Você só vê lideranças das suas
Praças.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/liderancas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
</EmptyContent>
</Empty>
)}
</CampaignPageShell>
)
}

View file

@ -1,85 +0,0 @@
'use server'
import config from '@payload-config'
import { getPayload } from 'payload'
import { revalidatePath } from 'next/cache'
import { assignNucleusCoordinators } from '@/app/(campaign)/campanha/actions/coordinatorAssignment'
import { repeatedRelationshipFormValues } from '@/lib/formData'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions'
export type CoordinatorAssignmentFormState = {
status?: 'success'
message?: string
fieldErrors?: Record<string, string[]>
}
export type CoordinatorAssignmentOptionsResult = {
expectedUpdatedAt: string
options: Array<{ id: number; name: string; isCurrent: boolean }>
}
const knownMessages = [
'Somente a coordenação geral pode alterar coordenadores.',
'Núcleo não encontrado ou sem acesso.',
'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.',
'Uma ou mais pessoas selecionadas não são mais elegíveis para coordenação.',
] as const
const errorState = (error: unknown): CoordinatorAssignmentFormState =>
mapCampaignFormActionError({
error,
safeMessages: knownMessages,
genericMessage: 'Não foi possível salvar a coordenação. Atualize a página e tente novamente.',
})
export const assignNucleusCoordinatorsFormAction = async (
slug: string,
_state: CoordinatorAssignmentFormState,
formData: FormData,
): Promise<CoordinatorAssignmentFormState> => {
try {
const expectedUpdatedAt = formData.get('expectedUpdatedAt')
if (typeof expectedUpdatedAt !== 'string' || !expectedUpdatedAt) {
return { message: 'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.' }
}
await assignNucleusCoordinators({
slug,
expectedUpdatedAt,
coordinatorIds: repeatedRelationshipFormValues(formData, 'coordinators'),
})
revalidatePath(`/campanha/nucleos/${slug}`)
revalidatePath('/campanha')
return { status: 'success', message: 'Coordenação atualizada.' }
} catch (error) {
return errorState(error)
}
}
export const loadCoordinatorAssignmentOptions = async (
slug: string,
): Promise<CoordinatorAssignmentOptionsResult> => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user || user.role !== 'geral') {
throw new Error('Somente a coordenação geral pode alterar coordenadores.')
}
const result = await payload.find({
collection: 'electoralNucleus',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
select: { updatedAt: true },
user,
overrideAccess: false,
})
const nucleus = result.docs[0]
if (!nucleus) throw new Error('Núcleo não encontrado ou sem acesso.')
return {
expectedUpdatedAt: nucleus.updatedAt,
options: await getEligibleNucleusCoordinatorOptions(payload, user),
}
}

View file

@ -1,49 +0,0 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { notFound, redirect } from 'next/navigation'
import { updateNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { NucleusForm } from '@/components/campaign/NucleusForm'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getNucleusEditPageData } from '@/utilities/nucleusPageData'
type EditNucleusPageProps = {
params: Promise<{ slug: string }>
}
export default async function EditNucleusPage({ params }: EditNucleusPageProps) {
const [{ slug }, user, payload] = await Promise.all([
params,
getCampaignUser(),
getPayload({ config }),
])
if (!user) redirect('/campanha/login')
if (user.role === 'lideranca') redirect('/campanha/nucleos')
if (!slug) notFound()
const view = await getNucleusEditPageData(payload, user, slug).catch(() => notFound())
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<header className="flex flex-col gap-2">
<CampaignScopeBadge>
{user.role === 'geral'
? 'Edição · Coordenação geral'
: 'Edição · Núcleo sob sua coordenação'}
</CampaignScopeBadge>
<p className="text-sm font-medium text-primary">Núcleos Eleitorais</p>
<h1 className="text-2xl font-semibold tracking-tight">Editar {view.name}</h1>
<p className="text-muted-foreground">
Atualize o território sem alterar o nome ou as estimativas confirmadas.
</p>
</header>
<NucleusForm
action={updateNucleusFormAction}
nucleus={view}
submitLabel="Salvar alterações"
/>
</div>
)
}

View file

@ -1,154 +0,0 @@
'use server'
import { revalidatePath } from 'next/cache'
import {
createLeadership,
updateLeadershipInternal,
} from '@/app/(campaign)/campanha/actions/leadership'
import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus'
import { leadershipInternalUpdateSchema } from '@/lib/schemas/leadership'
import {
FormDataBoundaryError,
nullableFormText,
optionalFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
export type LeadershipFormState = {
status?: 'success'
message?: string
fieldErrors?: Record<string, string[]>
leadershipId?: number
contactReused?: boolean
values?: LeadershipFormValues
revision?: number
}
export type LeadershipFormValues = {
name?: string
phone?: string
email?: string
gender?: string
sector?: string
sectorNotes?: string
supportStatus?: string
notes?: string
consentNote?: string
}
const safeActionMessages = [
'Esta pessoa já está cadastrada como liderança neste núcleo.',
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.',
'Escolha outro contato principal antes de alterar o status desta liderança.',
'Somente a coordenação pode gerenciar lideranças.',
] as const
const resolveLeadershipBoundaryMessage = (error: FormDataBoundaryError): string =>
error.field === 'contact'
? 'Não foi possível identificar a liderança. Atualize a página e tente novamente.'
: error.field === 'id' || error.field === 'nucleus'
? 'Não foi possível identificar o registro. Atualize a página e tente novamente.'
: error.message
const getLeadershipFormError = (
error: unknown,
values?: LeadershipFormValues,
revision?: number,
): LeadershipFormState =>
mapCampaignFormActionError({
error,
safeMessages: safeActionMessages,
genericMessage: 'Não foi possível salvar a liderança. Verifique os dados e tente novamente.',
values,
revision,
resolveBoundaryMessage: resolveLeadershipBoundaryMessage,
})
export const createLeadershipFormAction = async (
state: LeadershipFormState,
formData: FormData,
): Promise<LeadershipFormState> => {
let values: LeadershipFormValues | undefined
const revision = (state.revision ?? 0) + 1
try {
values = {
name: optionalFormText(formData, 'name'),
phone: optionalFormText(formData, 'phone'),
email: optionalFormText(formData, 'email'),
gender: optionalFormText(formData, 'gender'),
sector: optionalFormText(formData, 'sector'),
sectorNotes: optionalFormText(formData, 'sectorNotes'),
supportStatus: optionalFormText(formData, 'supportStatus'),
notes: optionalFormText(formData, 'notes'),
consentNote: optionalFormText(formData, 'consentNote'),
}
const input = {
nucleus: requiredRelationshipFormValue(formData, 'nucleus'),
...values,
name: values.name ?? '',
phone: values.phone ?? '',
}
const leadership = await createLeadership(input)
revalidatePath('/campanha/nucleos/[slug]', 'page')
return {
status: 'success',
message: leadership.contactReused
? 'Liderança cadastrada. O contato existente com este celular foi reutilizado.'
: 'Liderança cadastrada com sucesso.',
leadershipId: leadership.id,
contactReused: leadership.contactReused,
}
} catch (error) {
return getLeadershipFormError(error, values, revision)
}
}
export const updateLeadershipFormAction = async (
_state: LeadershipFormState,
formData: FormData,
): Promise<LeadershipFormState> => {
try {
const input = leadershipInternalUpdateSchema.parse({
id: requiredRelationshipFormValue(formData, 'id'),
sector: nullableFormText(formData, 'sector'),
sectorNotes: nullableFormText(formData, 'sectorNotes'),
supportStatus: optionalFormText(formData, 'supportStatus'),
notes: nullableFormText(formData, 'notes'),
consentNote: nullableFormText(formData, 'consentNote'),
})
const leadership = await updateLeadershipInternal(input)
revalidatePath('/campanha/nucleos/[slug]', 'page')
return {
status: 'success',
message: 'Liderança atualizada com sucesso.',
leadershipId: leadership.id,
}
} catch (error) {
return getLeadershipFormError(error)
}
}
export const setPrimaryContactFormAction = async (
_state: LeadershipFormState,
formData: FormData,
): Promise<LeadershipFormState> => {
try {
await updateNucleus({
id: requiredRelationshipFormValue(formData, 'nucleus'),
primaryContact: requiredRelationshipFormValue(formData, 'contact'),
})
revalidatePath('/campanha/nucleos/[slug]', 'page')
return {
status: 'success',
message: 'Contato principal atualizado.',
}
} catch (error) {
return getLeadershipFormError(error)
}
}

View file

@ -1,33 +0,0 @@
import Link from 'next/link'
import { MapPinOffIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
export default function NucleusNotFound() {
return (
<Empty className="mx-auto min-h-80 max-w-2xl border">
<EmptyHeader>
<EmptyMedia variant="icon">
<MapPinOffIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Núcleo não encontrado</EmptyTitle>
<EmptyDescription>
Ele pode não existir ou estar fora do seu escopo de acesso.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/nucleos">Voltar aos núcleos</Link>
</Button>
</EmptyContent>
</Empty>
)
}

View file

@ -1,95 +0,0 @@
'use server'
import { revalidatePath } from 'next/cache'
import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus'
import { requiredRelationshipFormValue } from '@/lib/formData'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import {
type CampaignFormActionState,
mapCampaignFormActionError,
} from '@/utilities/campaignFormActionError'
import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectionGeography'
import {
getNucleusIbgeVoterProfile,
IBGE_VOTER_PROFILE_LABEL,
} from '@/utilities/nucleusIbgeVoterProfile'
import { voterProfileNullsToUndefined } from '@/utilities/nucleusIntelligenceUi'
export type AppendIbgeVoterProfileFormState = CampaignFormActionState
const safeMessages = [
'Somente a coordenação pode editar a inteligência do núcleo.',
'Não há perfil IBGE disponível para este território.',
'Já existe um perfil manual com este rótulo.',
] as const
const errorState = (error: unknown): AppendIbgeVoterProfileFormState =>
mapCampaignFormActionError({
error,
safeMessages,
genericMessage: 'Não foi possível adicionar o perfil IBGE. Tente novamente.',
})
export const appendIbgeVoterProfileFormAction = async (
_state: AppendIbgeVoterProfileFormState,
formData: FormData,
): Promise<AppendIbgeVoterProfileFormState> => {
try {
const { payload, actor } = await getCampaignActionContext()
if (actor.role === 'lideranca') {
throw new Error('Somente a coordenação pode editar a inteligência do núcleo.')
}
const nucleusId = requiredRelationshipFormValue(formData, 'nucleus')
const nucleus = await payload.findByID({
collection: 'electoralNucleus',
id: nucleusId,
depth: 0,
user: actor,
overrideAccess: false,
select: {
slug: true,
cities: true,
regions: true,
voterProfiles: {
label: true,
ageRange: true,
incomeBand: true,
occupation: true,
localTraits: true,
notes: true,
},
},
})
const computed = getNucleusIbgeVoterProfile(toNucleusElectionGeographyInput(nucleus))
if (computed.status !== 'available') {
throw new Error('Não há perfil IBGE disponível para este território.')
}
const existingProfiles = (nucleus.voterProfiles ?? []).map(voterProfileNullsToUndefined)
if (existingProfiles.some((profile) => profile.label === IBGE_VOTER_PROFILE_LABEL)) {
throw new Error('Já existe um perfil manual com este rótulo.')
}
const updated = await updateNucleus({
id: nucleusId,
voterProfiles: [
...existingProfiles,
{
label: computed.profile.label,
ageRange: computed.profile.ageRange,
localTraits: computed.profile.localTraits,
notes: computed.profile.notes,
},
],
})
revalidatePath(`/campanha/nucleos/${updated.slug}`)
return { status: 'success', message: 'Perfil IBGE adicionado aos perfis manuais.' }
} catch (error) {
return errorState(error)
}
}

View file

@ -1,31 +0,0 @@
'use server'
import { revalidatePath } from 'next/cache'
import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import { parseNucleusIntelligenceFormData } from '@/utilities/nucleusIntelligenceUi'
export type NucleusIntelligenceFormState = {
status?: 'success'
message?: string
fieldErrors?: Record<string, string[]>
}
export const updateNucleusIntelligenceFormAction = async (
_state: NucleusIntelligenceFormState,
formData: FormData,
): Promise<NucleusIntelligenceFormState> => {
try {
const input = parseNucleusIntelligenceFormData(formData)
await updateNucleus(input)
revalidatePath('/campanha/nucleos/[slug]', 'page')
return { status: 'success', message: 'Inteligência do núcleo atualizada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
genericMessage:
'Não foi possível atualizar a inteligência. Verifique os dados e tente novamente.',
})
}
}

View file

@ -1,62 +0,0 @@
'use server'
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { revalidatePath } from 'next/cache'
import { createNucleusUpdateRecord } from '@/app/(campaign)/campanha/actions/nucleusUpdate'
import type { CampaignUser } from '@/payload-types'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import { parseNucleusUpdateFormData } from '@/utilities/nucleusUpdateUi'
import { requireRelationshipId } from '@/utilities/relationship'
export type NucleusUpdateFormState = {
status?: 'success'
message?: string
fieldErrors?: Record<string, string[]>
}
export const createNucleusUpdateFormRecordAction = async (
payload: Payload,
actor: CampaignUser,
formData: FormData,
): Promise<NucleusUpdateFormState & { nucleusId?: number }> => {
try {
const input = parseNucleusUpdateFormData(formData)
const created = await createNucleusUpdateRecord(payload, actor, input)
return {
status: 'success',
message: 'Atualização enviada com sucesso.',
nucleusId: requireRelationshipId(created.nucleus),
}
} catch (error) {
return mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
})
}
}
export const createNucleusUpdateFormAction = async (
_state: NucleusUpdateFormState,
formData: FormData,
): Promise<NucleusUpdateFormState> => {
const [payload, actor] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!actor) {
return {
message: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
}
}
const { nucleusId, ...state } = await createNucleusUpdateFormRecordAction(
payload,
actor,
formData,
)
if (state.status === 'success' && nucleusId) {
revalidatePath('/campanha')
revalidatePath('/campanha/nucleos/[slug]', 'page')
}
return state
}

View file

@ -1,226 +0,0 @@
import config from '@payload-config'
import { PencilIcon, PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { Suspense } from 'react'
import {
assignNucleusCoordinatorsFormAction,
loadCoordinatorAssignmentOptions,
} from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions'
import { loadNucleusShareRecipients } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/shareRecipientsActions'
import { confirmVoteEstimateFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions'
import { ArchiveNucleusDialog } from '@/components/campaign/ArchiveNucleusDialog'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { CoordinatorAssignmentCard } from '@/components/campaign/CoordinatorAssignmentCard'
import { CoordinatorAssignmentDialog } from '@/components/campaign/CoordinatorAssignmentDialog'
import { NucleusActiveTab, NucleusActiveTabLoading } from '@/components/campaign/NucleusActiveTab'
import { NucleusTabNav } from '@/components/campaign/NucleusTabNav'
import { RecentVisitTracker } from '@/components/campaign/RecentVisitTracker'
import { ShareNucleusDialogShell } from '@/components/campaign/ShareNucleusDialogShell'
import { TseZoneBadge } from '@/components/campaign/TseZoneBadge'
import { VoteEstimateCard } from '@/components/campaign/VoteEstimateCard'
import { VoteEstimateDialogShell } from '@/components/campaign/VoteEstimateDialogShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
buildLeadershipPanelHref,
nucleusDetailFocusFallbackId,
parseLeadershipFilterState,
} from '@/utilities/leadershipUi'
import { loadNucleusDetailPageData } from '@/utilities/nucleusDetailPageData'
import {
getNucleusDetailTabRedirect,
resolveNucleusDetailTab,
} from '@/utilities/nucleusDetailTabUi'
import { NucleusNotFoundError } from '@/utilities/nucleusPageData'
import { formatNucleusTerritoryLabel, organizationKindLabels } from '@/utilities/nucleusUi'
type NucleusDetailPageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
const detailScopeLabels = {
geral: 'Visível para você · Coordenação geral',
coordenador: 'Visível para você · Coordenador',
lideranca: 'Visível para você · Liderança',
} as const
export default async function NucleusDetailPage({ params, searchParams }: NucleusDetailPageProps) {
const [{ slug }, query, user, payload] = await Promise.all([
params,
searchParams,
getCampaignUser(),
getPayload({ config }),
])
if (!user) redirect('/campanha/login')
if (!slug) notFound()
const nucleusKind = user.role === 'lideranca' ? 'leadership' : 'staff'
const activeTab = resolveNucleusDetailTab(query, nucleusKind)
const { context, view, coordinatorAssignment } = await loadNucleusDetailPageData(
payload,
user,
slug,
activeTab,
).catch((error) => {
if (error instanceof NucleusNotFoundError) notFound()
throw error
})
const canonicalTabRedirect = getNucleusDetailTabRedirect(view.slug, query, view.kind)
if (canonicalTabRedirect) redirect(canonicalTabRedirect)
const leadershipFilters = parseLeadershipFilterState(query)
const canEdit = user.role === 'geral' || user.role === 'coordenador'
const isStaffView = view.kind === 'staff'
const confirmVoteEstimateAction = confirmVoteEstimateFormAction.bind(
null,
isStaffView ? view.proposedVoteEstimateVersion : null,
)
const territory = formatNucleusTerritoryLabel(view)
const voteEstimateFocusTargetId = `vote-estimate-${view.id}-focus-target`
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<header className="flex flex-col gap-4">
<CampaignScopeBadge>{detailScopeLabels[user.role]}</CampaignScopeBadge>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<h1
id={nucleusDetailFocusFallbackId}
tabIndex={-1}
className="text-2xl font-semibold tracking-tight focus-visible:outline-none"
>
{view.name}
</h1>
{view.status === 'arquivado' ? <Badge variant="secondary">Arquivado</Badge> : null}
</div>
<span className="block text-muted-foreground">
{view.organizationLabel ?? organizationKindLabels[view.organizationKind]}
{' · '}
{territory}
</span>
<div className="flex flex-wrap gap-1">
{view.tseZones.length ? (
view.tseZones.map((zoneNumber) => (
<TseZoneBadge key={zoneNumber} zoneNumber={zoneNumber} />
))
) : (
<Badge variant="tse">Sem Zona TSE</Badge>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<ShareNucleusDialogShell
loadRecipients={loadNucleusShareRecipients.bind(null, view.slug)}
nucleusName={view.name}
senderName={user.name}
/>
{canEdit ? (
<Button asChild variant="outline" className="min-h-11">
<Link href={`/campanha/nucleos/${view.slug}/editar`}>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Editar
</Link>
</Button>
) : null}
{user.role !== 'lideranca' ? (
<Button asChild variant="outline" className="min-h-11">
<Link
href={buildLeadershipPanelHref(view.slug, leadershipFilters, { mode: 'create' })}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
) : null}
<Button asChild variant="outline" className="min-h-11">
<Link href={`/campanha/nucleos/${view.slug}?tab=updates&newUpdate=1`}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova atualização
</Link>
</Button>
{user.role === 'geral' && view.status === 'ativo' ? (
<ArchiveNucleusDialog nucleusId={view.id} />
) : null}
</div>
</div>
</header>
<section
aria-label="Resumo operacional"
className="grid min-w-0 grid-cols-1 items-stretch gap-4 lg:grid-cols-2 *:min-w-0"
>
<CoordinatorAssignmentCard coordinators={coordinatorAssignment.coordinators}>
{coordinatorAssignment.canManage ? (
<CoordinatorAssignmentDialog
coordinators={coordinatorAssignment.coordinators}
action={assignNucleusCoordinatorsFormAction.bind(null, view.slug)}
loadOptions={loadCoordinatorAssignmentOptions.bind(null, view.slug)}
initialOpen={query.assignCoordinators === '1'}
/>
) : null}
</CoordinatorAssignmentCard>
<VoteEstimateCard
focusTargetId={voteEstimateFocusTargetId}
confirmedEstimate={view.confirmedVoteEstimate}
confirmedBy={isStaffView ? (view.confirmedVoteEstimateBy ?? undefined) : undefined}
confirmedAt={
isStaffView && view.confirmedVoteEstimateAt
? new Intl.DateTimeFormat('pt-BR').format(new Date(view.confirmedVoteEstimateAt))
: undefined
}
proposedEstimate={isStaffView ? view.proposedVoteEstimate : null}
proposedBy={isStaffView ? (view.proposedVoteEstimateBy ?? undefined) : undefined}
proposedAt={
isStaffView && view.proposedVoteEstimateAt
? new Intl.DateTimeFormat('pt-BR').format(new Date(view.proposedVoteEstimateAt))
: undefined
}
action={
<VoteEstimateDialogShell
key={`${view.confirmedVoteEstimate ?? 'none'}-${isStaffView ? (view.proposedVoteEstimateVersion ?? 'none') : 'private'}`}
nucleusId={view.id}
role={user.role}
confirmedEstimate={view.confirmedVoteEstimate}
confirmedEstimateRevision={isStaffView ? view.confirmedVoteEstimateAt : null}
fallbackFocusId={voteEstimateFocusTargetId}
proposedEstimate={isStaffView ? view.proposedVoteEstimate : null}
confirmAction={confirmVoteEstimateAction}
/>
}
/>
</section>
<NucleusTabNav
activeTab={activeTab}
nucleusKind={view.kind}
nucleusSlug={view.slug}
searchParams={query}
/>
<Suspense fallback={<NucleusActiveTabLoading />}>
<NucleusActiveTab
activeTab={activeTab}
context={context}
payload={payload}
searchParams={query}
user={user}
view={view}
/>
</Suspense>
<RecentVisitTracker
entry={{
href: `/campanha/nucleos/${view.slug}`,
label: view.name,
kind: 'nucleus',
}}
/>
</div>
)
}

View file

@ -1,29 +0,0 @@
'use server'
import config from '@payload-config'
import { getPayload } from 'payload'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { resolveAccessibleNucleusContext } from '@/utilities/nucleusPageData'
import {
searchNucleusPrimaryContactOptions,
type NucleusPrimaryContactPageData,
} from '@/utilities/primaryContactPageData'
export const searchPrimaryContactOptionsFormAction = async (
nucleusSlug: string,
query: string,
): Promise<NucleusPrimaryContactPageData> => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user || user.role === 'lideranca') {
throw new Error('Somente a coordenação pode buscar contatos principais.')
}
const context = await resolveAccessibleNucleusContext(payload, user, nucleusSlug)
return searchNucleusPrimaryContactOptions(
payload,
user,
context,
typeof query === 'string' ? query : '',
)
}

View file

@ -1,40 +0,0 @@
'use server'
import config from '@payload-config'
import { headers } from 'next/headers'
import { getPayload } from 'payload'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
import {
getNucleusShareRecipients,
type NucleusShareRecipients,
} from '@/utilities/nucleusShareRecipients'
export type NucleusShareRecipientsResult = {
recipients: NucleusShareRecipients
nucleusUrl: string
}
export const loadNucleusShareRecipients = async (
slug: string,
): Promise<NucleusShareRecipientsResult> => {
const [payload, user, requestHeaders] = await Promise.all([
getPayload({ config }),
getCampaignUser(),
headers(),
])
if (!user) throw new Error('Faça login para compartilhar este núcleo.')
const recipients = await getNucleusShareRecipients(payload, user, slug)
const baseURL = getCampaignInviteBaseURL({
requestOrigin: requestHeaders.get('origin'),
forwardedHost: requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host'),
forwardedProto: requestHeaders.get('x-forwarded-proto'),
})
return {
recipients,
nucleusUrl: `${baseURL}/campanha/nucleos/${slug}`,
}
}

View file

@ -1,73 +0,0 @@
'use server'
import { revalidatePath } from 'next/cache'
import {
confirmVoteEstimate,
suggestVoteEstimate,
} from '@/app/(campaign)/campanha/actions/voteEstimate'
import {
optionalFormText,
requiredIntegerFormValue,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
export type VoteEstimateFormState = {
status?: 'success'
message?: string
fieldErrors?: Record<string, string[]>
}
const safeMessages = [
'A liderança precisa de vínculo engajado com este núcleo.',
'Somente a coordenação pode confirmar estimativas.',
'A sugestão foi alterada. Atualize a página antes de confirmar.',
'Informe uma justificativa para ajustar a estimativa.',
] as const
const errorState = (error: unknown): VoteEstimateFormState =>
mapCampaignFormActionError({
error,
safeMessages,
genericMessage: 'Não foi possível salvar a estimativa. Tente novamente.',
})
export const suggestVoteEstimateFormAction = async (
_state: VoteEstimateFormState,
formData: FormData,
): Promise<VoteEstimateFormState> => {
try {
const updated = await suggestVoteEstimate({
nucleus: requiredRelationshipFormValue(formData, 'nucleus'),
estimate: requiredIntegerFormValue(formData, 'estimate', {
maximum: 100_000_000,
}),
})
revalidatePath(`/campanha/nucleos/${updated.slug}`)
return { status: 'success', message: 'Sugestão enviada para revisão.' }
} catch (error) {
return errorState(error)
}
}
export const confirmVoteEstimateFormAction = async (
expectedProposedVoteEstimateVersion: string | null,
_state: VoteEstimateFormState,
formData: FormData,
): Promise<VoteEstimateFormState> => {
try {
const updated = await confirmVoteEstimate({
nucleus: requiredRelationshipFormValue(formData, 'nucleus'),
estimate: requiredIntegerFormValue(formData, 'estimate', {
maximum: 100_000_000,
}),
expectedProposedVoteEstimateVersion,
confirmationNote: optionalFormText(formData, 'confirmationNote'),
})
revalidatePath(`/campanha/nucleos/${updated.slug}`)
return { status: 'success', message: 'Estimativa confirmada.' }
} catch (error) {
return errorState(error)
}
}

View file

@ -1,111 +0,0 @@
'use server'
import config from '@payload-config'
import { getPayload } from 'payload'
import { redirect } from 'next/navigation'
import {
archiveNucleus,
createNucleus,
updateNucleus,
} from '@/app/(campaign)/campanha/actions/nucleus'
import type { NucleusFormState } from '@/components/campaign/NucleusForm'
import { optionalFormText, requiredRelationshipFormValue } from '@/lib/formData'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import {
parseNucleusCreateFormData,
parseNucleusUpdateFormData,
} from '@/utilities/nucleusFormData'
import { slugify } from '@/utilities/slug'
const getActionError = (error: unknown): NucleusFormState =>
mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível concluir a ação. Atualize a página e tente novamente.',
})
const existingNucleusState = async (formData: FormData): Promise<NucleusFormState | null> => {
const name = optionalFormText(formData, 'name')
const slug = name ? slugify(name) : ''
if (!slug) return null
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user || user.role !== 'geral') return null
const result = await payload.find({
collection: 'electoralNucleus',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
pagination: false,
select: { slug: true },
user,
overrideAccess: false,
})
const existing = result.docs[0]
return existing
? {
fieldErrors: { name: ['Já existe um núcleo com este nome.'] },
existingHref: `/campanha/nucleos/${existing.slug}`,
submittedName: name,
}
: null
}
export const createNucleusFormAction = async (
_state: NucleusFormState,
formData: FormData,
): Promise<NucleusFormState> => {
let nucleusSlug: string
try {
const nucleus = await createNucleus(parseNucleusCreateFormData(formData))
nucleusSlug = nucleus.slug
} catch (error) {
const existing = await existingNucleusState(formData)
if (existing) return existing
return getActionError(error)
}
redirect(`/campanha/nucleos/${nucleusSlug}`)
}
export const updateNucleusFormAction = async (
_state: NucleusFormState,
formData: FormData,
): Promise<NucleusFormState> => {
let nucleusSlug: string
try {
const input = parseNucleusUpdateFormData(formData)
const nucleus = await updateNucleus(input)
nucleusSlug = nucleus.slug
} catch (error) {
return getActionError(error)
}
redirect(`/campanha/nucleos/${nucleusSlug}`)
}
export const archiveNucleusFormAction = async (
_state: NucleusFormState,
formData: FormData,
): Promise<NucleusFormState> => {
let nucleusId: number
try {
nucleusId = requiredRelationshipFormValue(formData, 'id')
} catch {
return {
message: 'Não foi possível identificar o núcleo. Atualize a página e tente novamente.',
}
}
try {
await archiveNucleus(nucleusId)
} catch {
return {
message:
'Não foi possível arquivar este núcleo. Verifique seu acesso ou atualize a página e tente novamente.',
}
}
redirect('/campanha/nucleos')
}

View file

@ -1,16 +0,0 @@
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { Skeleton } from '@/components/ui/skeleton'
export default function NucleiLoading() {
return (
<CampaignPageShell aria-busy="true" aria-label="Carregando núcleos">
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-5 w-80 max-w-full" />
</div>
<Skeleton className="h-11 w-full rounded-lg" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-72 w-full rounded-xl" />
</CampaignPageShell>
)
}

View file

@ -1,36 +0,0 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { redirect } from 'next/navigation'
import { createNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { NucleusForm } from '@/components/campaign/NucleusForm'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions'
export default async function NewNucleusPage() {
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (user.role !== 'geral') redirect('/campanha/nucleos')
const coordinators = await getEligibleNucleusCoordinatorOptions(payload, user)
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<header className="flex flex-col gap-2">
<CampaignScopeBadge>Criação · Coordenação geral</CampaignScopeBadge>
<p className="text-sm font-medium text-primary">Núcleos Eleitorais</p>
<h1 className="text-2xl font-semibold tracking-tight">Novo núcleo</h1>
<p className="text-muted-foreground">
Defina a operação, o território obrigatório e quem responde por ele.
</p>
</header>
<NucleusForm
action={createNucleusFormAction}
coordinators={coordinators}
submitLabel="Criar núcleo"
/>
</div>
)
}

View file

@ -1,134 +0,0 @@
import type { ElectoralNucleus } from '@/payload-types'
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { NucleusFilters } from '@/components/campaign/NucleusFilters'
import { NucleusList } from '@/components/campaign/NucleusList'
import { NucleusListOverview } from '@/components/campaign/NucleusListOverview'
import { RecentVisitTracker } from '@/components/campaign/RecentVisitTracker'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadNucleusListOverviewData } from '@/utilities/nucleusListOverviewPageData'
import { loadNucleusListPageData } from '@/utilities/nucleusPageData'
import {
buildNucleusFiltersKey,
buildNucleusListHref,
buildNucleusListVisitHref,
buildNucleusListVisitLabel,
getCampaignScopeLabel,
resolveNucleusListUrl,
} from '@/utilities/nucleusUi'
import { toNucleusListViewModel } from '@/utilities/nucleusViewModels'
type NucleiPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function NucleiPage({ searchParams }: NucleiPageProps) {
const rawSearchParams = await searchParams
const canonicalUrl = resolveNucleusListUrl(rawSearchParams)
if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref)
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) return null
const now = new Date()
const [{ result, scope }, overview] = await Promise.all([
loadNucleusListPageData(payload, user, rawSearchParams),
loadNucleusListOverviewData(payload, user, canonicalUrl.state, now),
])
const resolvedUrl = resolveNucleusListUrl(rawSearchParams, result.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
const listVisitLabel = buildNucleusListVisitLabel(state)
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Núcleos Eleitorais</h1>
<p className="text-muted-foreground">
Núcleo é a operação da campanha; Zona TSE é a circunscrição oficial.
</p>
<CampaignScopeBadge>
{getCampaignScopeLabel(user.role, scope.totalDocs)}
</CampaignScopeBadge>
</div>
{user.role === 'geral' ? (
<Button asChild className="min-h-11">
<Link href="/campanha/nucleos/novo">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo núcleo
</Link>
</Button>
) : null}
</header>
<NucleusFilters key={buildNucleusFiltersKey(state)} state={state} />
{result.docs.length ? (
<>
{overview ? <NucleusListOverview view={overview} now={now} /> : null}
<NucleusList
nuclei={result.docs.map((nucleus) =>
toNucleusListViewModel(nucleus as ElectoralNucleus),
)}
/>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{result.totalDocs}{' '}
{result.totalDocs === 1 ? 'núcleo encontrado' : 'núcleos encontrados'}
</p>
<CampaignListPagination
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildNucleusListHref(state, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum núcleo encontrado</EmptyTitle>
<EmptyDescription>
Ajuste a busca ou os filtros. Você só vê núcleos dentro do seu escopo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/nucleos">Limpar busca e filtros</Link>
</Button>
</EmptyContent>
</Empty>
)}
{listVisitLabel ? (
<RecentVisitTracker
entry={{
href: buildNucleusListVisitHref(state),
label: listVisitLabel,
kind: 'nucleusList',
}}
/>
) : null}
</CampaignPageShell>
)
}

View file

@ -0,0 +1,42 @@
'use server'
import { revalidatePath } from 'next/cache'
import { updateOrganization } from '@/app/(campaign)/campanha/actions/organization'
import {
nullableFormText,
optionalFormText,
repeatedRelationshipFormValues,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { organizationKinds, type OrganizationKind } from '@/lib/schemas/organization'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const updateOrganizationFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const rawKind = optionalFormText(formData, 'kind')
await updateOrganization({
id: requiredRelationshipFormValue(formData, 'organizationId'),
kind: organizationKinds.includes(rawKind as OrganizationKind)
? (rawKind as OrganizationKind)
: undefined,
notes: nullableFormText(formData, 'notes'),
plazas: repeatedRelationshipFormValues(formData, 'plazas'),
})
revalidatePath('/campanha/organizacoes/[slug]', 'page')
return { status: 'success', message: 'Organização atualizada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria gerenciam organizações.'],
genericMessage:
'Não foi possível salvar a organização. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -0,0 +1,150 @@
import config from '@payload-config'
import { ArrowLeftIcon, CalendarDaysIcon, HandshakeIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { OrganizationForm } from '@/components/campaign/OrganizationForm'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { actionPlanStatusLabels, actionPlanStatuses } from '@/lib/schemas/actionPlan'
import { organizationKindLabels } from '@/lib/schemas/organization'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadPlazaOptions } from '@/utilities/campaignRelationOptions'
import { loadOrganizationDetail } from '@/utilities/organizationData'
import { updateOrganizationFormAction } from './formActions'
type OrganizationDetailPageProps = {
params: Promise<{ slug: string }>
}
const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' })
const statusLabel = (status: string): string =>
actionPlanStatuses.includes(status as (typeof actionPlanStatuses)[number])
? actionPlanStatusLabels[status as (typeof actionPlanStatuses)[number]]
: status
export default async function OrganizationDetailPage({ params }: OrganizationDetailPageProps) {
const { slug } = await params
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
const organization = await loadOrganizationDetail(payload, user, slug)
if (!organization) notFound()
const plazaOptions = await loadPlazaOptions(payload, user)
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/organizacoes">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para organizações
</Link>
</Button>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{organization.name}</h1>
<Badge variant="secondary">{organizationKindLabels[organization.kind]}</Badge>
</div>
{organization.plazaNames.length ? (
<p className="text-muted-foreground">Atua em {organization.plazaNames.join(', ')}</p>
) : null}
{organization.notes ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{organization.notes}</p>
) : null}
</header>
<div className="grid gap-6 lg:grid-cols-2">
<section
aria-labelledby="organization-leaderships-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex items-center gap-2">
<HandshakeIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<h2 id="organization-leaderships-title" className="text-base font-medium">
Lideranças associadas
</h2>
<Badge variant="outline">{organization.leaderships.length}</Badge>
</div>
{organization.leaderships.length ? (
<ul className="flex flex-col gap-1">
{organization.leaderships.map((leadership) => (
<li key={leadership.id}>
<Link
href={`/campanha/liderancas/${leadership.id}`}
className="inline-flex min-h-11 items-center text-primary underline-offset-4 hover:underline"
>
{leadership.name}
</Link>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Nenhuma liderança vinculada. Vincule pela ficha da liderança.
</p>
)}
</section>
<section
aria-labelledby="organization-plans-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex items-center gap-2">
<CalendarDaysIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<h2 id="organization-plans-title" className="text-base font-medium">
Planos de Ação apoiados
</h2>
<Badge variant="outline">{organization.actionPlans.length}</Badge>
</div>
{organization.actionPlans.length ? (
<ul className="flex flex-col gap-2">
{organization.actionPlans.map((plan) => (
<li key={plan.id} className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<Link
href={`/campanha/planos/${plan.slug}`}
className="truncate font-medium text-primary underline-offset-4 hover:underline"
>
{plan.title}
</Link>
<span className="text-sm text-muted-foreground">
{statusLabel(plan.status)}
{plan.startAt ? ` · ${dateFormatter.format(new Date(plan.startAt))}` : ''}
{plan.deputyPresent ? ' · Deputado presente' : ''}
</span>
</div>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Nenhum Plano de Ação vinculado a esta organização ainda.
</p>
)}
</section>
</div>
<section aria-labelledby="organization-edit-title" className="flex flex-col gap-3">
<h2 id="organization-edit-title" className="text-base font-medium">
Editar organização
</h2>
<OrganizationForm
plazaOptions={plazaOptions}
formAction={updateOrganizationFormAction}
initial={{
id: organization.id,
kind: organization.kind,
notes: organization.notes,
plazaIDs: organization.plazaIDs,
}}
/>
</section>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,42 @@
'use server'
import { redirect } from 'next/navigation'
import { createOrganization } from '@/app/(campaign)/campanha/actions/organization'
import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData'
import { organizationKinds, type OrganizationKind } from '@/lib/schemas/organization'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const createOrganizationFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
let createdSlug: string | null = null
try {
const rawKind = requiredFormText(formData, 'kind')
const organization = await createOrganization({
name: requiredFormText(formData, 'name'),
kind: organizationKinds.includes(rawKind as OrganizationKind)
? (rawKind as OrganizationKind)
: 'outro',
notes: optionalFormText(formData, 'notes'),
plazas: repeatedRelationshipFormValues(formData, 'plazas'),
})
createdSlug = organization.slug
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: [
'Já existe uma organização com este nome.',
'Somente a coordenação e a assessoria gerenciam organizações.',
],
genericMessage:
'Não foi possível cadastrar a organização. Verifique os dados e tente novamente.',
})
}
redirect(`/campanha/organizacoes/${createdSlug}`)
}

View file

@ -0,0 +1,41 @@
import config from '@payload-config'
import { ArrowLeftIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { OrganizationForm } from '@/components/campaign/OrganizationForm'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadPlazaOptions } from '@/utilities/campaignRelationOptions'
import { createOrganizationFormAction } from './formActions'
export default async function NewOrganizationPage() {
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
const plazaOptions = await loadPlazaOptions(payload, user)
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/organizacoes">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para organizações
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Nova organização</h1>
<p className="text-muted-foreground">
Cadastre sindicatos, associações, movimentos e afins para vincular lideranças e Planos de
Ação.
</p>
</header>
<OrganizationForm plazaOptions={plazaOptions} formAction={createOrganizationFormAction} />
</CampaignPageShell>
)
}

View file

@ -0,0 +1,159 @@
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { organizationKindLabels } from '@/lib/schemas/organization'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
loadOrganizationListPageData,
parseOrganizationListParams,
} from '@/utilities/organizationData'
type OrganizationsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function OrganizationsPage({ searchParams }: OrganizationsPageProps) {
const rawSearchParams = await searchParams
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
const state = parseOrganizationListParams(rawSearchParams)
const { rows, totalDocs, totalPages } = await loadOrganizationListPageData(payload, user, state)
const hrefForPage = (page: number) => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (state.kind) params.set('kind', state.kind)
if (page > 1) params.set('page', String(page))
const query = params.toString()
return query ? `/campanha/organizacoes?${query}` : '/campanha/organizacoes'
}
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Organizações</h1>
<p className="text-muted-foreground">
Sindicatos, associações e movimentos que apoiam a campanha — com suas lideranças e
Planos de Ação.
</p>
</div>
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova organização
</Link>
</Button>
</header>
<form role="search" className="flex gap-2">
<input
type="search"
name="q"
defaultValue={state.q ?? ''}
placeholder="Buscar por nome…"
aria-label="Buscar organização por nome"
className="min-h-11 w-full max-w-md rounded-[6px] border border-input bg-background px-3 text-sm outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/30"
/>
<Button type="submit" variant="secondary" className="min-h-11">
Buscar
</Button>
</form>
{rows.length ? (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Praças de atuação</TableHead>
<TableHead>Lideranças</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/campanha/organizacoes/${row.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
</TableCell>
<TableCell>
<Badge variant="secondary">{organizationKindLabels[row.kind]}</Badge>
</TableCell>
<TableCell className="max-w-64 whitespace-normal text-muted-foreground">
{row.plazaNames.join(', ') || '—'}
</TableCell>
<TableCell className="tabular-nums">{row.leadershipCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'organização' : 'organizações'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={hrefForPage}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma organização cadastrada</EmptyTitle>
<EmptyDescription>
Cadastre sindicatos, associações e movimentos para vincular lideranças e Planos de
Ação.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova organização
</Link>
</Button>
</EmptyContent>
</Empty>
)}
</CampaignPageShell>
)
}

View file

@ -1,10 +1,10 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignDashboard } from '@/components/campaign/CampaignDashboard'
import { getCampaignDashboardPageData } from '@/utilities/campaignDashboardPageData'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
export const dynamic = 'force-dynamic'
@ -12,7 +12,6 @@ export default async function CampaignHomePage() {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) redirect('/campanha/login')
const now = new Date()
const view = await getCampaignDashboardPageData(payload, user, now)
return <CampaignDashboard view={view} now={now} />
const view = await getCampaignDashboardData(payload, user)
return <CampaignDashboard view={view} userName={user.name} />
}

View file

@ -8,12 +8,14 @@ import {
} from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
import { updateActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions'
import { ActionPlanForm } from '@/components/campaign/ActionPlanForm'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
ActionPlanNotFoundError,
getActionPlanEditPageData,
} from '@/utilities/actionPlanPageData'
import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions'
import { loadOrganizationOptions, loadPlazaOptions } from '@/utilities/campaignRelationOptions'
import { getEligibleAdvisorOptions } from '@/utilities/plazaViewModels'
type EditActionPlanPageProps = {
params: Promise<{ slug: string }>
@ -27,15 +29,18 @@ export default async function EditActionPlanPage({ params }: EditActionPlanPageP
])
if (!user) redirect('/campanha/login')
if (user.role === 'lideranca') redirect('/campanha/planos')
if (!isCampaignStaff(user)) redirect('/campanha/planos')
if (!slug) notFound()
const [view, coordinators] = await Promise.all([
const canManageAdvisors = user.role === 'coordinator'
const [view, plazaOptions, organizationOptions, advisorOptions] = await Promise.all([
getActionPlanEditPageData(payload, user, slug).catch((error) => {
if (error instanceof ActionPlanNotFoundError) notFound()
throw error
}),
getEligibleNucleusCoordinatorOptions(payload, user),
loadPlazaOptions(payload, user),
loadOrganizationOptions(payload, user),
canManageAdvisors ? getEligibleAdvisorOptions(payload, user) : Promise.resolve([]),
])
return (
@ -50,8 +55,10 @@ export default async function EditActionPlanPage({ params }: EditActionPlanPageP
<ActionPlanForm
action={updateActionPlanFormAction}
plan={view}
coordinators={coordinators}
canManageCoordinators={user.role === 'geral'}
plazaOptions={plazaOptions}
organizationOptions={organizationOptions}
advisorOptions={advisorOptions}
canManageAdvisors={canManageAdvisors}
submitLabel="Salvar alterações"
searchContacts={searchActionPlanContactOptions}
searchLeaderships={searchActionPlanLeadershipOptionsAction}

View file

@ -8,6 +8,7 @@ import {
CancelActionPlanDialog,
MarkActionPlanRealizedDialog,
} from '@/components/campaign/ActionPlanLifecycleDialog'
import { ActionPlanResultForm } from '@/components/campaign/ActionPlanResultForm'
import { ActionPlanStatusBadge } from '@/components/campaign/ActionPlanStatusBadge'
import { ActionPlanTabNav } from '@/components/campaign/ActionPlanTabNav'
import { ActionPlanTaskChecklist } from '@/components/campaign/ActionPlanTaskChecklist'
@ -17,6 +18,7 @@ import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
ActionPlanNotFoundError,
@ -59,9 +61,9 @@ export default async function ActionPlanDetailPage({
const canonicalTabRedirect = getActionPlanDetailTabRedirect(view.slug, query)
if (canonicalTabRedirect) redirect(canonicalTabRedirect)
const canEdit = user.role === 'geral' || user.role === 'coordenador'
const isStaff = isCampaignStaff(user)
const canManageLifecycle =
canEdit && view.status !== 'realizado' && view.status !== 'cancelado'
isStaff && view.status !== 'realizado' && view.status !== 'cancelado'
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
@ -71,17 +73,18 @@ export default async function ActionPlanDetailPage({
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{view.title}</h1>
<ActionPlanStatusBadge status={view.status} />
{view.deputyPresent ? <Badge>Deputado presente</Badge> : null}
</div>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground">
<Badge variant="secondary">{actionPlanKindLabels[view.kind]}</Badge>
<span>
{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'}
</span>
{view.territoryLabel ? <span>· {view.territoryLabel}</span> : null}
{view.locationLabel ? <span>· {view.locationLabel}</span> : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{canEdit ? (
{isStaff ? (
<Button asChild variant="outline" className="min-h-11">
<Link href={`/campanha/planos/${view.slug}/editar`}>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
@ -108,66 +111,128 @@ export default async function ActionPlanDetailPage({
<ActionPlanTabNav activeTab={activeTab} planSlug={view.slug} searchParams={query} />
{activeTab === 'overview' ? (
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Detalhes</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{view.description ? (
<p className="whitespace-pre-wrap">{view.description}</p>
) : (
<p className="text-muted-foreground">Sem descrição.</p>
)}
<dl className="grid gap-2 sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">Início</dt>
<dd>{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'A definir'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Término</dt>
<dd>{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Prazo de conclusão</dt>
<dd>{view.deadline ? formatBahiaDateTimeLabel(view.deadline) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Responsável</dt>
<dd>{view.responsibleName ?? 'Não definido'}</dd>
</div>
</dl>
{view.territoryNotes ? (
<div>
<p className="text-muted-foreground">Observações do território</p>
<p className="whitespace-pre-wrap">{view.territoryNotes}</p>
</div>
) : null}
</CardContent>
</Card>
<div className="flex flex-col gap-4">
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Detalhes</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{view.description ? (
<p className="whitespace-pre-wrap">{view.description}</p>
) : (
<p className="text-muted-foreground">Sem descrição.</p>
)}
<dl className="grid gap-2 sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">Praça</dt>
<dd>
{view.plaza ? (
<Link
href={`/campanha/pracas/${view.plaza.slug}`}
className="text-primary underline-offset-4 hover:underline"
>
{view.plaza.name}
</Link>
) : (
'—'
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Local</dt>
<dd>{view.locality ?? '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Início</dt>
<dd>{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'A definir'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Término</dt>
<dd>{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Prazo de conclusão</dt>
<dd>{view.deadline ? formatBahiaDateTimeLabel(view.deadline) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Responsável</dt>
<dd>{view.responsibleName ?? 'Não definido'}</dd>
</div>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Coordenação</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{view.coordinators.length ? (
<ul role="list" className="flex flex-col gap-1">
{view.coordinators.map((coordinator) => (
<li key={coordinator.id}>{coordinator.name}</li>
))}
</ul>
) : (
<p className="text-muted-foreground">Nenhum coordenador vinculado.</p>
)}
<p className="text-muted-foreground">
{view.taskProgress.done}/{view.taskProgress.total} tarefas concluídas
</p>
{view.createdByName ? (
<p className="text-muted-foreground">Criado por {view.createdByName}</p>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Equipe e organizações</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div>
<p className="text-muted-foreground">Assessores responsáveis</p>
{view.advisors.length ? (
<ul role="list" className="flex flex-col gap-1">
{view.advisors.map((advisor) => (
<li key={advisor.id}>{advisor.name}</li>
))}
</ul>
) : (
<p className="text-muted-foreground">Nenhum assessor vinculado.</p>
)}
</div>
<div>
<p className="text-muted-foreground">Organizações apoiadoras</p>
{view.organizations.length ? (
<ul role="list" className="flex flex-wrap gap-1.5 pt-1">
{view.organizations.map((organization) => (
<li key={organization.id}>
<Badge variant="outline">{organization.name}</Badge>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground">Nenhuma organização vinculada.</p>
)}
</div>
<p className="text-muted-foreground">
{view.taskProgress.done}/{view.taskProgress.total} tarefas concluídas
</p>
{view.createdByName ? (
<p className="text-muted-foreground">Criado por {view.createdByName}</p>
) : null}
</CardContent>
</Card>
</div>
{view.status === 'realizado' && isStaff ? (
<ActionPlanResultForm
planId={view.id}
initialSummary={view.result?.summary ?? null}
recordedByName={view.result?.recordedByName ?? null}
recordedAt={view.result?.recordedAt ?? null}
/>
) : view.result ? (
<Card>
<CardHeader>
<CardTitle>Resultado da ação</CardTitle>
<p className="text-sm text-muted-foreground">
{[
view.result.recordedByName
? `Registrado por ${view.result.recordedByName}`
: 'Registrado',
view.result.recordedAt
? `em ${formatBahiaDateTimeLabel(view.result.recordedAt)}`
: null,
]
.filter(Boolean)
.join(' ')}
</p>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm">{view.result.summary}</p>
</CardContent>
</Card>
) : null}
</div>
) : null}

View file

@ -0,0 +1,40 @@
'use server'
import { revalidatePath } from 'next/cache'
import { registerActionPlanResultAction } from '@/app/(campaign)/campanha/actions/actionPlan'
import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
// Validation/transaction messages thrown by `registerActionPlanResult` —
// safe to surface verbatim; anything else falls back to the generic message.
const safeMessages = [
'Informe o resultado da ação.',
'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.',
] as const
export const registerActionPlanResultFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const planId = requiredRelationshipFormValue(formData, 'planId')
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')
return { status: 'success', message: 'Resultado registrado com sucesso.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages,
genericMessage:
'Não foi possível registrar o resultado. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -8,16 +8,23 @@ import {
} from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
import { createActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions'
import { ActionPlanForm } from '@/components/campaign/ActionPlanForm'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions'
import { loadOrganizationOptions, loadPlazaOptions } from '@/utilities/campaignRelationOptions'
import { getEligibleAdvisorOptions } from '@/utilities/plazaViewModels'
export default async function NewActionPlanPage() {
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (user.role !== 'geral' && user.role !== 'coordenador') redirect('/campanha/planos')
if (!isCampaignStaff(user)) redirect('/campanha/planos')
const coordinators = await getEligibleNucleusCoordinatorOptions(payload, user)
const canManageAdvisors = user.role === 'coordinator'
const [plazaOptions, organizationOptions, advisorOptions] = await Promise.all([
loadPlazaOptions(payload, user),
loadOrganizationOptions(payload, user),
canManageAdvisors ? getEligibleAdvisorOptions(payload, user) : Promise.resolve([]),
])
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
@ -30,8 +37,10 @@ export default async function NewActionPlanPage() {
</header>
<ActionPlanForm
action={createActionPlanFormAction}
coordinators={coordinators}
canManageCoordinators={user.role === 'geral'}
plazaOptions={plazaOptions}
organizationOptions={organizationOptions}
advisorOptions={advisorOptions}
canManageAdvisors={canManageAdvisors}
submitLabel="Criar plano"
searchContacts={searchActionPlanContactOptions}
searchLeaderships={searchActionPlanLeadershipOptionsAction}

View file

@ -25,7 +25,9 @@ import {
resolveActionPlanListUrl,
} from '@/utilities/actionPlanUi'
import { toActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadPlazaOptions } from '@/utilities/campaignRelationOptions'
type ActionPlanListPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
@ -40,11 +42,14 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
if (!user) return null
const now = new Date()
const { result, state } = await loadActionPlanListPageData(payload, user, rawSearchParams, now)
const [{ result, state }, plazaOptions] = await Promise.all([
loadActionPlanListPageData(payload, user, rawSearchParams, now),
loadPlazaOptions(payload, user),
])
const resolvedUrl = resolveActionPlanListUrl(rawSearchParams, result.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const canCreate = user.role === 'geral' || user.role === 'coordenador'
const canCreate = isCampaignStaff(user)
return (
<CampaignPageShell>
@ -65,7 +70,11 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
) : null}
</header>
<ActionPlanFilters key={buildActionPlanFiltersKey(state)} state={state} />
<ActionPlanFilters
key={buildActionPlanFiltersKey(state)}
state={state}
plazaOptions={plazaOptions}
/>
{result.docs.length ? (
<>

View file

@ -0,0 +1,107 @@
'use server'
import { revalidatePath } from 'next/cache'
import {
assignPlazaAdvisors,
setPlazaPoliticalTrend,
updatePlazaStrategy,
} from '@/app/(campaign)/campanha/actions/plaza'
import {
nullableFormText,
optionalFormText,
optionalIntegerFormValue,
repeatedFormTexts,
repeatedRelationshipFormValues,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { politicalTrendStatuses, type PoliticalTrendStatusValue } from '@/lib/schemas/plaza'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const updatePlazaStrategyFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const plaza = requiredRelationshipFormValue(formData, 'plazaId')
const priority = optionalFormText(formData, 'priority')
await updatePlazaStrategy({
plaza,
priority: priority === 'alta' ? 'alta' : 'normal',
voteGoals: {
good: optionalIntegerFormValue(formData, 'goalGood') ?? null,
regular: optionalIntegerFormValue(formData, 'goalRegular') ?? null,
minimum: optionalIntegerFormValue(formData, 'goalMinimum') ?? null,
},
strengths: repeatedFormTexts(formData, 'strengths'),
risks: repeatedFormTexts(formData, 'risks'),
dobradinhaNotes: nullableFormText(formData, 'dobradinhaNotes'),
nextSteps: nullableFormText(formData, 'nextSteps'),
})
revalidatePath('/campanha/pracas/[slug]', 'page')
return { status: 'success', message: 'Estratégia atualizada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: [
'As metas devem seguir a ordem Bom ≥ Regular ≥ Mínimo quando informadas.',
'Somente a coordenação e a assessoria podem editar a Praça.',
],
genericMessage:
'Não foi possível salvar a estratégia. Verifique seu acesso e tente novamente.',
})
}
}
export const setPlazaPoliticalTrendFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const plaza = requiredRelationshipFormValue(formData, 'plazaId')
const rawStatus = optionalFormText(formData, 'trendStatus')
const status = politicalTrendStatuses.includes(rawStatus as PoliticalTrendStatusValue)
? (rawStatus as PoliticalTrendStatusValue)
: null
await setPlazaPoliticalTrend({
plaza,
status,
note: nullableFormText(formData, 'trendNote'),
})
revalidatePath('/campanha/pracas/[slug]', 'page')
return { status: 'success', message: 'Tendência política registrada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria podem editar a Praça.'],
genericMessage:
'Não foi possível registrar a tendência. Verifique seu acesso e tente novamente.',
})
}
}
export const assignPlazaAdvisorsFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const plaza = requiredRelationshipFormValue(formData, 'plazaId')
const advisors = repeatedRelationshipFormValues(formData, 'advisors')
await assignPlazaAdvisors({ plaza, advisors })
revalidatePath('/campanha/pracas/[slug]', 'page')
return { status: 'success', message: 'Assessores atualizados.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente o Coordenador Geral designa assessores.'],
genericMessage:
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -0,0 +1,82 @@
import config from '@payload-config'
import { ArrowLeftIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { PlazaAdvisorsForm } from '@/components/campaign/PlazaAdvisorsForm'
import { PlazaStrategyForm } from '@/components/campaign/PlazaStrategyForm'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
getPlazaDetailViewModel,
PlazaNotFoundError,
resolveAccessiblePlazaContext,
} from '@/utilities/plazaPageData'
import { getEligibleAdvisorOptions } from '@/utilities/plazaViewModels'
import {
assignPlazaAdvisorsFormAction,
setPlazaPoliticalTrendFormAction,
updatePlazaStrategyFormAction,
} from './formActions'
type PlazaEditPageProps = {
params: Promise<{ slug: string }>
}
export default async function PlazaEditPage({ params }: PlazaEditPageProps) {
const { slug } = await params
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect(`/campanha/pracas/${slug}`)
let context
try {
context = await resolveAccessiblePlazaContext(payload, user, slug)
} catch (error) {
if (error instanceof PlazaNotFoundError) notFound()
throw error
}
const view = await getPlazaDetailViewModel(payload, context, user)
if (!view.strategy) redirect(`/campanha/pracas/${slug}`)
const advisorOptions =
user.role === 'coordinator' ? await getEligibleAdvisorOptions(payload, user) : []
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href={`/campanha/pracas/${view.slug}`}>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para {view.name}
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Editar {view.name}</h1>
<p className="text-muted-foreground">
A geografia da Praça é pré-definida e não pode ser alterada. Aqui você edita metas,
inteligência e tendência política.
</p>
</header>
{user.role === 'coordinator' ? (
<PlazaAdvisorsForm
plazaID={view.id}
currentAdvisorIDs={view.advisorIDs}
options={advisorOptions}
formAction={assignPlazaAdvisorsFormAction}
/>
) : null}
<PlazaStrategyForm
plazaID={view.id}
strategy={view.strategy}
strategyFormAction={updatePlazaStrategyFormAction}
trendFormAction={setPlazaPoliticalTrendFormAction}
/>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,289 @@
import config from '@payload-config'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { CandidateComparePicker } from '@/components/campaign/CandidateComparePicker'
import { DeclareVotesForm } from '@/components/campaign/DeclareVotesForm'
import { PlazaBaselineCard } from '@/components/campaign/PlazaBaselineCard'
import { PlazaCandidateComparisonTable } from '@/components/campaign/PlazaCandidateComparisonTable'
import { PlazaLeadershipsPanel } from '@/components/campaign/PlazaLeadershipsPanel'
import { PlazaPledgesPanel } from '@/components/campaign/PlazaPledgesPanel'
import { PlazaStrategyCard } from '@/components/campaign/PlazaStrategyCard'
import { PlazaTabNav } from '@/components/campaign/PlazaTabNav'
import { PlazaUpdateFeed } from '@/components/campaign/PlazaUpdateFeed'
import { PlazaUpdateForm } from '@/components/campaign/PlazaUpdateForm'
import { RecentVisitTracker } from '@/components/campaign/RecentVisitTracker'
import { Badge } from '@/components/ui/Badge'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
import { loadPlazaLeaderships } from '@/utilities/leadershipData'
import {
loadPlazaCandidateComparison,
MAX_COMPARISON_CANDIDATES,
} from '@/utilities/plazaCandidateComparison'
import { resolvePlazaDetailTab, type PlazaDetailSearchParams } from '@/utilities/plazaDetailTabUi'
import { plazaElectionGeographyForSlug } from '@/utilities/plazaElectionGeography'
import { loadPlazaElectoralBaseline } from '@/utilities/plazaElectoralBaseline'
import {
getPlazaDetailViewModel,
PlazaNotFoundError,
resolveAccessiblePlazaContext,
} from '@/utilities/plazaPageData'
import { formatPlazaGeographyLabel, plazaKindLabels } from '@/utilities/plazaUi'
import { loadPlazaUpdatesFeed, parsePlazaUpdateFeedParams } from '@/utilities/plazaUpdatePageData'
import { loadAdvisorSummaries } from '@/utilities/plazaViewModels'
import { loadLeaderPledges, loadPlazaPledges } from '@/utilities/votePledgeData'
import { declareVotesFormAction, estimateVotesFormAction } from './pledgeFormActions'
import { createPlazaUpdateFormAction } from './updateFormActions'
type PlazaDetailPageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<PlazaDetailSearchParams>
}
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
export default async function PlazaDetailPage({ params, searchParams }: PlazaDetailPageProps) {
const [{ slug }, rawSearchParams] = await Promise.all([params, searchParams])
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
let context
try {
context = await resolveAccessiblePlazaContext(payload, user, slug)
} catch (error) {
if (error instanceof PlazaNotFoundError) notFound()
throw error
}
const isStaffView = isCampaignStaff(user)
const roleKind = isStaffView ? 'staff' : 'leader'
const activeTab = resolvePlazaDetailTab(rawSearchParams, roleKind)
const view = await getPlazaDetailViewModel(payload, context, user)
const advisorSummaries = await loadAdvisorSummaries(payload, user, view.advisorIDs)
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{view.name}</h1>
<Badge variant="scope">{plazaKindLabels[view.kind]}</Badge>
</div>
<p className="text-muted-foreground">{formatPlazaGeographyLabel(view)}</p>
<p className="text-sm text-muted-foreground">
{advisorSummaries.length
? `Assessoria: ${advisorSummaries.map((advisor) => advisor.name).join(', ')}`
: 'Sem assessor designado.'}
{view.lastUpdateAt
? ` · Última atualização em ${dateFormatter.format(new Date(view.lastUpdateAt))}`
: ''}
</p>
</header>
<PlazaTabNav
activeTab={activeTab}
plazaSlug={view.slug}
searchParams={rawSearchParams}
roleKind={roleKind}
/>
{activeTab === 'overview' ? (
<OverviewTab view={view} isStaffView={isStaffView} payloadUser={{ payload, user }} />
) : null}
{activeTab === 'elections' ? (
<ElectionsTab
slug={view.slug}
rawSearchParams={rawSearchParams}
payloadUser={{ payload, user }}
/>
) : null}
{activeTab === 'leaderships' && isStaffView ? (
<LeadershipsTab plazaID={view.id} payloadUser={{ payload, user }} />
) : null}
{activeTab === 'updates' ? (
<UpdatesTab
plazaID={view.id}
plazaSlug={view.slug}
rawSearchParams={rawSearchParams}
payloadUser={{ payload, user }}
/>
) : null}
{activeTab === 'demands' ? (
<section className="rounded-xl border px-4 py-6">
<p className="text-sm text-muted-foreground">
As demandas desta Praça aparecem em{' '}
<Link
href="/campanha/demandas"
className="text-primary underline-offset-4 hover:underline"
>
Demandas
</Link>
.
</p>
</section>
) : null}
<RecentVisitTracker
entry={{ href: `/campanha/pracas/${view.slug}`, label: view.name, kind: 'plaza' }}
/>
</CampaignPageShell>
)
}
type PayloadUser = {
payload: Awaited<ReturnType<typeof getPayload>>
user: NonNullable<Awaited<ReturnType<typeof getCampaignUser>>>
}
const OverviewTab = async ({
view,
isStaffView,
payloadUser: { payload, user },
}: {
view: Awaited<ReturnType<typeof getPlazaDetailViewModel>>
isStaffView: boolean
payloadUser: PayloadUser
}) => {
if (isStaffView && view.strategy) {
const pledges = await loadPlazaPledges(payload, user, view.id)
return (
<div className="flex flex-col gap-6">
<PlazaStrategyCard strategy={view.strategy} plazaSlug={view.slug} canEdit />
<PlazaPledgesPanel pledges={pledges} estimateFormAction={estimateVotesFormAction} />
</div>
)
}
const leaderPledges = await loadLeaderPledges(payload, user)
const ownPledge = leaderPledges.find((pledge) => pledge.plazaID === view.id) ?? null
return (
<section className="flex flex-col gap-4 rounded-xl border p-4">
<div className="flex flex-col gap-1">
<h2 className="text-base font-medium">Seus votos nesta Praça</h2>
<p className="text-sm text-muted-foreground">
Informe quantos votos você está trazendo. Você pode atualizar o número quando quiser.
</p>
</div>
<DeclareVotesForm
plazaID={view.id}
currentDeclaredVotes={ownPledge?.declaredVotes ?? null}
formAction={declareVotesFormAction}
/>
</section>
)
}
const ElectionsTab = async ({
slug,
rawSearchParams,
payloadUser: { payload, user },
}: {
slug: string
rawSearchParams: PlazaDetailSearchParams
payloadUser: PayloadUser
}) => {
const geography = plazaElectionGeographyForSlug(slug)
const rawCompare = rawSearchParams.compare
const compareNumbers = [
...new Set(
(Array.isArray(rawCompare) ? rawCompare : rawCompare ? [rawCompare] : [])
.filter((value) => /^[1-9]\d*$/.test(value))
.map(Number),
),
].slice(0, MAX_COMPARISON_CANDIDATES)
const [baseline, comparisonRows, candidateOptions] = await Promise.all([
geography ? loadPlazaElectoralBaseline(payload, user, geography) : null,
geography
? loadPlazaCandidateComparison(payload, user, geography, compareNumbers)
: ([] as Awaited<ReturnType<typeof loadPlazaCandidateComparison>>),
loadFederalCandidateOptions(payload, user),
])
return (
<div className="flex flex-col gap-6">
<PlazaBaselineCard baseline={baseline} />
<section
aria-labelledby="plaza-comparison-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="plaza-comparison-title" className="text-base font-medium">
Comparativo entre candidatos
</h2>
<p className="text-sm text-muted-foreground">
Compare a votação de deputados federais nesta Praça através dos anos (fonte TSE, votos
nominais do 1º turno).
</p>
</div>
<CandidateComparePicker
options={candidateOptions}
selectedNumbers={compareNumbers}
maxSelected={MAX_COMPARISON_CANDIDATES}
/>
{comparisonRows.length ? <PlazaCandidateComparisonTable rows={comparisonRows} /> : null}
</section>
</div>
)
}
const LeadershipsTab = async ({
plazaID,
payloadUser: { payload, user },
}: {
plazaID: number
payloadUser: PayloadUser
}) => {
const [leaderships, pledges] = await Promise.all([
loadPlazaLeaderships(payload, user, plazaID),
loadPlazaPledges(payload, user, plazaID),
])
return (
<PlazaLeadershipsPanel
plazaID={plazaID}
leaderships={leaderships}
pledges={pledges}
declareFormAction={declareVotesFormAction}
/>
)
}
const UpdatesTab = async ({
plazaID,
plazaSlug,
rawSearchParams,
payloadUser: { payload, user },
}: {
plazaID: number
plazaSlug: string
rawSearchParams: PlazaDetailSearchParams
payloadUser: PayloadUser
}) => {
const feedState = parsePlazaUpdateFeedParams(rawSearchParams)
const feed = await loadPlazaUpdatesFeed(payload, user, plazaID, feedState)
return (
<div className="flex flex-col gap-6">
<PlazaUpdateForm plazaID={plazaID} formAction={createPlazaUpdateFormAction} />
<PlazaUpdateFeed updates={feed.updates} />
<CampaignListPagination
page={feed.page}
totalPages={feed.totalPages}
hrefForPage={(page) =>
`/campanha/pracas/${plazaSlug}?tab=updates${feedState.kind ? `&updateKind=${feedState.kind}` : ''}&updatePage=${page}`
}
/>
</div>
)
}

View file

@ -0,0 +1,76 @@
'use server'
import { revalidatePath } from 'next/cache'
import { declareVotes, estimateVotes } from '@/app/(campaign)/campanha/actions/votePledge'
import {
nullableFormText,
nullableRelationshipFormValue,
optionalIntegerFormValue,
requiredIntegerFormValue,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
const declareSafeMessages = [
'Somente lideranças engajadas podem declarar votos.',
'A liderança precisa estar vinculada à Praça para declarar votos nela.',
'Informe a liderança da declaração.',
] as const
export const declareVotesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const plaza = requiredRelationshipFormValue(formData, 'plazaId')
const leadership = nullableRelationshipFormValue(formData, 'leadershipId') ?? undefined
const declaredVotes = requiredIntegerFormValue(formData, 'declaredVotes', {
minimum: 0,
maximum: 1_000_000,
})
await declareVotes({ plaza, leadership, declaredVotes })
revalidatePath('/campanha/pracas/[slug]', 'page')
return { status: 'success', message: 'Declaração de votos registrada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: declareSafeMessages,
genericMessage:
'Não foi possível registrar a declaração. Verifique seu acesso e tente novamente.',
})
}
}
export const estimateVotesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const pledge = requiredRelationshipFormValue(formData, 'pledgeId')
const estimatedVotes = optionalIntegerFormValue(formData, 'estimatedVotes', {
minimum: 0,
maximum: 1_000_000,
})
const estimateNote = nullableFormText(formData, 'estimateNote')
await estimateVotes({
pledge,
estimatedVotes: estimatedVotes ?? null,
estimateNote,
})
revalidatePath('/campanha/pracas/[slug]', 'page')
return { status: 'success', message: 'Estimativa registrada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria registram estimativas.'],
genericMessage:
'Não foi possível registrar a estimativa. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -0,0 +1,48 @@
'use server'
import { revalidatePath } from 'next/cache'
import { createPlazaUpdate } from '@/app/(campaign)/campanha/actions/plazaUpdate'
import {
optionalFormText,
optionalIntegerFormValue,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { plazaUpdateKinds, type PlazaUpdateKind } from '@/lib/schemas/plazaUpdate'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const createPlazaUpdateFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const plaza = requiredRelationshipFormValue(formData, 'plazaId')
const rawKind = requiredFormText(formData, 'kind')
const kind = plazaUpdateKinds.includes(rawKind as PlazaUpdateKind)
? (rawKind as PlazaUpdateKind)
: 'semanal'
await createPlazaUpdate({
plaza,
kind,
worked: optionalFormText(formData, 'worked'),
failed: optionalFormText(formData, 'failed'),
needs: optionalFormText(formData, 'needs'),
body: optionalFormText(formData, 'body'),
activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
newSupports: optionalIntegerFormValue(formData, 'newSupports'),
})
revalidatePath('/campanha/pracas/[slug]', 'page')
return { status: 'success', message: 'Atualização registrada com sucesso.' }
} catch (error) {
return mapCampaignFormActionError({
error,
genericMessage:
'Não foi possível registrar a atualização. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -0,0 +1,140 @@
import config from '@payload-config'
import { SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { PlazaFilters } from '@/components/campaign/PlazaFilters'
import { PlazaList } from '@/components/campaign/PlazaList'
import { PlazaListOverview } from '@/components/campaign/PlazaListOverview'
import { PlazaMapPanelDynamic } from '@/components/campaign/PlazaMapPanelDynamic'
import { RecentVisitTracker } from '@/components/campaign/RecentVisitTracker'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
import { loadPlazaMapBundle } from '@/utilities/plazaMapData'
import { loadPlazaListOverviewData, loadPlazaListPageData } from '@/utilities/plazaPageData'
import {
buildPlazaFiltersKey,
buildPlazaListHref,
buildPlazaListVisitHref,
buildPlazaListVisitLabel,
getCampaignScopeLabel,
resolvePlazaListUrl,
} from '@/utilities/plazaUi'
import { loadAdvisorSummaries } from '@/utilities/plazaViewModels'
type PlazasPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function PlazasPage({ searchParams }: PlazasPageProps) {
const rawSearchParams = await searchParams
const canonicalUrl = resolvePlazaListUrl(rawSearchParams)
if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref)
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) return null
const isStaffView = isCampaignStaff(user)
const [listData, overview, mapBundle] = await Promise.all([
loadPlazaListPageData(payload, user, rawSearchParams),
loadPlazaListOverviewData(payload, user, rawSearchParams),
loadPlazaMapBundle(payload, user, canonicalUrl.state.compare),
])
const candidateOptions = mapBundle ? await loadFederalCandidateOptions(payload, user) : []
const resolvedUrl = resolvePlazaListUrl(rawSearchParams, listData.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
const listVisitLabel = buildPlazaListVisitLabel(state)
const advisorIDs = [...new Set(listData.plazas.flatMap((plaza) => plaza.advisorIDs))]
const advisorSummaries = isStaffView ? await loadAdvisorSummaries(payload, user, advisorIDs) : []
const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor]))
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Praças</h1>
<p className="text-muted-foreground">
As 436 Praças da campanha: um município cada — em Salvador e Camaçari, uma zona eleitoral
cada.
</p>
<CampaignScopeBadge>
{getCampaignScopeLabel(user.role, listData.scopeTotal)}
</CampaignScopeBadge>
</header>
{mapBundle ? (
<PlazaMapPanelDynamic bundle={mapBundle} candidateOptions={candidateOptions} />
) : null}
<PlazaFilters
key={buildPlazaFiltersKey(state)}
state={state}
showStaffFilters={isStaffView}
/>
{listData.plazas.length ? (
<>
{overview ? <PlazaListOverview view={overview} /> : null}
<PlazaList
plazas={listData.plazas}
advisorNamesById={advisorNamesById}
isStaffView={isStaffView}
/>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{listData.totalDocs}{' '}
{listData.totalDocs === 1 ? 'Praça encontrada' : 'Praças encontradas'}
</p>
<CampaignListPagination
page={state.page}
totalPages={listData.totalPages}
hrefForPage={(page) => buildPlazaListHref(state, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma Praça encontrada</EmptyTitle>
<EmptyDescription>
Ajuste a busca ou os filtros. Você só vê Praças dentro do seu escopo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/pracas">Limpar busca e filtros</Link>
</Button>
</EmptyContent>
</Empty>
)}
{listVisitLabel ? (
<RecentVisitTracker
entry={{
href: buildPlazaListVisitHref(state),
label: listVisitLabel,
kind: 'plazaList',
}}
/>
) : null}
</CampaignPageShell>
)
}

View file

@ -10,10 +10,12 @@ import {
} from '@/lib/schemas/actionPlan'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
const MAX_ACTION_PLAN_UPDATE_BODY_LENGTH = 4000
const MAX_ACTION_PLAN_RESULT_SUMMARY_LENGTH = 6000
export const createActionPlanRecord = async (
payload: Payload,
@ -168,6 +170,46 @@ export const appendActionPlanUpdateRecord = async (
)
}
export const registerActionPlanResult = async (
payload: Payload,
actor: CampaignUser,
planId: number,
resultSummary: string,
mediaIDs: number[],
) => {
const trimmedSummary = resultSummary.trim()
if (!trimmedSummary) throw new Error('Informe o resultado da ação.')
if (trimmedSummary.length > MAX_ACTION_PLAN_RESULT_SUMMARY_LENGTH) {
throw new Error('Resultado muito longo. Reduza o texto e tente novamente.')
}
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (!isCampaignStaff(currentActor)) {
throw new Error('Apenas a equipe da campanha pode registrar o resultado do plano.')
}
return payload.update({
collection: 'actionPlan',
id: planId,
data: {
resultSummary: trimmedSummary,
// Media upload is a follow-up; an empty list must not wipe media
// attached elsewhere (e.g. via the Payload admin).
...(mediaIDs.length > 0 ? { resultMedia: mediaIDs } : {}),
},
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível registrar o resultado do plano.' },
)
}
export const createActionPlan = async (input: ActionPlanCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
return createActionPlanRecord(payload, actor, input)
@ -197,3 +239,12 @@ export const appendActionPlanUpdate = async (planId: number, body: string) => {
const { payload, actor } = await getCampaignActionContext()
return appendActionPlanUpdateRecord(payload, actor, planId, body)
}
export const registerActionPlanResultAction = async (
planId: number,
resultSummary: string,
mediaIDs: number[] = [],
) => {
const { payload, actor } = await getCampaignActionContext()
return registerActionPlanResult(payload, actor, planId, resultSummary, mediaIDs)
}

View file

@ -1,110 +0,0 @@
'use server'
import type { Payload } from 'payload'
import { z } from 'zod'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions'
import { acquireNucleusRowLocks } from '@/utilities/nucleusRowLocks'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
const coordinatorAssignmentSchema = z.object({
slug: z.string().trim().min(1).max(200),
coordinatorIds: z
.array(z.number().int().positive())
.max(100)
.transform((ids) => [...new Set(ids)]),
expectedUpdatedAt: z.iso.datetime(),
})
export type CoordinatorAssignmentInput = z.infer<typeof coordinatorAssignmentSchema>
export const assignNucleusCoordinatorsRecord = async (
payload: Payload,
actor: CampaignUser,
input: CoordinatorAssignmentInput,
) => {
const data = coordinatorAssignmentSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (currentActor.role !== 'geral') {
throw new Error('Somente a coordenação geral pode alterar coordenadores.')
}
const scopedResult = await payload.find({
collection: 'electoralNucleus',
where: { slug: { equals: data.slug } },
depth: 0,
limit: 1,
pagination: false,
select: { updatedAt: true },
user: currentActor,
overrideAccess: false,
req,
})
const scopedNucleus = scopedResult.docs[0]
if (!scopedNucleus) {
throw new Error('Núcleo não encontrado ou sem acesso.')
}
await acquireTextAdvisoryLocks(payload, req, [
`coordinator-assignment:${scopedNucleus.id}`,
])
await acquireNucleusRowLocks({ payload, ...req } as never, [scopedNucleus.id])
// Intentional admin bypass: the actor is freshly confirmed as geral and the scoped nucleus
// is locked before revision, eligibility, and assignment checks.
const currentNucleus = await payload.findByID({
collection: 'electoralNucleus',
id: scopedNucleus.id,
depth: 0,
select: { updatedAt: true },
overrideAccess: true,
req,
})
if (currentNucleus.updatedAt !== data.expectedUpdatedAt) {
throw new Error(
'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.',
)
}
if (data.coordinatorIds.length > 0) {
const eligible = await payload.find({
collection: 'campaignUser',
where: {
and: [{ id: { in: data.coordinatorIds } }, eligibleNucleusCoordinatorWhere],
},
depth: 0,
pagination: false,
select: { name: true },
overrideAccess: true,
req,
})
if (eligible.docs.length !== data.coordinatorIds.length) {
throw new Error(
'Uma ou mais pessoas selecionadas não são mais elegíveis para coordenação.',
)
}
}
return payload.update({
collection: 'electoralNucleus',
id: currentNucleus.id,
data: { coordinators: data.coordinatorIds },
depth: 0,
overrideAccess: true,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a transação da designação.' },
)
}
export const assignNucleusCoordinators = async (input: CoordinatorAssignmentInput) => {
const { payload, actor } = await getCampaignActionContext()
return assignNucleusCoordinatorsRecord(payload, actor, input)
}

View file

@ -0,0 +1,223 @@
'use server'
import type { Payload } from 'payload'
import {
campaignDemandCostSchema,
campaignDemandCreateSchema,
campaignDemandDetailsUpdateSchema,
campaignDemandTransitionSchema,
type CampaignDemandCostInput,
type CampaignDemandCreateInput,
type CampaignDemandDetailsUpdateInput,
type CampaignDemandTransitionInput,
} from '@/lib/schemas/campaignDemandInput'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
export const createCampaignDemandRecord = async (
payload: Payload,
actor: CampaignUser,
input: CampaignDemandCreateInput,
) => {
const data = campaignDemandCreateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
// Access create rule validates plaza scope per role; the collection hook
// links a leader's own leadership and enforces the initial status.
return payload.create({
collection: 'campaignDemand',
data: data as never,
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar o registro da demanda.' },
)
}
export const updateCampaignDemandDetailsRecord = async (
payload: Payload,
actor: CampaignUser,
input: CampaignDemandDetailsUpdateInput,
) => {
const { id, ...data } = campaignDemandDetailsUpdateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${id}`])
return payload.update({
collection: 'campaignDemand',
id,
data,
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização da demanda.' },
)
}
export const transitionCampaignDemandRecord = async (
payload: Payload,
actor: CampaignUser,
input: CampaignDemandTransitionInput,
) => {
const { id, status, decisionNote } = campaignDemandTransitionSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') {
throw new Error('Somente a coordenação e a assessoria movem demandas.')
}
await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${id}`])
// Transition validity + coordinator-only escalated decisions are
// enforced by the collection hook.
return payload.update({
collection: 'campaignDemand',
id,
data: {
status,
...(decisionNote === undefined ? {} : { decisionNote }),
},
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a transição da demanda.' },
)
}
export const setCampaignDemandCostRecord = async (
payload: Payload,
actor: CampaignUser,
input: CampaignDemandCostInput,
) => {
const { id, cost } = campaignDemandCostSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') {
throw new Error('Somente a coordenação e a assessoria registram custos.')
}
await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${id}`])
return payload.update({
collection: 'campaignDemand',
id,
data: { cost },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar o registro do custo.' },
)
}
const RECEIPT_MAX_BYTES = 10 * 1024 * 1024
const RECEIPT_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf'])
export const attachCampaignDemandReceiptRecord = async (
payload: Payload,
actor: CampaignUser,
demandID: number,
file: File,
) => {
if (!RECEIPT_MIME_TYPES.has(file.type)) {
throw new Error('Envie uma imagem (JPEG, PNG, WebP) ou PDF.')
}
if (file.size === 0) throw new Error('O arquivo enviado está vazio.')
if (file.size > RECEIPT_MAX_BYTES) throw new Error('O comprovante deve ter no máximo 10 MB.')
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') {
throw new Error('Somente a coordenação e a assessoria anexam comprovantes.')
}
await acquireTextAdvisoryLocks(payload, req, [`campaign-demand:${demandID}`])
// Row access verifies the demand is in the actor's scope before writing.
const demand = await payload.findByID({
collection: 'campaignDemand',
id: demandID,
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
// Intentional admin bypass: media creation for a receipt the actor may attach.
const media = await payload.create({
collection: 'media',
data: { alt: `Comprovante — ${demand.title}` },
file: {
data: Buffer.from(await file.arrayBuffer()),
mimetype: file.type,
name: file.name,
size: file.size,
},
overrideAccess: true,
req,
})
const currentReceipts = (demand.receipts ?? []).map((receipt) =>
typeof receipt === 'object' && receipt !== null ? receipt.id : receipt,
)
return payload.update({
collection: 'campaignDemand',
id: demandID,
data: { receipts: [...currentReceipts, media.id] },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar o anexo do comprovante.' },
)
}
export const createCampaignDemand = async (input: CampaignDemandCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
return createCampaignDemandRecord(payload, actor, input)
}
export const updateCampaignDemandDetails = async (input: CampaignDemandDetailsUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
return updateCampaignDemandDetailsRecord(payload, actor, input)
}
export const transitionCampaignDemand = async (input: CampaignDemandTransitionInput) => {
const { payload, actor } = await getCampaignActionContext()
return transitionCampaignDemandRecord(payload, actor, input)
}
export const setCampaignDemandCost = async (input: CampaignDemandCostInput) => {
const { payload, actor } = await getCampaignActionContext()
return setCampaignDemandCostRecord(payload, actor, input)
}

View file

@ -8,11 +8,11 @@ import {
type LeadershipInternalUpdateInput,
} from '@/lib/schemas/leadership'
import type { CampaignUser, Contact } from '@/payload-types'
import { getAdvisorPlazaIds } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { requireRelationshipId } from '@/utilities/relationship'
const getFreshStaffActor = async (
payload: Payload,
@ -21,38 +21,38 @@ const getFreshStaffActor = async (
): Promise<CampaignUser> => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (currentActor.role !== 'geral' && currentActor.role !== 'coordenador') {
throw new Error('Somente a coordenação pode gerenciar lideranças.')
if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') {
throw new Error('Somente a coordenação e a assessoria podem gerenciar lideranças.')
}
return currentActor
}
const assertNucleusManagement = async (
/** Advisors may only link leaderships to plazas they administer. */
const assertPlazasWithinScope = async (
payload: Payload,
actor: CampaignUser,
nucleusID: number,
plazaIDs: number[],
req?: PayloadTransactionRequest,
) =>
payload.findByID({
collection: 'electoralNucleus',
id: nucleusID,
depth: 0,
user: actor,
overrideAccess: false,
req,
})
) => {
if (actor.role !== 'advisor') return
const administered = new Set(await getAdvisorPlazaIds(payload, actor.id, req))
const outside = plazaIDs.filter((id) => !administered.has(id))
if (outside.length > 0) {
throw new Error('Você só pode vincular lideranças às Praças que assessora.')
}
}
const isUniqueLeadershipConflict = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error)
if (/leadership.*contact.*nucleus|leadership_contact_nucleus|duplicate key/i.test(message)) {
if (/leadership_contact|duplicate key/i.test(message)) {
return true
}
if (!(error instanceof Error) || error.name !== 'ValidationError') return false
const details = JSON.stringify(error)
return /contact(?:_id)?/i.test(details) && /nucleus(?:_id)?/i.test(details)
return /contact(?:_id)?/i.test(JSON.stringify(error))
}
type LeadershipCreateData = ReturnType<typeof leadershipCreateSchema.parse>
@ -67,7 +67,7 @@ const createValidatedLeadershipRecord = async (
payload,
async ({ req }) => {
const currentActor = await getFreshStaffActor(payload, actor, req)
const nucleus = await assertNucleusManagement(payload, currentActor, data.nucleus, req)
await assertPlazasWithinScope(payload, currentActor, data.plazas, req)
if (payload.db.name !== 'postgres') {
throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.')
}
@ -94,8 +94,18 @@ const createValidatedLeadershipRecord = async (
const contactReused = Boolean(contactID)
if (!contactID) {
const cities = Array.isArray(nucleus.cities) ? nucleus.cities : []
const city = cities.length === 1 ? cities[0] : (nucleus.locality ?? null)
let city: string | null = null
if (data.plazas.length === 1) {
const plaza = await payload.findByID({
collection: 'plaza',
id: data.plazas[0]!,
depth: 0,
select: { city: true },
overrideAccess: true,
req,
})
city = plaza.city
}
const contact = await payload.create({
collection: 'contact',
data: {
@ -117,7 +127,8 @@ const createValidatedLeadershipRecord = async (
collection: 'leadership',
data: {
contact: contactID,
nucleus: data.nucleus,
plazas: data.plazas,
organizations: data.organizations ?? [],
sector: data.sector,
sectorNotes: data.sectorNotes,
supportStatus: data.supportStatus,
@ -135,7 +146,9 @@ const createValidatedLeadershipRecord = async (
)
} catch (error) {
if (isUniqueLeadershipConflict(error)) {
throw new Error('Esta pessoa já está cadastrada como liderança neste núcleo.')
throw new Error(
'Esta pessoa já está cadastrada como liderança. Edite a ficha existente para vincular novas Praças.',
)
}
throw error
@ -153,37 +166,46 @@ export const updateLeadershipInternalRecord = async (
actor: CampaignUser,
input: LeadershipInternalUpdateInput,
) => {
const { id, ...data } = leadershipInternalUpdateSchema.parse(input)
const { id, plazas, organizations, ...data } = leadershipInternalUpdateSchema.parse(input)
const currentActor = await getFreshStaffActor(payload, actor)
// Row access verifies the current record is in the actor's scope.
const current = await payload.findByID({
collection: 'leadership',
id,
depth: 0,
overrideAccess: true,
user: currentActor,
overrideAccess: false,
})
await assertNucleusManagement(payload, currentActor, requireRelationshipId(current.nucleus))
if (plazas !== undefined) {
await assertPlazasWithinScope(payload, currentActor, plazas)
}
return payload.update({
collection: 'leadership',
id,
data,
id: current.id,
data: {
...data,
...(plazas === undefined ? {} : { plazas }),
...(organizations === undefined ? {} : { organizations: organizations ?? [] }),
},
depth: 0,
user: currentActor,
overrideAccess: false,
})
}
export const listNucleusLeaderships = async (
export const listPlazaLeaderships = async (
payload: Payload,
actor: CampaignUser,
nucleusID: number,
plazaID: number,
) => {
const currentActor = await getFreshStaffActor(payload, actor)
await assertNucleusManagement(payload, currentActor, nucleusID)
return payload.find({
collection: 'leadership',
where: { nucleus: { equals: nucleusID } },
where: { plazas: { in: [plazaID] } },
depth: 1,
sort: 'createdAt',
user: currentActor,

View file

@ -1,94 +0,0 @@
'use server'
import type { Payload } from 'payload'
import {
nucleusCreateSchema,
nucleusUpdateSchema,
type NucleusCreateInput,
type NucleusUpdateInput,
} from '@/lib/schemas/nucleus'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { acquireNucleusRowLocks } from '@/utilities/nucleusRowLocks'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
export const createElectoralNucleus = async (
payload: Payload,
actor: CampaignUser,
input: NucleusCreateInput,
) => {
const data = nucleusCreateSchema.parse(input)
return payload.create({
collection: 'electoralNucleus',
data: data as never,
depth: 0,
user: actor,
overrideAccess: false,
})
}
export const updateElectoralNucleus = async (
payload: Payload,
actor: CampaignUser,
input: NucleusUpdateInput,
) => {
const { id, ...data } = nucleusUpdateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
await acquireNucleusRowLocks({ payload, ...req } as never, [id])
return payload.update({
collection: 'electoralNucleus',
id,
data: data as never,
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a atualização do núcleo.' },
)
}
export const archiveElectoralNucleus = async (
payload: Payload,
actor: CampaignUser,
id: number,
) => {
const currentActor = await reloadCampaignActor(payload, actor)
if (currentActor.role !== 'geral') {
throw new Error('Somente a coordenação geral pode arquivar núcleos.')
}
return payload.update({
collection: 'electoralNucleus',
id,
data: {
status: 'arquivado',
},
depth: 0,
user: currentActor,
overrideAccess: false,
})
}
export const createNucleus = async (input: NucleusCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
return createElectoralNucleus(payload, actor, input)
}
export const updateNucleus = async (input: NucleusUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
return updateElectoralNucleus(payload, actor, input)
}
export const archiveNucleus = async (id: number) => {
const { payload, actor } = await getCampaignActionContext()
return archiveElectoralNucleus(payload, actor, id)
}

View file

@ -1,63 +0,0 @@
'use server'
import type { Payload } from 'payload'
import {
nucleusUpdateCreateSchema,
type NucleusUpdateCreateInput,
} from '@/lib/schemas/nucleusUpdate'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
export const createNucleusUpdateRecord = async (
payload: Payload,
actor: CampaignUser,
input: NucleusUpdateCreateInput,
) => {
const data = nucleusUpdateCreateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
return payload.create({
collection: 'nucleusUpdate',
data: data as never,
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a transação da atualização.' },
)
}
export const listNucleusUpdates = async (
payload: Payload,
actor: CampaignUser,
nucleus: number,
) => {
const currentActor = await reloadCampaignActor(payload, actor)
await payload.findByID({
collection: 'electoralNucleus',
id: nucleus,
depth: 0,
user: currentActor,
overrideAccess: false,
})
return payload.find({
collection: 'nucleusUpdate',
where: { nucleus: { equals: nucleus } },
depth: 1,
sort: '-createdAt',
user: currentActor,
overrideAccess: false,
})
}
export const createNucleusUpdate = async (input: NucleusUpdateCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
return createNucleusUpdateRecord(payload, actor, input)
}

View file

@ -0,0 +1,77 @@
'use server'
import type { Payload } from 'payload'
import {
organizationCreateSchema,
organizationUpdateSchema,
type OrganizationCreateInput,
type OrganizationUpdateInput,
} from '@/lib/schemas/organization'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
const assertStaffActor = async (payload: Payload, actor: CampaignUser): Promise<CampaignUser> => {
const currentActor = await reloadCampaignActor(payload, actor)
if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') {
throw new Error('Somente a coordenação e a assessoria gerenciam organizações.')
}
return currentActor
}
const isUniqueOrganizationConflict = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error)
return /organization_(name|slug)|duplicate key/i.test(message)
}
export const createOrganizationRecord = async (
payload: Payload,
actor: CampaignUser,
input: OrganizationCreateInput,
) => {
const data = organizationCreateSchema.parse(input)
const currentActor = await assertStaffActor(payload, actor)
try {
return await payload.create({
collection: 'organization',
data: data as never,
depth: 0,
user: currentActor,
overrideAccess: false,
})
} catch (error) {
if (isUniqueOrganizationConflict(error)) {
throw new Error('Já existe uma organização com este nome.')
}
throw error
}
}
export const updateOrganizationRecord = async (
payload: Payload,
actor: CampaignUser,
input: OrganizationUpdateInput,
) => {
const { id, ...data } = organizationUpdateSchema.parse(input)
const currentActor = await assertStaffActor(payload, actor)
return payload.update({
collection: 'organization',
id,
data,
depth: 0,
user: currentActor,
overrideAccess: false,
})
}
export const createOrganization = async (input: OrganizationCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
return createOrganizationRecord(payload, actor, input)
}
export const updateOrganization = async (input: OrganizationUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
return updateOrganizationRecord(payload, actor, input)
}

View file

@ -0,0 +1,123 @@
'use server'
import type { Payload } from 'payload'
import {
plazaAdvisorsAssignmentSchema,
plazaPoliticalTrendSchema,
plazaStrategyUpdateSchema,
type PlazaAdvisorsAssignmentInput,
type PlazaPoliticalTrendInput,
type PlazaStrategyUpdateInput,
} from '@/lib/schemas/plaza'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
const getFreshStaffActor = async (
payload: Payload,
actor: CampaignUser,
req?: PayloadTransactionRequest,
): Promise<CampaignUser> => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') {
throw new Error('Somente a coordenação e a assessoria podem editar a Praça.')
}
return currentActor
}
/** Staff strategy fields: goals, priority, intel notes. Access enforced by row scope. */
export const updatePlazaStrategyRecord = async (
payload: Payload,
actor: CampaignUser,
input: PlazaStrategyUpdateInput,
) => {
const { plaza, strengths, risks, ...fields } = plazaStrategyUpdateSchema.parse(input)
const currentActor = await getFreshStaffActor(payload, actor)
return payload.update({
collection: 'plaza',
id: plaza,
data: {
...fields,
...(strengths === undefined ? {} : { strengths: strengths.map((text) => ({ text })) }),
...(risks === undefined ? {} : { risks: risks.map((text) => ({ text })) }),
},
depth: 0,
user: currentActor,
overrideAccess: false,
})
}
export const updatePlazaStrategy = async (input: PlazaStrategyUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
return updatePlazaStrategyRecord(payload, actor, input)
}
/** Political trend: manual conjuncture reading by staff (author/date derived by hook). */
export const setPlazaPoliticalTrendRecord = async (
payload: Payload,
actor: CampaignUser,
input: PlazaPoliticalTrendInput,
) => {
const { plaza, status, note } = plazaPoliticalTrendSchema.parse(input)
const currentActor = await getFreshStaffActor(payload, actor)
return payload.update({
collection: 'plaza',
id: plaza,
data: {
politicalTrend: { status, note },
},
depth: 0,
user: currentActor,
overrideAccess: false,
})
}
export const setPlazaPoliticalTrend = async (input: PlazaPoliticalTrendInput) => {
const { payload, actor } = await getCampaignActionContext()
return setPlazaPoliticalTrendRecord(payload, actor, input)
}
/** Advisor assignment is coordinator-only; the collection hook validates eligibility. */
export const assignPlazaAdvisorsRecord = async (
payload: Payload,
actor: CampaignUser,
input: PlazaAdvisorsAssignmentInput,
) => {
const { plaza, advisors } = plazaAdvisorsAssignmentSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (currentActor.role !== 'coordinator') {
throw new Error('Somente o Coordenador Geral designa assessores.')
}
await acquireTextAdvisoryLocks(payload, req, [`plaza-advisors:${plaza}`])
// Intentional admin bypass: coordinator role was freshly verified above;
// the advisors field is admin-only by field access.
return payload.update({
collection: 'plaza',
id: plaza,
data: { advisors },
depth: 0,
overrideAccess: true,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a designação de assessores.' },
)
}
export const assignPlazaAdvisors = async (input: PlazaAdvisorsAssignmentInput) => {
const { payload, actor } = await getCampaignActionContext()
return assignPlazaAdvisorsRecord(payload, actor, input)
}

View file

@ -0,0 +1,38 @@
'use server'
import type { Payload } from 'payload'
import { plazaUpdateCreateSchema, type PlazaUpdateCreateInput } from '@/lib/schemas/plazaUpdate'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
export const createPlazaUpdateRecord = async (
payload: Payload,
actor: CampaignUser,
input: PlazaUpdateCreateInput,
) => {
const data = plazaUpdateCreateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
return payload.create({
collection: 'plazaUpdate',
data: data as never,
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar o registro da atualização.' },
)
}
export const createPlazaUpdate = async (input: PlazaUpdateCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
return createPlazaUpdateRecord(payload, actor, input)
}

View file

@ -72,34 +72,34 @@ const getFreshStaffActor = async (
): Promise<CampaignUser> => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (currentActor.role !== 'geral' && currentActor.role !== 'coordenador') {
throw new Error('Somente a coordenação pode gerenciar apoiadores.')
if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') {
throw new Error('Somente a coordenação e a assessoria podem gerenciar apoiadores.')
}
return currentActor
}
const getFreshGeneralActor = async (
const getFreshCoordinatorActor = async (
payload: Payload,
actor: CampaignUser,
req?: PayloadTransactionRequest,
): Promise<CampaignUser> => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (currentActor.role !== 'geral') {
throw new Error('Somente a coordenação geral pode importar apoiadores.')
if (currentActor.role !== 'coordinator') {
throw new Error('Somente o Coordenador Geral pode importar apoiadores.')
}
return currentActor
}
const assertNucleusManagement = async (
const assertPlazaManagement = async (
payload: Payload,
actor: CampaignUser,
nucleusID: number,
plazaID: number,
req?: PayloadTransactionRequest,
) =>
payload.findByID({
collection: 'electoralNucleus',
id: nucleusID,
collection: 'plaza',
id: plazaID,
depth: 0,
user: actor,
overrideAccess: false,
@ -123,7 +123,7 @@ const assertCanManageSupporter = async (
const isUniqueSupporterConflict = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error)
return /supporter.*contact.*nucleus|supporter_contact_nucleus|nulls_not_distinct|duplicate key/i.test(
return /supporter.*contact.*plaza|supporter_contact_plaza|nulls_not_distinct|duplicate key/i.test(
message,
)
}
@ -194,10 +194,10 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i
async ({ req }) => {
const currentActor = await getFreshStaffActor(payload, actor, req)
if (data.nucleus) {
await assertNucleusManagement(payload, currentActor, data.nucleus, req)
} else if (currentActor.role !== 'geral') {
throw new Error('Somente a coordenação geral pode cadastrar apoiadores sem núcleo.')
if (data.plaza) {
await assertPlazaManagement(payload, currentActor, data.plaza, req)
} else if (currentActor.role !== 'coordinator') {
throw new Error('Somente o Coordenador Geral pode cadastrar apoiadores sem Praça.')
}
const registrationConsent = await requireSupporterRegistrationConsent(
@ -233,7 +233,7 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i
collection: 'supporter',
data: {
contact: contactID,
nucleus: data.nucleus,
plaza: data.plaza,
voteIntention: data.voteIntention,
source: 'manual',
consent: registrationConsent.id,
@ -259,7 +259,7 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i
)
} catch (error) {
if (isUniqueSupporterConflict(error)) {
throw new Error('Esta pessoa já está cadastrada como apoiador neste núcleo.')
throw new Error('Esta pessoa já está cadastrada como apoiador nesta Praça.')
}
throw error
}
@ -363,7 +363,7 @@ export const previewSupporterImportText = async (
actor: CampaignUser,
csvText: string,
): Promise<SupporterImportPreviewResult> => {
const currentActor = await getFreshGeneralActor(payload, actor)
const currentActor = await getFreshCoordinatorActor(payload, actor)
await requireSupporterRegistrationConsent(payload)
await requireSupporterVoteIntentionConsent(payload)
@ -466,7 +466,7 @@ export const previewSupporterImportText = async (
})
}
// Duplicate against an existing supporter without nucleus (Contact reuse alone is OK).
// Duplicate against an existing supporter without plaza (Contact reuse alone is OK).
const candidatePhones = [
...new Set(rows.filter(isSupporterImportOkRow).map((row) => row.normalizedPhone)),
]
@ -488,7 +488,7 @@ export const previewSupporterImportText = async (
const existingSupporters = await payload.find({
collection: 'supporter',
where: {
and: [{ contact: { in: contactIDs } }, { nucleus: { exists: false } }],
and: [{ contact: { in: contactIDs } }, { plaza: { exists: false } }],
},
depth: 0,
limit: contactIDs.length,
@ -570,7 +570,7 @@ export const confirmSupporterImportRecord = async (
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await getFreshGeneralActor(payload, actor, req)
const currentActor = await getFreshCoordinatorActor(payload, actor, req)
const registrationConsent = await requireSupporterRegistrationConsent(payload, req)
const voteIntentionConsent = await requireSupporterVoteIntentionConsent(payload, req)

View file

@ -1,179 +0,0 @@
'use server'
import { randomUUID } from 'node:crypto'
import type { Payload } from 'payload'
import {
confirmVoteEstimateSchema,
suggestVoteEstimateSchema,
type ConfirmVoteEstimateInput,
type SuggestVoteEstimateInput,
} from '@/lib/schemas/voteEstimate'
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
const assertSuggestionScope = async (
payload: Payload,
actor: CampaignUser,
nucleus: number,
req: PayloadTransactionRequest,
): Promise<void> => {
if (actor.role === 'geral') return
if (actor.role === 'coordenador') {
await payload.findByID({
collection: 'electoralNucleus',
id: nucleus,
depth: 0,
user: actor,
overrideAccess: false,
req,
})
return
}
const links = await payload.find({
collection: 'leadership',
where: {
and: [
{ nucleus: { equals: nucleus } },
{ user: { equals: actor.id } },
{ supportStatus: { equals: 'engajado' } },
],
},
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
})
if (links.totalDocs === 0) {
throw new Error('A liderança precisa de vínculo engajado com este núcleo.')
}
}
const getConfirmableNucleus = async (
payload: Payload,
actor: CampaignUser,
nucleus: number,
req: PayloadTransactionRequest,
): Promise<ElectoralNucleus> => {
if (actor.role !== 'geral' && actor.role !== 'coordenador') {
throw new Error('Somente a coordenação pode confirmar estimativas.')
}
return payload.findByID({
collection: 'electoralNucleus',
id: nucleus,
depth: 0,
user: actor,
overrideAccess: false,
req,
})
}
export const suggestVoteEstimateRecord = async (
payload: Payload,
actor: CampaignUser,
input: SuggestVoteEstimateInput,
) => {
const data = suggestVoteEstimateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
if (payload.db.name !== 'postgres') {
throw new Error('O bloqueio de estimativa exige o adaptador PostgreSQL.')
}
await acquireTextAdvisoryLocks(payload, req, [`vote-estimate:${data.nucleus}`])
const currentActor = await reloadCampaignActor(payload, actor, req)
await assertSuggestionScope(payload, currentActor, data.nucleus, req)
// Intentional admin bypass: scope was checked under the nucleus transaction lock; audit
// fields and proposal version are derived exclusively on the server.
return payload.update({
collection: 'electoralNucleus',
id: data.nucleus,
data: {
proposedVoteEstimate: data.estimate,
proposedVoteEstimateAt: new Date().toISOString(),
proposedVoteEstimateBy: currentActor.id,
proposedVoteEstimateVersion: randomUUID(),
},
depth: 0,
overrideAccess: true,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a transação da estimativa.' },
)
}
export const confirmVoteEstimateRecord = async (
payload: Payload,
actor: CampaignUser,
input: ConfirmVoteEstimateInput,
) => {
const data = confirmVoteEstimateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
if (payload.db.name !== 'postgres') {
throw new Error('O bloqueio de estimativa exige o adaptador PostgreSQL.')
}
await acquireTextAdvisoryLocks(payload, req, [`vote-estimate:${data.nucleus}`])
const currentActor = await reloadCampaignActor(payload, actor, req)
const nucleus = await getConfirmableNucleus(payload, currentActor, data.nucleus, req)
const proposal = nucleus.proposedVoteEstimate
const proposalVersion = nucleus.proposedVoteEstimateVersion ?? null
if (proposal == null) {
if (proposalVersion !== null || data.expectedProposedVoteEstimateVersion !== null) {
throw new Error('A sugestão foi alterada. Atualize a página antes de confirmar.')
}
} else if (
proposalVersion === null ||
proposalVersion !== data.expectedProposedVoteEstimateVersion
) {
throw new Error('A sugestão foi alterada. Atualize a página antes de confirmar.')
}
if ((proposal == null || data.estimate !== proposal) && !data.confirmationNote) {
throw new Error('Informe uma justificativa para ajustar a estimativa.')
}
// Intentional admin bypass: confirmation role/scope and proposal revision were checked
// under the same transaction lock; all audit fields are server-derived.
return payload.update({
collection: 'electoralNucleus',
id: data.nucleus,
data: {
confirmedVoteEstimate: data.estimate,
confirmedVoteEstimateAt: new Date().toISOString(),
confirmedVoteEstimateBy: currentActor.id,
confirmationNote: data.confirmationNote ?? null,
proposedVoteEstimate: null,
proposedVoteEstimateAt: null,
proposedVoteEstimateBy: null,
proposedVoteEstimateVersion: null,
},
depth: 0,
overrideAccess: true,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a transação da estimativa.' },
)
}
export const suggestVoteEstimate = async (input: SuggestVoteEstimateInput) => {
const { payload, actor } = await getCampaignActionContext()
return suggestVoteEstimateRecord(payload, actor, input)
}
export const confirmVoteEstimate = async (input: ConfirmVoteEstimateInput) => {
const { payload, actor } = await getCampaignActionContext()
return confirmVoteEstimateRecord(payload, actor, input)
}

View file

@ -0,0 +1,172 @@
'use server'
import type { Payload } from 'payload'
import {
declareVotesSchema,
estimateVotesSchema,
type DeclareVotesInput,
type EstimateVotesInput,
} from '@/lib/schemas/votePledge'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
const OWN_ENGAGED_LEADERSHIP_REQUIRED = 'Somente lideranças engajadas podem declarar votos.'
const PLAZA_NOT_LINKED = 'A liderança precisa estar vinculada à Praça para declarar votos nela.'
/**
* Declare (or update) the votes a leadership is bringing in one plaza.
* A leader always declares for their own leadership; staff may declare on
* behalf of any leadership in their scope.
*/
export const declareVotesRecord = async (
payload: Payload,
actor: CampaignUser,
input: DeclareVotesInput,
) => {
const data = declareVotesSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
let leadershipID: number
if (currentActor.role === 'leader') {
const ownLeadership = await payload.find({
collection: 'leadership',
where: {
and: [{ user: { equals: currentActor.id } }, { supportStatus: { equals: 'engajado' } }],
},
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
})
const leadership = ownLeadership.docs[0]
if (!leadership) throw new Error(OWN_ENGAGED_LEADERSHIP_REQUIRED)
leadershipID = leadership.id
const linkedPlazaIDs = (leadership.plazas ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null)
if (!linkedPlazaIDs.includes(data.plaza)) throw new Error(PLAZA_NOT_LINKED)
} else {
if (!data.leadership) throw new Error('Informe a liderança da declaração.')
leadershipID = data.leadership
// Row access on the plaza read verifies the staff scope (advisor only
// reaches administered plazas).
await payload.findByID({
collection: 'plaza',
id: data.plaza,
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
}
await acquireTextAdvisoryLocks(payload, req, [`vote-pledge:${leadershipID}:${data.plaza}`])
const existing = await payload.find({
collection: 'votePledge',
where: {
and: [{ leadership: { equals: leadershipID } }, { plaza: { equals: data.plaza } }],
},
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
})
const current = existing.docs[0]
if (current) {
return payload.update({
collection: 'votePledge',
id: current.id,
data: { declaredVotes: data.declaredVotes },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
}
return payload.create({
collection: 'votePledge',
data: {
leadership: leadershipID,
plaza: data.plaza,
declaredVotes: data.declaredVotes,
},
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a declaração de votos.' },
)
}
export const declareVotes = async (input: DeclareVotesInput) => {
const { payload, actor } = await getCampaignActionContext()
return declareVotesRecord(payload, actor, input)
}
/** Staff estimate of the real value — never visible to the leadership. */
export const estimateVotesRecord = async (
payload: Payload,
actor: CampaignUser,
input: EstimateVotesInput,
) => {
const data = estimateVotesSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (currentActor.role !== 'coordinator' && currentActor.role !== 'advisor') {
throw new Error('Somente a coordenação e a assessoria registram estimativas.')
}
// Row access scopes the pledge to the actor (advisor: administered plazas).
const pledge = await payload.findByID({
collection: 'votePledge',
id: data.pledge,
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
await acquireTextAdvisoryLocks(payload, req, [
`vote-pledge:${requireRelationshipId(pledge.leadership)}:${requireRelationshipId(pledge.plaza)}`,
])
return payload.update({
collection: 'votePledge',
id: pledge.id,
data: {
estimatedVotes: data.estimatedVotes,
estimateNote: data.estimateNote,
},
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar o registro da estimativa.' },
)
}
export const estimateVotes = async (input: EstimateVotesInput) => {
const { payload, actor } = await getCampaignActionContext()
return estimateVotesRecord(payload, actor, input)
}

View file

@ -3,7 +3,6 @@ import { Inter } from 'next/font/google'
import React from 'react'
import { RegisterServiceWorker } from '@/components/campaign/RegisterServiceWorker'
import { VoteEstimateFocusProvider } from '@/components/campaign/VoteEstimateFocusProvider'
import { CAMPAIGN_PWA_MANIFEST_PATH, CAMPAIGN_PWA_THEME_COLOR } from '@/utilities/campaignPwa'
import '../(frontend)/styles.css'
@ -40,7 +39,7 @@ export default function CampanhaRootLayout({ children }: { children: React.React
style={{ colorScheme: 'light' }}
>
<body className="bg-background bg-none text-foreground antialiased">
<VoteEstimateFocusProvider>{children}</VoteEstimateFocusProvider>
{children}
<RegisterServiceWorker />
</body>
</html>

View file

@ -5,30 +5,23 @@ import type {
} from 'payload'
import { APIError } from 'payload'
import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import {
actionPlanKindLabels,
actionPlanKinds,
actionPlanStatusLabels,
actionPlanStatuses,
} from '@/lib/schemas/actionPlan'
import {
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
MAX_NUCLEUS_REGIONS,
} from '@/lib/schemas/nucleus'
import {
canCreateActionPlan,
canCreateActionPlanCoordinators,
canCreateActionPlanAdvisors,
canDeleteActionPlan,
canManageActionPlanCoordinators,
canManageActionPlanAdvisors,
canReadActionPlan,
canSetActionPlanStatus,
canSetActionPlanSystemField,
canUpdateActionPlan,
eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess'
import { createCampaignTerritoryValidationHook } from '@/utilities/campaignTerritoryValidation'
import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions'
import { relationshipId } from '@/utilities/relationship'
import { slugify } from '@/utilities/slug'
@ -47,6 +40,9 @@ const ACTION_PLAN_STATUS_OPTIONS = actionPlanStatuses.map((value) => ({
label: actionPlanStatusLabels[value],
}))
const relationshipIds = (value: unknown): number[] =>
(Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null)
const actionPlanStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
title: trimmedText(doc.title),
slug: doc.slug ?? null,
@ -56,27 +52,17 @@ const actionPlanStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
startAt: doc.startAt ?? null,
endAt: doc.endAt ?? null,
deadline: doc.deadline ?? null,
regions: Array.isArray(doc.regions) ? doc.regions.map(String) : [],
cities: Array.isArray(doc.cities) ? doc.cities.map(String) : [],
neighborhoods: Array.isArray(doc.neighborhoods) ? doc.neighborhoods.map(String) : [],
plaza: relationshipId(doc.plaza),
locality: trimmedText(doc.locality),
territoryNotes: trimmedText(doc.territoryNotes),
coordinators: (Array.isArray(doc.coordinators) ? doc.coordinators : [])
.map(relationshipId)
.filter((id): id is number => id !== null),
deputyPresent: Boolean(doc.deputyPresent),
organizations: relationshipIds(doc.organizations),
advisors: relationshipIds(doc.advisors),
responsible: relationshipId(doc.responsible),
leadership: relationshipId(doc.leadership),
resultSummary: trimmedText(doc.resultSummary),
resultMedia: relationshipIds(doc.resultMedia),
})
const validateActionPlanTerritoryCore = createCampaignTerritoryValidationHook({
entityLabel: 'plano',
})
const validateActionPlanTerritory: CollectionBeforeValidateHook = (args) => {
if (isActionPlanMutationShortcut(args.context)) return args.data
return validateActionPlanTerritoryCore(args)
}
const setCanonicalActionPlanSlug: CollectionBeforeValidateHook = ({
data,
operation,
@ -113,10 +99,7 @@ const validateActionPlanSchedule: CollectionBeforeValidateHook = ({
const endAt = nextData.endAt ?? null
if (status !== 'rascunho' && !startAt) {
throw new APIError(
'Informe a data e horário de início ao planejar ou confirmar o plano.',
400,
)
throw new APIError('Informe a data e horário de início ao planejar ou confirmar o plano.', 400)
}
if (startAt && endAt) {
@ -130,48 +113,48 @@ const validateActionPlanSchedule: CollectionBeforeValidateHook = ({
return data
}
const validateActionPlanCoordinators: CollectionBeforeValidateHook = async ({ data, req, context }) => {
const validateActionPlanAdvisors: CollectionBeforeValidateHook = async ({ data, req, context }) => {
if (isActionPlanMutationShortcut(context)) return data
if (!data) return data
if (data.coordinators === undefined) return data
if (data.advisors === undefined) return data
const coordinatorValues = data.coordinators
const coordinatorIDs = Array.isArray(coordinatorValues)
? [...new Set(coordinatorValues.map(relationshipId).filter((id): id is number => id !== null))]
: []
const advisorIDs = [...new Set(relationshipIds(data.advisors))]
if (advisorIDs.length === 0) return data
if (coordinatorIDs.length === 0) return data
const eligibleCoordinators = await req.payload.find({
const eligibleAdvisors = await req.payload.find({
collection: 'campaignUser',
depth: 0,
pagination: false,
where: {
and: [{ id: { in: coordinatorIDs } }, eligibleNucleusCoordinatorWhere],
and: [{ id: { in: advisorIDs } }, eligibleCampaignStaffWhere],
},
select: { name: true },
overrideAccess: true,
req,
})
if (eligibleCoordinators.docs.length !== coordinatorIDs.length) {
throw new APIError('Cada responsável deve ter papel de coordenação geral ou coordenador.', 400)
if (eligibleAdvisors.docs.length !== advisorIDs.length) {
throw new APIError('Cada responsável deve ter papel de Coordenador Geral ou Assessor.', 400)
}
return data
}
const deriveActionPlanFields: CollectionBeforeChangeHook = ({ data, operation, originalDoc, req }) => {
const deriveActionPlanFields: CollectionBeforeChangeHook = ({
data,
operation,
originalDoc,
req,
}) => {
if (!data) return data
if (operation === 'create' && req.user?.collection === 'campaignUser') {
data.createdBy = req.user.id
if (
req.user.role === 'coordenador' &&
(data.coordinators === undefined ||
(Array.isArray(data.coordinators) && data.coordinators.length === 0))
req.user.role === 'advisor' &&
(data.advisors === undefined || (Array.isArray(data.advisors) && data.advisors.length === 0))
) {
data.coordinators = [req.user.id]
data.advisors = [req.user.id]
}
}
@ -204,16 +187,29 @@ const deriveActionPlanFields: CollectionBeforeChangeHook = ({ data, operation, o
}
return {
body: trimmedText(update.body),
author:
req.user?.collection === 'campaignUser'
? req.user.id
: (update.author ?? null),
author: req.user?.collection === 'campaignUser' ? req.user.id : (update.author ?? null),
createdAt: new Date().toISOString(),
}
})
}
if (operation === 'update' && req.user?.collection === 'campaignUser' && req.user.role === 'lideranca') {
const resultChanged =
(data.resultSummary !== undefined &&
trimmedText(data.resultSummary) !== trimmedText(originalDoc?.resultSummary)) ||
(data.resultMedia !== undefined &&
JSON.stringify(relationshipIds(data.resultMedia)) !==
JSON.stringify(relationshipIds(originalDoc?.resultMedia)))
if (resultChanged) {
data.resultRecordedAt = new Date().toISOString()
if (req.user?.collection === 'campaignUser') data.resultRecordedBy = req.user.id
}
if (
operation === 'update' &&
req.user?.collection === 'campaignUser' &&
req.user.role === 'leader'
) {
if (!isActionPlanMutationShortcut(req.context)) {
const previous = (originalDoc ?? {}) as Record<string, unknown>
const merged = { ...previous, ...(data as Record<string, unknown>) }
@ -241,10 +237,7 @@ const deriveActionPlanFields: CollectionBeforeChangeHook = ({ data, operation, o
relationshipId(taskRecord.responsible) !== relationshipId(previousTask.responsible) ||
(taskRecord.due ?? null) !== (previousTask.due ?? null)
) {
throw new APIError(
'Lideranças só podem marcar tarefas como concluídas.',
403,
)
throw new APIError('Lideranças só podem marcar tarefas como concluídas.', 403)
}
}
} else if (Array.isArray(data.tasks) && !Array.isArray(originalDoc?.tasks)) {
@ -264,7 +257,7 @@ export const ActionPlan: CollectionConfig = {
admin: {
group: 'Campanha',
useAsTitle: 'title',
defaultColumns: ['title', 'kind', 'status', 'startAt', 'updatedAt'],
defaultColumns: ['title', 'kind', 'status', 'plaza', 'startAt', 'updatedAt'],
},
access: {
create: canCreateActionPlan,
@ -275,9 +268,8 @@ export const ActionPlan: CollectionConfig = {
hooks: {
beforeValidate: [
setCanonicalActionPlanSlug,
validateActionPlanTerritory,
validateActionPlanSchedule,
validateActionPlanCoordinators,
validateActionPlanAdvisors,
],
beforeChange: [deriveActionPlanFields],
},
@ -333,6 +325,16 @@ export const ActionPlan: CollectionConfig = {
label: 'Descrição',
maxLength: 4000,
},
{
name: 'deputyPresent',
type: 'checkbox',
label: 'Deputado presente',
defaultValue: false,
index: true,
admin: {
description: 'Marque quando o deputado Jorge Solla estiver presente na ação.',
},
},
{
name: 'startAt',
type: 'date',
@ -365,65 +367,39 @@ export const ActionPlan: CollectionConfig = {
},
},
{
name: 'regions',
type: 'text',
label: 'Territórios de identidade',
hasMany: true,
name: 'plaza',
type: 'relationship',
relationTo: 'plaza',
label: 'Praça',
required: true,
index: true,
maxRows: MAX_NUCLEUS_REGIONS,
validate: (value: unknown) => {
if (value === null || value === undefined) return true
if (!Array.isArray(value)) {
return 'Selecione um território de identidade válido da Bahia.'
}
return value.every(
(item) => typeof item === 'string' && isBahiaIdentityTerritory(item.trim()),
)
? true
: 'Selecione um território de identidade válido da Bahia.'
},
},
{
name: 'cities',
type: 'text',
label: 'Municípios',
hasMany: true,
maxLength: 120,
index: true,
maxRows: MAX_NUCLEUS_CITIES,
},
{
name: 'neighborhoods',
type: 'text',
label: 'Bairros',
hasMany: true,
maxLength: 160,
maxRows: MAX_NUCLEUS_NEIGHBORHOODS,
},
{
name: 'locality',
type: 'text',
label: 'Localidade',
label: 'Local (bairro, endereço ou referência)',
maxLength: 160,
},
{
name: 'territoryNotes',
type: 'textarea',
label: 'Observações do território',
maxLength: 2000,
name: 'organizations',
type: 'relationship',
relationTo: 'organization',
label: 'Organizações apoiadoras',
hasMany: true,
index: true,
},
{
name: 'coordinators',
name: 'advisors',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Coordenadores',
label: 'Assessores responsáveis',
hasMany: true,
index: true,
access: {
create: canCreateActionPlanCoordinators,
update: canManageActionPlanCoordinators,
create: canCreateActionPlanAdvisors,
update: canManageActionPlanAdvisors,
},
filterOptions: eligibleNucleusCoordinatorWhere,
filterOptions: eligibleCampaignStaffWhere,
},
{
name: 'responsible',
@ -562,6 +538,56 @@ export const ActionPlan: CollectionConfig = {
},
],
},
{
name: 'resultSummary',
type: 'textarea',
label: 'Resultado da ação',
maxLength: 6000,
access: {
create: canSetActionPlanStatus,
update: canSetActionPlanStatus,
},
admin: {
description:
'O que aconteceu, público, aprendizados. Vira base para futuras atividades na mesma Praça.',
},
},
{
name: 'resultMedia',
type: 'upload',
relationTo: 'media',
label: 'Fotos e vídeos do resultado',
hasMany: true,
access: {
create: canSetActionPlanStatus,
update: canSetActionPlanStatus,
},
},
{
name: 'resultRecordedBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Resultado registrado por',
admin: {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
},
},
{
name: 'resultRecordedAt',
type: 'date',
label: 'Resultado registrado em',
admin: {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
},
},
{
name: 'createdBy',
type: 'relationship',

View file

@ -0,0 +1,382 @@
import type {
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
CollectionConfig,
} from 'payload'
import { APIError } from 'payload'
import {
campaignDemandKindLabels,
campaignDemandKinds,
campaignDemandStatusLabels,
campaignDemandStatuses,
campaignDemandTransitions,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import {
canCreateCampaignDemand,
canDeleteCampaignDemand,
canManageCampaignStaffField,
canReadCampaignDemand,
canReadCampaignStaffField,
canSetCampaignSystemField,
canUpdateCampaignDemand,
getOwnEngagedLeadership,
} from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
import { slugify } from '@/utilities/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const DEMAND_KIND_OPTIONS = campaignDemandKinds.map((value) => ({
value,
label: campaignDemandKindLabels[value],
}))
const DEMAND_STATUS_OPTIONS = campaignDemandStatuses.map((value) => ({
value,
label: campaignDemandStatusLabels[value],
}))
const setCanonicalDemandSlug: CollectionBeforeValidateHook = ({ data, operation, originalDoc }) => {
if (!data) return data
const title = trimmedText(data.title ?? originalDoc?.title)
const slug = slugify(title)
if (!slug) {
throw new APIError('Informe um título com letras ou números.', 400)
}
data.title = title
data.slug = operation === 'create' ? slug : originalDoc?.slug
return data
}
const isDemandStatus = (value: unknown): value is CampaignDemandStatus =>
typeof value === 'string' && (campaignDemandStatuses as readonly string[]).includes(value)
/**
* Workflow + authorship rules:
* - a leader's demand is linked to their own leadership on create;
* - a leader may edit only title/description/kind while the demand is "aberta";
* - status moves follow campaignDemandTransitions; decisions on "escalada"
* demands are coordinator-only;
* - every status move appends a server-derived statusHistory entry.
*/
const enforceDemandWorkflow: CollectionBeforeChangeHook = async ({
data,
operation,
originalDoc,
req,
}) => {
const actor = req.user?.collection === 'campaignUser' ? req.user : null
const isLeaderActor = actor?.role === 'leader'
if (operation === 'create') {
if (actor) data.createdBy = actor.id
if (isLeaderActor) {
const ownLeadership = await getOwnEngagedLeadership(req, actor)
if (!ownLeadership) {
throw new APIError('Somente lideranças engajadas podem abrir demandas.', 403)
}
data.leadership = ownLeadership.id
data.status = 'aberta'
}
const initialStatus = isDemandStatus(data.status) ? data.status : 'aberta'
data.status = initialStatus
data.statusHistory = [
{
status: initialStatus,
note: trimmedText(data.decisionNote) || null,
author: actor?.id ?? null,
createdAt: new Date().toISOString(),
},
]
return data
}
const previousStatus: CampaignDemandStatus = isDemandStatus(originalDoc?.status)
? originalDoc.status
: 'aberta'
const rawNextStatus: unknown = data.status === undefined ? previousStatus : data.status
if (isLeaderActor) {
if (previousStatus !== 'aberta') {
throw new APIError('A demanda já está em análise e não pode mais ser editada.', 409)
}
if (data.status !== undefined && data.status !== previousStatus) {
throw new APIError('Lideranças não alteram o status da demanda.', 403)
}
const previousPlaza = relationshipId(originalDoc?.plaza)
if (data.plaza !== undefined && relationshipId(data.plaza) !== previousPlaza) {
throw new APIError('A Praça da demanda não pode ser alterada pela liderança.', 403)
}
if (
data.leadership !== undefined &&
relationshipId(data.leadership) !== relationshipId(originalDoc?.leadership)
) {
throw new APIError('A liderança da demanda não pode ser alterada.', 403)
}
}
if (!isDemandStatus(rawNextStatus)) {
throw new APIError('Status de demanda inválido.', 400)
}
const nextStatus: CampaignDemandStatus = rawNextStatus
if (nextStatus !== previousStatus) {
const allowed = campaignDemandTransitions[previousStatus] ?? []
if (!allowed.includes(nextStatus)) {
throw new APIError(
`Transição de status inválida: ${campaignDemandStatusLabels[previousStatus]} → ${campaignDemandStatusLabels[nextStatus]}.`,
409,
)
}
if (
previousStatus === 'escalada' &&
(nextStatus === 'aprovada' || nextStatus === 'rejeitada') &&
actor &&
actor.role !== 'coordinator'
) {
throw new APIError('Demandas escaladas são decididas pelo Coordenador Geral.', 403)
}
const history = Array.isArray(originalDoc?.statusHistory) ? [...originalDoc.statusHistory] : []
history.push({
status: nextStatus,
note: trimmedText(data.decisionNote ?? originalDoc?.decisionNote) || null,
author: actor?.id ?? null,
createdAt: new Date().toISOString(),
})
data.statusHistory = history
if (nextStatus === 'aprovada' || nextStatus === 'rejeitada') {
data.decidedBy = actor?.id ?? null
data.decidedAt = new Date().toISOString()
}
} else if (data.statusHistory !== undefined) {
// History is append-only and server-derived.
data.statusHistory = originalDoc?.statusHistory ?? []
}
return data
}
export const CampaignDemand: CollectionConfig = {
slug: 'campaignDemand',
labels: {
singular: 'Demanda',
plural: 'Demandas',
},
admin: {
group: 'Campanha',
useAsTitle: 'title',
defaultColumns: ['title', 'kind', 'plaza', 'status', 'updatedAt'],
description:
'Necessidades operacionais da campanha. Custo e comprovantes são controle interno — não substituem a prestação de contas oficial (SPCE/TSE).',
},
access: {
create: canCreateCampaignDemand,
read: canReadCampaignDemand,
update: canUpdateCampaignDemand,
delete: canDeleteCampaignDemand,
},
hooks: {
beforeValidate: [setCanonicalDemandSlug],
beforeChange: [enforceDemandWorkflow],
},
fields: [
{
name: 'title',
type: 'text',
label: 'Título',
required: true,
minLength: 2,
maxLength: 160,
index: true,
},
{
name: 'slug',
type: 'text',
label: 'Slug',
required: true,
unique: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'kind',
type: 'select',
label: 'Tipo',
required: true,
index: true,
options: [...DEMAND_KIND_OPTIONS],
},
{
name: 'description',
type: 'textarea',
label: 'Descrição',
maxLength: 4000,
},
{
name: 'plaza',
type: 'relationship',
relationTo: 'plaza',
label: 'Praça',
required: true,
index: true,
},
{
name: 'actionPlan',
type: 'relationship',
relationTo: 'actionPlan',
label: 'Plano de ação',
index: true,
},
{
name: 'leadership',
type: 'relationship',
relationTo: 'leadership',
label: 'Liderança solicitante',
index: true,
},
{
name: 'status',
type: 'select',
label: 'Status',
required: true,
defaultValue: 'aberta',
index: true,
access: {
create: canManageCampaignStaffField,
update: canManageCampaignStaffField,
},
options: [...DEMAND_STATUS_OPTIONS],
},
{
name: 'decisionNote',
type: 'textarea',
label: 'Nota da decisão',
maxLength: 2000,
access: {
create: canManageCampaignStaffField,
update: canManageCampaignStaffField,
},
},
{
name: 'decidedBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Decidida por',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'decidedAt',
type: 'date',
label: 'Decidida em',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'cost',
type: 'number',
label: 'Custo estimado (R$)',
min: 0,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
admin: {
description: 'Controle interno de gastos. Não substitui a prestação de contas oficial.',
},
},
{
name: 'receipts',
type: 'upload',
relationTo: 'media',
label: 'Comprovantes',
hasMany: true,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
admin: {
description:
'Documentos fiscais podem conter CPF/CNPJ — acesso restrito ao staff. Não substitui o SPCE.',
},
},
{
name: 'statusHistory',
type: 'array',
label: 'Histórico de status',
access: {
read: canReadCampaignStaffField,
update: canSetCampaignSystemField,
},
admin: {
readOnly: true,
},
fields: [
{
name: 'status',
type: 'select',
label: 'Status',
required: true,
options: [...DEMAND_STATUS_OPTIONS],
},
{
name: 'note',
type: 'textarea',
label: 'Nota',
maxLength: 2000,
},
{
name: 'author',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Autor',
},
{
name: 'createdAt',
type: 'date',
label: 'Registrado em',
},
],
},
{
name: 'createdBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Criado por',
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
],
}

View file

@ -1,8 +1,8 @@
import type { CampaignUser as CampaignUserDocument } from '@/payload-types'
import {
canCreateCampaignUserPhone,
canManageCampaignUsers,
canManageCampaignUserRole,
canManageCampaignUsers,
canReadCampaignUserIdentity,
canReadCampaignUserPhone,
canReadCampaignUsers,
@ -41,7 +41,7 @@ const removePrivateAuthFields: CollectionAfterReadHook<CampaignUserDocument> = a
return doc
}
const preventAssignedCoordinatorDowngrade: CollectionBeforeChangeHook<CampaignUserDocument> = async ({
const preventAssignedAdvisorDowngrade: CollectionBeforeChangeHook<CampaignUserDocument> = async ({
data,
operation,
originalDoc,
@ -50,27 +50,27 @@ const preventAssignedCoordinatorDowngrade: CollectionBeforeChangeHook<CampaignUs
if (
operation !== 'update' ||
!originalDoc ||
data.role !== 'lideranca' ||
originalDoc.role === 'lideranca'
data.role !== 'leader' ||
originalDoc.role === 'leader'
) {
return data
}
const assignedNucleus = await req.payload.find({
collection: 'electoralNucleus',
const assignedPlaza = await req.payload.find({
collection: 'plaza',
where: {
coordinators: { contains: originalDoc.id },
advisors: { contains: originalDoc.id },
},
depth: 0,
limit: 1,
select: { name: true, status: true },
select: { name: true },
overrideAccess: true,
req,
})
if (assignedNucleus.totalDocs > 0) {
if (assignedPlaza.totalDocs > 0) {
throw new APIError(
'Remova ou substitua este usuário da coordenação de todos os núcleos antes de alterar o papel para liderança.',
'Remova ou substitua este usuário da assessoria de todas as Praças antes de alterar o papel para liderança.',
409,
)
}
@ -78,12 +78,9 @@ const preventAssignedCoordinatorDowngrade: CollectionBeforeChangeHook<CampaignUs
return data
}
const preventSelfServicePrivilegedFields: CollectionBeforeChangeHook<CampaignUserDocument> = async ({
data,
operation,
originalDoc,
req,
}) => {
const preventSelfServicePrivilegedFields: CollectionBeforeChangeHook<
CampaignUserDocument
> = async ({ data, operation, originalDoc, req }) => {
if (operation !== 'update' || !originalDoc || req.user?.collection !== 'campaignUser') {
return data
}
@ -148,7 +145,7 @@ export const CampaignUser: CollectionConfig = {
delete: canManageCampaignUsers,
},
hooks: {
beforeChange: [preventAssignedCoordinatorDowngrade, preventSelfServicePrivilegedFields],
beforeChange: [preventAssignedAdvisorDowngrade, preventSelfServicePrivilegedFields],
afterRead: [removePrivateAuthFields],
},
fields: [
@ -173,12 +170,12 @@ export const CampaignUser: CollectionConfig = {
type: 'select',
label: 'Papel',
required: true,
defaultValue: 'lideranca',
defaultValue: 'leader',
saveToJWT: true,
options: [
{ label: 'Coordenação geral', value: 'geral' },
{ label: 'Coordenador', value: 'coordenador' },
{ label: 'Liderança', value: 'lideranca' },
{ label: 'Coordenador Geral', value: 'coordinator' },
{ label: 'Assessor', value: 'advisor' },
{ label: 'Liderança', value: 'leader' },
],
access: {
update: canManageCampaignUserRole,

View file

@ -1,671 +0,0 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import {
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
MAX_NUCLEUS_REGIONS,
} from '@/lib/schemas/nucleus'
import {
canCreateElectoralNucleus,
canCreateNucleusCoordinators,
canDeleteElectoralNucleus,
canManageNucleusCoordinators,
canManageNucleusLifecycle,
canReadLeadershipInternal,
canReadElectoralNucleus,
canSetDerivedNucleusField,
canUpdateElectoralNucleus,
} from '@/utilities/campaignAccess'
import { createCampaignTerritoryValidationHook } from '@/utilities/campaignTerritoryValidation'
import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions'
import { acquirePrimaryContactInvariantLocks } from '@/utilities/primaryContactInvariantLock'
import { relationshipId } from '@/utilities/relationship'
import { slugify } from '@/utilities/slug'
import {
getVoteGoalsOrderViolation,
VOTE_GOALS_ORDER_ERROR_MESSAGE,
type VoteGoalsFields,
} from '@/utilities/voteGoals'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const voteGoalNumber = (value: unknown): number | null | undefined => {
if (value === null || value === undefined || value === '') return null
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw new APIError('Cada meta de votos deve ser um número inteiro não negativo.', 400)
}
return Math.trunc(value)
}
const validateVoteGoals: CollectionBeforeValidateHook = ({ data, originalDoc, operation }) => {
if (!data) return data
const mergedGoals =
data.voteGoals !== undefined
? { ...(originalDoc?.voteGoals ?? {}), ...(data.voteGoals as object) }
: operation === 'update'
? originalDoc?.voteGoals
: undefined
if (!mergedGoals || typeof mergedGoals !== 'object') return data
const good = voteGoalNumber((mergedGoals as VoteGoalsFields).good)
const regular = voteGoalNumber((mergedGoals as VoteGoalsFields).regular)
const minimum = voteGoalNumber((mergedGoals as VoteGoalsFields).minimum)
if (getVoteGoalsOrderViolation({ good, regular, minimum })) {
throw new APIError(VOTE_GOALS_ORDER_ERROR_MESSAGE, 400)
}
if (data.voteGoals && typeof data.voteGoals === 'object') {
data.voteGoals = {
good:
(data.voteGoals as { good?: unknown }).good === undefined
? (originalDoc?.voteGoals?.good ?? null)
: good,
regular:
(data.voteGoals as { regular?: unknown }).regular === undefined
? (originalDoc?.voteGoals?.regular ?? null)
: regular,
minimum:
(data.voteGoals as { minimum?: unknown }).minimum === undefined
? (originalDoc?.voteGoals?.minimum ?? null)
: minimum,
}
}
return data
}
const validateNucleusTerritoryAndZones: CollectionBeforeValidateHook = (args) => {
const validateTerritory = createCampaignTerritoryValidationHook({ entityLabel: 'núcleo' })
const data = validateTerritory(args)
if (!data) return data
const nextData = args.operation === 'update' ? { ...args.originalDoc, ...data } : data
const zones = Array.isArray(nextData.tseZones) ? nextData.tseZones : []
const zoneNumbers = zones.map((zone: { zoneNumber?: unknown }) => zone?.zoneNumber)
if (new Set(zoneNumbers).size !== zoneNumbers.length) {
throw new APIError('Cada Zona TSE deve aparecer apenas uma vez.', 400)
}
return data
}
const setCanonicalNucleusSlug: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
}) => {
if (!data) return data
const name = trimmedText(data.name ?? originalDoc?.name)
const slug = slugify(name)
if (!slug) {
throw new APIError('Informe um nome com letras ou números.', 400)
}
if (operation === 'update' && data.name !== undefined && name !== originalDoc?.name) {
throw new APIError('O nome do núcleo não pode ser alterado após a criação.', 409)
}
data.name = name
data.slug = operation === 'create' ? slug : originalDoc?.slug
return data
}
const validateNucleusCoordinators: CollectionBeforeValidateHook = async ({ data, req }) => {
if (!data) return data
if (data.coordinators === undefined) return data
const coordinatorValues = data.coordinators
const coordinatorIDs = Array.isArray(coordinatorValues)
? [...new Set(coordinatorValues.map(relationshipId).filter((id): id is number => id !== null))]
: []
if (coordinatorIDs.length === 0) return data
const eligibleCoordinators = await req.payload.find({
collection: 'campaignUser',
depth: 0,
pagination: false,
where: {
and: [{ id: { in: coordinatorIDs } }, eligibleNucleusCoordinatorWhere],
},
select: { name: true },
overrideAccess: true,
req,
})
if (eligibleCoordinators.docs.length !== coordinatorIDs.length) {
throw new APIError('Cada responsável deve ter papel de coordenação geral ou coordenador.', 400)
}
return data
}
export const ElectoralNucleus: CollectionConfig = {
slug: 'electoralNucleus',
labels: {
singular: 'Núcleo eleitoral',
plural: 'Núcleos eleitorais',
},
admin: {
group: 'Campanha',
useAsTitle: 'name',
defaultColumns: ['name', 'status', 'regions', 'cities', 'updatedAt'],
},
access: {
create: canCreateElectoralNucleus,
read: canReadElectoralNucleus,
update: canUpdateElectoralNucleus,
delete: canDeleteElectoralNucleus,
},
hooks: {
beforeValidate: [
setCanonicalNucleusSlug,
validateNucleusTerritoryAndZones,
validateNucleusCoordinators,
validateVoteGoals,
],
beforeChange: [
({ data, operation, req }) => {
if (operation === 'create' && req.user?.collection === 'campaignUser') {
data.createdBy = req.user.id
}
return data
},
async ({ data, operation, originalDoc, req }) => {
if (data.primaryContact === undefined || data.primaryContact === null) return data
const contact = relationshipId(data.primaryContact)
const nucleus = relationshipId(originalDoc?.id)
if (!contact || operation !== 'update' || !nucleus) {
throw new APIError(
'Escolha o contato principal entre as lideranças engajadas após criar o núcleo.',
400,
)
}
await acquirePrimaryContactInvariantLocks(req, [nucleus])
const engagedLeadership = await req.payload.find({
collection: 'leadership',
where: {
and: [
{ nucleus: { equals: nucleus } },
{ contact: { equals: contact } },
{ supportStatus: { equals: 'engajado' } },
],
},
depth: 0,
limit: 1,
overrideAccess: true,
req,
})
if (engagedLeadership.totalDocs === 0) {
throw new APIError(
'O contato principal deve ser uma liderança engajada neste núcleo.',
409,
)
}
return data
},
],
},
fields: [
{
name: 'name',
type: 'text',
label: 'Nome',
required: true,
minLength: 2,
maxLength: 160,
index: true,
},
{
name: 'slug',
type: 'text',
label: 'Slug',
required: true,
unique: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetDerivedNucleusField,
update: canSetDerivedNucleusField,
},
},
{
name: 'status',
type: 'select',
label: 'Status',
required: true,
defaultValue: 'ativo',
index: true,
access: {
create: canSetDerivedNucleusField,
update: canManageNucleusLifecycle,
},
options: [
{ label: 'Ativo', value: 'ativo' },
{ label: 'Arquivado', value: 'arquivado' },
],
},
{
name: 'coordinators',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Coordenadores',
hasMany: true,
index: true,
access: {
create: canCreateNucleusCoordinators,
update: canManageNucleusCoordinators,
},
filterOptions: eligibleNucleusCoordinatorWhere,
},
{
name: 'regions',
type: 'text',
label: 'Territórios de identidade',
hasMany: true,
index: true,
maxRows: MAX_NUCLEUS_REGIONS,
validate: (value: unknown) => {
if (value === null || value === undefined) return true
if (!Array.isArray(value)) {
return 'Selecione um território de identidade válido da Bahia.'
}
return value.every(
(item) => typeof item === 'string' && isBahiaIdentityTerritory(item.trim()),
)
? true
: 'Selecione um território de identidade válido da Bahia.'
},
},
{
name: 'cities',
type: 'text',
label: 'Municípios',
hasMany: true,
maxLength: 120,
index: true,
maxRows: MAX_NUCLEUS_CITIES,
},
{
name: 'neighborhoods',
type: 'text',
label: 'Bairros',
hasMany: true,
maxLength: 160,
maxRows: MAX_NUCLEUS_NEIGHBORHOODS,
},
{
name: 'locality',
type: 'text',
label: 'Localidade',
maxLength: 160,
},
{
name: 'territoryNotes',
type: 'textarea',
label: 'Observações do território',
maxLength: 2000,
},
{
name: 'organizationKind',
type: 'select',
label: 'Natureza organizativa',
required: true,
defaultValue: 'territorial',
index: true,
options: [
{ label: 'Territorial', value: 'territorial' },
{ label: 'Associação', value: 'associacao' },
{ label: 'Sindicato', value: 'sindicato' },
{ label: 'Religioso', value: 'religioso' },
{ label: 'Movimento', value: 'movimento' },
{ label: 'Categoria profissional', value: 'categoria_profissional' },
{ label: 'Outro', value: 'outro' },
],
},
{
name: 'organizationLabel',
type: 'text',
label: 'Nome da organização',
maxLength: 160,
},
{
name: 'sectorKind',
type: 'select',
label: 'Setor',
index: true,
options: [
{ label: 'Rural', value: 'rural' },
{ label: 'Religioso', value: 'religioso' },
{ label: 'Sindical', value: 'sindical' },
{ label: 'Empresarial', value: 'empresarial' },
{ label: 'Juventude', value: 'juventude' },
{ label: 'Saúde', value: 'saude' },
{ label: 'Educação', value: 'educacao' },
{ label: 'Cultura', value: 'cultura' },
{ label: 'Outro', value: 'outro' },
],
},
{
name: 'tseZones',
type: 'array',
label: 'Zonas TSE',
labels: {
singular: 'Zona TSE',
plural: 'Zonas TSE',
},
fields: [
{
name: 'zoneNumber',
type: 'number',
label: 'Número',
required: true,
min: 1,
max: 999,
validate: (value: unknown) =>
typeof value === 'number' && Number.isInteger(value)
? true
: 'O número da Zona TSE deve ser inteiro.',
},
{
name: 'label',
type: 'text',
label: 'Descrição',
maxLength: 160,
},
],
},
{
name: 'primaryContact',
type: 'relationship',
relationTo: 'contact',
label: 'Contato principal',
index: true,
},
{
name: 'voterProfiles',
type: 'array',
label: 'Perfis do eleitorado',
labels: {
singular: 'Perfil do eleitorado',
plural: 'Perfis do eleitorado',
},
fields: [
{
name: 'label',
type: 'text',
label: 'Nome do perfil',
required: true,
maxLength: 120,
},
{
name: 'ageRange',
type: 'text',
label: 'Faixa etária',
maxLength: 80,
},
{
name: 'incomeBand',
type: 'text',
label: 'Faixa de renda',
maxLength: 80,
},
{
name: 'occupation',
type: 'text',
label: 'Ocupação',
maxLength: 120,
},
{
name: 'localTraits',
type: 'textarea',
label: 'Características locais',
maxLength: 500,
},
{
name: 'notes',
type: 'textarea',
label: 'Observações',
maxLength: 1000,
},
],
},
{
name: 'strengths',
type: 'array',
label: 'Forças',
fields: [
{
name: 'text',
type: 'textarea',
label: 'Força',
required: true,
maxLength: 1000,
},
],
},
{
name: 'risks',
type: 'array',
label: 'Riscos',
fields: [
{
name: 'text',
type: 'textarea',
label: 'Risco',
required: true,
maxLength: 1000,
},
],
},
{
name: 'dobradinhaNotes',
type: 'textarea',
label: 'Dobradinhas (notas)',
maxLength: 4000,
admin: {
description:
'Quem dobra no território hoje e o estado da negociação. Distinto do bloco estruturado “Dobrada”.',
},
},
{
name: 'nextSteps',
type: 'textarea',
label: 'Encaminhamentos',
maxLength: 4000,
admin: {
description: 'Próximos passos operacionais para o núcleo.',
},
},
{
name: 'voteGoals',
type: 'group',
label: 'Metas de votos 2026',
fields: [
{
name: 'good',
type: 'number',
label: 'Bom',
min: 0,
},
{
name: 'regular',
type: 'number',
label: 'Regular',
min: 0,
},
{
name: 'minimum',
type: 'number',
label: 'Mínimo',
min: 0,
},
],
},
{
name: 'priority',
type: 'select',
label: 'Prioridade',
defaultValue: 'normal',
index: true,
options: [
{ label: 'Alta', value: 'alta' },
{ label: 'Normal', value: 'normal' },
],
},
{
name: 'confirmedVoteEstimate',
type: 'number',
label: 'Estimativa de votos confirmada',
min: 0,
index: true,
access: {
create: canSetDerivedNucleusField,
update: canSetDerivedNucleusField,
},
},
{
name: 'confirmedVoteEstimateAt',
type: 'date',
label: 'Estimativa confirmada em',
access: {
create: canSetDerivedNucleusField,
update: canSetDerivedNucleusField,
},
},
{
name: 'confirmedVoteEstimateBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Estimativa confirmada por',
access: {
create: canSetDerivedNucleusField,
update: canSetDerivedNucleusField,
},
},
{
name: 'confirmationNote',
type: 'textarea',
label: 'Justificativa da confirmação',
maxLength: 1000,
access: {
create: canSetDerivedNucleusField,
update: canSetDerivedNucleusField,
},
},
{
name: 'proposedVoteEstimate',
type: 'number',
label: 'Sugestão de estimativa de votos',
min: 0,
index: true,
access: {
create: canSetDerivedNucleusField,
update: canSetDerivedNucleusField,
},
},
{
name: 'proposedVoteEstimateAt',
type: 'date',
label: 'Sugestão enviada em',
access: {
create: canSetDerivedNucleusField,
update: canSetDerivedNucleusField,
},
},
{
name: 'proposedVoteEstimateBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Sugestão enviada por',
access: {
create: canSetDerivedNucleusField,
update: canSetDerivedNucleusField,
},
},
{
name: 'proposedVoteEstimateVersion',
type: 'text',
label: 'Versão da sugestão de estimativa',
index: true,
admin: {
readOnly: true,
},
access: {
read: canReadLeadershipInternal,
create: () => false,
update: () => false,
},
},
{
name: 'ticketAlliance',
type: 'group',
label: 'Dobrada',
fields: [
{
name: 'partnerName',
type: 'text',
label: 'Nome da parceria',
maxLength: 120,
},
{
name: 'office',
type: 'text',
label: 'Cargo',
maxLength: 120,
},
{
name: 'isCampaignPartner',
type: 'checkbox',
label: 'Parceiro da campanha',
defaultValue: false,
},
{
name: 'notes',
type: 'textarea',
label: 'Observações',
maxLength: 1000,
},
],
},
{
name: 'lastUpdateAt',
type: 'date',
label: 'Última atualização',
index: true,
admin: {
readOnly: true,
description: 'Preenchido automaticamente quando o domínio de atualizações for ativado.',
},
access: {
create: canSetDerivedNucleusField,
update: canSetDerivedNucleusField,
},
},
{
name: 'createdBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Criado por',
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetDerivedNucleusField,
update: canSetDerivedNucleusField,
},
},
],
}

View file

@ -1,18 +1,42 @@
import type { CollectionConfig } from 'payload'
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import {
canCreateLeadership,
canDeleteLeadership,
canManageCampaignStaffField,
canManageLeadership,
canManageLeadershipInternal,
canReadCampaignStaffField,
canReadLeadership,
canReadLeadershipInternal,
canSetAdministrativeLeadershipField,
} from '@/utilities/campaignAccess'
import { acquirePrimaryContactInvariantLocks } from '@/utilities/primaryContactInvariantLock'
import { relationshipId } from '@/utilities/relationship'
const MAX_LEADERSHIP_PLAZAS = 30
const requireAtLeastOnePlaza: CollectionBeforeValidateHook = ({ data, operation, originalDoc }) => {
if (!data) return data
const plazas =
data.plazas !== undefined
? data.plazas
: operation === 'update'
? originalDoc?.plazas
: undefined
const plazaIDs = (Array.isArray(plazas) ? plazas : [])
.map(relationshipId)
.filter((id): id is number => id !== null)
if (plazaIDs.length === 0) {
throw new APIError('Vincule a liderança a pelo menos uma Praça.', 400)
}
if (new Set(plazaIDs).size !== plazaIDs.length) {
throw new APIError('Cada Praça deve aparecer apenas uma vez.', 400)
}
return data
}
export const Leadership: CollectionConfig = {
slug: 'leadership',
labels: {
@ -22,7 +46,7 @@ export const Leadership: CollectionConfig = {
admin: {
group: 'Campanha',
useAsTitle: 'contact',
defaultColumns: ['contact', 'nucleus', 'sector', 'supportStatus', 'updatedAt'],
defaultColumns: ['contact', 'plazas', 'sector', 'supportStatus', 'updatedAt'],
},
access: {
create: canCreateLeadership,
@ -30,55 +54,14 @@ export const Leadership: CollectionConfig = {
update: canManageLeadership,
delete: canDeleteLeadership,
},
indexes: [
{
fields: ['contact', 'nucleus'],
unique: true,
},
],
hooks: {
beforeValidate: [requireAtLeastOnePlaza],
beforeChange: [
async ({ data, operation, originalDoc, req }) => {
({ data, operation, req }) => {
if (operation === 'create' && req.user?.collection === 'campaignUser') {
data.createdBy = req.user.id
}
if (operation !== 'update') return data
const previousContact = relationshipId(originalDoc?.contact)
const previousNucleus = relationshipId(originalDoc?.nucleus)
const nextContact = relationshipId(data.contact ?? originalDoc?.contact)
const nextNucleus = relationshipId(data.nucleus ?? originalDoc?.nucleus)
const nextStatus = data.supportStatus ?? originalDoc?.supportStatus
if (!previousContact || !previousNucleus || !nextContact || !nextNucleus) return data
await acquirePrimaryContactInvariantLocks(req, [previousNucleus, nextNucleus])
const remainsEligible =
nextStatus === 'engajado' &&
nextContact === previousContact &&
nextNucleus === previousNucleus
if (remainsEligible) return data
const primaryContactUse = await req.payload.find({
collection: 'electoralNucleus',
where: {
and: [
{ id: { equals: previousNucleus } },
{ primaryContact: { equals: previousContact } },
],
},
depth: 0,
limit: 1,
overrideAccess: true,
req,
})
if (primaryContactUse.totalDocs > 0) {
throw new APIError(
'Escolha outro contato principal antes de alterar o status desta liderança.',
409,
)
}
return data
},
],
@ -90,21 +73,29 @@ export const Leadership: CollectionConfig = {
relationTo: 'contact',
label: 'Contato',
required: true,
unique: true,
index: true,
access: {
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'nucleus',
name: 'plazas',
type: 'relationship',
relationTo: 'electoralNucleus',
label: 'Núcleo eleitoral',
relationTo: 'plaza',
label: 'Praças',
required: true,
hasMany: true,
index: true,
maxRows: MAX_LEADERSHIP_PLAZAS,
},
{
name: 'organizations',
type: 'relationship',
relationTo: 'organization',
label: 'Organizações',
hasMany: true,
index: true,
access: {
update: canSetAdministrativeLeadershipField,
},
},
{
name: 'sector',
@ -138,9 +129,9 @@ export const Leadership: CollectionConfig = {
defaultValue: 'a_abordar',
index: true,
access: {
create: canManageLeadershipInternal,
read: canReadLeadershipInternal,
update: canManageLeadershipInternal,
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
options: [
{ label: 'Engajado', value: 'engajado' },
@ -200,9 +191,9 @@ export const Leadership: CollectionConfig = {
label: 'Observações internas',
maxLength: 3000,
access: {
create: canManageLeadershipInternal,
read: canReadLeadershipInternal,
update: canManageLeadershipInternal,
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
},
{
@ -211,9 +202,9 @@ export const Leadership: CollectionConfig = {
label: 'Registro de consentimento externo',
maxLength: 2000,
access: {
create: canManageLeadershipInternal,
read: canReadLeadershipInternal,
update: canManageLeadershipInternal,
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
},
{
@ -227,7 +218,7 @@ export const Leadership: CollectionConfig = {
},
access: {
create: canSetAdministrativeLeadershipField,
read: canReadLeadershipInternal,
read: canReadCampaignStaffField,
update: canSetAdministrativeLeadershipField,
},
},

View file

@ -0,0 +1,135 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import {
canCreateOrganization,
canDeleteOrganization,
canManageOrganization,
canReadOrganization,
canSetCampaignSystemField,
} from '@/utilities/campaignAccess'
import { slugify } from '@/utilities/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const setCanonicalOrganizationSlug: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
}) => {
if (!data) return data
const name = trimmedText(data.name ?? originalDoc?.name)
const slug = slugify(name)
if (!slug) {
throw new APIError('Informe um nome com letras ou números.', 400)
}
if (operation === 'update' && data.name !== undefined && name !== originalDoc?.name) {
throw new APIError('O nome da organização não pode ser alterado após a criação.', 409)
}
data.name = name
data.slug = operation === 'create' ? slug : originalDoc?.slug
return data
}
export const Organization: CollectionConfig = {
slug: 'organization',
labels: {
singular: 'Organização',
plural: 'Organizações',
},
admin: {
group: 'Campanha',
useAsTitle: 'name',
defaultColumns: ['name', 'kind', 'updatedAt'],
description:
'Sindicatos, associações, movimentos e afins. Concentra lideranças associadas e Planos de Ação apoiados.',
},
access: {
create: canCreateOrganization,
read: canReadOrganization,
update: canManageOrganization,
delete: canDeleteOrganization,
},
hooks: {
beforeValidate: [setCanonicalOrganizationSlug],
beforeChange: [
({ data, operation, req }) => {
if (operation === 'create' && req.user?.collection === 'campaignUser') {
data.createdBy = req.user.id
}
return data
},
],
},
fields: [
{
name: 'name',
type: 'text',
label: 'Nome',
required: true,
minLength: 2,
maxLength: 160,
unique: true,
index: true,
},
{
name: 'slug',
type: 'text',
label: 'Slug',
required: true,
unique: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'kind',
type: 'select',
label: 'Tipo',
required: true,
index: true,
options: [
{ label: 'Sindicato', value: 'sindicato' },
{ label: 'Associação', value: 'associacao' },
{ label: 'Religiosa', value: 'religioso' },
{ label: 'Movimento', value: 'movimento' },
{ label: 'Categoria profissional', value: 'categoria_profissional' },
{ label: 'Outro', value: 'outro' },
],
},
{
name: 'plazas',
type: 'relationship',
relationTo: 'plaza',
label: 'Praças de atuação',
hasMany: true,
index: true,
},
{
name: 'notes',
type: 'textarea',
label: 'Observações',
maxLength: 4000,
},
{
name: 'createdBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Criado por',
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
],
}

462
src/collections/Plaza.ts Normal file
View file

@ -0,0 +1,462 @@
import type {
CollectionBeforeChangeHook,
CollectionBeforeValidateHook,
CollectionConfig,
} from 'payload'
import { APIError } from 'payload'
import {
canAssignPlazaAdvisors,
canCreatePlaza,
canDeletePlaza,
canManageCampaignStaffField,
canManagePlazaAdvisors,
canReadCampaignStaffField,
canReadPlaza,
canSetCampaignSystemField,
canUpdatePlaza,
eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
import {
getVoteGoalsOrderViolation,
VOTE_GOALS_ORDER_ERROR_MESSAGE,
type VoteGoalsFields,
} from '@/utilities/voteGoals'
const voteGoalNumber = (value: unknown): number | null | undefined => {
if (value === null || value === undefined || value === '') return null
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw new APIError('Cada meta de votos deve ser um número inteiro não negativo.', 400)
}
return Math.trunc(value)
}
const validateVoteGoals: CollectionBeforeValidateHook = ({ data, originalDoc, operation }) => {
if (!data) return data
const mergedGoals =
data.voteGoals !== undefined
? { ...(originalDoc?.voteGoals ?? {}), ...(data.voteGoals as object) }
: operation === 'update'
? originalDoc?.voteGoals
: undefined
if (!mergedGoals || typeof mergedGoals !== 'object') return data
const good = voteGoalNumber((mergedGoals as VoteGoalsFields).good)
const regular = voteGoalNumber((mergedGoals as VoteGoalsFields).regular)
const minimum = voteGoalNumber((mergedGoals as VoteGoalsFields).minimum)
if (getVoteGoalsOrderViolation({ good, regular, minimum })) {
throw new APIError(VOTE_GOALS_ORDER_ERROR_MESSAGE, 400)
}
if (data.voteGoals && typeof data.voteGoals === 'object') {
data.voteGoals = {
good:
(data.voteGoals as { good?: unknown }).good === undefined
? (originalDoc?.voteGoals?.good ?? null)
: good,
regular:
(data.voteGoals as { regular?: unknown }).regular === undefined
? (originalDoc?.voteGoals?.regular ?? null)
: regular,
minimum:
(data.voteGoals as { minimum?: unknown }).minimum === undefined
? (originalDoc?.voteGoals?.minimum ?? null)
: minimum,
}
}
return data
}
const validatePlazaAdvisors: CollectionBeforeValidateHook = async ({ data, req }) => {
if (!data || data.advisors === undefined) return data
const advisorIDs = Array.isArray(data.advisors)
? [...new Set(data.advisors.map(relationshipId).filter((id): id is number => id !== null))]
: []
if (advisorIDs.length === 0) return data
const eligibleAdvisors = await req.payload.find({
collection: 'campaignUser',
depth: 0,
pagination: false,
where: {
and: [{ id: { in: advisorIDs } }, eligibleCampaignStaffWhere],
},
select: { name: true },
overrideAccess: true,
req,
})
if (eligibleAdvisors.docs.length !== advisorIDs.length) {
throw new APIError('Cada assessor deve ter papel de Coordenador Geral ou Assessor.', 400)
}
return data
}
const trendSnapshot = (value: unknown) => {
const trend = (value ?? {}) as { status?: unknown; note?: unknown }
return `${trend.status ?? ''}\u0000${typeof trend.note === 'string' ? trend.note.trim() : ''}`
}
const derivePoliticalTrendAudit: CollectionBeforeChangeHook = ({ data, originalDoc, req }) => {
if (!data || data.politicalTrend === undefined) return data
const next = { ...(originalDoc?.politicalTrend ?? {}), ...(data.politicalTrend as object) }
if (trendSnapshot(next) === trendSnapshot(originalDoc?.politicalTrend)) return data
data.politicalTrend = {
...next,
recordedBy:
req.user?.collection === 'campaignUser'
? req.user.id
: (relationshipId((next as { recordedBy?: unknown }).recordedBy) ?? null),
recordedAt: new Date().toISOString(),
}
return data
}
export const Plaza: CollectionConfig = {
slug: 'plaza',
labels: {
singular: 'Praça',
plural: 'Praças',
},
admin: {
group: 'Campanha',
useAsTitle: 'name',
defaultColumns: ['name', 'kind', 'region', 'priority', 'updatedAt'],
description:
'As 436 Praças são pré-definidas (município, ou zona eleitoral em Salvador e Camaçari) e criadas por migração. A geografia não é editável.',
},
access: {
create: canCreatePlaza,
read: canReadPlaza,
update: canUpdatePlaza,
delete: canDeletePlaza,
},
hooks: {
beforeValidate: [validatePlazaAdvisors, validateVoteGoals],
beforeChange: [derivePoliticalTrendAudit],
},
fields: [
{
name: 'name',
type: 'text',
label: 'Nome',
required: true,
unique: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'slug',
type: 'text',
label: 'Slug',
required: true,
unique: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'kind',
type: 'select',
label: 'Tipo',
required: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
options: [
{ label: 'Município', value: 'municipio' },
{ label: 'Zona eleitoral', value: 'zona' },
],
},
{
name: 'city',
type: 'text',
label: 'Município',
required: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'region',
type: 'text',
label: 'Território de identidade',
required: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'ibgeCode',
type: 'text',
label: 'Código IBGE',
required: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'tseCityCode',
type: 'text',
label: 'Código TSE do município',
required: true,
index: true,
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'zoneNumber',
type: 'number',
label: 'Zona eleitoral',
min: 1,
max: 999,
index: true,
admin: {
readOnly: true,
description: 'Preenchido apenas para Praças de zona (Salvador e Camaçari).',
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'advisors',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Assessores',
hasMany: true,
index: true,
access: {
create: canAssignPlazaAdvisors,
update: canManagePlazaAdvisors,
},
filterOptions: eligibleCampaignStaffWhere,
},
{
name: 'priority',
type: 'select',
label: 'Prioridade',
defaultValue: 'normal',
index: true,
access: {
read: canReadCampaignStaffField,
create: canManageCampaignStaffField,
update: canManageCampaignStaffField,
},
options: [
{ label: 'Alta', value: 'alta' },
{ label: 'Normal', value: 'normal' },
],
},
{
name: 'voteGoals',
type: 'group',
label: 'Metas de votos 2026',
access: {
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
fields: [
{
name: 'good',
type: 'number',
label: 'Bom',
min: 0,
},
{
name: 'regular',
type: 'number',
label: 'Regular',
min: 0,
},
{
name: 'minimum',
type: 'number',
label: 'Mínimo',
min: 0,
},
],
},
{
name: 'politicalTrend',
type: 'group',
label: 'Tendência política',
admin: {
description:
'Leitura de conjuntura registrada pela coordenação (alianças, prefeitos, disputas locais) — não é série numérica.',
},
access: {
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
fields: [
{
name: 'status',
type: 'select',
label: 'Tendência',
options: [
{ label: 'Favorável', value: 'favoravel' },
{ label: 'Neutra', value: 'neutra' },
{ label: 'Desfavorável', value: 'desfavoravel' },
],
},
{
name: 'note',
type: 'textarea',
label: 'Justificativa',
maxLength: 2000,
},
{
name: 'recordedBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Registrada por',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'recordedAt',
type: 'date',
label: 'Registrada em',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
],
},
{
name: 'strengths',
type: 'array',
label: 'Forças',
access: {
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
fields: [
{
name: 'text',
type: 'textarea',
label: 'Força',
required: true,
maxLength: 1000,
},
],
},
{
name: 'risks',
type: 'array',
label: 'Riscos',
access: {
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
fields: [
{
name: 'text',
type: 'textarea',
label: 'Risco',
required: true,
maxLength: 1000,
},
],
},
{
name: 'dobradinhaNotes',
type: 'textarea',
label: 'Dobradinhas (notas)',
maxLength: 4000,
access: {
read: canReadCampaignStaffField,
create: canManageCampaignStaffField,
update: canManageCampaignStaffField,
},
admin: {
description: 'Quem dobra na Praça hoje e o estado da negociação.',
},
},
{
name: 'nextSteps',
type: 'textarea',
label: 'Encaminhamentos',
maxLength: 4000,
access: {
read: canReadCampaignStaffField,
create: canManageCampaignStaffField,
update: canManageCampaignStaffField,
},
admin: {
description: 'Próximos passos operacionais para a Praça.',
},
},
{
name: 'lastUpdateAt',
type: 'date',
label: 'Última atualização',
index: true,
admin: {
readOnly: true,
description: 'Derivado automaticamente do feed de atualizações da Praça.',
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
],
}

View file

@ -10,20 +10,20 @@ import type {
import { APIError } from 'payload'
import {
canCreateNucleusUpdate,
canMutateNucleusUpdate,
canReadNucleusUpdate,
canSetNucleusUpdateAuthor,
canCreatePlazaUpdate,
canMutatePlazaUpdate,
canReadPlazaUpdate,
canSetPlazaUpdateAuthor,
} from '@/utilities/campaignAccess'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { relationshipId } from '@/utilities/relationship'
const DERIVED_NUCLEUS_UPDATE_CONTEXT = 'nucleusUpdateDerivedField'
const DERIVED_PLAZA_UPDATE_CONTEXT = 'plazaUpdateDerivedField'
const nonEmptyText = (value: unknown): boolean =>
typeof value === 'string' && value.trim().length > 0
const validateNucleusUpdateKind: CollectionBeforeValidateHook = ({
const validatePlazaUpdateKind: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
@ -44,23 +44,23 @@ const validateNucleusUpdateKind: CollectionBeforeValidateHook = ({
return data
}
const uniqueSortedNucleusIDs = (values: Array<number | null>): number[] =>
const uniqueSortedPlazaIDs = (values: Array<number | null>): number[] =>
[...new Set(values.filter((value): value is number => value !== null))].sort(
(left, right) => left - right,
)
const acquireNucleusUpdateLocks = (req: PayloadRequest, nucleusIDs: number[]) =>
const acquirePlazaUpdateLocks = (req: PayloadRequest, plazaIDs: number[]) =>
acquireTextAdvisoryLocks(
req.payload,
req,
nucleusIDs.map((nucleusID) => `nucleus-updates:${nucleusID}`),
plazaIDs.map((plazaID) => `plaza-updates:${plazaID}`),
)
const recomputeNucleusLastUpdateAt = async (req: PayloadRequest, nucleusIDs: number[]) => {
for (const nucleusID of nucleusIDs) {
const recomputePlazaLastUpdateAt = async (req: PayloadRequest, plazaIDs: number[]) => {
for (const plazaID of plazaIDs) {
const latest = await req.payload.find({
collection: 'nucleusUpdate',
where: { nucleus: { equals: nucleusID } },
collection: 'plazaUpdate',
where: { plaza: { equals: plazaID } },
depth: 0,
limit: 1,
sort: '-createdAt',
@ -69,118 +69,118 @@ const recomputeNucleusLastUpdateAt = async (req: PayloadRequest, nucleusIDs: num
})
await req.payload.update({
collection: 'electoralNucleus',
id: nucleusID,
collection: 'plaza',
id: plazaID,
data: { lastUpdateAt: latest.docs[0]?.createdAt ?? null },
depth: 0,
overrideAccess: true,
context: {
[DERIVED_NUCLEUS_UPDATE_CONTEXT]: true,
[DERIVED_PLAZA_UPDATE_CONTEXT]: true,
},
req,
})
}
}
const lockNucleiBeforeChange: CollectionBeforeChangeHook = async ({
const lockPlazasBeforeChange: CollectionBeforeChangeHook = async ({
context,
data,
operation,
originalDoc,
req,
}) => {
if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return data
if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return data
if (operation === 'create' && req.user?.collection === 'campaignUser') {
data.author = req.user.id
}
const nucleusIDs = uniqueSortedNucleusIDs([
relationshipId(originalDoc?.nucleus),
relationshipId(data.nucleus ?? originalDoc?.nucleus),
const plazaIDs = uniqueSortedPlazaIDs([
relationshipId(originalDoc?.plaza),
relationshipId(data.plaza ?? originalDoc?.plaza),
])
if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500)
if (plazaIDs.length === 0) throw new APIError('Praça da atualização inválida.', 500)
await acquireNucleusUpdateLocks(req, nucleusIDs)
await acquirePlazaUpdateLocks(req, plazaIDs)
return data
}
const lockNucleusBeforeDelete: CollectionBeforeDeleteHook = async ({ context, id, req }) => {
if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return
const lockPlazaBeforeDelete: CollectionBeforeDeleteHook = async ({ context, id, req }) => {
if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return
const doc = await req.payload.findByID({
collection: 'nucleusUpdate',
collection: 'plazaUpdate',
id,
depth: 0,
overrideAccess: true,
req,
})
const nucleusIDs = uniqueSortedNucleusIDs([relationshipId(doc.nucleus)])
if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500)
const plazaIDs = uniqueSortedPlazaIDs([relationshipId(doc.plaza)])
if (plazaIDs.length === 0) throw new APIError('Praça da atualização inválida.', 500)
await acquireNucleusUpdateLocks(req, nucleusIDs)
await acquirePlazaUpdateLocks(req, plazaIDs)
}
const recomputeChangedNuclei: CollectionAfterChangeHook = async ({
const recomputeChangedPlazas: CollectionAfterChangeHook = async ({
context,
doc,
previousDoc,
req,
}) => {
if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return doc
if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return doc
const nucleusIDs = uniqueSortedNucleusIDs([
relationshipId(previousDoc?.nucleus),
relationshipId(doc.nucleus),
const plazaIDs = uniqueSortedPlazaIDs([
relationshipId(previousDoc?.plaza),
relationshipId(doc.plaza),
])
if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500)
if (plazaIDs.length === 0) throw new APIError('Praça da atualização inválida.', 500)
await acquireNucleusUpdateLocks(req, nucleusIDs)
await recomputeNucleusLastUpdateAt(req, nucleusIDs)
await acquirePlazaUpdateLocks(req, plazaIDs)
await recomputePlazaLastUpdateAt(req, plazaIDs)
return doc
}
const recomputeDeletedNucleus: CollectionAfterDeleteHook = async ({ context, doc, req }) => {
if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return doc
const recomputeDeletedPlaza: CollectionAfterDeleteHook = async ({ context, doc, req }) => {
if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return doc
const nucleusIDs = uniqueSortedNucleusIDs([relationshipId(doc.nucleus)])
if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500)
const plazaIDs = uniqueSortedPlazaIDs([relationshipId(doc.plaza)])
if (plazaIDs.length === 0) throw new APIError('Praça da atualização inválida.', 500)
await acquireNucleusUpdateLocks(req, nucleusIDs)
await recomputeNucleusLastUpdateAt(req, nucleusIDs)
await acquirePlazaUpdateLocks(req, plazaIDs)
await recomputePlazaLastUpdateAt(req, plazaIDs)
return doc
}
export const NucleusUpdate: CollectionConfig = {
slug: 'nucleusUpdate',
export const PlazaUpdate: CollectionConfig = {
slug: 'plazaUpdate',
labels: {
singular: 'Atualização do núcleo',
plural: 'Atualizações dos núcleos',
singular: 'Atualização da Praça',
plural: 'Atualizações das Praças',
},
admin: {
group: 'Campanha',
useAsTitle: 'kind',
defaultColumns: ['nucleus', 'author', 'kind', 'createdAt'],
defaultColumns: ['plaza', 'author', 'kind', 'createdAt'],
},
access: {
create: canCreateNucleusUpdate,
read: canReadNucleusUpdate,
update: canMutateNucleusUpdate,
delete: canMutateNucleusUpdate,
create: canCreatePlazaUpdate,
read: canReadPlazaUpdate,
update: canMutatePlazaUpdate,
delete: canMutatePlazaUpdate,
},
hooks: {
beforeValidate: [validateNucleusUpdateKind],
beforeChange: [lockNucleiBeforeChange],
beforeDelete: [lockNucleusBeforeDelete],
afterChange: [recomputeChangedNuclei],
afterDelete: [recomputeDeletedNucleus],
beforeValidate: [validatePlazaUpdateKind],
beforeChange: [lockPlazasBeforeChange],
beforeDelete: [lockPlazaBeforeDelete],
afterChange: [recomputeChangedPlazas],
afterDelete: [recomputeDeletedPlaza],
},
fields: [
{
name: 'nucleus',
name: 'plaza',
type: 'relationship',
relationTo: 'electoralNucleus',
label: 'Núcleo eleitoral',
relationTo: 'plaza',
label: 'Praça',
required: true,
index: true,
},
@ -195,8 +195,8 @@ export const NucleusUpdate: CollectionConfig = {
readOnly: true,
},
access: {
create: canSetNucleusUpdateAuthor,
update: canSetNucleusUpdateAuthor,
create: canSetPlazaUpdateAuthor,
update: canSetPlazaUpdateAuthor,
},
},
{

View file

@ -4,9 +4,9 @@ import { APIError } from 'payload'
import {
canCreateSupporter,
canDeleteSupporter,
canManageLeadershipInternal,
canManageCampaignStaffField,
canManageSupporter,
canReadLeadershipInternal,
canReadCampaignStaffField,
canReadSupporter,
canSetAdministrativeLeadershipField,
} from '@/utilities/campaignAccess'
@ -21,7 +21,7 @@ export const Supporter: CollectionConfig = {
admin: {
group: 'Campanha',
useAsTitle: 'contact',
defaultColumns: ['contact', 'nucleus', 'voteIntention', 'source', 'updatedAt'],
defaultColumns: ['contact', 'plaza', 'voteIntention', 'source', 'updatedAt'],
},
access: {
create: canCreateSupporter,
@ -37,13 +37,13 @@ export const Supporter: CollectionConfig = {
}
const contactID = relationshipId(data.contact ?? originalDoc?.contact)
const nucleusID = relationshipId(data.nucleus ?? originalDoc?.nucleus)
if (!contactID || !nucleusID) return data
const plazaID = relationshipId(data.plaza ?? originalDoc?.plaza)
if (!contactID || !plazaID) return data
const existingLeadership = await req.payload.find({
collection: 'leadership',
where: {
and: [{ contact: { equals: contactID } }, { nucleus: { equals: nucleusID } }],
and: [{ contact: { equals: contactID } }, { plazas: { in: [plazaID] } }],
},
depth: 0,
limit: 1,
@ -54,7 +54,7 @@ export const Supporter: CollectionConfig = {
if (existingLeadership.totalDocs > 0) {
throw new APIError(
'Este contato já é liderança neste núcleo e não pode ser cadastrado como apoiador.',
'Este contato já é liderança nesta Praça e não pode ser cadastrado como apoiador.',
409,
)
}
@ -76,13 +76,13 @@ export const Supporter: CollectionConfig = {
},
},
{
name: 'nucleus',
name: 'plaza',
type: 'relationship',
relationTo: 'electoralNucleus',
label: 'Núcleo eleitoral',
relationTo: 'plaza',
label: 'Praça',
index: true,
access: {
update: canManageLeadershipInternal,
update: canManageCampaignStaffField,
},
},
{
@ -91,9 +91,9 @@ export const Supporter: CollectionConfig = {
label: 'Intenção de voto',
index: true,
access: {
create: canManageLeadershipInternal,
read: canReadLeadershipInternal,
update: canManageLeadershipInternal,
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
options: [
{ label: 'Certo', value: 'certo' },
@ -193,9 +193,9 @@ export const Supporter: CollectionConfig = {
label: 'Registro de consentimento externo',
maxLength: 2000,
access: {
create: canManageLeadershipInternal,
read: canReadLeadershipInternal,
update: canManageLeadershipInternal,
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
},
{
@ -204,9 +204,9 @@ export const Supporter: CollectionConfig = {
label: 'Observações internas',
maxLength: 3000,
access: {
create: canManageLeadershipInternal,
read: canReadLeadershipInternal,
update: canManageLeadershipInternal,
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
},
{
@ -220,7 +220,7 @@ export const Supporter: CollectionConfig = {
},
access: {
create: canSetAdministrativeLeadershipField,
read: canReadLeadershipInternal,
read: canReadCampaignStaffField,
update: canSetAdministrativeLeadershipField,
},
},

View file

@ -0,0 +1,214 @@
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import {
canCreateVotePledge,
canDeleteVotePledge,
canManageCampaignStaffField,
canReadCampaignStaffField,
canReadVotePledge,
canSetCampaignSystemField,
canUpdateVotePledge,
} from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
/**
* Vote pledge per leadership × plaza. The leader DECLARES how many votes they
* are bringing (`declaredVotes` — the only number they ever see); staff record
* their own ESTIMATE of the real value (`estimatedVotes`), which is never
* serialized to the leader (field access denies read). Plaza aggregates use
* `estimatedVotes ?? declaredVotes` on staff surfaces only.
*/
const validatePledgeIntegrity: CollectionBeforeChangeHook = async ({
data,
operation,
originalDoc,
req,
}) => {
const leadershipID = relationshipId(data.leadership ?? originalDoc?.leadership)
const plazaID = relationshipId(data.plaza ?? originalDoc?.plaza)
if (!leadershipID || !plazaID) {
throw new APIError('Informe a liderança e a Praça do compromisso de votos.', 400)
}
if (operation === 'update') {
const previousLeadership = relationshipId(originalDoc?.leadership)
const previousPlaza = relationshipId(originalDoc?.plaza)
if (
(data.leadership !== undefined && leadershipID !== previousLeadership) ||
(data.plaza !== undefined && plazaID !== previousPlaza)
) {
throw new APIError('A liderança e a Praça do compromisso não podem ser alteradas.', 409)
}
}
const leadership = await req.payload.findByID({
collection: 'leadership',
id: leadershipID,
depth: 0,
overrideAccess: true,
req,
})
const linkedPlazaIDs = (Array.isArray(leadership.plazas) ? leadership.plazas : [])
.map(relationshipId)
.filter((id): id is number => id !== null)
if (!linkedPlazaIDs.includes(plazaID)) {
throw new APIError('A liderança precisa estar vinculada à Praça para declarar votos nela.', 409)
}
return data
}
const deriveVotePledgeAudit: CollectionBeforeChangeHook = ({ data, originalDoc, req }) => {
const actorID = req.user?.collection === 'campaignUser' ? req.user.id : null
if (data.declaredVotes !== undefined && data.declaredVotes !== originalDoc?.declaredVotes) {
data.declaredAt = new Date().toISOString()
if (actorID) data.declaredBy = actorID
}
const estimateChanged =
(data.estimatedVotes !== undefined && data.estimatedVotes !== originalDoc?.estimatedVotes) ||
(data.estimateNote !== undefined && data.estimateNote !== originalDoc?.estimateNote)
if (estimateChanged) {
data.estimatedAt = new Date().toISOString()
if (actorID) data.estimatedBy = actorID
}
return data
}
export const VotePledge: CollectionConfig = {
slug: 'votePledge',
labels: {
singular: 'Votos declarados',
plural: 'Votos declarados',
},
admin: {
group: 'Campanha',
useAsTitle: 'id',
defaultColumns: ['leadership', 'plaza', 'declaredVotes', 'estimatedVotes', 'updatedAt'],
},
access: {
create: canCreateVotePledge,
read: canReadVotePledge,
update: canUpdateVotePledge,
delete: canDeleteVotePledge,
},
indexes: [
{
fields: ['leadership', 'plaza'],
unique: true,
},
],
hooks: {
beforeChange: [validatePledgeIntegrity, deriveVotePledgeAudit],
},
fields: [
{
name: 'leadership',
type: 'relationship',
relationTo: 'leadership',
label: 'Liderança',
required: true,
index: true,
},
{
name: 'plaza',
type: 'relationship',
relationTo: 'plaza',
label: 'Praça',
required: true,
index: true,
},
{
name: 'declaredVotes',
type: 'number',
label: 'Votos declarados pela liderança',
required: true,
min: 0,
index: true,
},
{
name: 'declaredAt',
type: 'date',
label: 'Declarado em',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'declaredBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Declarado por',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
update: canSetCampaignSystemField,
},
},
{
name: 'estimatedVotes',
type: 'number',
label: 'Votos estimados pelo assessor',
min: 0,
index: true,
admin: {
description: 'Estimativa interna do valor real. A liderança nunca vê este número.',
},
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
},
{
name: 'estimateNote',
type: 'textarea',
label: 'Justificativa da estimativa',
maxLength: 1000,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
},
{
name: 'estimatedBy',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Estimado por',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
read: canReadCampaignStaffField,
update: canSetCampaignSystemField,
},
},
{
name: 'estimatedAt',
type: 'date',
label: 'Estimado em',
admin: {
readOnly: true,
},
access: {
create: canSetCampaignSystemField,
read: canReadCampaignStaffField,
update: canSetCampaignSystemField,
},
},
],
}

View file

@ -8,10 +8,8 @@ import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import type { ActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
const formatWhen = (plan: ActionPlanListViewModel): string => {
if (!plan.startAt) return 'Data a definir'
return plan.city ? `${formatBahiaDateTimeLabel(plan.startAt)} · ${plan.city}` : formatBahiaDateTimeLabel(plan.startAt)
}
const formatWhen = (plan: ActionPlanListViewModel): string =>
plan.startAt ? formatBahiaDateTimeLabel(plan.startAt) : 'Data a definir'
export const ActionPlanCard = ({ plan }: { plan: ActionPlanListViewModel }) => (
<Card>
@ -19,6 +17,7 @@ export const ActionPlanCard = ({ plan }: { plan: ActionPlanListViewModel }) => (
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{actionPlanKindLabels[plan.kind]}</Badge>
<ActionPlanStatusBadge status={plan.status} />
{plan.deputyPresent ? <Badge>Deputado presente</Badge> : null}
</div>
<CardTitle>
<Link
@ -34,10 +33,10 @@ export const ActionPlanCard = ({ plan }: { plan: ActionPlanListViewModel }) => (
<CalendarIcon aria-hidden="true" className="size-4 shrink-0" />
<span>{formatWhen(plan)}</span>
</div>
{plan.territoryLabel ? (
{plan.locationLabel ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPinIcon aria-hidden="true" className="size-4 shrink-0" />
<Badge variant="outline">{plan.territoryLabel}</Badge>
<Badge variant="outline">{plan.locationLabel}</Badge>
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2 pt-1 text-sm">

View file

@ -5,13 +5,12 @@ import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { cn } from '@/lib/utils'
import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
import { territoryForCity } from '@/lib/bahiaTerritories'
import {
actionPlanTabLabels,
actionPlanTabs,
@ -19,33 +18,31 @@ import {
type ActionPlanListState,
type ActionPlanTab,
} from '@/utilities/actionPlanUi'
import {
municipalityComboboxOptions,
territoryComboboxOptions,
} from '@/utilities/territoryComboboxOptions'
type FilterValues = {
kind: string
status: string
region: string
city: string
plaza: string
}
const valuesFromState = (state: ActionPlanListState): FilterValues => ({
kind: state.kind ?? '',
status: state.status ?? '',
region: state.region ?? '',
city: state.city ?? '',
plaza: state.plaza ? String(state.plaza) : '',
})
const plazaIdFromValue = (value: string): number | undefined => {
const id = Number(value)
return Number.isInteger(id) && id > 0 ? id : undefined
}
const buildTabHref = (state: ActionPlanListState, tab: ActionPlanTab): string => {
const params = buildActionPlanListSearchParams({
page: 1,
tab,
kind: state.kind,
status: tab === 'todos' ? state.status : undefined,
region: state.region,
city: state.city,
plaza: state.plaza,
})
const query = params.toString()
return query ? `/campanha/planos?${query}` : '/campanha/planos'
@ -71,7 +68,13 @@ const ActionPlanTabSwitch = ({ state }: { state: ActionPlanListState }) => (
</nav>
)
export const ActionPlanFilters = ({ state }: { state: ActionPlanListState }) => {
export const ActionPlanFilters = ({
state,
plazaOptions,
}: {
state: ActionPlanListState
plazaOptions: RelationOption[]
}) => {
const router = useRouter()
const pathname = usePathname()
const initialValues = valuesFromState(state)
@ -88,8 +91,7 @@ export const ActionPlanFilters = ({ state }: { state: ActionPlanListState }) =>
tab: state.tab,
kind: nextValues.kind as ActionPlanListState['kind'],
status: nextValues.status as ActionPlanListState['status'],
region: nextValues.region as ActionPlanListState['region'],
city: nextValues.city,
plaza: plazaIdFromValue(nextValues.plaza),
})
const query = params.toString()
startTransition(() => {
@ -99,21 +101,9 @@ export const ActionPlanFilters = ({ state }: { state: ActionPlanListState }) =>
const updateKind = (kind: string) => replaceValues({ ...valuesRef.current, kind })
const updateStatus = (status: string) => replaceValues({ ...valuesRef.current, status })
const updatePlaza = (plaza: string) => replaceValues({ ...valuesRef.current, plaza })
const updateTerritory = (region: string) => {
const current = valuesRef.current
const city = current.city && region && territoryForCity(current.city) !== region ? '' : current.city
replaceValues({ ...current, region, city })
}
const updateCity = (city: string) => {
const current = valuesRef.current
const region = territoryForCity(city) ?? current.region
replaceValues({ ...current, city, region })
}
const clearFilters = () =>
replaceValues({ kind: '', status: '', region: '', city: '' })
const clearFilters = () => replaceValues({ kind: '', status: '', plaza: '' })
const hasFilters = Object.values(values).some(Boolean)
@ -152,7 +142,7 @@ export const ActionPlanFilters = ({ state }: { state: ActionPlanListState }) =>
<div
className={cn(
'grid gap-4',
state.tab === 'todos' ? 'lg:grid-cols-4' : 'lg:grid-cols-3',
state.tab === 'todos' ? 'lg:grid-cols-3' : 'lg:grid-cols-2',
)}
>
<Field>
@ -190,22 +180,20 @@ export const ActionPlanFilters = ({ state }: { state: ActionPlanListState }) =>
</Field>
) : null}
<Field>
<FieldLabel htmlFor="action-plan-lookup-a">Território de identidade</FieldLabel>
<StrictCombobox
id="action-plan-lookup-a"
options={territoryComboboxOptions}
value={values.region}
onValueChange={updateTerritory}
/>
</Field>
<Field>
<FieldLabel htmlFor="action-plan-lookup-b">Município</FieldLabel>
<StrictCombobox
id="action-plan-lookup-b"
options={municipalityComboboxOptions(values.region)}
value={values.city}
onValueChange={updateCity}
/>
<FieldLabel htmlFor="action-plan-plaza">Praça</FieldLabel>
<NativeSelect
id="action-plan-plaza"
value={values.plaza}
onChange={(event) => updatePlaza(event.target.value)}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todas as Praças</NativeSelectOption>
{plazaOptions.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.name}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
{hasFilters ? (

View file

@ -3,10 +3,13 @@
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox'
import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/ContactCombobox'
import { LeadershipCombobox } from '@/components/campaign/LeadershipCombobox'
import { ActionPlanTaskFields } from '@/components/campaign/ActionPlanTaskFields'
import { CampaignTerritoryFields } from '@/components/campaign/NucleusTerritoryFields'
import {
RelationMultiSelect,
type RelationOption,
} from '@/components/campaign/RelationMultiSelect'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -25,12 +28,13 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { Spinner } from '@/components/ui/Spinner'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions'
import type { ActionPlanFormViewModel } from '@/utilities/actionPlanViewModels'
import { fieldError } from '@/utilities/campaignFormFields'
import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime'
import type { NucleusCoordinatorOption } from '@/utilities/nucleusCoordinatorOptions'
import type { EligibleAdvisorOption } from '@/utilities/plazaViewModels'
export type ActionPlanFormState = {
message?: string
@ -45,8 +49,10 @@ type ActionPlanFormAction = (
) => Promise<ActionPlanFormState>
export type ActionPlanFormFieldsProps = {
coordinators?: NucleusCoordinatorOption[]
canManageCoordinators?: boolean
plazaOptions: RelationOption[]
organizationOptions: RelationOption[]
advisorOptions?: EligibleAdvisorOption[]
canManageAdvisors?: boolean
plan?: ActionPlanFormViewModel
fieldErrors?: Record<string, string[]>
submittedTitle?: string
@ -57,8 +63,10 @@ export type ActionPlanFormFieldsProps = {
const editableStatuses = ['rascunho', 'planejado'] as const
export const ActionPlanFormFields = ({
coordinators = [],
canManageCoordinators = false,
plazaOptions,
organizationOptions,
advisorOptions = [],
canManageAdvisors = false,
plan,
fieldErrors = {},
submittedTitle,
@ -175,6 +183,12 @@ export const ActionPlanFormFields = ({
<FieldError id="description-error">{errorFor('description')}</FieldError>
) : null}
</Field>
<FieldLabel>
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
<Checkbox name="deputyPresent" defaultChecked={plan?.deputyPresent ?? false} />
<span>Deputado presente</span>
</Field>
</FieldLabel>
</FieldGroup>
</CardContent>
</Card>
@ -237,44 +251,43 @@ export const ActionPlanFormFields = ({
<Card>
<CardHeader>
<CardTitle>Território</CardTitle>
<CardTitle>Onde</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<CampaignTerritoryFields values={plan} fieldErrors={fieldErrors} />
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={Boolean(errorFor('locality'))}>
<FieldLabel htmlFor="locality">Localidade</FieldLabel>
<Input
id="locality"
name="locality"
defaultValue={plan?.locality ?? ''}
maxLength={160}
className="min-h-11"
aria-invalid={Boolean(errorFor('locality'))}
aria-describedby={errorFor('locality') ? 'locality-error' : undefined}
/>
{errorFor('locality') ? (
<FieldError id="locality-error">{errorFor('locality')}</FieldError>
) : null}
</Field>
</div>
<FieldDescription>
Obrigatório: informe ao menos território de identidade, município ou localidade.
</FieldDescription>
<Field data-invalid={Boolean(errorFor('territoryNotes'))}>
<FieldLabel htmlFor="territoryNotes">Observações do território</FieldLabel>
<Textarea
id="territoryNotes"
name="territoryNotes"
defaultValue={plan?.territoryNotes ?? ''}
maxLength={2000}
className="min-h-24"
aria-invalid={Boolean(errorFor('territoryNotes'))}
aria-describedby={errorFor('territoryNotes') ? 'territoryNotes-error' : undefined}
<Field data-invalid={Boolean(errorFor('plaza'))}>
<FieldLabel htmlFor="plaza">Praça *</FieldLabel>
<NativeSelect
id="plaza"
name="plaza"
defaultValue={plan?.plazaId ? String(plan.plazaId) : ''}
required
aria-invalid={Boolean(errorFor('plaza'))}
aria-describedby={errorFor('plaza') ? 'plaza-error' : undefined}
className="w-full **:data-[slot=native-select]:min-h-11"
>
<NativeSelectOption value="">Selecione a Praça</NativeSelectOption>
{plazaOptions.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.name}
</NativeSelectOption>
))}
</NativeSelect>
{errorFor('plaza') ? <FieldError id="plaza-error">{errorFor('plaza')}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errorFor('locality'))}>
<FieldLabel htmlFor="locality">Local (bairro, endereço ou referência)</FieldLabel>
<Input
id="locality"
name="locality"
defaultValue={plan?.locality ?? ''}
maxLength={160}
className="min-h-11"
aria-invalid={Boolean(errorFor('locality'))}
aria-describedby={errorFor('locality') ? 'locality-error' : undefined}
/>
{errorFor('territoryNotes') ? (
<FieldError id="territoryNotes-error">{errorFor('territoryNotes')}</FieldError>
{errorFor('locality') ? (
<FieldError id="locality-error">{errorFor('locality')}</FieldError>
) : null}
</Field>
</FieldGroup>
@ -283,7 +296,7 @@ export const ActionPlanFormFields = ({
<Card>
<CardHeader>
<CardTitle>Pessoas</CardTitle>
<CardTitle>Pessoas e organizações</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
@ -303,12 +316,16 @@ export const ActionPlanFormFields = ({
<Field data-invalid={Boolean(errorFor('leadership'))}>
<FieldLabel>Liderança vinculada</FieldLabel>
<LeadershipCombobox
<AsyncSearchCombobox
name="leadership"
label="Liderança vinculada"
value={leadership}
onChange={setLeadership}
emptyOptionLabel="Nenhuma"
dialogDescription="Busque lideranças engajadas por nome ou celular."
isQueryReady={isContactSearchQueryReady}
queryTooShortMessage="Digite ao menos dois caracteres para buscar."
search={searchLeaderships}
onChange={setLeadership}
/>
<FieldDescription>
Quando vinculada, a liderança engajada vê o plano e pode marcar tarefas e registrar
@ -319,43 +336,50 @@ export const ActionPlanFormFields = ({
) : null}
</Field>
{canManageCoordinators ? (
<RelationMultiSelect
name="organizations"
label="Organizações apoiadoras"
options={organizationOptions}
initialSelectedIDs={plan?.organizationIDs ?? []}
error={errorFor('organizations')}
placeholder="Adicionar organização…"
/>
{canManageAdvisors ? (
<FieldSet
aria-invalid={Boolean(errorFor('coordinators'))}
aria-describedby={errorFor('coordinators') ? 'coordinators-error' : undefined}
aria-invalid={Boolean(errorFor('advisors'))}
aria-describedby={errorFor('advisors') ? 'advisors-error' : undefined}
>
<FieldLegend>Coordenadores responsáveis</FieldLegend>
<FieldLegend>Assessores responsáveis</FieldLegend>
<FieldDescription>
Coordenadores selecionados podem editar este plano e gerenciar suas tarefas.
Assessores selecionados podem editar este plano e gerenciar suas tarefas.
</FieldDescription>
<input type="hidden" name="coordinatorsSubmitted" value="1" />
{coordinators.length ? (
<input type="hidden" name="advisorsSubmitted" value="1" />
{advisorOptions.length ? (
<div data-slot="checkbox-group" className="grid gap-2 sm:grid-cols-2">
{coordinators.map((coordinator) => (
<FieldLabel key={coordinator.id}>
{advisorOptions.map((advisor) => (
<FieldLabel key={advisor.id}>
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
<Checkbox
name="coordinators"
value={String(coordinator.id)}
name="advisors"
value={String(advisor.id)}
defaultChecked={
plan
? plan.coordinators.some(({ id }) => id === coordinator.id)
: coordinator.isCurrent
plan ? plan.advisorIDs.includes(advisor.id) : advisor.isCurrent
}
/>
<span>
{coordinator.name}
{coordinator.isCurrent ? ' (você)' : ''}
{advisor.name}
{advisor.isCurrent ? ' (você)' : ''}
</span>
</Field>
</FieldLabel>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Nenhum coordenador disponível.</p>
<p className="text-sm text-muted-foreground">Nenhum assessor disponível.</p>
)}
{errorFor('coordinators') ? (
<FieldError id="coordinators-error">{errorFor('coordinators')}</FieldError>
{errorFor('advisors') ? (
<FieldError id="advisors-error">{errorFor('advisors')}</FieldError>
) : null}
</FieldSet>
) : null}
@ -381,8 +405,10 @@ export const ActionPlanFormFields = ({
export const ActionPlanForm = ({
action,
coordinators,
canManageCoordinators = false,
plazaOptions,
organizationOptions,
advisorOptions,
canManageAdvisors = false,
plan,
submitLabel,
searchContacts,
@ -408,8 +434,10 @@ export const ActionPlanForm = ({
</Button>
) : null}
<ActionPlanFormFields
coordinators={coordinators}
canManageCoordinators={canManageCoordinators}
plazaOptions={plazaOptions}
organizationOptions={organizationOptions}
advisorOptions={advisorOptions}
canManageAdvisors={canManageAdvisors}
plan={plan}
fieldErrors={state.fieldErrors}
submittedTitle={state.submittedTitle}

View file

@ -0,0 +1,94 @@
'use client'
import { useActionState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { registerActionPlanResultFormAction } from '@/app/(campaign)/campanha/(app)/planos/[slug]/resultFormActions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { fieldError } from '@/utilities/campaignFormFields'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
type ActionPlanResultFormProps = {
planId: number
initialSummary: string | null
recordedByName: string | null
recordedAt: string | null
}
export const ActionPlanResultForm = ({
planId,
initialSummary,
recordedByName,
recordedAt,
}: ActionPlanResultFormProps) => {
const router = useRouter()
const [state, formAction, pending] = useActionState(registerActionPlanResultFormAction, {})
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
router.refresh()
}, [router, state.message, state.status])
const summaryError = fieldError(state.fieldErrors, 'resultSummary')
const recordedLabel = initialSummary
? [
recordedByName ? `Registrado por ${recordedByName}` : 'Registrado',
recordedAt ? `em ${formatBahiaDateTimeLabel(recordedAt)}` : null,
]
.filter(Boolean)
.join(' ')
: null
return (
<Card>
<CardHeader>
<CardTitle>Resultado da ação</CardTitle>
{recordedLabel ? (
<p className="text-sm text-muted-foreground">{recordedLabel}</p>
) : null}
</CardHeader>
<CardContent>
<form action={formAction} className="flex flex-col gap-4">
<input type="hidden" name="planId" value={planId} />
{state.message && state.status !== 'success' ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível registrar</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<Field data-invalid={Boolean(summaryError)}>
<FieldLabel htmlFor="result-summary">O que aconteceu *</FieldLabel>
<Textarea
id="result-summary"
name="resultSummary"
required
maxLength={6000}
defaultValue={initialSummary ?? ''}
className="min-h-32"
aria-invalid={Boolean(summaryError)}
aria-describedby={summaryError ? 'result-summary-error' : undefined}
/>
<FieldDescription>
Público, aprendizados e próximos passos. Fotos e vídeos podem ser anexados em breve —
registre links no texto por enquanto.
</FieldDescription>
{summaryError ? (
<FieldError id="result-summary-error">{summaryError}</FieldError>
) : null}
</Field>
<Button type="submit" className="min-h-11 w-fit" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Salvando…' : initialSummary ? 'Atualizar resultado' : 'Registrar resultado'}
</Button>
</form>
</CardContent>
</Card>
)
}

View file

@ -1,129 +0,0 @@
import { ArrowRightIcon, CheckCircle2Icon } from 'lucide-react'
import Link from 'next/link'
import { campaignPrioritySurfaceClassName } from '@/components/campaign/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/Empty'
import { cn } from '@/lib/utils'
import type {
DashboardQueueItem,
GeneralDashboardViewModel,
} from '@/utilities/campaignDashboardViewModels'
export const buildDashboardQueueItemHref = (
slug: string,
openCoordinatorAssignment: boolean,
): string => `/campanha/nucleos/${slug}${openCoordinatorAssignment ? '?assignCoordinators=1' : ''}`
const QueueSection = ({
title,
items,
priority = false,
openCoordinatorAssignment = false,
}: {
title: string
items: DashboardQueueItem[]
priority?: boolean
openCoordinatorAssignment?: boolean
}) => (
<div className={priority ? 'rounded-lg bg-muted/40 p-2' : undefined}>
<div className="mb-2 flex items-center justify-between gap-2">
<h3
className={priority ? 'text-sm font-semibold' : 'text-sm font-medium text-muted-foreground'}
>
{priority ? `Prioridade · ${title}` : title}
</h3>
<Badge variant={priority ? 'estimate-pending' : 'secondary'}>{items.length}</Badge>
</div>
<ul className="m-0 flex list-none flex-col gap-2 p-0">
{items.map((item) => (
<li key={item.id}>
<Button asChild variant="ghost" className="h-auto min-h-11 w-full justify-between">
<Link href={buildDashboardQueueItemHref(item.slug, openCoordinatorAssignment)}>
<span className="min-w-0 text-left">
<strong className="block truncate font-medium">{item.name}</strong>
<span className="block truncate text-xs text-muted-foreground">
{item.territory}
</span>
</span>
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</Button>
</li>
))}
</ul>
</div>
)
type ActionQueues = GeneralDashboardViewModel['queues']
export const ActionQueuesCard = ({ queues }: { queues: ActionQueues }) => {
const sections = [
{
key: 'withoutCoordinator',
title: 'Sem coordenador',
items: queues.withoutCoordinator,
priority: true,
openCoordinatorAssignment: true,
},
{
key: 'withoutRecentUpdate',
title: 'Sem atualização há mais de 7 dias',
items: queues.withoutRecentUpdate,
priority: false,
openCoordinatorAssignment: false,
},
{
key: 'pendingEstimate',
title: 'Estimativas pendentes',
items: queues.pendingEstimate,
priority: false,
openCoordinatorAssignment: false,
},
] as const
const activeSections = sections.filter((section) => section.items.length > 0)
const allClear = activeSections.length === 0
return (
<Card className={cn('flex h-full flex-col', campaignPrioritySurfaceClassName)}>
<CardHeader>
<CardTitle id="action-queues">Filas de ação</CardTitle>
<CardDescription>
{allClear
? 'Nenhuma pendência de coordenação, cadência ou estimativa.'
: 'Trate a prioridade primeiro; as demais filas vêm em seguida.'}
</CardDescription>
</CardHeader>
<CardContent className="flex-1">
{allClear ? (
<Empty className="border bg-muted/20 py-6">
<EmptyHeader>
<EmptyMedia variant="icon">
<CheckCircle2Icon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Tudo em dia</EmptyTitle>
<EmptyDescription>
Nenhum núcleo aguarda coordenador, cadência ou confirmação de estimativa.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="flex flex-col gap-4">
{activeSections.map((section) => (
<QueueSection
key={section.key}
title={section.title}
items={section.items}
priority={section.priority}
openCoordinatorAssignment={section.openCoordinatorAssignment}
/>
))}
</div>
)}
</CardContent>
</Card>
)
}

View file

@ -1,60 +0,0 @@
'use client'
import { useActionState } from 'react'
import { ArchiveIcon } from 'lucide-react'
import { archiveNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/AlertDialog'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
export const ArchiveNucleusDialog = ({ nucleusId }: { nucleusId: number }) => {
const [state, action, pending] = useActionState(archiveNucleusFormAction, {})
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" variant="destructive" className="min-h-11">
<ArchiveIcon data-icon="inline-start" aria-hidden="true" />
Arquivar
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Arquivar este núcleo?</AlertDialogTitle>
<AlertDialogDescription>
O núcleo sairá das listas ativas. Os dados permanecem preservados no sistema.
</AlertDialogDescription>
</AlertDialogHeader>
{state.message ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível arquivar</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<form action={action}>
<input type="hidden" name="id" value={nucleusId} />
<AlertDialogFooter>
<AlertDialogCancel type="button" className="min-h-11" disabled={pending}>
Cancelar
</AlertDialogCancel>
<Button type="submit" variant="destructive" className="min-h-11" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Arquivando…' : 'Confirmar arquivamento'}
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -1,20 +1,26 @@
'use client'
import L from 'leaflet'
import type { Feature } from 'geojson'
import L from 'leaflet'
import { useEffect, useMemo, useRef, useState } from 'react'
import 'leaflet/dist/leaflet.css'
import { choroplethFillColor, choroplethMaxValue } from '@/lib/choroplethColorScale'
import {
loadMunicipalityGeometryModule,
loadTerritoryGeometryModule,
type MunicipalityGeometryModule,
type TerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import {
choroplethFillColor,
choroplethMaxAbsValue,
choroplethMaxValue,
divergingFillColor,
} from '@/lib/choroplethColorScale'
import { cn } from '@/lib/utils'
import type { ChoroplethValues } from '@/utilities/nucleusChoroplethTypes'
export type ChoroplethValues = Record<string, number>
const BAHIA_BOUNDS: L.LatLngBoundsExpression = [
[-18.5, -46.8],
@ -30,9 +36,13 @@ const featureKey = (
keyProperty: FeatureKeyProperty,
): string | undefined => properties?.[keyProperty]
export type BahiaMapFillMode = 'sequential' | 'diverging'
type BahiaMapProps = {
mode: BahiaMapMode
values: ChoroplethValues
/** 'diverging': positive = campaign red, negative = blue, zero = white. */
fillMode?: BahiaMapFillMode
highlightKeys?: string[]
className?: string
heightClassName?: string
@ -42,6 +52,7 @@ type BahiaMapProps = {
export const BahiaMap = ({
mode,
values,
fillMode = 'sequential',
highlightKeys = [],
className,
heightClassName = 'h-[320px]',
@ -105,7 +116,8 @@ export const BahiaMap = ({
layerRef.current?.remove()
const max = choroplethMaxValue(values)
const max =
fillMode === 'diverging' ? choroplethMaxAbsValue(values) : choroplethMaxValue(values)
const keyProperty: FeatureKeyProperty = mode === 'municipality' ? 'codarea' : 'code'
const highlightSet = new Set(
highlightKey.length > 0 ? highlightKey.split(',').filter(Boolean) : [],
@ -121,8 +133,11 @@ export const BahiaMap = ({
return {
weight: highlighted ? 2.5 : 1,
color: highlighted ? '#c51414' : '#a8a29e',
fillColor: choroplethFillColor(metric, max),
fillOpacity: highlighted ? 0.92 : metric > 0 ? 0.78 : 0.35,
fillColor:
fillMode === 'diverging'
? divergingFillColor(metric, max)
: choroplethFillColor(metric, max),
fillOpacity: highlighted ? 0.92 : metric !== 0 ? 0.78 : 0.35,
}
},
})
@ -136,13 +151,15 @@ export const BahiaMap = ({
const highlightedFeatures =
mode === 'municipality'
? [...highlightSet].flatMap((key) => {
const feature = (geometryModule as MunicipalityGeometryModule).getMunicipalityFeature(
key,
)
const feature = (
geometryModule as MunicipalityGeometryModule
).getMunicipalityFeature(key)
return feature ? [feature] : []
})
: [...highlightSet].flatMap((key) => {
const feature = (geometryModule as TerritoryGeometryModule).getTerritoryFeature(key)
const feature = (geometryModule as TerritoryGeometryModule).getTerritoryFeature(
key,
)
return feature ? [feature] : []
})
if (highlightedFeatures.length > 0) {
@ -168,7 +185,7 @@ export const BahiaMap = ({
layerRef.current?.remove()
layerRef.current = null
}
}, [highlightKey, mode, values])
}, [fillMode, highlightKey, mode, values])
return (
<div className={cn('relative w-full', className)}>
@ -182,9 +199,7 @@ export const BahiaMap = ({
heightClassName,
)}
/>
{status === 'loading' ? (
<p className="sr-only">Carregando mapa…</p>
) : null}
{status === 'loading' ? <p className="sr-only">Carregando mapa…</p> : null}
{status === 'error' ? (
<p className="absolute inset-0 flex items-center justify-center bg-background/80 px-4 text-center text-sm text-muted-foreground">
Não foi possível carregar o mapa. Tente recarregar a página.

View file

@ -3,13 +3,13 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav'
import { getCampaignBottomNav, isCampaignNavActive } from '@/components/campaign/nav'
import { cn } from '@/lib/utils'
import type { CampaignUser } from '@/payload-types'
export const CampaignBottomNav = ({ role }: { role: CampaignUser['role'] }) => {
const pathname = usePathname()
const navItems = getCampaignNav(role)
const navItems = getCampaignBottomNav(role)
return (
<nav

View file

@ -1,325 +1,198 @@
import {
AlertTriangleIcon,
ClipboardListIcon,
MessageCircleIcon,
PlusIcon,
UserRoundPlusIcon,
} from 'lucide-react'
import { ArrowRightIcon, MapPinIcon } from 'lucide-react'
import Link from 'next/link'
import { CampaignDataFreshness } from '@/components/campaign/CampaignDataFreshness'
import { CampaignMetricStrip } from '@/components/campaign/CampaignMetricStrip'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { DashboardMapDynamic } from '@/components/campaign/DashboardMapDynamic'
import { GeneralDashboardTopRow } from '@/components/campaign/GeneralDashboardTopRow'
import { LeadershipStatusCard } from '@/components/campaign/LeadershipStatusCard'
import { NucleusCard } from '@/components/campaign/NucleusCard'
import { RecentlyVisited } from '@/components/campaign/RecentlyVisited'
import { TseZoneBadge } from '@/components/campaign/TseZoneBadge'
import {
CampaignPageShell,
campaignPrioritySurfaceClassName,
} from '@/components/campaign/CampaignPageShell'
import { RecentlyVisitedCard } from '@/components/campaign/RecentlyVisitedCard'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { formatElectionNumber } from '@/lib/electionInsights'
import type {
GeneralDashboardViewModel,
ScopedDashboardViewModel,
} from '@/utilities/campaignDashboardViewModels'
import { formatRelativeAge } from '@/utilities/formatRelativeAge'
import { buildLeadershipPanelHref } from '@/utilities/leadershipUi'
import { getCampaignScopeLabel } from '@/utilities/nucleusUi'
import { buildWhatsAppUrl } from '@/utilities/phone'
import type { CampaignDashboardView } from '@/utilities/campaignDashboardData'
const lastUpdateLabel = (value: string | null, now: Date): string => {
if (!value) return 'Nenhuma atualização'
return `Última atualização ${formatRelativeAge(new Date(value).getTime(), now.getTime())}`
}
const GeneralDashboard = ({ view, now }: { view: GeneralDashboardViewModel; now: Date }) => (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Visão geral da campanha</h1>
<p className="text-muted-foreground">Cobertura, mobilização e pendências dos núcleos.</p>
<CampaignScopeBadge>
{getCampaignScopeLabel('geral', view.kpis.activeNuclei)}
</CampaignScopeBadge>
</div>
<Button asChild className="min-h-11">
<Link href="/campanha/nucleos/novo">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo núcleo
</Link>
</Button>
</header>
<GeneralDashboardTopRow
queues={view.queues}
upcomingPlans={view.upcomingActionPlans}
now={now}
/>
<div className="grid gap-8 lg:grid-cols-2 lg:items-start lg:gap-6">
<section aria-labelledby="campaign-kpis" className="flex flex-col gap-4">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h2 id="campaign-kpis" className="text-sm font-medium text-muted-foreground">
Indicadores
</h2>
<CampaignDataFreshness asOf={now} />
</div>
<CampaignMetricStrip
metrics={[
{
label: 'Estimativas confirmadas',
value: `${formatElectionNumber(view.kpis.confirmedVoteEstimateTotal)} votos`,
emphasize: true,
},
{
label: '% com estimativa',
value: `${view.kpis.confirmedEstimatePercent}%`,
progress: view.kpis.confirmedEstimatePercent,
},
{
label: '% com coordenador',
value: `${view.kpis.coordinatorCoveragePercent}%`,
progress: view.kpis.coordinatorCoveragePercent,
},
]}
/>
<CampaignMetricStrip
metrics={[
{
label: 'Núcleos ativos',
value: formatElectionNumber(view.kpis.activeNuclei),
},
{
label: 'Meta regular (soma)',
value: `${formatElectionNumber(view.kpis.regularVoteGoalTotal)} votos`,
},
{
label: 'Atualizações esta semana',
value: formatElectionNumber(view.kpis.updatesThisWeek),
},
]}
/>
<LeadershipStatusCard supportCounts={view.supportCounts} />
</section>
<div className="flex flex-col gap-8 lg:sticky lg:top-6">
<Card>
<CardContent className="pt-6">
<DashboardMapDynamic choropleth={view.choropleth} />
</CardContent>
</Card>
</div>
</div>
</CampaignPageShell>
)
const CoordinatorDashboard = ({
view,
now,
}: {
view: Extract<ScopedDashboardViewModel, { role: 'coordenador' }>
now: Date
}) => (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<CampaignScopeBadge>{getCampaignScopeLabel(view.role, view.cards.length)}</CampaignScopeBadge>
<h1 className="text-2xl font-semibold tracking-tight">Sua região</h1>
<p className="text-muted-foreground">O que precisa de atenção nos seus núcleos?</p>
</header>
<RecentlyVisited now={now} upcomingPlans={view.upcomingActionPlans} />
{view.cards.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{view.cards.map((card) => (
<NucleusCard
key={card.id}
name={card.name}
territory={card.territory}
organization={card.organization}
tseZones={card.tseZones}
confirmedVoteEstimate={card.confirmedVoteEstimate}
hasPendingEstimate={card.hasPendingEstimate}
lastUpdateLabel={lastUpdateLabel(card.lastUpdateAt, now)}
isUpdateOverdue={card.isUpdateOverdue}
leadershipCounts={card.leadershipCounts}
actions={
<>
<Button asChild variant="outline" className="min-h-11">
<Link href={buildLeadershipPanelHref(card.slug, {}, { mode: 'create' })}>
<UserRoundPlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
<Button asChild className="min-h-11">
<Link href={`/campanha/nucleos/${card.slug}?tab=updates&newUpdate=1`}>
<ClipboardListIcon data-icon="inline-start" aria-hidden="true" />
Nova atualização
</Link>
</Button>
</>
}
/>
))}
</div>
) : (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<AlertTriangleIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum núcleo sob sua coordenação</EmptyTitle>
<EmptyDescription>
A coordenação geral ainda não atribuiu um núcleo a você. Peça que te incluam como
coordenador no núcleo — aí a região aparece aqui.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</CampaignPageShell>
)
const LeadershipDashboard = ({
view,
now,
}: {
view: Extract<ScopedDashboardViewModel, { role: 'lideranca' }>
now: Date
}) => (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<CampaignScopeBadge>{getCampaignScopeLabel(view.role, view.cards.length)}</CampaignScopeBadge>
<h1 className="text-2xl font-semibold tracking-tight">Meus núcleos</h1>
<p className="text-muted-foreground">
Envie seu reporte e acompanhe a estimativa confirmada.
</p>
</header>
<RecentlyVisited now={now} upcomingPlans={view.upcomingActionPlans} />
{view.cards.length ? (
<div className="grid gap-4 md:grid-cols-2">
{view.cards.map((card) => (
<Card key={card.id}>
<CardHeader>
<CardTitle>{card.name}</CardTitle>
<CardDescription>
{[card.organization, card.territory].filter(Boolean).join(' · ')}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-1">
{card.tseZones.map((zoneNumber) => (
<TseZoneBadge key={zoneNumber} zoneNumber={zoneNumber} />
))}
</div>
<p
className={
card.confirmedVoteEstimate === null
? 'text-sm text-muted-foreground'
: 'text-base font-medium tabular-nums'
}
>
{card.confirmedVoteEstimate === null
? 'Sem estimativa confirmada'
: `${formatElectionNumber(card.confirmedVoteEstimate)} votos`}
</p>
{card.confirmedVoteEstimate === null ? (
<p className="text-xs text-muted-foreground">
Sugira na página do núcleo; a coordenação confirma o número.
</p>
) : null}
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">Coordenação</p>
{card.coordinators.length ? (
card.coordinators.map((coordinator) => (
<div key={coordinator.name} className="flex flex-wrap items-center gap-2">
<span>{coordinator.name}</span>
{coordinator.phone ? (
<Button asChild size="sm" variant="outline" className="min-h-11">
<a
href={buildWhatsAppUrl(coordinator.phone)}
target="_blank"
rel="noreferrer"
>
<MessageCircleIcon data-icon="inline-start" aria-hidden="true" />
Falar no WhatsApp
</a>
</Button>
) : (
<span className="text-sm text-muted-foreground">
Contato não disponível
</span>
)}
</div>
))
) : (
<span className="text-sm text-muted-foreground">Coordenação não informada</span>
)}
</div>
</CardContent>
<CardFooter>
<Button asChild className="min-h-11 w-full">
<Link href={`/campanha/nucleos/${card.slug}?tab=updates&newUpdate=1`}>
<ClipboardListIcon data-icon="inline-start" aria-hidden="true" />
Enviar atualização
</Link>
</Button>
</CardFooter>
</Card>
))}
</div>
) : (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<AlertTriangleIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Ainda sem núcleo por aqui</EmptyTitle>
<EmptyDescription>
Seu acesso a um núcleo só aparece depois que a coordenação te cadastra como liderança e
marca o apoio como engajado (participando de verdade). Peça isso ao coordenador — o
mesmo caminho de quem entra só com o celular.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/perfil">Ver meu perfil</Link>
</Button>
<p className="text-xs text-muted-foreground">
No perfil você confere seus dados de acesso enquanto a coordenação libera o núcleo.
</p>
</EmptyContent>
</Empty>
)}
</CampaignPageShell>
)
const voteFormatter = new Intl.NumberFormat('pt-BR')
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
timeStyle: 'short',
})
export const CampaignDashboard = ({
view,
now,
userName,
}: {
view: GeneralDashboardViewModel | ScopedDashboardViewModel
now: Date
view: CampaignDashboardView
userName: string
}) => {
if (view.role === 'geral') return <GeneralDashboard view={view} now={now} />
if (view.role === 'coordenador') return <CoordinatorDashboard view={view} now={now} />
return <LeadershipDashboard view={view} now={now} />
if (view.kind === 'leader') {
return (
<CampaignPageShell>
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Olá, {userName}</h1>
<p className="text-muted-foreground">
Suas Praças e os votos que você declarou para a campanha.
</p>
</header>
{view.plazas.length ? (
<ul className="grid gap-4 sm:grid-cols-2">
{view.plazas.map((plaza) => (
<li key={plaza.id} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-center gap-2">
<MapPinIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<h2 className="font-medium">{plaza.name}</h2>
</div>
<p className="text-sm text-muted-foreground">
{plaza.declaredVotes == null
? 'Você ainda não declarou votos nesta Praça.'
: `Você declarou ${voteFormatter.format(plaza.declaredVotes)} votos.`}
</p>
<Button asChild variant="outline" className="min-h-11 self-start">
<Link href={`/campanha/pracas/${plaza.slug}`}>
{plaza.declaredVotes == null ? 'Declarar votos' : 'Abrir Praça'}
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</Button>
</li>
))}
</ul>
) : (
<p className="rounded-xl border px-4 py-6 text-sm text-muted-foreground">
Você ainda não está vinculada a nenhuma Praça. Fale com a assessoria da campanha.
</p>
)}
</CampaignPageShell>
)
}
return (
<CampaignPageShell>
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Olá, {userName}</h1>
<p className="text-muted-foreground">
{view.role === 'coordinator'
? 'Quadro geral da campanha por Praça.'
: 'Quadro das Praças sob sua assessoria.'}
</p>
</header>
<section
aria-label="Indicadores das Praças"
className={`rounded-xl ${campaignPrioritySurfaceClassName}`}
>
<CampaignMetricStrip
metrics={[
{
label: 'Votos estimados',
value: view.pledgeCount ? voteFormatter.format(view.effectiveVotesTotal) : '—',
emphasize: true,
},
{
label: 'Cobertura de assessoria',
value: `${view.withAdvisorCount} de ${view.plazaCount} Praças`,
progress:
view.plazaCount > 0
? Math.round((view.withAdvisorCount / view.plazaCount) * 100)
: undefined,
},
{
label: 'Declarações sem estimativa',
value: voteFormatter.format(view.missingEstimateCount),
},
]}
/>
</section>
<div className="grid gap-6 lg:grid-cols-2">
<section
aria-labelledby="dashboard-missing-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex items-center justify-between gap-2">
<h2 id="dashboard-missing-title" className="text-base font-medium">
Declarações aguardando estimativa
</h2>
<Badge variant="estimate-pending">{view.missingEstimateCount}</Badge>
</div>
{view.missingEstimates.length ? (
<ul className="flex flex-col gap-2">
{view.missingEstimates.map((item) => (
<li key={item.pledgeID} className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{item.contactName}</span>
<span className="truncate text-sm text-muted-foreground">
{item.plazaName} · declarou {voteFormatter.format(item.declaredVotes)} votos
</span>
</div>
<Button asChild variant="ghost" className="min-h-11 shrink-0">
<Link href={`/campanha/pracas/${item.plazaSlug}`}>Revisar</Link>
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Todas as declarações do seu escopo têm estimativa registrada.
</p>
)}
</section>
<section
aria-labelledby="dashboard-updates-title"
className="flex flex-col gap-3 rounded-xl border p-4"
>
<h2 id="dashboard-updates-title" className="text-base font-medium">
Últimas atualizações de campo
</h2>
{view.recentUpdates.length ? (
<ul className="flex flex-col gap-2">
{view.recentUpdates.map((update) => (
<li key={update.id} className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{update.plazaName}</span>
<span className="truncate text-sm text-muted-foreground">
{update.authorName} · {dateTimeFormatter.format(new Date(update.createdAt))}
</span>
</div>
<Button asChild variant="ghost" className="min-h-11 shrink-0">
<Link href={`/campanha/pracas/${update.plazaSlug}?tab=updates`}>Abrir</Link>
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhuma atualização registrada ainda.</p>
)}
</section>
</div>
{view.priorityPlazas.length ? (
<section aria-labelledby="dashboard-priority-title" className="flex flex-col gap-3">
<h2 id="dashboard-priority-title" className="text-base font-medium">
Praças prioritárias
</h2>
<ul className="flex flex-wrap gap-2">
{view.priorityPlazas.map((plaza) => (
<li key={plaza.slug}>
<Button asChild variant="outline" className="min-h-11">
<Link href={`/campanha/pracas/${plaza.slug}`}>{plaza.name}</Link>
</Button>
</li>
))}
</ul>
</section>
) : null}
<RecentlyVisitedCard now={new Date()} />
<div>
<Button asChild className="min-h-11">
<Link href="/campanha/pracas">
Ver todas as Praças
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</Button>
</div>
</CampaignPageShell>
)
}

View file

@ -16,7 +16,7 @@ import { Spinner } from '@/components/ui/Spinner'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
import { campaignRoleLabels, type CampaignUserShellView } from '@/utilities/campaignUserProfile'
import { formatLeadershipPhone } from '@/utilities/leadershipUi'
import { formatBrazilianPhoneDisplay } from '@/utilities/phone'
type CampaignProfileSettingsProps = {
user: CampaignUserShellView & {
@ -98,7 +98,7 @@ export const CampaignProfileSettings = ({
{user.username ? (
<div>
<dt className="text-muted-foreground">Celular de acesso</dt>
<dd>{formatLeadershipPhone(user.username)}</dd>
<dd>{formatBrazilianPhoneDisplay(user.username)}</dd>
</div>
) : null}
</dl>

View file

@ -1,244 +0,0 @@
'use client'
import { useMemo } from 'react'
import { XIcon } from 'lucide-react'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { TerritorySuggestionChips } from '@/components/campaign/TerritorySuggestionChips'
import type { CampaignTerritoryFieldsState } from '@/components/campaign/useCampaignTerritoryFieldsState'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { InputGroup, InputGroupInput } from '@/components/ui/input-group'
import { buildTerritorySuggestions } from '@/lib/territorySuggestions'
import { fieldError } from '@/utilities/campaignFormFields'
const ChipList = ({
values,
onRemove,
removeLabel,
}: {
values: string[]
onRemove: (value: string) => void
removeLabel: (value: string) => string
}) => (
<div className="flex flex-wrap gap-1">
{values.map((value) => (
<Badge key={value} asChild variant="secondary" className="h-8 gap-1 rounded-sm px-2.5 py-1">
<button type="button" onClick={() => onRemove(value)} aria-label={removeLabel(value)}>
<span>{value}</span>
<XIcon aria-hidden="true" className="size-3" />
</button>
</Badge>
))}
</div>
)
export const CampaignTerritoryCoreFields = ({
state,
idPrefix,
fieldErrors = {},
citySuggestions: citySuggestionsOverride,
}: {
state: CampaignTerritoryFieldsState
idPrefix: string
fieldErrors?: Record<string, string[]>
citySuggestions?: ReturnType<typeof buildTerritorySuggestions>['citySuggestions']
}) => {
const errorFor = (name: string) => fieldError(fieldErrors, name)
const {
regions,
cities,
neighborhoods,
cityDraft,
regionDraft,
neighborhoodDraft,
regionError,
cityError,
neighborhoodError,
setCityDraft,
setRegionDraft,
setNeighborhoodDraft,
setRegionError,
setCityError,
setNeighborhoodError,
displayRegions,
regionsAreDerived,
neighborhoodsEnabled,
availableCityOptions,
availableRegionOptions,
addCity,
removeCity,
addRegion,
removeRegion,
commitNeighborhood,
removeNeighborhood,
} = state
const citySuggestions = useMemo(() => {
if (citySuggestionsOverride) return citySuggestionsOverride
return buildTerritorySuggestions({
cities,
regions: displayRegions,
tseZones: [],
}).citySuggestions
}, [citySuggestionsOverride, cities, displayRegions])
const visibleRegionError = regionError ?? errorFor('regions')
const visibleCityError = cityError ?? errorFor('cities')
const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods')
const regionFieldId = `${idPrefix}-lookup-a`
const cityFieldId = `${idPrefix}-lookup-b`
const neighborhoodFieldId = `${idPrefix}-neighborhoodDraft`
return (
<>
{cities.map((city) => (
<input key={`city-${city}`} type="hidden" name="cities" value={city} />
))}
{!regionsAreDerived
? regions.map((region) => (
<input key={`region-${region}`} type="hidden" name="regions" value={region} />
))
: null}
{neighborhoodsEnabled
? neighborhoods.map((neighborhood) => (
<input
key={`neighborhood-${neighborhood}`}
type="hidden"
name="neighborhoods"
value={neighborhood}
/>
))
: null}
<Field data-invalid={Boolean(visibleRegionError)} className="sm:col-span-2">
<FieldLabel htmlFor={regionFieldId}>Territórios de identidade</FieldLabel>
{displayRegions.length ? (
<ChipList
values={displayRegions}
onRemove={removeRegion}
removeLabel={(region) => `Remover território ${region}`}
/>
) : null}
{regionsAreDerived ? (
<FieldDescription>
Derivados automaticamente dos municípios selecionados. Remova municípios para editar
territórios manualmente.
</FieldDescription>
) : (
<StrictCombobox
id={regionFieldId}
options={availableRegionOptions}
value={regionDraft}
onValueChange={(nextRegion) => {
if (nextRegion) addRegion(nextRegion)
else setRegionDraft('')
}}
onInvalid={() => {
setRegionError('Selecione um território de identidade válido da Bahia.')
}}
error={visibleRegionError}
/>
)}
{visibleRegionError ? (
<FieldError id={`${regionFieldId}-error`}>{visibleRegionError}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(visibleCityError)} className="sm:col-span-2">
<FieldLabel htmlFor={cityFieldId}>Municípios</FieldLabel>
<TerritorySuggestionChips
kind="city"
suggestions={citySuggestions}
onAccept={(suggestion) => addCity(suggestion.city)}
/>
{cities.length ? (
<ChipList
values={cities}
onRemove={removeCity}
removeLabel={(city) => `Remover município ${city}`}
/>
) : null}
<StrictCombobox
id={cityFieldId}
options={availableCityOptions}
value={cityDraft}
onValueChange={(nextCity) => {
if (nextCity) addCity(nextCity)
else setCityDraft('')
}}
onInvalid={() => {
setCityError('Selecione um município válido da Bahia.')
}}
error={visibleCityError}
/>
<FieldDescription>
Selecione um ou mais municípios. Territórios são derivados automaticamente; bairros só
ficam disponíveis com exatamente um município.
</FieldDescription>
{visibleCityError ? (
<FieldError id={`${cityFieldId}-error`}>{visibleCityError}</FieldError>
) : null}
</Field>
<Field
data-disabled={!neighborhoodsEnabled}
data-invalid={Boolean(visibleNeighborhoodError)}
className="sm:col-span-2"
>
<FieldLabel htmlFor={neighborhoodFieldId}>Bairros</FieldLabel>
{neighborhoodsEnabled && neighborhoods.length ? (
<ChipList
values={neighborhoods}
onRemove={removeNeighborhood}
removeLabel={(neighborhood) => `Remover bairro ${neighborhood}`}
/>
) : null}
<InputGroup className="min-h-11 h-auto flex-wrap gap-1 p-1">
<InputGroupInput
id={neighborhoodFieldId}
value={neighborhoodDraft}
disabled={!neighborhoodsEnabled}
placeholder={
neighborhoodsEnabled
? 'Digite um bairro e pressione Enter'
: 'Selecione exatamente um município para informar bairros'
}
autoComplete="address-level3"
aria-invalid={Boolean(visibleNeighborhoodError)}
aria-describedby={
visibleNeighborhoodError
? `${idPrefix}-neighborhoods-error`
: `${idPrefix}-neighborhoods-description`
}
className="min-w-48"
onChange={(event) => {
setNeighborhoodDraft(event.target.value)
setNeighborhoodError(undefined)
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commitNeighborhood(neighborhoodDraft)
} else if (
event.key === 'Backspace' &&
!neighborhoodDraft &&
neighborhoods.length > 0
) {
removeNeighborhood(neighborhoods[neighborhoods.length - 1]!)
}
}}
onBlur={() => commitNeighborhood(neighborhoodDraft)}
/>
</InputGroup>
<FieldDescription id={`${idPrefix}-neighborhoods-description`}>
Use Enter para adicionar. Disponível somente quando há exatamente um município.
</FieldDescription>
{visibleNeighborhoodError ? (
<FieldError id={`${idPrefix}-neighborhoods-error`}>{visibleNeighborhoodError}</FieldError>
) : null}
</Field>
</>
)
}

View file

@ -0,0 +1,93 @@
'use client'
import { XIcon } from 'lucide-react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
type CandidateComparePickerProps = {
options: FederalCandidateOption[]
selectedNumbers: number[]
maxSelected: number
}
/** URL-driven picker: repeated `compare` params on the elections tab. */
export const CandidateComparePicker = ({
options,
selectedNumbers,
maxSelected,
}: CandidateComparePickerProps) => {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const optionByNumber = new Map(options.map((option) => [option.candidateNumber, option]))
const navigate = (numbers: number[]) => {
const params = new URLSearchParams(searchParams.toString())
params.delete('compare')
for (const number of numbers) params.append('compare', String(number))
params.set('tab', 'elections')
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
}
return (
<div className="flex flex-col gap-2">
<Field className="max-w-md">
<FieldLabel htmlFor="candidate-compare-picker">Comparar com outros candidatos</FieldLabel>
<NativeSelect
id="candidate-compare-picker"
value=""
disabled={selectedNumbers.length >= maxSelected}
onChange={(event) => {
const number = Number(event.target.value)
if (Number.isInteger(number) && number > 0 && !selectedNumbers.includes(number)) {
navigate([...selectedNumbers, number])
}
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="">
{selectedNumbers.length >= maxSelected
? `Máximo de ${maxSelected} candidatos`
: 'Adicionar candidato…'}
</NativeSelectOption>
{options
.filter((option) => !selectedNumbers.includes(option.candidateNumber))
.map((option) => (
<NativeSelectOption
key={option.candidateNumber}
value={String(option.candidateNumber)}
>
{option.name}
{option.party ? ` (${option.party})` : ''} — {option.candidateNumber}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
{selectedNumbers.length ? (
<ul className="flex flex-wrap gap-1.5">
{selectedNumbers.map((number) => (
<li key={number}>
<Badge variant="secondary" className="gap-1 pr-1">
{optionByNumber.get(number)?.name ?? `Candidato ${number}`}
<button
type="button"
aria-label={`Remover ${optionByNumber.get(number)?.name ?? number} da comparação`}
className="inline-flex size-6 items-center justify-center rounded-full hover:bg-foreground/10"
onClick={() =>
navigate(selectedNumbers.filter((selected) => selected !== number))
}
>
<XIcon className="size-3.5" aria-hidden="true" />
</button>
</Badge>
</li>
))}
</ul>
) : null}
</div>
)
}

View file

@ -21,7 +21,7 @@ export const ChoroplethLegend = ({ max, metricLabel }: ChoroplethLegendProps) =>
</span>
</div>
<p className="text-xs text-muted-foreground">
Escala: intensidade da cor indica {metricLabel.toLowerCase()} no território.
Escala: intensidade da cor indica {metricLabel} no território.
</p>
</div>
)

View file

@ -1,142 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import { BahiaMap, type BahiaMapMode } from '@/components/campaign/BahiaMap'
import { ChoroplethLegend } from '@/components/campaign/ChoroplethLegend'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { choroplethMaxValue } from '@/lib/choroplethColorScale'
import {
choroplethMetricLabels,
type ChoroplethMetric,
type NucleusChoroplethBundle,
} from '@/utilities/nucleusChoroplethTypes'
const modeLabels: Record<BahiaMapMode, string> = {
municipality: 'Municípios',
territory: 'Territórios de Identidade',
}
type ChoroplethMapControlsProps = {
mode: BahiaMapMode
metric: ChoroplethMetric
max: number
onModeChange: (mode: BahiaMapMode) => void
onMetricChange: (metric: ChoroplethMetric) => void
idPrefix: string
}
const ChoroplethMapControls = ({
mode,
metric,
max,
onModeChange,
onMetricChange,
idPrefix,
}: ChoroplethMapControlsProps) => {
const hasData = max > 0
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<Field className="min-w-[12rem] flex-1">
<FieldLabel htmlFor={`${idPrefix}-mode`}>Visualização</FieldLabel>
<NativeSelect
id={`${idPrefix}-mode`}
value={mode}
onChange={(event) => onModeChange(event.target.value as BahiaMapMode)}
className="min-h-11 w-full"
>
{(Object.keys(modeLabels) as BahiaMapMode[]).map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{modeLabels[entry]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field className="min-w-[12rem] flex-1">
<FieldLabel htmlFor={`${idPrefix}-metric`}>Métrica</FieldLabel>
<NativeSelect
id={`${idPrefix}-metric`}
value={metric}
onChange={(event) => onMetricChange(event.target.value as ChoroplethMetric)}
className="min-h-11 w-full"
>
{(Object.keys(choroplethMetricLabels) as ChoroplethMetric[]).map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{choroplethMetricLabels[entry]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
{hasData ? (
<ChoroplethLegend max={max} metricLabel={choroplethMetricLabels[metric]} />
) : (
<p className="text-sm text-muted-foreground">
Sem dados para esta métrica no conjunto atual.
</p>
)}
</div>
)
}
type ChoroplethMapPanelProps = {
bundle: NucleusChoroplethBundle
title: string
description: string
ariaLabel: string
idPrefix: string
defaultMode?: BahiaMapMode
defaultMetric?: ChoroplethMetric
highlightKeys?: string[]
heightClassName?: string
}
export const ChoroplethMapPanel = ({
bundle,
title,
description,
ariaLabel,
idPrefix,
defaultMode = 'municipality',
defaultMetric = 'nucleusCount',
highlightKeys = [],
heightClassName,
}: ChoroplethMapPanelProps) => {
const [mode, setMode] = useState<BahiaMapMode>(defaultMode)
const [metric, setMetric] = useState<ChoroplethMetric>(defaultMetric)
const values = useMemo(
() => (mode === 'municipality' ? bundle.municipality[metric] : bundle.territory[metric]),
[bundle, metric, mode],
)
const max = choroplethMaxValue(values)
return (
<section aria-labelledby={`${idPrefix}-title`} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h3 id={`${idPrefix}-title`} className="text-base font-medium">
{title}
</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<ChoroplethMapControls
mode={mode}
metric={metric}
max={max}
onModeChange={setMode}
onMetricChange={setMetric}
idPrefix={idPrefix}
/>
<BahiaMap
mode={mode}
values={values}
highlightKeys={highlightKeys}
ariaLabel={ariaLabel}
heightClassName={heightClassName}
/>
</section>
)
}

View file

@ -1,85 +0,0 @@
import type { ReactNode } from 'react'
import { AlertTriangleIcon, MessageCircleIcon } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { Button } from '@/components/ui/button'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import type { AssignedCoordinatorViewModel } from '@/utilities/nucleusCoordinatorAssignmentPageData'
import { buildWhatsAppUrl } from '@/utilities/phone'
type CoordinatorAssignmentCardProps = {
coordinators: AssignedCoordinatorViewModel[]
children?: ReactNode
}
const initials = (name: string): string =>
name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('')
export const CoordinatorAssignmentCard = ({
coordinators,
children,
}: CoordinatorAssignmentCardProps) => (
<Card>
<CardHeader>
<CardTitle>Coordenação responsável</CardTitle>
<CardDescription>
Pessoas atualmente responsáveis pela articulação deste núcleo.
</CardDescription>
{children ? <CardAction>{children}</CardAction> : null}
</CardHeader>
<CardContent>
{coordinators.length ? (
<ul className="flex flex-col gap-3">
{coordinators.map((coordinator) => (
<li
key={coordinator.id}
className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 items-center gap-3">
<Avatar size="lg">
<AvatarFallback>{initials(coordinator.name)}</AvatarFallback>
</Avatar>
<span className="truncate font-medium">{coordinator.name}</span>
</div>
{coordinator.phone ? (
<Button asChild variant="outline" className="min-h-11">
<a
href={buildWhatsAppUrl(coordinator.phone)}
target="_blank"
rel="noreferrer"
>
<MessageCircleIcon data-icon="inline-start" aria-hidden="true" />
Falar no WhatsApp
</a>
</Button>
) : (
<span className="text-sm text-muted-foreground">Contato não disponível</span>
)}
</li>
))}
</ul>
) : (
<Alert variant="pending">
<AlertTriangleIcon aria-hidden="true" />
<AlertTitle>Sem coordenação responsável</AlertTitle>
<AlertDescription>
Este núcleo ainda não tem uma pessoa elegível designada para coordenação.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
)

View file

@ -1,167 +0,0 @@
'use client'
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertTriangleIcon, UserRoundCogIcon } from 'lucide-react'
import type { CoordinatorAssignmentFormState } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Spinner } from '@/components/ui/Spinner'
import type { AssignedCoordinatorViewModel } from '@/utilities/nucleusCoordinatorAssignmentPageData'
import type { NucleusCoordinatorOption } from '@/utilities/nucleusCoordinatorOptions'
type CoordinatorAssignmentDialogBodyModule = {
default: typeof import('./CoordinatorAssignmentDialogBody').CoordinatorAssignmentDialogBody
}
const loadCoordinatorAssignmentDialogBody = (): Promise<CoordinatorAssignmentDialogBodyModule> =>
import('./CoordinatorAssignmentDialogBody').then((module) => ({
default: module.CoordinatorAssignmentDialogBody,
}))
export type CoordinatorAssignmentFormAction = (
state: CoordinatorAssignmentFormState,
formData: FormData,
) => Promise<CoordinatorAssignmentFormState>
export type CoordinatorAssignmentOptionsResult = {
expectedUpdatedAt: string
options: NucleusCoordinatorOption[]
}
type CoordinatorAssignmentOptionsAction = () => Promise<CoordinatorAssignmentOptionsResult>
export const CoordinatorAssignmentDialog = ({
action,
coordinators,
initialOpen = false,
loadBodyModule = loadCoordinatorAssignmentDialogBody,
loadOptions,
}: {
action: CoordinatorAssignmentFormAction
coordinators: AssignedCoordinatorViewModel[]
initialOpen?: boolean
loadBodyModule?: () => Promise<CoordinatorAssignmentDialogBodyModule>
loadOptions: CoordinatorAssignmentOptionsAction
}) => {
const [open, setOpen] = useState(initialOpen)
const [result, setResult] = useState<CoordinatorAssignmentOptionsResult | null>(null)
const [error, setError] = useState(false)
const [mutationPending, setMutationPending] = useState(false)
const requestId = useRef(0)
const LazyBody = useMemo(() => lazy(loadBodyModule), [loadBodyModule])
const triggerLabel = coordinators.length ? 'Alterar coordenadores' : 'Designar coordenadores'
const load = useCallback(async () => {
const currentRequest = ++requestId.current
setResult(null)
setError(false)
try {
const [, nextResult] = await Promise.all([loadBodyModule(), loadOptions()])
if (requestId.current === currentRequest) setResult(nextResult)
} catch {
if (requestId.current === currentRequest) setError(true)
}
}, [loadBodyModule, loadOptions])
useEffect(() => {
if (open) void load()
return () => {
requestId.current += 1
}
}, [load, open])
const closeDialog = () => {
requestId.current += 1
setResult(null)
setError(false)
setMutationPending(false)
setOpen(false)
}
const setDialogOpen = (nextOpen: boolean) => {
if (!nextOpen) {
if (mutationPending) return
closeDialog()
return
}
setOpen(true)
}
return (
<Dialog open={open} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" className="min-h-11">
<UserRoundCogIcon data-icon="inline-start" aria-hidden="true" />
{triggerLabel}
</Button>
</DialogTrigger>
<DialogContent
showCloseButton={!mutationPending}
onEscapeKeyDown={(event) => {
if (mutationPending) event.preventDefault()
}}
onPointerDownOutside={(event) => {
if (mutationPending) event.preventDefault()
}}
>
<DialogHeader>
<DialogTitle>{triggerLabel}</DialogTitle>
<DialogDescription>
Selecione zero ou mais pessoas. Salvar sem seleção deixa o núcleo sem cobertura.
</DialogDescription>
</DialogHeader>
{error ? (
<Alert variant="destructive">
<AlertTriangleIcon aria-hidden="true" />
<AlertTitle>Não foi possível carregar</AlertTitle>
<AlertDescription>Atualize as opções antes de alterar a coordenação.</AlertDescription>
<Button
type="button"
variant="outline"
className="mt-3 min-h-11"
onClick={() => void load()}
>
Tentar novamente
</Button>
</Alert>
) : result ? (
<Suspense
fallback={
<div
className="flex min-h-24 items-center justify-center"
aria-label="Carregando seletor"
>
<Spinner aria-hidden="true" />
</div>
}
>
<LazyBody
key={result.expectedUpdatedAt}
action={action}
initialSelectedIds={coordinators.map(({ id }) => id)}
onClose={closeDialog}
onPendingChange={setMutationPending}
result={result}
/>
</Suspense>
) : (
<div
className="flex min-h-24 items-center justify-center"
aria-label="Carregando coordenadores"
>
<Spinner aria-hidden="true" />
</div>
)}
</DialogContent>
</Dialog>
)
}

View file

@ -1,169 +0,0 @@
'use client'
import { useActionState, useEffect, useId, useState } from 'react'
import { AlertTriangleIcon, CheckIcon, SearchIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import type {
CoordinatorAssignmentFormAction,
CoordinatorAssignmentOptionsResult,
} from '@/components/campaign/CoordinatorAssignmentDialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { DialogFooter } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
export const CoordinatorAssignmentDialogBody = ({
action,
initialSelectedIds,
onClose,
onPendingChange,
result,
}: {
action: CoordinatorAssignmentFormAction
initialSelectedIds: number[]
onClose: () => void
onPendingChange?: (pending: boolean) => void
result: CoordinatorAssignmentOptionsResult
}) => {
const router = useRouter()
const optionsId = useId()
const optionsHeadingId = useId()
const [state, formAction, pending] = useActionState(action, {})
const [selectedIds, setSelectedIds] = useState(initialSelectedIds)
const [query, setQuery] = useState('')
const selected = new Set(selectedIds)
const visibleOptions = result.options.filter((option) =>
option.name.toLocaleLowerCase('pt-BR').includes(query.trim().toLocaleLowerCase('pt-BR')),
)
useEffect(() => {
if (!pending) return
toast.loading('Salvando coordenação…', { id: 'coordinator-assignment' })
}, [pending])
useEffect(() => {
onPendingChange?.(pending)
}, [onPendingChange, pending])
useEffect(() => {
if (!state.message || pending) return
if (state.status === 'success') {
toast.success(state.message, { id: 'coordinator-assignment' })
onClose()
router.refresh()
return
}
toast.error(state.message, { id: 'coordinator-assignment' })
}, [onClose, pending, router, state])
return (
<form action={formAction} className="flex min-h-0 flex-col gap-4">
<input type="hidden" name="expectedUpdatedAt" value={result.expectedUpdatedAt} />
{selectedIds.map((id) => (
<input key={id} type="hidden" name="coordinators" value={String(id)} />
))}
{result.options.length ? (
<div className="min-h-0 rounded-lg border p-1">
<div className="relative">
<SearchIcon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-controls={optionsId}
aria-describedby={optionsHeadingId}
autoComplete="off"
spellCheck={false}
className="min-h-11 rounded-lg pr-3 pl-9"
aria-label="Buscar coordenadores"
placeholder="Buscar por nome…"
autoFocus
/>
</div>
<div
id={optionsHeadingId}
className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground"
>
Usuários elegíveis
</div>
<div id={optionsId}>
{visibleOptions.length ? (
<ul className="no-scrollbar max-h-72 overflow-x-hidden overflow-y-auto p-1">
{visibleOptions.map((option) => {
const checked = selected.has(option.id)
return (
<li key={option.id}>
<button
type="button"
role="checkbox"
aria-checked={checked}
data-checked={checked}
className="flex min-h-11 w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm outline-none hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 data-[checked=true]:bg-muted"
onClick={() =>
setSelectedIds((current) =>
current.includes(option.id)
? current.filter((id) => id !== option.id)
: [...current, option.id],
)
}
>
<span
data-checked={checked}
aria-hidden="true"
className="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[checked=true]:border-primary data-[checked=true]:bg-primary data-[checked=true]:text-primary-foreground"
>
{checked ? <CheckIcon className="size-3.5" /> : null}
</span>
<span>
{option.name}
{option.isCurrent ? ' (você)' : ''}
</span>
</button>
</li>
)
})}
</ul>
) : (
<p className="py-6 text-center text-sm">Nenhuma pessoa corresponde à busca.</p>
)}
</div>
</div>
) : (
<Alert>
<AlertTriangleIcon aria-hidden="true" />
<AlertTitle>Nenhum usuário elegível disponível</AlertTitle>
<AlertDescription>
Não há usuários com papel de coordenação geral ou coordenador para designar.
</AlertDescription>
</Alert>
)}
{state.message && state.status !== 'success' ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível salvar</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={onClose}
disabled={pending}
>
Cancelar
</Button>
<Button type="submit" className="min-h-11" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar coordenação
</Button>
</DialogFooter>
</form>
)
}

View file

@ -1,16 +0,0 @@
'use client'
import { ChoroplethMapPanel } from '@/components/campaign/ChoroplethMapPanel'
import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
export const DashboardMap = ({ choropleth }: { choropleth: NucleusChoroplethBundle }) => (
<ChoroplethMapPanel
bundle={choropleth}
idPrefix="dashboard-map"
title="Mapa da campanha"
description="Visão por Território de Identidade dos núcleos ativos e estimativas confirmadas."
ariaLabel="Mapa coroplético da Bahia com agregados dos núcleos ativos"
defaultMode="territory"
defaultMetric="confirmedEstimate"
/>
)

View file

@ -1,14 +0,0 @@
'use client'
import { createCampaignClientDynamic } from '@/components/campaign/CreateCampaignClientDynamic'
import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
const DashboardMap = createCampaignClientDynamic<{ choropleth: NucleusChoroplethBundle }>(
() => import('@/components/campaign/DashboardMap'),
'DashboardMap',
'h-[360px] lg:h-[min(28rem,50vh)]',
)
export const DashboardMapDynamic = ({ choropleth }: { choropleth: NucleusChoroplethBundle }) => (
<DashboardMap choropleth={choropleth} />
)

View file

@ -0,0 +1,75 @@
'use client'
import { useActionState } from 'react'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
type DeclareVotesFormProps = {
plazaID: number
/** Present when staff declare on behalf of a leadership. */
leadershipID?: number
currentDeclaredVotes: number | null
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}
export const DeclareVotesForm = ({
plazaID,
leadershipID,
currentDeclaredVotes,
formAction,
}: DeclareVotesFormProps) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
return (
<form action={submitAction} className="flex flex-col gap-3">
<input type="hidden" name="plazaId" value={plazaID} />
{leadershipID !== undefined ? (
<input type="hidden" name="leadershipId" value={leadershipID} />
) : null}
<Field>
<FieldLabel htmlFor={`declare-votes-${plazaID}-${leadershipID ?? 'own'}`}>
Quantos votos você está trazendo nesta Praça?
</FieldLabel>
<div className="flex gap-2">
<Input
id={`declare-votes-${plazaID}-${leadershipID ?? 'own'}`}
name="declaredVotes"
type="number"
min={0}
max={1000000}
required
inputMode="numeric"
defaultValue={currentDeclaredVotes ?? undefined}
className="min-h-11 w-36"
/>
<Button type="submit" disabled={isPending} className="min-h-11">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{currentDeclaredVotes == null ? 'Declarar' : 'Atualizar'}
</Button>
</div>
{fieldError(state.fieldErrors, 'declaredVotes') ? (
<FieldError>{fieldError(state.fieldErrors, 'declaredVotes')}</FieldError>
) : null}
</Field>
{state.message && state.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
{state.status === 'success' ? (
<Alert>
<AlertDescription>{state.message ?? 'Declaração registrada.'}</AlertDescription>
</Alert>
) : null}
</form>
)
}

View file

@ -0,0 +1,102 @@
'use client'
import { useActionState } from 'react'
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { campaignDemandKindLabels, campaignDemandKinds } from '@/lib/schemas/campaignDemand'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
type DemandFormProps = {
plazaOptions: RelationOption[]
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}
export const DemandForm = ({ plazaOptions, formAction }: DemandFormProps) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
return (
<form action={submitAction} className="flex max-w-2xl flex-col gap-4">
<Field>
<FieldLabel htmlFor="demand-title">O que você precisa?</FieldLabel>
<Input
id="demand-title"
name="title"
required
minLength={2}
maxLength={160}
className="min-h-11"
/>
{fieldError(state.fieldErrors, 'title') ? (
<FieldError>{fieldError(state.fieldErrors, 'title')}</FieldError>
) : null}
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="demand-kind">Tipo</FieldLabel>
<NativeSelect
id="demand-kind"
name="kind"
defaultValue="material"
className="min-h-11 w-full"
>
{campaignDemandKinds.map((kind) => (
<NativeSelectOption key={kind} value={kind}>
{campaignDemandKindLabels[kind]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="demand-plaza">Praça</FieldLabel>
<NativeSelect
id="demand-plaza"
name="plazaId"
required
defaultValue=""
className="min-h-11 w-full"
>
<NativeSelectOption value="" disabled>
Selecione a Praça…
</NativeSelectOption>
{plazaOptions.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.name}
</NativeSelectOption>
))}
</NativeSelect>
{fieldError(state.fieldErrors, 'plaza') ? (
<FieldError>{fieldError(state.fieldErrors, 'plaza')}</FieldError>
) : null}
</Field>
</div>
<Field>
<FieldLabel htmlFor="demand-description">Detalhe a necessidade</FieldLabel>
<Textarea id="demand-description" name="description" rows={4} maxLength={4000} />
{fieldError(state.fieldErrors, 'description') ? (
<FieldError>{fieldError(state.fieldErrors, 'description')}</FieldError>
) : null}
</Field>
{state.message && state.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<Button type="submit" disabled={isPending} className="min-h-11 self-start">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Abrir demanda
</Button>
</form>
)
}

View file

@ -0,0 +1,184 @@
'use client'
import { useActionState } from 'react'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import {
campaignDemandTransitionLabels,
campaignDemandTransitions,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
type FormAction = (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
type DemandWorkflowCardProps = {
demandID: number
status: CampaignDemandStatus
isCoordinator: boolean
currentCost: number | null
transitionFormAction: FormAction
costFormAction: FormAction
receiptFormAction: FormAction
}
const transitionVariant = (target: CampaignDemandStatus) =>
target === 'aprovada' ? 'default' : target === 'rejeitada' ? 'destructive' : 'secondary'
/** Staff-only workflow: analysis, escalation, decision, cost and receipts. */
export const DemandWorkflowCard = ({
demandID,
status,
isCoordinator,
currentCost,
transitionFormAction,
costFormAction,
receiptFormAction,
}: DemandWorkflowCardProps) => {
const [transitionState, submitTransition, transitionPending] = useActionState(
transitionFormAction,
{},
)
const [costState, submitCost, costPending] = useActionState(costFormAction, {})
const [receiptState, submitReceipt, receiptPending] = useActionState(receiptFormAction, {})
const availableTransitions = campaignDemandTransitions[status].filter(
(target) =>
isCoordinator || status !== 'escalada' || (target !== 'aprovada' && target !== 'rejeitada'),
)
return (
<section
aria-labelledby="demand-workflow-title"
className="flex flex-col gap-6 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="demand-workflow-title" className="text-base font-medium">
Tratar demanda
</h2>
<p className="text-sm text-muted-foreground">
O assessor analisa e decide — ou escala ao Coordenador Geral. Demandas escaladas são
decididas somente por ele.
</p>
</div>
{availableTransitions.length ? (
<form action={submitTransition} className="flex flex-col gap-3">
<input type="hidden" name="demandId" value={demandID} />
<Field>
<FieldLabel htmlFor={`demand-note-${demandID}`}>Nota da decisão</FieldLabel>
<Textarea
id={`demand-note-${demandID}`}
name="decisionNote"
rows={2}
maxLength={2000}
placeholder="Contexto do encaminhamento (visível para quem abriu)…"
/>
</Field>
<div className="flex flex-wrap gap-2">
{availableTransitions.map((target) => (
<Button
key={target}
type="submit"
name="status"
value={target}
variant={transitionVariant(target)}
disabled={transitionPending}
className="min-h-11"
>
{transitionPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{campaignDemandTransitionLabels[target]}
</Button>
))}
</div>
{transitionState.message && transitionState.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{transitionState.message}</AlertDescription>
</Alert>
) : null}
{transitionState.status === 'success' ? (
<Alert>
<AlertDescription>{transitionState.message}</AlertDescription>
</Alert>
) : null}
</form>
) : (
<p className="text-sm text-muted-foreground">Esta demanda já foi decidida.</p>
)}
<form action={submitCost} className="flex flex-col gap-2">
<input type="hidden" name="demandId" value={demandID} />
<FieldLabel htmlFor={`demand-cost-${demandID}`}>
Custo estimado (R$) — controle interno
</FieldLabel>
<div className="flex gap-2">
<Input
id={`demand-cost-${demandID}`}
name="cost"
type="number"
min={0}
step="0.01"
inputMode="decimal"
defaultValue={currentCost ?? undefined}
className="min-h-11 w-40"
/>
<Button type="submit" variant="secondary" disabled={costPending} className="min-h-11">
{costPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar custo
</Button>
</div>
{fieldError(costState.fieldErrors, 'cost') ? (
<FieldError>{fieldError(costState.fieldErrors, 'cost')}</FieldError>
) : null}
{costState.message && costState.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{costState.message}</AlertDescription>
</Alert>
) : null}
</form>
<form action={submitReceipt} className="flex flex-col gap-2">
<input type="hidden" name="demandId" value={demandID} />
<FieldLabel htmlFor={`demand-receipt-${demandID}`}>
Anexar comprovante (imagem ou PDF, até 10 MB)
</FieldLabel>
<p className="text-xs text-muted-foreground">
Controle interno de gastos — não substitui a prestação de contas oficial (SPCE/TSE).
</p>
<div className="flex flex-wrap items-center gap-2">
<Input
id={`demand-receipt-${demandID}`}
name="receipt"
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
required
className="min-h-11 max-w-xs"
/>
<Button type="submit" variant="secondary" disabled={receiptPending} className="min-h-11">
{receiptPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Anexar
</Button>
</div>
{receiptState.message && receiptState.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{receiptState.message}</AlertDescription>
</Alert>
) : null}
{receiptState.status === 'success' ? (
<Alert>
<AlertDescription>{receiptState.message}</AlertDescription>
</Alert>
) : null}
</form>
</section>
)
}

View file

@ -1,45 +0,0 @@
'use client'
import { useCallback, useState } from 'react'
import { ActionQueuesCard } from '@/components/campaign/ActionQueuesCard'
import { RecentlyVisitedCard } from '@/components/campaign/RecentlyVisitedCard'
import { UpcomingActionPlansCard } from '@/components/campaign/UpcomingActionPlansCard'
import { cn } from '@/lib/utils'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import type { GeneralDashboardViewModel } from '@/utilities/campaignDashboardViewModels'
import { listRecentVisits } from '@/utilities/recentVisits'
type GeneralDashboardTopRowProps = {
queues: GeneralDashboardViewModel['queues']
upcomingPlans: ActionPlanUpcomingPreviewRecord[]
now: Date
}
export const GeneralDashboardTopRow = ({
queues,
upcomingPlans,
now,
}: GeneralDashboardTopRowProps) => {
const [hasRecentVisits, setHasRecentVisits] = useState(() => listRecentVisits().length > 0)
const handleVisitCountChange = useCallback((count: number) => {
setHasRecentVisits(count > 0)
}, [])
return (
<div className={cn('grid gap-6 lg:grid-cols-6 lg:items-stretch')}>
<section
aria-labelledby="action-queues"
className={cn('h-full', hasRecentVisits ? 'lg:col-span-3' : 'lg:col-span-4')}
>
<ActionQueuesCard queues={queues} />
</section>
<div className="h-full lg:col-span-2">
<UpcomingActionPlansCard plans={upcomingPlans} />
</div>
<div className={cn('h-full min-w-0', hasRecentVisits ? 'lg:col-span-1' : 'contents')}>
<RecentlyVisitedCard now={now} compact onVisitCountChange={handleVisitCountChange} />
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show more