Merge branch 'feat/engineering-standards-hardening'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-24 01:08:54 -03:00
commit 8712ae9181
159 changed files with 11000 additions and 4651 deletions

View file

@ -0,0 +1,49 @@
---
description: Teqo engineering standards — dead-code hygiene, client-boundary composition, caching ladder, typed escape hatches, zero-warning gate. Read before and enforce after ANY code change.
alwaysApply: true
---
# Engineering standards (Teqo)
Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IMPROVE-CODE-QUALITY-PLAN.md`; ledger: `docs/TECH-DEBT.md`; safety net: `docs/TESTING.md`). These are gates, not suggestions — CI enforces most of them.
## After every change (non-negotiable)
1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive.
2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment.
3. **Full gate before handoff:** `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm test` (unit + int, local DB only), `pnpm build`. CI (`.github/workflows/ci.yml`) runs the same on the GitHub mirror.
## Type honesty
- **`as never` is banned** (ESLint `no-restricted-syntax`). Use the typed escape hatches that keep property-level checking: `hookFilledCreateData<Slug>()` for create data whose remaining required fields are filled by collection hooks; tests use `stub<T>(partial)` (`tests/helpers/stub.ts`). Casting from `unknown` after a runtime check is acceptable; a justified `eslint-disable-next-line` is the last resort.
- Prefer fixing the type over casting. When a generic can't be proven, restructure so the caller keeps concrete types (see `runStaffEntityMutation` — policy in the helper, typed mutation in the caller).
## Client boundary (Next.js composition)
- **Server-first.** Pages and data assembly are Server Components; `'use client'` only where there is interactivity (hooks, handlers, browser APIs). Static subtrees of an interactive area are passed as `children`/slots from the server (`LeaderContactsPanel` + `LeaderContactForm`, `CampaignDashboard.mapSlot` are the references).
- **Client components must not import server data modules for values.** Types/constants shared with the client live in a client-safe contract module (`municipalityMapContract.ts` is the reference; `municipalityMapData.ts` is `server-only`). `import type` is always safe.
- **State lives where it is used.** Hover/selection in the leaf that renders it (`MunicipalityMapSelection`); context providers wrap only their consumers (estimate-scenario provider wraps results, not filters). Do not lift state so high that siblings re-render.
- Heavy view payloads: pass view models with `select`ed fields, never whole Payload docs.
## Loading feedback (see also `campanha-action-feedback.mdc`)
- Every navigation-triggering control participates in a transition; the RESULTS region dims (`CampaignListPendingBoundary`/`CampaignListResults`), not just the control. Optimistic UI must revalidate on success (`revalidatePath`) or it will lie by reverting.
- Heavy server subtrees stream behind `<Suspense>` with a local skeleton; heavy route segments get their own `loading.tsx`.
## Caching ladder (decide in this order)
1. **React `cache()`** for per-request dedup of repeated reads in one RSC tree (`loadMunicipalityScope`, `getCampaignUser`).
2. **`unstable_cache` + tag** for immutable/slow-changing cross-request data (election reads use tag `election-tse`; bust via `POST /api/revalidate?tag=...` — allowlist in `revalidateRequest.ts`). Auth checks stay OUTSIDE the cached core.
3. **Committed artifact via re-runnable script** for truly immutable derived data (`pnpm build:election-aggregates` → `src/lib/electionAggregates/`, pinned to the catalog by a snapshot test). Never compute artifacts during `pnpm build` — deploys must not depend on DB content.
4. Never long-cache live 2026 campaign data (pledges, expectedVotes) without a write-path invalidation.
## Access control (see also `security-critical.mdc`)
- **Every collection declares `access` explicitly.** Payload's default ("any authenticated user") is a bug here: campaign JWTs work against `/api/*`. Admin-only collections use `payloadAdminOnly`.
- Access helpers live in `src/utilities/access/` per domain, re-exported through `campaignAccess.ts`. New collections get helpers there plus int tests proving the denied paths.
## Module organization
- `src/lib/` = pure logic and static data (client-safe unless marked). `src/utilities/` = Payload/Next-coupled server code (mark `server-only` when server-bound). React components never live in `utilities/`.
- Follow the domain prefixes (`campaign*`, `municipality*`, `supporter*`, `actionPlan*`); the access domain has a real folder (`utilities/access/`).
- Abstractions need 3+ call sites or a policy worth naming (`runCampaignRedirectFormAction`, `runStaffEntityMutation`); don't build generic services for 2 uses.

68
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,68 @@
# Continuous integration for the GitHub mirror (pushes go to Codeberg + GitHub).
# Runs the same gate as .husky/pre-push plus tests and dead-code analysis.
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
# Matches production Neon major (17) and docker-compose.yml.
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
env:
# Tests load .env.test themselves (database teqo_test on localhost:5432).
# These are only for the migrate step, which reads the process env.
DATABASE_URL: postgresql://teqo:teqo@localhost:5432/teqo_test
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint (zero warnings)
run: pnpm lint
- name: Typecheck
run: pnpm typecheck
- name: Dead code (knip)
run: pnpm exec knip
- name: Unit tests
run: pnpm test:unit
- name: Migrate test database
run: pnpm migrate
- name: Integration tests
run: pnpm test:int

View file

@ -11,6 +11,15 @@
],
"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"
},
{
"rule": "broken-image",
"value": "*",
"files": [
"src/components/MetaPixel.tsx"
],
"createdAt": "2026-07-23T22:40:00.000Z",
"reason": "Meta's documented noscript tracking pixel: a hidden 1x1 <img> whose src is a templated facebook.com/tr URL — not a rendered image"
}
]
}

View file

@ -38,7 +38,7 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si
Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.md`](docs/roadmap.md).
1. **Payload admin RBAC is still absent.** The campaign auth collection now has `role`, but the `users` admin collection still has no roles; every admin user has full Payload admin access. Add admin roles before opening `/admin` to a broader team.
1. **Payload admin roles are still absent (access lockdown shipped 2026-07-23).** Every collection now declares explicit `access` — `users`, `signature`, `subscription`, `consent` are Payload-admin-only, and CMS writes (`post`/`tag`/`media`/`petition`) are admin-only — so campaign JWTs can no longer act on them via `/api`. But the `users` collection still has no `roles` field: every admin user remains omnipotent inside `/admin`. Add admin roles (schema migration) before opening `/admin` to a broader team.
2. **Legacy public consent flows still hardcode document IDs** (e.g. `consent: 2` in `submitWhatsapp.ts`). The campaign leadership flow does not: it requires `Consent.key = 'lideranca-autopreenchimento'` and fails closed when missing. Migrating the older public flows remains separate work.
3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas.
4. **Campaign LGPD consent texts are not yet configured in production.** Invites and self-service leadership flows fail closed until an admin creates `Consent.key = 'lideranca-autopreenchimento'` with counsel-approved copy. The C2 supporter registry (merged to `main`, production-blocked) additionally requires `Consent.key = 'apoiador-cadastro'` and `Consent.key = 'apoiador-intencao-voto'` (vote intention is sensitive data) — same fail-closed behavior. All four keys are bundled in the single legal batch of Onda 0 (see deploy checklist).
@ -59,6 +59,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-23):** **Export CSV de assinaturas e contatos** no admin Payload — `@payloadcms/plugin-import-export@3.82.0` habilitado em `signature` e `contact` (`import: false`, export CSV síncrono); flatten de `contact`/`petition` via `toCSV` em [`src/utilities/signatureExport.ts`](src/utilities/signatureExport.ts) (só assinaturas); `consent` excluído do export de assinaturas; collections `exports`/`imports` com access `isPayloadAdmin`; migration `20260723_025513_add_import_export_plugin`. Plano: [`docs/plans/exportar-csv-assinaturas.md`](docs/plans/exportar-csv-assinaturas.md).
**Recently resolved (2026-07-23):** **Engineering-standards hardening** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md); ledger: [`docs/TECH-DEBT.md`](docs/TECH-DEBT.md); safety net: [`docs/TESTING.md`](docs/TESTING.md); standards rule: `.cursor/rules/engineering-standards.mdc` — READ IT before coding). Phases: (0) explicit `access` on every collection — `users`/`signature`/`subscription`/`consent` admin-only, CMS writes admin-only, leader route gates on `demandas*`/`planos*`, `createLeaderSupporter` scope fixed, `candidate` treated as unrestricted staff consistently; (1) tooling gate — knip in CI (`.github/workflows/ci.yml` on the GitHub mirror), `pnpm lint --max-warnings=0`, `as never` banned (typed helpers `hookFilledCreateData` / tests' `stub<T>`), tsconfig include consolidated + `noUnusedLocals`, ~12 dead modules deleted; (2) caching — committed TSE aggregate artifact (`pnpm build:election-aggregates` → `src/lib/electionAggregates/`, loader `bahiaElectionAggregates.ts`), `loadMunicipalityScope` React `cache()` dedup, `unstable_cache` + `election-tse` tag on elections-tab loaders; (3) "Feel the action" gaps closed — task-toggle revalidation bug, shared `CampaignListPendingBoundary` dimming results, map-compare transition, dashboard map + detail tabs stream behind Suspense; (4) state scoping — map hover isolated in `MunicipalityMapSelection`, scenario provider narrowed, `LeaderContactsPanel` server-rendered with a client form island; (5) structure — `campaignAccess` split into `src/utilities/access/*` (re-exported), supporter actions split (`supporterImport.ts`), `runStaffEntityMutation` + `runCampaignRedirectFormAction` dedupe the org/dobradinha twins and 4 formActions ladders, Plaza→Municipality identifier rename finished.
**Recently resolved (2026-07-23):** **A10** Cenários de estimativa (pessimista/média/otimista) — `votePledge.estimatedVotes` e `municipality.expectedVotes` viram groups (`pessimistic|central|optimistic`); migration `20260723_124200_add_vote_estimate_scenarios` (backfill scalar→`central`); agregação por cenário em `votePledgeData` com default `central`; mapa 2026 com `values2026ByScenario` + seletor local; lista/overview/dashboard com média + faixa secundária; liderança continua com um `declaredVotes` sem ver estimativas. Plano: [`docs/plans/cenarios-estimativa-votos.md`](docs/plans/cenarios-estimativa-votos.md). Desbloqueia **E8**.
## Posts & Tags (news / publications)
@ -98,7 +100,7 @@ curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
- **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer <secret>`; it is compared to `REVALIDATE_SECRET` with a constant-time check.
- `REVALIDATE_SECRET` must be set in the Vercel **production** env (and in your local env if you want to test the endpoint). It is documented in `.env.example`.
- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`. Unknown tag → `400`.
- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`, `election-tse` (bust after re-running `pnpm db:seed:tse` so the cached elections-tab reads refresh). Unknown tag → `400`.
- **Responses:** `200 { revalidated: true, tag: '...' }` on success; `401` if the secret is missing/wrong; `500` if `REVALIDATE_SECRET` is not configured on the server.
- This POST is the required last step of the post-seed / direct-DB-change runbook.
@ -152,7 +154,7 @@ Static Bahia map foundation for future Leaflet surfaces (roadmap B2; UI is B3).
- **Artifacts (committed):** `src/lib/geometries/bahia-municipalities.topo.json` (~132 KB; Topology object `municipalities`, 417 features, `properties: { codarea, name }`) and `src/lib/geometries/bahia-identity-territories.topo.json` (~15 KB; object `territories`, 27 features, `properties: { code, name }`). Soft size budget in tests: ≤ ~600 KB each.
- **Code table:** `src/lib/bahiaMunicipalityCodes.ts` — canonical municipality name → IBGE 7-digit `codarea`, plus `codeForMunicipality` / `municipalityForCode`. Fixture `tests/fixtures/bahia-municipality-codes.official.json` + int tests for bijection/coverage.
- **Runtime helpers:** `src/lib/bahiaGeometries.ts` — `getMunicipalityFeature` / `getTerritoryFeature`, typed topologies, feature arrays. Eagerly decodes TopoJSON→GeoJSON at module load (lazy split is B5 F1 — prefer with B3).
- **Runtime helpers:** `src/lib/bahiaGeometries.ts` — `getMunicipalityFeature` / `getTerritoryFeature`, typed topologies, feature arrays. TopoJSON→GeoJSON decode is LAZY (dynamic import via `loadMunicipalityGeometryModule`; B5 F1 done) — the default `/campanha` server graph never pays it.
- **Territory polygons:** dissolved from IBGE municipality meshes via `bahiaIdentityTerritoryRecords` (`topojson` `merge`), not the IDE Bahia shapefile (IDE remains provenance/validation reference only).
- **Rebuild:** `pnpm build:geometries` (`scripts/build-bahia-geometries.mjs`) downloads IBGE Malhas (qualidade intermediaria) + Localidades, reconciles names with `canonicalizeMunicipalityName`, simplifies/quantizes, emits the artifacts above. Cache under `data/geometries/` (gitignored; override with `GEOMETRIES_CACHE_DIR`). **Does not touch the database** — no `assertLocalDatabase` / no Neon risk. Not part of `pnpm build` or `pnpm dev`. Details: `docs/plans/mapa-bahia-geometrias.md`. Scale/DRY follow-ups from `/simplify`: `docs/plans/escala-dry-pos-b2.md` (B5).
@ -166,7 +168,7 @@ Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Thr
1. Confirm `DATABASE_URL` is `postgresql://teqo:teqo@localhost:5432/teqo` and `.env.test` points to `teqo_test`; never use Neon for development, tests, E2E, or local builds.
2. Run `pnpm db:start`, `pnpm migrate:status`, and `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status`; every migration must show `Ran: Yes`.
3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed, `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm test` (unit + int), `pnpm test:e2e` (or `pnpm test:all`), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js.
3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm test` (unit + int), `pnpm test:e2e` (or `pnpm test:all`), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js.
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.
@ -197,7 +199,7 @@ src/
│ ├── (frontend)/ # Frontend routes + server actions (src/app/(frontend)/actions/*.ts)
│ ├── (payload)/ # Payload admin routes
│ └── (campaign)/ # Internal campaign-management area (auth barrier, see "Campaign auth")
├── collections/ # Users, CampaignUser, CampaignInvite, ElectoralNucleus, Leadership, NucleusUpdate, Supporter, SupporterImportBatch, ActionPlan, ElectionTally, ElectionCandidateVote, ElectionCandidate, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag
├── collections/ # Users, CampaignUser, CampaignInvite, Municipality, Leadership, VotePledge, MunicipalityUpdate, Organization, StateDeputy, CampaignDemand, Supporter, SupporterImportBatch, ActionPlan, ElectionTally, ElectionCandidateVote, ElectionCandidate, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag
├── globals/ # Global configs (SiteSettings, HomePage, Metadata)
├── components/ # Custom React components (campaign/*, ui from shadcn)
├── lib/ # cities.ts, bahiaTerritories.ts, bahiaTseZones.ts, bahiaMunicipalityCodes.ts, bahiaGeometries.ts, geometries/*.topo.json, territorySuggestions.ts, formData.ts, zod schemas (lib/schemas/*)

View file

@ -0,0 +1,51 @@
# Improve Code Quality Plan
## Context
- **Started:** 2026-07-23
- **Trigger:** full-codebase engineering-standards review (five parallel audits: structure/dead code, RBAC, client boundary/state, loading UX, caching) of a rapidly built ("vibe coded") codebase.
- **Scope:** whole repo; production is live (Neon Postgres with real citizens' PII), so security ordered first.
- **Method:** `improve-code-quality` journey, compressed into the seven delivery phases below (mapping to the skill's phases noted per row). The audits replaced a separate intake; the existing test suite (238 unit + 345 int + 5 e2e specs) is the safety net.
- **Companion docs:** [TECH-DEBT.md](TECH-DEBT.md) (ledger), [TESTING.md](TESTING.md) (safety-net map), [roadmap.md](roadmap.md) (canonical product/engineering backlog — larger debts become roadmap items per the `capture-review-debts` convention).
## Phase Status
| Phase | Content | Skill phases | Status | Date |
| ----- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------ | ---------- |
| 0 | Security lockdown: collection access defaults, leader route gates, broken leader-supporter create, candidate alignment | — (P0 pre-work) | done | 2026-07-23 |
| 1 | Tooling gate: knip + dead code, CI, tsconfig, zero lint warnings, `as never` ban, docs artifacts | 1 (net) + 2 (readability) + 6 (habits) | done | 2026-07-23 |
| 2 | Caching: React `cache()` dedup, committed election-aggregate artifact, `unstable_cache`, select/depth trims | 8 (sizing) | done | 2026-07-23 |
| 3 | Loading feedback: task-toggle revalidation bug, map compare transition, result-region pending, Suspense streaming | — (UX correctness) | done | 2026-07-23 |
| 4 | Client boundary/state: map hover isolation, provider narrowing, composition splits (bundle slimming deferred with rationale) | 4 (deep modules, UI) | done | 2026-07-23 |
| 5 | Structure: split campaignAccess + supporter actions, dedupe clones, utilities organization, finish rename | 3 (refactoring) + 4 (deep modules) | done | 2026-07-24 |
| 6 | Documentation: engineering-standards rule, AGENTS.md drift fixes, debt registration | 6 (habits) | done | 2026-07-24 |
Statuses: pending · in-progress · awaiting-evidence · done · deferred: \<reason\> · skipped: \<reason\>
## Key Decisions
| Date | Phase | Decision | Rationale |
| ---------- | ----- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| 2026-07-23 | 0 | Collection access lockdown before anything else | Payload defaults allowed any authenticated user (incl. campaign JWTs) to CRUD `users`/`signature`/`subscription`/`consent` via `/api` — live PII |
| 2026-07-23 | 0 | Keep pinned test contracts; stale tests updated, not silently changed code | Two leader-phone tests encoded the pre-remodel model; code was right, tests were stale |
| 2026-07-23 | 1 | `as never` banned repo-wide (ESLint `no-restricted-syntax`) | It silences all type checking; replaced by typed helpers (`hookFilledCreateData`, tests' `stub<T>`) that keep property-level checking |
| 2026-07-23 | 1 | Lint gate is zero warnings (`--max-warnings=0`) | Warnings that never fail teach everyone to ignore the linter |
| 2026-07-23 | 1 | knip: files/dependencies = error; unused exports = warn (ledgered) | 158 export findings are dominated by god modules Phase 5 splits; failing CI on them day one would freeze the repo |
| 2026-07-23 | 1 | CI on the GitHub mirror (`.github/workflows/ci.yml`) | Pushes go to Codeberg + GitHub; GitHub Actions runs on the mirror with a Postgres 17 service |
| 2026-07-23 | 2 | Immutable TSE aggregates become a committed artifact (script-generated), not build-time computation | Vercel builds must not depend on prod DB content (TSE seeds are local-only); provenance stays explicit like `build:geometries` |
| 2026-07-23 | 2 | Salvador is the only zone-split municipality in the artifact | 2026-07-23 remodel: Camaçari is a single municipality (ZE 170/171 aggregated) |
| 2026-07-23 | 4 | Map-bundle slimming deferred | gzip crushes the repeating JSON keys to ~15-25KB on the wire; instant year/scenario switching is a core analysis flow (discovery kernel) |
| 2026-07-23 | 4 | Props→state effect mirrors accepted as correct | guarded-equality sync is the deliberate server-echo/local-draft reconciliation (A10 pattern), not derived state |
| 2026-07-23 | 5 | Entity twins deduped by POLICY, not by generic data plumbing | `runStaffEntityMutation` keeps callers' concrete Payload types; a generic slug-union factory could not be type-proven |
## Next Actions
- [x] Phase 0 shipped (`fe0fb1c`)
- [x] Phase 1 — tooling gate (`1f73626`)
- [x] Phase 2 — caching (`a9b111b`)
- [x] Phase 3 — loading feedback (`3323275`)
- [x] Phase 4 — client boundary (`c4ec15f`)
- [x] Phase 5 — structure (`524ae57`)
- [x] Phase 6 — documentation (`deca5c0`)
- [x] E2E-driven fixes: leader home Forbidden, leader contact visibility, duplicate nav keys, RSC function-prop contracts, fixed e2e dist dir (`cb6c152`)
- [ ] Remaining leftovers live in [TECH-DEBT.md](TECH-DEBT.md) Debt Ledger (drive knip `exports` warn→error, consent-window lease redesign, e2e local-latency flake, users roles migration)

57
docs/TECH-DEBT.md Normal file
View file

@ -0,0 +1,57 @@
# Tech Debt
Ledger of known engineering debt. Items large enough to be scheduled work become entries in [roadmap.md](roadmap.md) (linked in the Ref column); this file tracks the full inventory so nothing is lost. Sourced initially from the 2026-07-23 engineering-standards audits.
## Debt Ledger
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ----------------------------------------------------------------------------------------- | -------------------- |
| 158 unused exports reported by knip (warn-level) | god modules, ui kit families | dead code | low | M | P2 | open — drive `exports` rule to error after splits | Phase 5 |
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | open | AGENTS Known Gaps #1 |
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/campaignAccess.ts` | info disclosure | low | S | P2 | open | |
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | open | AGENTS Known Gaps #2 |
| E2E suite thin (5 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
| E2E campaign journeys latency-flake on loaded local machines (5s expect / 30s test budgets vs cold dev compiles; suite green with CI retries) | `tests/e2e/campaignMunicipalities.e2e.spec.ts` | test-infra | low | S | P3 | open — consider per-step budgets or a warmed dist dir | |
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | planned (Phase 2) | |
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
| Map bundle serializes all years × scenarios (~100KB raw) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~15-25KB on the wire; instant year/scenario switching is a core analysis flow worth the bytes | M | P3 | deferred 2026-07-23 with rationale (revisit only if RSC payload profiling says otherwise) | |
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
## Smell Inventory
| Smell | Location | Refactoring | Status |
| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------- | ------------------- |
| God module: RBAC for every collection in one file (964 lines, ~80 exports) | `src/utilities/campaignAccess.ts` | split per domain under `src/utilities/access/` | planned (Phase 5) |
| God module: ~71 exports of electoral math | `src/lib/electionInsights.ts` | split by concern when next touched | ledgered |
| Multi-feature action bag: CRUD + CSV import pipeline + LGPD in one file (722 lines) | `src/app/(campaign)/campanha/actions/supporter.ts` | split CRUD vs import | planned (Phase 5) |
| Clone twins: create/update/staff-assert/unique-conflict skeleton | `actions/organization.ts` ↔ `actions/stateDeputy.ts` | extract shared factory | planned (Phase 5) |
| 13 near-identical `formActions.ts` try/catch→mapError→redirect ladders | `src/app/(campaign)/campanha/(app)/*/formActions.ts` | shared form-action wrapper | planned (Phase 5) |
| Flat 80+-file `src/utilities/` junk drawer; `lib/` vs `utilities/` boundary undocumented | `src/utilities/`, `src/lib/` | document convention + move misfiled modules | planned (Phase 5/6) |
| Praça→Município rename leftovers in identifiers (`PLAZA_MAP_YEARS`, `ACCESSIBLE_PLAZA_IDS_CONTEXT_KEY`, …) | map/list utilities | finish rename | planned (Phase 5) |
| Props→state `useEffect` mirrors | `MunicipalityListExpectedVotesControl`, comboboxes | key-remount or fully controlled | planned (Phase 4) |
| Detail-page RSCs mixing loading + tab UI + forms (300+ lines) | `municipios/[slug]/page.tsx`, `planos/[slug]/page.tsx` | extract tab loaders/sections | ledgered |
## Sprout / Wrap Register
| Host | Sprouted/Wrapped | Notes |
| ---------- | ---------------- | ----- |
| (none yet) | | |
## Adopted Conventions
- **Zero lint warnings.** `pnpm lint` runs with `--max-warnings=0`; deliberate placeholders use a `_` prefix (`argsIgnorePattern`).
- **`as never` is banned** (ESLint `no-restricted-syntax`). Production code uses typed helpers (e.g. `hookFilledCreateData` for hook-completed create data); tests use `stub<T>(partial)` which keeps property-level checking. A justified `eslint-disable-next-line` is the only escape hatch.
- **Dead code is deleted, not commented.** knip runs in CI (files/dependencies at error level). After any change: `pnpm exec knip` and remove what died.
- **Collections always declare `access` explicitly.** Payload's default ("any authenticated user") is treated as a bug — see `payloadAdminOnly`.
- **Generated migration signatures are exempt** from unused-args linting (`src/migrations/**` override); never hand-edit migration content for lint cosmetics.
- **Shared consent fixture rows are leased, never owned** in integration tests (`tests/helpers/testDatabaseLease.ts`); deleting them steals from parallel spec files.
## Debt Budget & Broken-Windows Policy
- New warnings/`as never`/unused exports fail or get ledgered the same day — no untracked `// TODO`s.
- Each delivery that runs `/simplify` registers surviving debts here or in `docs/plans/escala-dry-pos-*.md` (existing convention), whichever the item's size warrants.

32
docs/TESTING.md Normal file
View file

@ -0,0 +1,32 @@
# Testing
## Test Strategy
Three layers, all local-database-only (see `local-database` skill; tests refuse any database not ending in `_test`):
- **Unit** (`tests/unit/**/*.unit.spec.ts`, 33+ specs): pure logic; runs with a deliberately invalid `DATABASE_URL` so nothing can touch a database.
- **Integration** (`tests/int/**/*.int.spec.ts`, 42+ specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
- **E2E** (`tests/e2e`, Playwright, 5 specs): smoke-level; most campaign behavior is pinned at the int layer.
CI (`.github/workflows/ci.yml`) runs lint (zero warnings), typecheck, knip, unit, migrate, int against a Postgres 17 service.
## Safety Net Map
Modules the 2026-07 hardening phases touch, and where their behavior is pinned:
| Module | Pinned behaviors | Test files | Gaps |
| ------ | ---------------- | ---------- | ---- |
| `campaignAccess.ts` (RBAC) | per-role municipality/leadership/pledge/supporter/demand/plan scoping; leader lockdown; staff field asymmetry | `campaignMunicipalityAccess`, `campaignVotePledge`, `campaignSupporter`, `campaignUserPhone`, `campaignActionPlan`, `campaignDemandWorkflow`, `collectionAccessLockdown` (int) | no dedicated per-export suite; covered through collection behavior |
| Collection access lockdown | campaign JWT denied on `users`/PII/CMS writes; anonymous read of public content; admin unaffected | `collectionAccessLockdown.int.spec.ts` | REST-transport variant untested (Local API with `overrideAccess: false` is equivalent) |
| `municipalityMapData.ts` | bundle scoping by URL filters + advisor access; `validVotesByYear` incl. 2026 reuse of 2022; null on empty scope | `municipalityMapData.int.spec.ts` | zone breakdown shape |
| `votePledgeData.ts` | `estimated[scenario] ?? declared` aggregation; role scoping; leader denial | `campaignVotePledge.int.spec.ts` | |
| Supporter import (token/bulk) | preview→confirm flow, dedup by phone, consent fail-closed | `campaignSupporter.int.spec.ts` | `supporterImportBulk`/`supporterImportToken` internals only via flow |
| Leader supporter create | linked-municipality scope; out-of-scope rejection; staff rejection | `collectionAccessLockdown.int.spec.ts` | |
| Posts visibility/caching | `isPostVisible` fail-closed; cached listing | (indirect via routes) | no dedicated `posts` spec — backlog |
## Characterization Backlog
- [ ] `src/utilities/posts.ts` — dedicated spec for `isPostVisible` fail-closed matrix and cache wrappers.
- [ ] `actionPlanViewModels.ts` + other `actionPlan*` utilities (~1k lines) — currently untested directly.
- [ ] `campaignDemandData.ts` / `leadershipData.ts` / `organizationData.ts` / `stateDeputyData.ts` list loaders.
- [ ] Election aggregate artifact (Phase 2): snapshot test pinning artifact ↔ `municipalityCatalog` key consistency.

View file

@ -246,9 +246,13 @@ Paralelizáveis agora: **E8** (pós-A10), fill-ins (O0+, RS+, …). **D3** (baix
## Admin Payload
- `roles` em `users` + access real antes de ampliar `/admin`
- `roles` em `users` + access real antes de ampliar `/admin` — access explícito por collection SHIPPED 2026-07-23 (hardening Fase 0); falta apenas o campo `roles` (migration)
- Seed reproduzível de `Consent` por chave (não por ID)
## Débitos de engenharia (hardening 2026-07-23)
- Ledger vivo em [`TECH-DEBT.md`](TECH-DEBT.md) (tracker: [`IMPROVE-CODE-QUALITY-PLAN.md`](IMPROVE-CODE-QUALITY-PLAN.md)); itens maiores: dirigir `knip` unused-exports de warn→error após os splits, redesenho do lease da janela de consent ausente nos testes, e2e fino, CI só no mirror GitHub
## Plataforma white-label
- Fase 2 do README (multi-tenant / marca por mandato) — **depois da eleição**

View file

@ -141,6 +141,40 @@ const eslintConfig = [
'local/use-server-async-exports': 'error',
},
},
{
// Underscore-prefixed bindings are deliberate placeholders (unused action
// state, ignored tuple slots); everything else unused is dead code.
files: ['**/*.{js,mjs,jsx,ts,tsx}'],
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
// `as never` silences every type check on the expression (worse than
// `any`). Fix the types instead; a justified eslint-disable comment is
// the only sanctioned escape hatch.
'no-restricted-syntax': [
'error',
{
selector: 'TSAsExpression > TSNeverKeyword',
message:
'Do not cast with `as never` — it disables type checking entirely. Fix the types or use a narrowly-typed helper.',
},
],
},
},
{
// Payload generates migration signatures with unused destructured args.
files: ['src/migrations/**/*.ts'],
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }],
},
},
prettier,
// Override default ignores of eslint-config-next.
globalIgnores([
@ -149,6 +183,9 @@ const eslintConfig = [
'out/**',
'build/**',
'next-env.d.ts',
// Playwright artifacts (generated on e2e failures/reports):
'playwright-report/**',
'test-results/**',
]),
]

31
knip.json Normal file
View file

@ -0,0 +1,31 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": [
"src/payload.config.ts",
"src/migrations/index.ts",
"src/app/(payload)/admin/importMap.js",
"scripts/*.mjs",
"eslint-local-rules.mjs"
],
"project": ["src/**/*.{ts,tsx,js}", "scripts/**/*.mjs"],
"ignore": ["src/payload-types.ts", "src/migrations/**"],
"paths": {
"server-only": ["tests/helpers/serverOnly.ts"]
},
"ignoreBinaries": ["playwright"],
"ignoreDependencies": [
"tsx",
"sharp",
"tailwindcss-animate",
"tw-animate-css",
"shadcn",
"eslint-config-next",
"prettier"
],
"rules": {
"exports": "warn",
"types": "warn",
"enumMembers": "warn",
"duplicates": "warn"
}
}

View file

@ -29,6 +29,7 @@
"db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs",
"db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs",
"build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs",
"build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs",
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
"build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
@ -38,7 +39,9 @@
"migrate": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate",
"migrate:create": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate:create",
"migrate:status": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate:status",
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint --cache --cache-location .next/cache/eslint/",
"knip": "knip",
"knip:production": "knip --production",
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint --cache --cache-location .next/cache/eslint/ --max-warnings=0",
"lint:fix": "cross-env NODE_OPTIONS=--no-deprecation eslint --fix",
"payload": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
@ -62,10 +65,6 @@
"@payloadcms/richtext-lexical": "3.82.0",
"@payloadcms/storage-vercel-blob": "3.82.0",
"@payloadcms/ui": "3.82.0",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"class-variance-authority": "^0.7.1",
@ -101,6 +100,7 @@
"@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.21",
"@types/node": "22.19.9",
"@types/pg": "^8.10.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
@ -117,6 +117,8 @@
"eslint-plugin-check-file": "^3.3.1",
"husky": "^9.1.7",
"jsdom": "28.0.0",
"knip": "^6.27.0",
"pg": "^8.22.0",
"postcss": "^8.5.6",
"prettier": "^3.4.2",
"schema-dts": "^2.0.0",
@ -126,7 +128,6 @@
"tsx": "4.21.0",
"typescript": "npm:@typescript/typescript6@6.0.2",
"typescript-7": "npm:typescript@7.0.2",
"typescript-eslint": "^8.64.0",
"vite-tsconfig-paths": "6.0.5",
"vitest": "4.0.18",
"yauzl": "^3.4.0"

View file

@ -9,6 +9,13 @@ import { assertTestDatabase } from './tests/helpers/assertTestDatabase'
* `override: true` guarantees the test DB wins over anything in the shell.
*/
loadEnv({ path: '.env.test', override: true })
// Optional local override when the default Docker port is occupied (e.g. mapped
// to 5433) — same escape hatch vitest.setup.ts documents.
if (process.env.TEQO_TEST_DATABASE_URL) {
process.env.DATABASE_URL = process.env.TEQO_TEST_DATABASE_URL
}
assertTestDatabase(process.env.DATABASE_URL)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const webServerPort = new URL(baseURL).port || (baseURL.startsWith('https:') ? '443' : '80')
@ -77,9 +84,12 @@ export default defineConfig({
env: {
DATABASE_URL: process.env.DATABASE_URL as string,
PORT: webServerPort,
// A prior Playwright process can finish tearing down after the next starts.
// Per-process output prevents that cleanup from deleting the active server's files.
NEXT_DIST_DIR: `.next/e2e-${process.pid}`,
// Fixed dist dir: Next dev appends `<distDir>/types/**/*.ts` to
// tsconfig.json's include with an EXACT-string check, so per-process
// names polluted tsconfig with one entry per run. A prior Playwright
// process tearing down late can, rarely, delete files of the next run's
// server — if a boot flakes, rerun (dev-only trade-off).
NEXT_DIST_DIR: '.next/e2e',
PAYLOAD_SECRET: process.env.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production',
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL ?? baseURL,
},

File diff suppressed because it is too large Load diff

View file

@ -130,7 +130,7 @@ const buildDemographicsByCode = ({ ageCategoryRows, femaleRows, medianRows }) =>
current.medianAge = Number.isFinite(median) ? median : null
}
for (const [code, record] of byCode.entries()) {
for (const record of byCode.values()) {
record.population = Object.values(record.ageBands).reduce((sum, value) => sum + value, 0)
record.sexShareFemale =
record.population > 0 ? record.femalePopulation / record.population : 0

View file

@ -0,0 +1,111 @@
/**
* Builds the committed per-municipality election baseline artifact consumed by
* the campaign map (src/lib/electionAggregates/bahia-federal-baseline.json).
*
* Why an artifact instead of runtime queries: the 2014/2018/2022 TSE results
* are immutable, but the map used to re-scan statewide city×zone slices on
* every dashboard visit. Precomputing the per-municipality sums (Solla nominal
* votes + federal T1 valid votes, cut by the same `municipalityCatalog`
* geography used at runtime — Salvador split into zone municipalities, every
* other municipality whole) removes all historical-year DB work.
*
* Why not compute during `pnpm build`: Vercel builds must not depend on the
* production database CONTENT (TSE seeds are local-only by policy), and the
* artifact should only change when the electoral scope deliberately changes.
*
* Prerequisite: `pnpm db:seed:tse` (2022) + `--year=2018` + `--year=2014` on
* the LOCAL database.
*
* Usage:
* pnpm build:election-aggregates
*
* Safety: read-only against the local database (assertLocalDatabase guard);
* writes only src/lib/electionAggregates/.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { config as loadEnv } from 'dotenv'
import { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs'
loadEnv({ path: '.env.local' })
loadEnv({ path: '.env' })
const payloadConfig = (await import('../src/payload.config.ts')).default
const { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } = await import(
'../src/lib/electionResults.ts'
)
const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts')
const { municipalityElectionGeography } = await import(
'../src/utilities/municipalityElectionGeography.ts'
)
const {
loadCandidateVotesByCityZone,
loadValidVotesByCityZone,
sumVotesForGeography,
} = await import('../src/utilities/municipalityElectoralBaseline.ts')
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const OUTPUT_DIR = join(ROOT, 'src', 'lib', 'electionAggregates')
const OUTPUT_FILE = join(OUTPUT_DIR, 'bahia-federal-baseline.json')
assertLocalDatabase(
'build:election-aggregates',
'This script only reads the LOCAL seeded TSE data (pnpm db:seed:tse).',
)
const payload = await getPayload({ config: payloadConfig })
/** CLI reader — satisfies the election-data read assertion (admin realm). */
const cliReader = { collection: 'users' }
const candidateNumber = BASELINE_TICKET_2022.candidate.candidateNumber
const municipalities = {}
for (const entry of municipalityCatalog) {
municipalities[entry.slug] = { votesByYear: {}, validVotesByYear: {} }
}
for (const year of HISTORICAL_SERIES_YEARS) {
const [candidateVotes, validVotes] = await Promise.all([
loadCandidateVotesByCityZone(payload, cliReader, { year, candidateNumber }),
loadValidVotesByCityZone(payload, cliReader, { year }),
])
if (candidateVotes.size === 0) {
console.error(
`[build:election-aggregates] No nominal votes found for ${year}. ` +
`Seed the local database first: pnpm db:seed:tse -- --year=${year}`,
)
process.exit(1)
}
let yearTotal = 0
for (const entry of municipalityCatalog) {
const geography = municipalityElectionGeography(entry)
const votes = sumVotesForGeography(candidateVotes, geography)
const valid = sumVotesForGeography(validVotes, geography)
municipalities[entry.slug].votesByYear[String(year)] = votes
municipalities[entry.slug].validVotesByYear[String(year)] = valid
yearTotal += votes
}
console.log(`[build:election-aggregates] ${year}: ${yearTotal} votes for #${candidateNumber}.`)
}
const artifact = {
provenance:
'Derived from TSE open data seeded by pnpm db:seed:tse; regenerate with pnpm build:election-aggregates.',
candidateNumber,
candidateName: BASELINE_TICKET_2022.candidate.name,
years: [...HISTORICAL_SERIES_YEARS],
municipalities,
}
await mkdir(OUTPUT_DIR, { recursive: true })
await writeFile(OUTPUT_FILE, `${JSON.stringify(artifact, null, 2)}\n`)
console.log(
`[build:election-aggregates] Wrote ${OUTPUT_FILE} (${municipalityCatalog.length} municipalities × ${HISTORICAL_SERIES_YEARS.length} years).`,
)
process.exit(0)

View file

@ -1,214 +0,0 @@
#!/usr/bin/env node
/**
* One-shot mechanical rename: plaza → municipality across src/ and tests/.
* Excludes frozen historical migration artifacts.
*/
import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs'
import { dirname, join, relative } from 'path'
const ROOT = join(import.meta.dirname, '..')
const EXCLUDE_PATH_FRAGMENTS = [
'src/migrations/data/plazaCatalog20260721.ts',
'src/migrations/20260721_020109_remodel_plazas.ts',
'src/migrations/20260721_020109_remodel_plazas.json',
'src/migrations/20260721_133444_add_plaza_expected_votes.ts',
'src/migrations/20260721_133444_add_plaza_expected_votes.json',
'node_modules/',
'.git/',
'scripts/rename-plaza-to-municipality.mjs',
]
const shouldExclude = (relPath) => EXCLUDE_PATH_FRAGMENTS.some((frag) => relPath.includes(frag))
const renamePathSegment = (segment) =>
segment
.replace(/PlazaUpdate/g, 'MunicipalityUpdate')
.replace(/plazaUpdate/g, 'municipalityUpdate')
.replace(/Plaza/g, 'Municipality')
.replace(/plaza/g, 'municipality')
.replace(/^pracas$/, 'municipios')
function walk(dir, files = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
const rel = relative(ROOT, full)
if (shouldExclude(rel)) continue
const st = statSync(full)
if (st.isDirectory()) {
if (entry === 'node_modules' || entry === '.git') continue
walk(full, files)
} else if (/\.(ts|tsx|js|jsx|json|md|mdc)$/.test(entry)) {
files.push(full)
}
}
return files
}
// Rename directories bottom-up
function collectDirs(dir, dirs = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
const rel = relative(ROOT, full)
if (shouldExclude(rel)) continue
if (statSync(full).isDirectory()) {
collectDirs(full, dirs)
if (/plaza|Plaza|pracas/i.test(entry)) dirs.push(full)
}
}
return dirs
}
const dirRenames = collectDirs(join(ROOT, 'src'))
.concat(collectDirs(join(ROOT, 'tests')))
.sort((a, b) => b.length - a.length)
for (const dir of dirRenames) {
const parent = dirname(dir)
const base = renamePathSegment(dir.split('/').pop())
const target = join(parent, base)
if (dir !== target) {
mkdirSync(dirname(target), { recursive: true })
try {
renameSync(dir, target)
console.log('DIR', relative(ROOT, dir), '->', relative(ROOT, target))
} catch (e) {
// already renamed
}
}
}
// Rename files
const allFiles = walk(join(ROOT, 'src')).concat(walk(join(ROOT, 'tests')))
const fileRenames = allFiles
.filter((f) => /plaza|Plaza|pracas/i.test(f.split('/').pop()))
.sort((a, b) => b.length - a.length)
for (const file of fileRenames) {
const parent = dirname(file)
const base = renamePathSegment(file.split('/').pop())
const target = join(parent, base)
if (file !== target) {
try {
renameSync(file, target)
console.log('FILE', relative(ROOT, file), '->', relative(ROOT, target))
} catch {
/* noop */
}
}
}
const REPLACEMENTS = [
['/campanha/pracas', '/campanha/municipios'],
["collection: 'plazaUpdate'", "collection: 'municipalityUpdate'"],
["collection: 'plaza'", "collection: 'municipality'"],
["relationTo: 'plazaUpdate'", "relationTo: 'municipalityUpdate'"],
["relationTo: 'plaza'", "relationTo: 'municipality'"],
['slug: plazaUpdate', 'slug: municipalityUpdate'],
['slug: plaza', 'slug: municipality'],
["'plazaUpdate'", "'municipalityUpdate'"],
["'plaza'", "'municipality'"],
['PlazaUpdate', 'MunicipalityUpdate'],
['plazaUpdate', 'municipalityUpdate'],
['PlazaEstimateScenario', 'MunicipalityEstimateScenario'],
['plazaEstimateScenario', 'municipalityEstimateScenario'],
['PlazaZoneNeighborhoods', 'MunicipalityZoneNeighborhoods'],
['plazaZoneNeighborhoods', 'municipalityZoneNeighborhoods'],
['plaza-zone-neighborhoods', 'municipality-zone-neighborhoods'],
['plaza-catalog', 'municipality-catalog'],
['plazaStaffFormActions', 'municipalityStaffFormActions'],
['plazaStaffEditMessages', 'municipalityStaffEditMessages'],
['plazaStaffVoteTotal', 'municipalityStaffVoteTotal'],
['StaffPlazaVotes', 'StaffMunicipalityVotes'],
['aggregatePledgesByPlaza', 'aggregatePledgesByMunicipality'],
['loadPlazaListPageBundle', 'loadMunicipalityListPageBundle'],
['loadPlazaMapBundle', 'loadMunicipalityMapBundle'],
['buildPlazaMapBundleFromPlazas', 'buildMunicipalityMapBundleFromMunicipalities'],
['buildPlazaMapBundle', 'buildMunicipalityMapBundle'],
['buildPlazaListWhere', 'buildMunicipalityListWhere'],
['shouldUpdatePlazaSearchUrl', 'shouldUpdateMunicipalitySearchUrl'],
['buildPlazaFiltersKey', 'buildMunicipalityFiltersKey'],
['getAccessiblePlazaIds', 'getAccessibleMunicipalityIds'],
['canAssignPlazaAdvisors', 'canAssignMunicipalityAdvisors'],
['canManagePlazaAdvisors', 'canManageMunicipalityAdvisors'],
['canCreatePlaza', 'canCreateMunicipality'],
['canDeletePlaza', 'canDeleteMunicipality'],
['canReadPlaza', 'canReadMunicipality'],
['canUpdatePlaza', 'canUpdateMunicipality'],
['validatePlazaAdvisors', 'validateMunicipalityAdvisors'],
['plazaCatalogEntriesForCity', 'municipalityCatalogEntriesForCity'],
['getPlazaCatalogEntry', 'getMunicipalityCatalogEntry'],
['plazaCatalog', 'municipalityCatalog'],
['isPlazaSlug', 'isMunicipalitySlug'],
['PlazaCatalogEntry', 'MunicipalityCatalogEntry'],
['PlazaKind', 'MunicipalityKind'],
['ZONE_PLAZA_CITIES', 'ZONE_MUNICIPALITY_CITIES'],
['zonePlazaCitySet', 'zoneMunicipalityCitySet'],
['plazasByIbgeCode', 'municipalitiesByIbgeCode'],
['resolvePlazaMapNavigation', 'resolveMunicipalityMapNavigation'],
['plazaMapNavigation', 'municipalityMapNavigation'],
['plazaRevalidation', 'municipalityRevalidation'],
['plazaSlug', 'municipalitySlug'],
['plazaPageData', 'municipalityPageData'],
['plazaViewModels', 'municipalityViewModels'],
['plazaMapData', 'municipalityMapData'],
['plazaUi', 'municipalityUi'],
['plazaElectoralBaseline', 'municipalityElectoralBaseline'],
['plazaElectionGeography', 'municipalityElectionGeography'],
['plazaCandidateComparison', 'municipalityCandidateComparison'],
['plazaDetailTabUi', 'municipalityDetailTabUi'],
['campaignPlazaAccess', 'campaignMunicipalityAccess'],
['campaignPlazas', 'campaignMunicipalities'],
['campaignPlazaUpdate', 'campaignMunicipalityUpdate'],
['updatePlazaStrategy', 'updateMunicipalityStrategy'],
['PlazaMapBundle', 'MunicipalityMapBundle'],
['plazaMapBundle', 'municipalityMapBundle'],
['PlazaList', 'MunicipalityList'],
['PlazaFilters', 'MunicipalityFilters'],
['PlazaMap', 'MunicipalityMap'],
['PlazaStrategy', 'MunicipalityStrategy'],
['PlazaBaseline', 'MunicipalityBaseline'],
['PlazaCandidate', 'MunicipalityCandidate'],
['PlazaAdvisor', 'MunicipalityAdvisor'],
['PlazaLeadership', 'MunicipalityLeadership'],
['PlazaPledges', 'MunicipalityPledges'],
['PlazaTab', 'MunicipalityTab'],
['PlazaUpdate', 'MunicipalityUpdate'],
['plazas', 'municipalities'],
['Plaza', 'Municipality'],
['plaza', 'municipality'],
]
const contentFiles = walk(join(ROOT, 'src'))
.concat(walk(join(ROOT, 'tests')))
.concat(
[
join(ROOT, 'AGENTS.md'),
join(ROOT, 'PRODUCT.md'),
join(ROOT, 'docs/roadmap.md'),
join(ROOT, '.cursor/rules/projects/nucleos-eleitorais.mdc'),
].filter((f) => {
try {
statSync(f)
return true
} catch {
return false
}
}),
)
for (const file of contentFiles) {
const rel = relative(ROOT, file)
if (shouldExclude(rel)) continue
let content = readFileSync(file, 'utf8')
const original = content
for (const [from, to] of REPLACEMENTS) {
content = content.split(from).join(to)
}
if (content !== original) {
writeFileSync(file, content)
console.log('CONTENT', rel)
}
}
console.log('Done.')

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 { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
loadSupporterDetailConsentData,
@ -69,9 +69,7 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
Voltar para apoiadores
</Link>
</Button>
<CampaignScopeBadge>
{isCampaignCoordinator(user) ? 'Coordenador Geral' : 'Assessor'}
</CampaignScopeBadge>
<CampaignScopeBadge>{campaignRoleLabels[user.role]}</CampaignScopeBadge>
<h1 className="text-2xl font-semibold tracking-tight">{supporter.name}</h1>
<p className="text-muted-foreground">
{supporter.city ?? 'Município não informado'}

View file

@ -0,0 +1,20 @@
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { Skeleton } from '@/components/ui/skeleton'
/** List-shaped skeleton for /apoiadores and its detail routes. */
export default function SupportersLoading() {
return (
<CampaignPageShell aria-label="Carregando apoiadores" aria-busy="true">
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-5 w-80 max-w-full" />
</div>
<div className="flex flex-wrap gap-3">
<Skeleton className="h-11 w-full max-w-md" />
<Skeleton className="h-11 w-44" />
</div>
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-80 w-full rounded-xl" />
</CampaignPageShell>
)
}

View file

@ -34,7 +34,7 @@ const safeActionMessages = [
'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 e a assessoria podem gerenciar apoiadores.',
'Somente o Coordenador Geral pode cadastrar apoiadores sem Praça.',
'Somente o Coordenador Geral pode cadastrar apoiadores sem município.',
'Consentimento de cadastro de apoiador ainda não configurado.',
'Consentimento de intenção de voto ainda não configurado.',
] as const

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 { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadSupporterCreatePageData } from '@/utilities/supporterPageData'
import { canAccessSupporterArea } from '@/utilities/supporterUi'
@ -26,9 +26,7 @@ export default async function NewSupporterPage() {
<Button asChild variant="ghost" className="w-fit px-0">
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
</Button>
<CampaignScopeBadge>
{isCampaignCoordinator(user) ? 'Coordenador Geral' : 'Assessor'}
</CampaignScopeBadge>
<CampaignScopeBadge>{campaignRoleLabels[user.role]}</CampaignScopeBadge>
<h1 className="text-2xl font-semibold tracking-tight">Novo apoiador</h1>
<p className="text-muted-foreground">
Cadastre um apoiador com telefone obrigatório e consentimento LGPD.

View file

@ -1,3 +1,7 @@
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import config from '@payload-config'
import { FileUpIcon, PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
@ -19,8 +23,9 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { isCampaignCoordinator, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { loadSupportersPageData } from '@/utilities/supporterPageData'
import {
buildSupporterFiltersKey,
@ -42,13 +47,47 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
const rawSearchParams = await searchParams
const now = new Date()
const { result, state, redirectHref, municipalityOptions, overview } = await loadSupportersPageData(
payload,
user,
rawSearchParams,
)
const { result, state, redirectHref, municipalityOptions, overview } =
await loadSupportersPageData(payload, user, rawSearchParams)
if (redirectHref) redirect(redirectHref)
const listBody = result.docs.length ? (
<>
{overview ? <SupporterListOverview view={overview} now={now} /> : null}
<SupporterList
supporters={result.docs.map((supporter) => toSupporterListItemViewModel(supporter))}
/>
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground">
{result.totalDocs}{' '}
{result.totalDocs === 1 ? 'apoiador encontrado' : 'apoiadores encontrados'}
</p>
<CampaignListPagination
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildSupporterListHref(state, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum apoiador encontrado</EmptyTitle>
<EmptyDescription>
Ajuste a busca ou os filtros. Você só vê apoiadores dentro do seu escopo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores">Limpar busca e filtros</Link>
</Button>
</EmptyContent>
</Empty>
)
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@ -58,8 +97,8 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
Base nominal de apoio com intenção de voto e vínculo opcional a Praças.
</p>
<CampaignScopeBadge>
{isCampaignCoordinator(user)
? 'Coordenador Geral · todos os apoiadores'
{isCampaignUnrestricted(user)
? `${campaignRoleLabels[user.role]} · todos os apoiadores`
: getSupporterScopeLabel(result.totalDocs)}
</CampaignScopeBadge>
</div>
@ -81,48 +120,15 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
</div>
</header>
<SupporterFilters
key={buildSupporterFiltersKey(state)}
state={state}
municipalityOptions={municipalityOptions}
/>
<CampaignListPendingBoundary>
<SupporterFilters
key={buildSupporterFiltersKey(state)}
state={state}
municipalityOptions={municipalityOptions}
/>
{result.docs.length ? (
<>
{overview ? <SupporterListOverview view={overview} now={now} /> : null}
<SupporterList
supporters={result.docs.map((supporter) => toSupporterListItemViewModel(supporter))}
/>
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground">
{result.totalDocs}{' '}
{result.totalDocs === 1 ? 'apoiador encontrado' : 'apoiadores encontrados'}
</p>
<CampaignListPagination
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildSupporterListHref(state, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum apoiador encontrado</EmptyTitle>
<EmptyDescription>
Ajuste a busca ou os filtros. Você só vê apoiadores dentro do seu escopo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores">Limpar busca e filtros</Link>
</Button>
</EmptyContent>
</Empty>
)}
<CampaignListResults>{listBody}</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -13,7 +13,7 @@ import {
campaignDemandStatusLabels,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { isCampaignStaff, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadDemandDetail } from '@/utilities/campaignDemandData'
import {
@ -51,12 +51,11 @@ 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')
if (!isCampaignStaff(user)) redirect('/campanha')
const demand = await loadDemandDetail(payload, user, slug)
if (!demand) notFound()
const isStaffView = isCampaignStaff(user)
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
@ -105,82 +104,67 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps
</section>
) : null}
{isStaffView ? (
<>
<DemandWorkflowCard
demandID={demand.id}
status={demand.status}
isCoordinator={user.role === 'coordinator'}
currentCost={demand.cost}
transitionFormAction={transitionDemandFormAction}
costFormAction={setDemandCostFormAction}
receiptFormAction={attachDemandReceiptFormAction}
/>
<DemandWorkflowCard
demandID={demand.id}
status={demand.status}
canDecideEscalated={isCampaignUnrestricted(user)}
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>
)}
<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>
</CampaignPageShell>
)
}

View file

@ -1,38 +1,30 @@
'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,
runCampaignRedirectFormAction,
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'),
municipality: requiredRelationshipFormValue(formData, 'municipalityId'),
})
createdSlug = demand.slug
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria podem abrir demandas.'],
genericMessage: 'Não foi possível abrir a demanda. Verifique os dados e tente novamente.',
})
}
redirect(`/campanha/demandas/${createdSlug}`)
}
): Promise<CampaignFormActionState> =>
runCampaignRedirectFormAction({
execute: () => {
const rawKind = requiredFormText(formData, 'kind')
return createCampaignDemand({
title: requiredFormText(formData, 'title'),
kind: campaignDemandKinds.includes(rawKind as CampaignDemandKind)
? (rawKind as CampaignDemandKind)
: 'outro',
description: optionalFormText(formData, 'description'),
municipality: requiredRelationshipFormValue(formData, 'municipalityId'),
})
},
redirectTo: (demand) => `/campanha/demandas/${demand.slug}`,
safeMessages: ['Somente a coordenação e a assessoria podem abrir demandas.'],
genericMessage: 'Não foi possível abrir a demanda. Verifique os dados e tente novamente.',
})

View file

@ -7,6 +7,7 @@ import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { DemandForm } from '@/components/campaign/DemandForm'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { createDemandFormAction } from './formActions'
@ -14,6 +15,7 @@ import { createDemandFormAction } from './formActions'
export default async function NewDemandPage() {
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
const municipalityOptions = await loadMunicipalityOptions(payload, user)

View file

@ -4,7 +4,12 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignFilterChips } from '@/components/campaign/CampaignFilterChips'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
@ -30,6 +35,7 @@ import {
campaignDemandStatuses,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadDemandListPageData, parseDemandListParams } from '@/utilities/campaignDemandData'
@ -54,6 +60,7 @@ 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')
if (!isCampaignStaff(user)) redirect('/campanha')
const state = parseDemandListParams(rawSearchParams)
const { rows, totalDocs, totalPages, openCount } = await loadDemandListPageData(
@ -71,6 +78,78 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
return query ? `/campanha/demandas?${query}` : '/campanha/demandas'
}
const listBody = 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.municipalityName}</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>
)
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@ -92,100 +171,21 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
</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>
<CampaignListPendingBoundary>
<CampaignFilterChips
ariaLabel="Filtrar por status"
chips={[
{ href: hrefFor(undefined), label: 'Todas', active: state.status === undefined },
...campaignDemandStatuses.map((status) => ({
href: hrefFor(status),
label: campaignDemandStatusLabels[status],
active: state.status === status,
})),
]}
/>
{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.municipalityName}</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>
)}
<CampaignListResults>{listBody}</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -1,37 +1,28 @@
'use server'
import { redirect } from 'next/navigation'
import { createStateDeputy } from '@/app/(campaign)/campanha/actions/stateDeputy'
import { nullableFormText, optionalFormText, requiredFormText } from '@/lib/formData'
import { optionalFormText, requiredFormText } from '@/lib/formData'
import {
mapCampaignFormActionError,
runCampaignRedirectFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
export const createStateDeputyFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
let createdSlug: string | null = null
try {
const stateDeputy = await createStateDeputy({
name: requiredFormText(formData, 'name'),
party: optionalFormText(formData, 'party'),
notes: optionalFormText(formData, 'notes'),
})
createdSlug = stateDeputy.slug
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: [
'Já existe uma dobradinha com este nome.',
'Somente a coordenação e a assessoria gerenciam dobradinhas.',
],
genericMessage:
'Não foi possível cadastrar a dobradinha. Verifique os dados e tente novamente.',
})
}
redirect(`/campanha/dobradinhas/${createdSlug}`)
}
): Promise<CampaignFormActionState> =>
runCampaignRedirectFormAction({
execute: () =>
createStateDeputy({
name: requiredFormText(formData, 'name'),
party: optionalFormText(formData, 'party'),
notes: optionalFormText(formData, 'notes'),
}),
redirectTo: (stateDeputy) => `/campanha/dobradinhas/${stateDeputy.slug}`,
safeMessages: [
'Já existe uma dobradinha com este nome.',
'Somente a coordenação e a assessoria gerenciam dobradinhas.',
],
genericMessage:
'Não foi possível cadastrar a dobradinha. Verifique os dados e tente novamente.',
})

View file

@ -1,4 +1,3 @@
import config from '@payload-config'
import { ArrowLeftIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'

View file

@ -1,3 +1,8 @@
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
@ -6,7 +11,6 @@ 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,
@ -70,83 +74,80 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
</Button>
</header>
<form role="search" className="flex gap-2">
<input
type="search"
name="q"
defaultValue={state.q ?? ''}
<CampaignListPendingBoundary>
<CampaignSearchForm
ariaLabel="Buscar dobradinha por nome"
placeholder="Buscar por nome…"
aria-label="Buscar dobradinha 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"
initialQuery={state.q ?? ''}
basePath="/campanha/dobradinhas"
/>
<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>Partido</TableHead>
<TableHead>Municípios</TableHead>
<TableHead>Lideranças</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/campanha/dobradinhas/${row.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">{row.party ?? '—'}</TableCell>
<TableCell className="tabular-nums">{row.municipalityCount}</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 ? 'dobradinha' : 'dobradinhas'}
</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 dobradinha cadastrada</EmptyTitle>
<EmptyDescription>
Cadastre deputados estaduais parceiros para vincular a municípios e lideranças.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/dobradinhas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova dobradinha
</Link>
</Button>
</EmptyContent>
</Empty>
)}
<CampaignListResults>
{rows.length ? (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Partido</TableHead>
<TableHead>Municípios</TableHead>
<TableHead>Lideranças</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/campanha/dobradinhas/${row.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">{row.party ?? '—'}</TableCell>
<TableCell className="tabular-nums">{row.municipalityCount}</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 ? 'dobradinha' : 'dobradinhas'}
</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 dobradinha cadastrada</EmptyTitle>
<EmptyDescription>
Cadastre deputados estaduais parceiros para vincular a municípios e lideranças.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/dobradinhas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova dobradinha
</Link>
</Button>
</EmptyContent>
</Empty>
)}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -1,12 +1,10 @@
'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,
runCampaignRedirectFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
@ -20,39 +18,32 @@ const safeMessages = [
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')
): Promise<CampaignFormActionState> =>
runCampaignRedirectFormAction({
execute: () => {
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'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
stateDeputies: repeatedRelationshipFormValues(formData, 'stateDeputies'),
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}`)
}
return createLeadership({
name: requiredFormText(formData, 'name'),
phone: requiredFormText(formData, 'phone'),
email: optionalFormText(formData, 'email'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
stateDeputies: repeatedRelationshipFormValues(formData, 'stateDeputies'),
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'),
})
},
redirectTo: (leadership) => `/campanha/liderancas/${leadership.id}`,
safeMessages,
genericMessage: 'Não foi possível cadastrar a liderança. Verifique os dados e tente novamente.',
})

View file

@ -1,3 +1,8 @@
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
@ -67,96 +72,97 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
</Button>
</header>
<form role="search" className="flex gap-2">
<input
type="search"
name="q"
defaultValue={state.q ?? ''}
<CampaignListPendingBoundary>
<CampaignSearchForm
ariaLabel="Buscar liderança por nome"
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"
initialQuery={state.q ?? ''}
basePath="/campanha/liderancas"
/>
<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.municipalityNames.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>
)}
<CampaignListResults>
{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.municipalityNames.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>
)}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -1,3 +1,4 @@
import { Suspense } from 'react'
import config from '@payload-config'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
@ -111,24 +112,32 @@ export default async function MunicipalityDetailPage({
) : null}
{activeTab === 'elections' ? (
<ElectionsTab
slug={view.slug}
rawSearchParams={rawSearchParams}
payloadUser={{ payload, user }}
/>
// Streams below the header/tab chrome — the TSE baselines and the
// comparison table are the slowest reads on this page.
<Suspense fallback={<MunicipalityTabFallback />}>
<ElectionsTab
slug={view.slug}
rawSearchParams={rawSearchParams}
payloadUser={{ payload, user }}
/>
</Suspense>
) : null}
{activeTab === 'leaderships' ? (
<LeadershipsTab municipalityID={view.id} payloadUser={{ payload, user }} />
<Suspense fallback={<MunicipalityTabFallback />}>
<LeadershipsTab municipalityID={view.id} payloadUser={{ payload, user }} />
</Suspense>
) : null}
{activeTab === 'updates' ? (
<UpdatesTab
municipalityID={view.id}
municipalitySlug={view.slug}
rawSearchParams={rawSearchParams}
payloadUser={{ payload, user }}
/>
<Suspense fallback={<MunicipalityTabFallback />}>
<UpdatesTab
municipalityID={view.id}
municipalitySlug={view.slug}
rawSearchParams={rawSearchParams}
payloadUser={{ payload, user }}
/>
</Suspense>
) : null}
{activeTab === 'demands' ? (
@ -193,10 +202,17 @@ const OverviewTab = async ({
)
}
const MunicipalityTabFallback = () => (
<div aria-hidden="true" className="flex flex-col gap-4">
<div className="h-40 w-full animate-pulse rounded-xl border bg-muted/40" />
<div className="h-64 w-full animate-pulse rounded-xl border bg-muted/40" />
</div>
)
const ElectionsTab = async ({
slug,
rawSearchParams,
payloadUser: { payload, user },
payloadUser: { user },
}: {
slug: string
rawSearchParams: MunicipalityDetailSearchParams
@ -213,11 +229,11 @@ const ElectionsTab = async ({
].slice(0, MAX_COMPARISON_CANDIDATES)
const [baseline, comparisonRows, candidateOptions] = await Promise.all([
geography ? loadMunicipalityElectoralBaseline(payload, user, geography) : null,
geography ? loadMunicipalityElectoralBaseline(user, geography) : null,
geography
? loadMunicipalityCandidateComparison(payload, user, geography, compareNumbers)
? loadMunicipalityCandidateComparison(user, geography, compareNumbers)
: ([] as Awaited<ReturnType<typeof loadMunicipalityCandidateComparison>>),
loadFederalCandidateOptions(payload, user),
loadFederalCandidateOptions(user),
])
return (

View file

@ -0,0 +1,20 @@
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { Skeleton } from '@/components/ui/skeleton'
/** List-shaped skeleton for /municipios and its detail routes. */
export default function MunicipalitiesLoading() {
return (
<CampaignPageShell aria-label="Carregando municípios" aria-busy="true">
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-5 w-96 max-w-full" />
</div>
<div className="flex flex-wrap gap-3">
<Skeleton className="h-11 w-full max-w-md" />
<Skeleton className="h-11 w-40" />
<Skeleton className="h-11 w-40" />
</div>
<Skeleton className="h-96 w-full rounded-xl" />
</CampaignPageShell>
)
}

View file

@ -1,3 +1,7 @@
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import config from '@payload-config'
import { SearchXIcon } from 'lucide-react'
import Link from 'next/link'
@ -128,16 +132,20 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
</Empty>
)
const main = isStaffView ? (
<MunicipalityEstimateScenarioProvider>
// Shared transition: the filters navigate, the results dim ("Feel the action").
// The estimate-scenario provider wraps ONLY the results — a scenario switch
// re-renders the overview/list cells, never the filter controls.
const main = (
<CampaignListPendingBoundary>
{filters}
{listBody}
</MunicipalityEstimateScenarioProvider>
) : (
<>
{filters}
{listBody}
</>
<CampaignListResults>
{isStaffView ? (
<MunicipalityEstimateScenarioProvider>{listBody}</MunicipalityEstimateScenarioProvider>
) : (
listBody
)}
</CampaignListResults>
</CampaignListPendingBoundary>
)
return (

View file

@ -1,42 +1,34 @@
'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,
runCampaignRedirectFormAction,
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'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
})
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}`)
}
): Promise<CampaignFormActionState> =>
runCampaignRedirectFormAction({
execute: () => {
const rawKind = requiredFormText(formData, 'kind')
return createOrganization({
name: requiredFormText(formData, 'name'),
kind: organizationKinds.includes(rawKind as OrganizationKind)
? (rawKind as OrganizationKind)
: 'outro',
notes: optionalFormText(formData, 'notes'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
})
},
redirectTo: (organization) => `/campanha/organizacoes/${organization.slug}`,
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.',
})

View file

@ -1,3 +1,8 @@
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
@ -72,88 +77,85 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
</Button>
</header>
<form role="search" className="flex gap-2">
<input
type="search"
name="q"
defaultValue={state.q ?? ''}
<CampaignListPendingBoundary>
<CampaignSearchForm
ariaLabel="Buscar organização por nome"
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"
initialQuery={state.q ?? ''}
basePath="/campanha/organizacoes"
/>
<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.municipalityNames.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>
)}
<CampaignListResults>
{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.municipalityNames.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>
)}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -1,9 +1,13 @@
import config from '@payload-config'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { getPayload, type Payload } from 'payload'
import { Suspense } from 'react'
import { CampaignDashboard } from '@/components/campaign/CampaignDashboard'
import { LeaderContactsPanel } from '@/components/campaign/LeaderContactsPanel'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/MunicipalityEstimateScenarioContext'
import { MunicipalityMapPanelDynamic } from '@/components/campaign/MunicipalityMapPanelDynamic'
import type { CampaignUser } from '@/payload-types'
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
@ -37,20 +41,50 @@ export default async function CampaignHomePage({ searchParams }: CampaignHomePag
}
const isStaff = isCampaignStaff(user)
const [view, mapBundle, candidateOptions] = await Promise.all([
getCampaignDashboardData(payload, user),
isStaff
? loadMunicipalityMapBundle(payload, user, { compare: rawSearchParams.compare })
: Promise.resolve(null),
isStaff ? loadFederalCandidateOptions(payload, user) : Promise.resolve([]),
])
const view = await getCampaignDashboardData(payload, user)
return (
<CampaignDashboard
view={view}
userName={user.name}
mapBundle={mapBundle}
candidateOptions={candidateOptions}
mapSlot={
isStaff ? (
// Streams after the KPI shell paints; the shared request-scoped
// municipality scope is reused (React cache), not reloaded.
<Suspense
fallback={
<div
aria-hidden="true"
className="h-[420px] w-full animate-pulse rounded-xl border bg-muted/40"
/>
}
>
<DashboardMapSection payload={payload} user={user} compare={rawSearchParams.compare} />
</Suspense>
) : null
}
/>
)
}
const DashboardMapSection = async ({
payload,
user,
compare,
}: {
payload: Payload
user: CampaignUser
compare: string | string[] | undefined
}) => {
const [mapBundle, candidateOptions] = await Promise.all([
loadMunicipalityMapBundle(payload, user, { compare }),
loadFederalCandidateOptions(user),
])
if (!mapBundle) return null
return (
<MunicipalityEstimateScenarioProvider>
<MunicipalityMapPanelDynamic bundle={mapBundle} candidateOptions={candidateOptions} />
</MunicipalityEstimateScenarioProvider>
)
}

View file

@ -48,6 +48,7 @@ export default async function ActionPlanDetailPage({
])
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
if (!slug) notFound()
const activeTab = resolveActionPlanDetailTab(query)

View file

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

View file

@ -1,3 +1,7 @@
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
@ -40,6 +44,7 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) return null
if (!isCampaignStaff(user)) redirect('/campanha')
const now = new Date()
const [{ result, state }, municipalityOptions] = await Promise.all([
@ -70,47 +75,51 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
) : null}
</header>
<ActionPlanFilters
key={buildActionPlanFiltersKey(state)}
state={state}
municipalityOptions={municipalityOptions}
/>
<CampaignListPendingBoundary>
<ActionPlanFilters
key={buildActionPlanFiltersKey(state)}
state={state}
municipalityOptions={municipalityOptions}
/>
{result.docs.length ? (
<>
<ActionPlanList
plans={result.docs.map((plan) => toActionPlanListViewModel(plan as ActionPlan))}
/>
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground">
{result.totalDocs}{' '}
{result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}
</p>
<CampaignListPagination
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildActionPlanListHref(state, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum plano encontrado</EmptyTitle>
<EmptyDescription>
Ajuste os filtros ou a janela selecionada. Você só vê planos dentro do seu escopo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/planos">Limpar filtros</Link>
</Button>
</EmptyContent>
</Empty>
)}
<CampaignListResults>
{result.docs.length ? (
<>
<ActionPlanList
plans={result.docs.map((plan) => toActionPlanListViewModel(plan as ActionPlan))}
/>
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground">
{result.totalDocs}{' '}
{result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}
</p>
<CampaignListPagination
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildActionPlanListHref(state, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum plano encontrado</EmptyTitle>
<EmptyDescription>
Ajuste os filtros ou a janela selecionada. Você só vê planos dentro do seu escopo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/planos">Limpar filtros</Link>
</Button>
</EmptyContent>
</Empty>
)}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -13,6 +13,7 @@ import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campa
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
const MAX_ACTION_PLAN_UPDATE_BODY_LENGTH = 4000
const MAX_ACTION_PLAN_RESULT_SUMMARY_LENGTH = 6000
@ -26,7 +27,7 @@ export const createActionPlanRecord = async (
return payload.create({
collection: 'actionPlan',
data: data as never,
data: hookFilledCreateData<'actionPlan'>(data),
depth: 0,
user: actor,
overrideAccess: false,

View file

@ -21,6 +21,7 @@ import {
} from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
export const createCampaignDemandRecord = async (
payload: Payload,
@ -38,7 +39,7 @@ export const createCampaignDemandRecord = async (
// links a leader's own leadership and enforces the initial status.
return payload.create({
collection: 'campaignDemand',
data: data as never,
data: hookFilledCreateData<'campaignDemand'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,

View file

@ -3,14 +3,11 @@
import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import {
assertMunicipalityManagement,
upsertContactByPhone,
} from '@/app/(campaign)/campanha/actions/supporter'
import { upsertContactByPhone } from '@/app/(campaign)/campanha/actions/supporter'
import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData'
import { leaderSupporterCreateSchema } from '@/lib/schemas/supporter'
import type { CampaignUser, Supporter } from '@/payload-types'
import { isCampaignLeader } from '@/utilities/campaignAccess'
import { getEngagedLeaderMunicipalityIds, isCampaignLeader } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import {
requireConsentByKey,
@ -38,11 +35,15 @@ export type LeaderSupporterFormValues = {
municipality?: string
}
const MUNICIPALITY_OUT_OF_SCOPE_MESSAGE =
'Você só pode cadastrar contatos nos municípios da sua liderança.'
const safeActionMessages = [
'Esta pessoa já está cadastrada como apoiador neste município.',
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.',
'Somente lideranças podem cadastrar contatos por aqui.',
'Consentimento de cadastro de apoiador ainda não configurado.',
MUNICIPALITY_OUT_OF_SCOPE_MESSAGE,
] as const
const getLeaderSupporterFormError = (
@ -82,7 +83,18 @@ export const createLeaderSupporterRecord = async (
payload,
async ({ req }) => {
const currentActor = await getFreshLeaderActor(payload, actor, req)
await assertMunicipalityManagement(payload, currentActor, data.municipality, req)
// Leaders cannot read the municipality collection, so scope is asserted
// against their own engaged leadership's municipalities (mirroring the
// leader branch of the collection-level `canCreateSupporter`).
const accessibleMunicipalityIDs = await getEngagedLeaderMunicipalityIds(
payload,
currentActor.id,
req,
)
if (!accessibleMunicipalityIDs.includes(data.municipality)) {
throw new Error(MUNICIPALITY_OUT_OF_SCOPE_MESSAGE)
}
const registrationConsent = await requireConsentByKey(
payload,

View file

@ -6,6 +6,7 @@ import { municipalityUpdateCreateSchema, type MunicipalityUpdateCreateInput } fr
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
export const createMunicipalityUpdateRecord = async (
payload: Payload,
@ -21,7 +22,7 @@ export const createMunicipalityUpdateRecord = async (
return payload.create({
collection: 'municipalityUpdate',
data: data as never,
data: hookFilledCreateData<'municipalityUpdate'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,

View file

@ -9,14 +9,14 @@ import {
type OrganizationUpdateInput,
} from '@/lib/schemas/organization'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { runStaffEntityMutation, type StaffEntityPolicy } from '@/utilities/campaignEntityActions'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
const assertStaffActor = (payload: Payload, actor: CampaignUser): Promise<CampaignUser> =>
reloadStaffActor(payload, actor, 'Somente a coordenação e a assessoria gerenciam organizações.')
const isUniqueOrganizationConflict = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error)
return /organization_(name|slug)|duplicate key/i.test(message)
const organizationPolicy: StaffEntityPolicy = {
staffMessage: 'Somente a coordenação e a assessoria gerenciam organizações.',
conflictPattern: /organization_(name|slug)|duplicate key/i,
conflictMessage: 'Já existe uma organização com este nome.',
}
export const createOrganizationRecord = async (
@ -25,22 +25,15 @@ export const createOrganizationRecord = async (
input: OrganizationCreateInput,
) => {
const data = organizationCreateSchema.parse(input)
const currentActor = await assertStaffActor(payload, actor)
try {
return await payload.create({
return runStaffEntityMutation(payload, actor, organizationPolicy, (currentActor) =>
payload.create({
collection: 'organization',
data: data as never,
data: hookFilledCreateData<'organization'>(data),
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 (
@ -49,16 +42,16 @@ export const updateOrganizationRecord = async (
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,
})
return runStaffEntityMutation(payload, actor, organizationPolicy, (currentActor) =>
payload.update({
collection: 'organization',
id,
data,
depth: 0,
user: currentActor,
overrideAccess: false,
}),
)
}
export const createOrganization = async (input: OrganizationCreateInput) => {

View file

@ -9,14 +9,14 @@ import {
type StateDeputyUpdateInput,
} from '@/lib/schemas/stateDeputy'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { runStaffEntityMutation, type StaffEntityPolicy } from '@/utilities/campaignEntityActions'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
const assertStaffActor = (payload: Payload, actor: CampaignUser): Promise<CampaignUser> =>
reloadStaffActor(payload, actor, 'Somente a coordenação e a assessoria gerenciam dobradinhas.')
const isUniqueStateDeputyConflict = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error)
return /state_deputy_(name|slug)|duplicate key/i.test(message)
const stateDeputyPolicy: StaffEntityPolicy = {
staffMessage: 'Somente a coordenação e a assessoria gerenciam dobradinhas.',
conflictPattern: /state_deputy_(name|slug)|duplicate key/i,
conflictMessage: 'Já existe uma dobradinha com este nome.',
}
export const createStateDeputyRecord = async (
@ -25,22 +25,15 @@ export const createStateDeputyRecord = async (
input: StateDeputyCreateInput,
) => {
const data = stateDeputyCreateSchema.parse(input)
const currentActor = await assertStaffActor(payload, actor)
try {
return await payload.create({
return runStaffEntityMutation(payload, actor, stateDeputyPolicy, (currentActor) =>
payload.create({
collection: 'stateDeputy',
data: data as never,
data: hookFilledCreateData<'stateDeputy'>(data),
depth: 0,
user: currentActor,
overrideAccess: false,
})
} catch (error) {
if (isUniqueStateDeputyConflict(error)) {
throw new Error('Já existe uma dobradinha com este nome.')
}
throw error
}
}),
)
}
export const updateStateDeputyRecord = async (
@ -49,16 +42,16 @@ export const updateStateDeputyRecord = async (
input: StateDeputyUpdateInput,
) => {
const { id, ...data } = stateDeputyUpdateSchema.parse(input)
const currentActor = await assertStaffActor(payload, actor)
return payload.update({
collection: 'stateDeputy',
id,
data,
depth: 0,
user: currentActor,
overrideAccess: false,
})
return runStaffEntityMutation(payload, actor, stateDeputyPolicy, (currentActor) =>
payload.update({
collection: 'stateDeputy',
id,
data,
depth: 0,
user: currentActor,
overrideAccess: false,
}),
)
}
export const createStateDeputy = async (input: StateDeputyCreateInput) => {

View file

@ -1,22 +1,14 @@
'use server'
import { parse } from 'csv-parse/sync'
import type { Payload } from 'payload'
import {
resolveBahiaMunicipality,
supporterCreateSchema,
supporterImportConfirmSchema,
supporterRemoveSchema,
supporterVoteIntentionSchema,
type SupporterVoteIntention,
} from '@/lib/schemas/supporter'
import type { CampaignUser, Contact } from '@/payload-types'
import {
getCampaignActionContext,
reloadCoordinatorActor,
reloadStaffActor,
} from '@/utilities/campaignActionContext'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import {
requireSupporterRegistrationConsent,
requireSupporterVoteIntentionConsent,
@ -25,24 +17,15 @@ import {
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { normalizeBrazilianPhone } from '@/utilities/phone'
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
import { requireRelationshipId } from '@/utilities/relationship'
import { isUniqueSupporterConflict } from '@/utilities/supporterErrors'
import {
isPreviewErrorRow,
isSupporterImportOkRow,
type SupporterImportPreviewResult,
type SupporterImportPreviewRow,
type SupporterImportPreviewRowBase,
} from '@/utilities/supporterImport'
import { bulkInsertSupporterImport } from '@/utilities/supporterImportBulk'
import {
deleteSupporterImportBatch,
issueSupporterImportToken,
loadSupporterImportBatch,
storeSupporterImportBatch,
verifySupporterImportToken,
} from '@/utilities/supporterImportToken'
confirmSupporterImport as confirmSupporterImportAction,
confirmSupporterImportRecord as confirmSupporterImportRecordAction,
previewSupporterImport as previewSupporterImportAction,
previewSupporterImportText as previewSupporterImportTextAction,
} from './supporterImport'
export type {
SupporterImportOkRow,
@ -51,25 +34,6 @@ export type {
SupporterImportRowStatus,
} from '@/utilities/supporterImport'
const MAX_IMPORT_ROWS = 5000
/** On-screen preview is capped; the full ok set is staged server-side (Phase 5). */
const SUPPORTER_IMPORT_SAMPLE_SIZE = 100
const escapeCsvCell = (value: string): string => `"${value.replace(/"/g, '""')}"`
const buildSupporterImportErrorReportCsv = (rows: SupporterImportPreviewRow[]): string => {
const header = 'linha,nome,telefone,municipio,intencao,status\n'
const body = rows
.filter(isPreviewErrorRow)
.map(
(row) =>
`${row.line},${escapeCsvCell(row.nome)},${escapeCsvCell(row.telefone)},${escapeCsvCell(row.municipio)},${escapeCsvCell(row.intencao)},${row.status}`,
)
.join('\n')
return header + body
}
const getFreshStaffActor = (
payload: Payload,
actor: CampaignUser,
@ -82,18 +46,6 @@ const getFreshStaffActor = (
req,
)
const getFreshCoordinatorActor = (
payload: Payload,
actor: CampaignUser,
req?: PayloadTransactionRequest,
): Promise<CampaignUser> =>
reloadCoordinatorActor(
payload,
actor,
'Somente o Coordenador Geral pode importar apoiadores.',
req,
)
export const assertMunicipalityManagement = async (
payload: Payload,
actor: CampaignUser,
@ -193,7 +145,7 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i
if (data.municipality) {
await assertMunicipalityManagement(payload, currentActor, data.municipality, req)
} else if (currentActor.role !== 'coordinator') {
throw new Error('Somente o Coordenador Geral pode cadastrar apoiadores sem Praça.')
throw new Error('Somente o Coordenador Geral pode cadastrar apoiadores sem município.')
}
const registrationConsent = await requireSupporterRegistrationConsent(
@ -314,295 +266,28 @@ export const setSupporterVoteIntention = async (input: unknown) => {
return setSupporterVoteIntentionRecord(payload, actor, input)
}
const normalizeImportHeader = (header: string): string =>
header
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '_')
const mapImportHeader = (header: string): string | null => {
const normalized = normalizeImportHeader(header)
const aliases: Record<string, string> = {
nome: 'nome',
name: 'nome',
telefone: 'telefone',
phone: 'telefone',
celular: 'telefone',
municipio: 'municipio',
cidade: 'municipio',
city: 'municipio',
intencao: 'intencao',
intencao_de_voto: 'intencao',
vote_intention: 'intencao',
}
return aliases[normalized] ?? null
}
const parseVoteIntentionCell = (value: string): SupporterVoteIntention | undefined | 'invalid' => {
const trimmed = value.trim()
if (!trimmed) return undefined
const normalized = normalizeImportHeader(trimmed).replace(/-/g, '_')
const aliases: Record<string, SupporterVoteIntention> = {
certo: 'certo',
tende_a_certo: 'tende_a_certo',
tende: 'tende_a_certo',
indeciso: 'indeciso',
outro: 'outro',
}
return aliases[normalized] ?? 'invalid'
}
// ---------------------------------------------------------------------------
// CSV import pipeline — implementation lives in ./supporterImport. A top-level
// `'use server'` module cannot re-export values (only async functions defined
// here), so these async wrappers keep the import sites of this module stable.
// ---------------------------------------------------------------------------
export const previewSupporterImportText = async (
payload: Payload,
actor: CampaignUser,
csvText: string,
): Promise<SupporterImportPreviewResult> => {
const currentActor = await getFreshCoordinatorActor(payload, actor)
await requireSupporterRegistrationConsent(payload)
await requireSupporterVoteIntentionConsent(payload)
) => previewSupporterImportTextAction(payload, actor, csvText)
let records: Record<string, string>[]
try {
records = parse(csvText, {
columns: (headers: string[]) =>
headers.map((header) => {
const mapped = mapImportHeader(header)
if (!mapped) return `__unknown_${header}`
return mapped
}),
skip_empty_lines: true,
trim: true,
bom: true,
relax_column_count: true,
}) as Record<string, string>[]
} catch {
throw new Error('Não foi possível ler o CSV. Verifique o formato e tente novamente.')
}
if (records.length === 0) {
throw new Error('O CSV está vazio.')
}
if (records.length > MAX_IMPORT_ROWS) {
throw new Error(`O CSV excede o limite de ${MAX_IMPORT_ROWS} linhas.`)
}
const unknownColumns = Object.keys(records[0] ?? {}).filter((key) => key.startsWith('__unknown_'))
if (unknownColumns.length > 0) {
throw new Error(
`Colunas não reconhecidas no CSV: ${unknownColumns
.map((column) => column.replace(/^__unknown_/, ''))
.join(', ')}. Use apenas nome, telefone, municipio e intencao.`,
)
}
const phonesInFile = new Set<string>()
const rows: SupporterImportPreviewRow[] = []
for (const [index, record] of records.entries()) {
const line = index + 2
const nome = String(record.nome ?? '').trim()
const telefoneRaw = String(record.telefone ?? '').trim()
const municipioRaw = String(record.municipio ?? '').trim()
const intencaoRaw = String(record.intencao ?? '').trim()
const base: SupporterImportPreviewRowBase = {
line,
nome,
telefone: telefoneRaw,
municipio: municipioRaw,
intencao: intencaoRaw,
}
if (nome.length < 2) {
rows.push({ ...base, status: 'nome_invalido' })
continue
}
const normalizedPhone = normalizeBrazilianPhone(telefoneRaw)
if (!normalizedPhone) {
rows.push({ ...base, status: 'telefone_invalido' })
continue
}
let canonicalCity: string | undefined
if (municipioRaw) {
const city = resolveBahiaMunicipality(municipioRaw)
if (!city) {
rows.push({ ...base, status: 'municipio_nao_reconhecido', normalizedPhone })
continue
}
canonicalCity = city
}
const voteIntention = parseVoteIntentionCell(intencaoRaw)
if (voteIntention === 'invalid') {
rows.push({ ...base, status: 'intencao_invalida', normalizedPhone, canonicalCity })
continue
}
if (phonesInFile.has(normalizedPhone)) {
rows.push({
...base,
status: 'duplicado_pelo_telefone',
normalizedPhone,
canonicalCity,
voteIntention,
})
continue
}
phonesInFile.add(normalizedPhone)
rows.push({
...base,
status: 'ok',
normalizedPhone,
canonicalCity,
voteIntention,
})
}
// Duplicate against an existing supporter without municipality (Contact reuse alone is OK).
const candidatePhones = [
...new Set(rows.filter(isSupporterImportOkRow).map((row) => row.normalizedPhone)),
]
if (candidatePhones.length > 0) {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { in: candidatePhones } },
depth: 0,
limit: candidatePhones.length,
pagination: false,
select: { phone: true },
overrideAccess: true,
})
const contactByPhone = new Map(contacts.docs.map((doc) => [doc.phone, doc.id] as const))
const contactIDs = [...contactByPhone.values()]
if (contactIDs.length > 0) {
const existingSupporters = await payload.find({
collection: 'supporter',
where: {
and: [{ contact: { in: contactIDs } }, { municipality: { exists: false } }],
},
depth: 0,
limit: contactIDs.length,
pagination: false,
select: { contact: true },
overrideAccess: true,
})
const supporterContactIDs = new Set(
existingSupporters.docs
.map((doc) => relationshipId(doc.contact))
.filter((id): id is number => id !== null),
)
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index]
if (!row || !isSupporterImportOkRow(row)) continue
const contactID = contactByPhone.get(row.normalizedPhone)
if (contactID && supporterContactIDs.has(contactID)) {
rows[index] = { ...row, status: 'duplicado_pelo_telefone' }
}
}
}
}
const counts = {
ok: rows.filter(isSupporterImportOkRow).length,
duplicate: rows.filter((row) => row.status === 'duplicado_pelo_telefone').length,
error: rows.filter((row) => row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone')
.length,
}
const okRows = rows.filter(isSupporterImportOkRow).map((row) => ({
nome: row.nome,
telefone: row.normalizedPhone,
municipio: row.canonicalCity,
intencao: row.voteIntention,
}))
const errorReportCsv = buildSupporterImportErrorReportCsv(rows)
const sampleRows = rows.slice(0, SUPPORTER_IMPORT_SAMPLE_SIZE)
// Stage the full ok set server-side keyed by an HMAC-signed token so the wizard
// never has to hold/re-send thousands of rows across the Server Action boundary.
const issued = issueSupporterImportToken(currentActor.id)
await storeSupporterImportBatch(payload, issued.batchId, {
actorID: currentActor.id,
expiresAt: issued.expiresAt,
okRows,
})
return {
counts,
sampleRows,
errorReportCsv,
importToken: issued.token,
}
}
export const previewSupporterImport = async (csvText: string) => {
const { payload, actor } = await getCampaignActionContext()
return previewSupporterImportText(payload, actor, csvText)
}
export const previewSupporterImport = async (csvText: string) =>
previewSupporterImportAction(csvText)
export const confirmSupporterImportRecord = async (
payload: Payload,
actor: CampaignUser,
input: unknown,
) => {
const data = supporterImportConfirmSchema.parse(input)
) => confirmSupporterImportRecordAction(payload, actor, input)
// Verify the HMAC token and load the staged batch BEFORE opening the write
// transaction, so an invalid/expired token is rejected without holding locks.
const verified = verifySupporterImportToken(data.importToken, actor.id)
const batch = await loadSupporterImportBatch(payload, verified.batchId, actor.id)
if (batch.okRows.length === 0) {
throw new Error('O lote de importação não contém apoiadores válidos.')
}
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await getFreshCoordinatorActor(payload, actor, req)
const registrationConsent = await requireSupporterRegistrationConsent(payload, req)
const voteIntentionConsent = await requireSupporterVoteIntentionConsent(payload, req)
if (payload.db.name !== 'postgres') {
throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.')
}
const phones = [...new Set(batch.okRows.map((row) => row.telefone))]
await acquireContactPhoneLocks(payload, req, phones)
const consentNote =
data.consentNote ??
'Importação CSV com atestado do operador sobre consentimento dos titulares.'
const result = await bulkInsertSupporterImport({
payload,
req,
actorID: currentActor.id,
rows: batch.okRows,
registrationConsent,
voteIntentionConsent,
consentNote,
})
// Single-use token: drop the staged batch once the import commits.
await deleteSupporterImportBatch(payload, verified.batchId, req)
return result
},
{ beginFailureMessage: 'Não foi possível iniciar a transação de importação de apoiadores.' },
)
}
export const confirmSupporterImport = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
return confirmSupporterImportRecord(payload, actor, input)
}
export const confirmSupporterImport = async (input: unknown) => confirmSupporterImportAction(input)
const contactHasOtherJoins = async (
payload: Payload,

View file

@ -0,0 +1,357 @@
'use server'
import { parse } from 'csv-parse/sync'
import type { Payload } from 'payload'
import {
resolveBahiaMunicipality,
supporterImportConfirmSchema,
type SupporterVoteIntention,
} from '@/lib/schemas/supporter'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCoordinatorActor } from '@/utilities/campaignActionContext'
import {
requireSupporterRegistrationConsent,
requireSupporterVoteIntentionConsent,
} from '@/utilities/campaignConsent'
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { normalizeBrazilianPhone } from '@/utilities/phone'
import { relationshipId } from '@/utilities/relationship'
import {
isPreviewErrorRow,
isSupporterImportOkRow,
type SupporterImportPreviewResult,
type SupporterImportPreviewRow,
type SupporterImportPreviewRowBase,
} from '@/utilities/supporterImport'
import { bulkInsertSupporterImport } from '@/utilities/supporterImportBulk'
import {
deleteSupporterImportBatch,
issueSupporterImportToken,
loadSupporterImportBatch,
storeSupporterImportBatch,
verifySupporterImportToken,
} from '@/utilities/supporterImportToken'
const MAX_IMPORT_ROWS = 5000
/** On-screen preview is capped; the full ok set is staged server-side (Phase 5). */
const SUPPORTER_IMPORT_SAMPLE_SIZE = 100
const escapeCsvCell = (value: string): string => `"${value.replace(/"/g, '""')}"`
const buildSupporterImportErrorReportCsv = (rows: SupporterImportPreviewRow[]): string => {
const header = 'linha,nome,telefone,municipio,intencao,status\n'
const body = rows
.filter(isPreviewErrorRow)
.map(
(row) =>
`${row.line},${escapeCsvCell(row.nome)},${escapeCsvCell(row.telefone)},${escapeCsvCell(row.municipio)},${escapeCsvCell(row.intencao)},${row.status}`,
)
.join('\n')
return header + body
}
const getFreshCoordinatorActor = (
payload: Payload,
actor: CampaignUser,
req?: PayloadTransactionRequest,
): Promise<CampaignUser> =>
reloadCoordinatorActor(
payload,
actor,
'Somente o Coordenador Geral pode importar apoiadores.',
req,
)
const normalizeImportHeader = (header: string): string =>
header
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '_')
const mapImportHeader = (header: string): string | null => {
const normalized = normalizeImportHeader(header)
const aliases: Record<string, string> = {
nome: 'nome',
name: 'nome',
telefone: 'telefone',
phone: 'telefone',
celular: 'telefone',
municipio: 'municipio',
cidade: 'municipio',
city: 'municipio',
intencao: 'intencao',
intencao_de_voto: 'intencao',
vote_intention: 'intencao',
}
return aliases[normalized] ?? null
}
const parseVoteIntentionCell = (value: string): SupporterVoteIntention | undefined | 'invalid' => {
const trimmed = value.trim()
if (!trimmed) return undefined
const normalized = normalizeImportHeader(trimmed).replace(/-/g, '_')
const aliases: Record<string, SupporterVoteIntention> = {
certo: 'certo',
tende_a_certo: 'tende_a_certo',
tende: 'tende_a_certo',
indeciso: 'indeciso',
outro: 'outro',
}
return aliases[normalized] ?? 'invalid'
}
export const previewSupporterImportText = async (
payload: Payload,
actor: CampaignUser,
csvText: string,
): Promise<SupporterImportPreviewResult> => {
const currentActor = await getFreshCoordinatorActor(payload, actor)
await requireSupporterRegistrationConsent(payload)
await requireSupporterVoteIntentionConsent(payload)
let records: Record<string, string>[]
try {
records = parse(csvText, {
columns: (headers: string[]) =>
headers.map((header) => {
const mapped = mapImportHeader(header)
if (!mapped) return `__unknown_${header}`
return mapped
}),
skip_empty_lines: true,
trim: true,
bom: true,
relax_column_count: true,
}) as Record<string, string>[]
} catch {
throw new Error('Não foi possível ler o CSV. Verifique o formato e tente novamente.')
}
if (records.length === 0) {
throw new Error('O CSV está vazio.')
}
if (records.length > MAX_IMPORT_ROWS) {
throw new Error(`O CSV excede o limite de ${MAX_IMPORT_ROWS} linhas.`)
}
const unknownColumns = Object.keys(records[0] ?? {}).filter((key) => key.startsWith('__unknown_'))
if (unknownColumns.length > 0) {
throw new Error(
`Colunas não reconhecidas no CSV: ${unknownColumns
.map((column) => column.replace(/^__unknown_/, ''))
.join(', ')}. Use apenas nome, telefone, municipio e intencao.`,
)
}
const phonesInFile = new Set<string>()
const rows: SupporterImportPreviewRow[] = []
for (const [index, record] of records.entries()) {
const line = index + 2
const nome = String(record.nome ?? '').trim()
const telefoneRaw = String(record.telefone ?? '').trim()
const municipioRaw = String(record.municipio ?? '').trim()
const intencaoRaw = String(record.intencao ?? '').trim()
const base: SupporterImportPreviewRowBase = {
line,
nome,
telefone: telefoneRaw,
municipio: municipioRaw,
intencao: intencaoRaw,
}
if (nome.length < 2) {
rows.push({ ...base, status: 'nome_invalido' })
continue
}
const normalizedPhone = normalizeBrazilianPhone(telefoneRaw)
if (!normalizedPhone) {
rows.push({ ...base, status: 'telefone_invalido' })
continue
}
let canonicalCity: string | undefined
if (municipioRaw) {
const city = resolveBahiaMunicipality(municipioRaw)
if (!city) {
rows.push({ ...base, status: 'municipio_nao_reconhecido', normalizedPhone })
continue
}
canonicalCity = city
}
const voteIntention = parseVoteIntentionCell(intencaoRaw)
if (voteIntention === 'invalid') {
rows.push({ ...base, status: 'intencao_invalida', normalizedPhone, canonicalCity })
continue
}
if (phonesInFile.has(normalizedPhone)) {
rows.push({
...base,
status: 'duplicado_pelo_telefone',
normalizedPhone,
canonicalCity,
voteIntention,
})
continue
}
phonesInFile.add(normalizedPhone)
rows.push({
...base,
status: 'ok',
normalizedPhone,
canonicalCity,
voteIntention,
})
}
// Duplicate against an existing supporter without municipality (Contact reuse alone is OK).
const candidatePhones = [
...new Set(rows.filter(isSupporterImportOkRow).map((row) => row.normalizedPhone)),
]
if (candidatePhones.length > 0) {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { in: candidatePhones } },
depth: 0,
limit: candidatePhones.length,
pagination: false,
select: { phone: true },
overrideAccess: true,
})
const contactByPhone = new Map(contacts.docs.map((doc) => [doc.phone, doc.id] as const))
const contactIDs = [...contactByPhone.values()]
if (contactIDs.length > 0) {
const existingSupporters = await payload.find({
collection: 'supporter',
where: {
and: [{ contact: { in: contactIDs } }, { municipality: { exists: false } }],
},
depth: 0,
limit: contactIDs.length,
pagination: false,
select: { contact: true },
overrideAccess: true,
})
const supporterContactIDs = new Set(
existingSupporters.docs
.map((doc) => relationshipId(doc.contact))
.filter((id): id is number => id !== null),
)
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index]
if (!row || !isSupporterImportOkRow(row)) continue
const contactID = contactByPhone.get(row.normalizedPhone)
if (contactID && supporterContactIDs.has(contactID)) {
rows[index] = { ...row, status: 'duplicado_pelo_telefone' }
}
}
}
}
const counts = {
ok: rows.filter(isSupporterImportOkRow).length,
duplicate: rows.filter((row) => row.status === 'duplicado_pelo_telefone').length,
error: rows.filter((row) => row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone')
.length,
}
const okRows = rows.filter(isSupporterImportOkRow).map((row) => ({
nome: row.nome,
telefone: row.normalizedPhone,
municipio: row.canonicalCity,
intencao: row.voteIntention,
}))
const errorReportCsv = buildSupporterImportErrorReportCsv(rows)
const sampleRows = rows.slice(0, SUPPORTER_IMPORT_SAMPLE_SIZE)
// Stage the full ok set server-side keyed by an HMAC-signed token so the wizard
// never has to hold/re-send thousands of rows across the Server Action boundary.
const issued = issueSupporterImportToken(currentActor.id)
await storeSupporterImportBatch(payload, issued.batchId, {
actorID: currentActor.id,
expiresAt: issued.expiresAt,
okRows,
})
return {
counts,
sampleRows,
errorReportCsv,
importToken: issued.token,
}
}
export const previewSupporterImport = async (csvText: string) => {
const { payload, actor } = await getCampaignActionContext()
return previewSupporterImportText(payload, actor, csvText)
}
export const confirmSupporterImportRecord = async (
payload: Payload,
actor: CampaignUser,
input: unknown,
) => {
const data = supporterImportConfirmSchema.parse(input)
// Verify the HMAC token and load the staged batch BEFORE opening the write
// transaction, so an invalid/expired token is rejected without holding locks.
const verified = verifySupporterImportToken(data.importToken, actor.id)
const batch = await loadSupporterImportBatch(payload, verified.batchId, actor.id)
if (batch.okRows.length === 0) {
throw new Error('O lote de importação não contém apoiadores válidos.')
}
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await getFreshCoordinatorActor(payload, actor, req)
const registrationConsent = await requireSupporterRegistrationConsent(payload, req)
const voteIntentionConsent = await requireSupporterVoteIntentionConsent(payload, req)
if (payload.db.name !== 'postgres') {
throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.')
}
const phones = [...new Set(batch.okRows.map((row) => row.telefone))]
await acquireContactPhoneLocks(payload, req, phones)
const consentNote =
data.consentNote ??
'Importação CSV com atestado do operador sobre consentimento dos titulares.'
const result = await bulkInsertSupporterImport({
payload,
req,
actorID: currentActor.id,
rows: batch.okRows,
registrationConsent,
voteIntentionConsent,
consentNote,
})
// Single-use token: drop the staged batch once the import commits.
await deleteSupporterImportBatch(payload, verified.batchId, req)
return result
},
{ beginFailureMessage: 'Não foi possível iniciar a transação de importação de apoiadores.' },
)
}
export const confirmSupporterImport = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
return confirmSupporterImportRecord(payload, actor, input)
}

View file

@ -3,7 +3,7 @@ import { MetaPixel } from '@/components/MetaPixel'
import { SignatureCounter } from '@/components/SignatureCounter'
import { getSignatureCount } from '@/app/(frontend)/actions/getSignatureCount'
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
import { getCachedDocumentById, getDocuments } from '@/utilities/documents'
import { getCachedDocumentById, getPetitionIds } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globals'
import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical'
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
@ -26,11 +26,10 @@ const toAbsoluteUrl = (url: string, siteUrl: string) =>
: `${stripTrailingSlash(siteUrl)}${url.startsWith('/') ? '' : '/'}${url}`
export async function generateStaticParams() {
const payload = await getDocuments('petition')
// Build-time enumeration only needs the ids — skip relationship population.
const ids = await getPetitionIds()
return payload.docs.map((doc) => ({
id: doc.id,
}))
return ids.map((id) => ({ id }))
}
export async function generateMetadata({

View file

@ -1,6 +1,6 @@
import { getCachedDocumentById } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globals'
import { RefreshRouteOnSave } from '@/utilities/RefreshRouteOnSave'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave'
import { Metadata } from 'next'
import React from 'react'
import './styles.css'

View file

@ -1,3 +1,4 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { CollectionConfig } from 'payload'
export const Consent: CollectionConfig = {
@ -10,6 +11,15 @@ export const Consent: CollectionConfig = {
group: 'Contatos',
useAsTitle: 'text',
},
// Versioned legal texts referenced by signatures/subscriptions/supporters.
// Server flows resolve them via the Local API without a user; only admins
// may create or alter them.
access: {
create: payloadAdminOnly,
read: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
fields: [
{
name: 'key',

View file

@ -12,7 +12,7 @@ import {
} from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
const MAX_LEADERSHIP_PLAZAS = 30
const MAX_LEADERSHIP_MUNICIPALITIES = 30
const requireAtLeastOneMunicipality: CollectionBeforeValidateHook = ({
data,
@ -91,7 +91,7 @@ export const Leadership: CollectionConfig = {
required: true,
hasMany: true,
index: true,
maxRows: MAX_LEADERSHIP_PLAZAS,
maxRows: MAX_LEADERSHIP_MUNICIPALITIES,
},
{
name: 'organizations',

View file

@ -1,3 +1,4 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
@ -9,8 +10,13 @@ export const Media: CollectionConfig = {
admin: {
group: 'Coleções',
},
// Campaign flows (avatar, demand receipts) upload via server actions with an
// explicit overrideAccess: true after their own role checks.
access: {
read: () => true,
create: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
fields: [
{

View file

@ -18,7 +18,7 @@ import {
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { relationshipId } from '@/utilities/relationship'
const DERIVED_PLAZA_UPDATE_CONTEXT = 'municipalityUpdateDerivedField'
const DERIVED_MUNICIPALITY_UPDATE_CONTEXT = 'municipalityUpdateDerivedField'
const nonEmptyText = (value: unknown): boolean =>
typeof value === 'string' && value.trim().length > 0
@ -75,7 +75,7 @@ const recomputeMunicipalityLastUpdateAt = async (req: PayloadRequest, municipali
depth: 0,
overrideAccess: true,
context: {
[DERIVED_PLAZA_UPDATE_CONTEXT]: true,
[DERIVED_MUNICIPALITY_UPDATE_CONTEXT]: true,
},
req,
})
@ -89,7 +89,7 @@ const lockMunicipalitiesBeforeChange: CollectionBeforeChangeHook = async ({
originalDoc,
req,
}) => {
if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return data
if (context[DERIVED_MUNICIPALITY_UPDATE_CONTEXT]) return data
if (operation === 'create' && req.user?.collection === 'campaignUser') {
data.author = req.user.id
@ -106,7 +106,7 @@ const lockMunicipalitiesBeforeChange: CollectionBeforeChangeHook = async ({
}
const lockMunicipalityBeforeDelete: CollectionBeforeDeleteHook = async ({ context, id, req }) => {
if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return
if (context[DERIVED_MUNICIPALITY_UPDATE_CONTEXT]) return
const doc = await req.payload.findByID({
collection: 'municipalityUpdate',
@ -127,7 +127,7 @@ const recomputeChangedMunicipalities: CollectionAfterChangeHook = async ({
previousDoc,
req,
}) => {
if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return doc
if (context[DERIVED_MUNICIPALITY_UPDATE_CONTEXT]) return doc
const municipalityIDs = uniqueSortedMunicipalityIDs([
relationshipId(previousDoc?.municipality),
@ -141,7 +141,7 @@ const recomputeChangedMunicipalities: CollectionAfterChangeHook = async ({
}
const recomputeDeletedMunicipality: CollectionAfterDeleteHook = async ({ context, doc, req }) => {
if (context[DERIVED_PLAZA_UPDATE_CONTEXT]) return doc
if (context[DERIVED_MUNICIPALITY_UPDATE_CONTEXT]) return doc
const municipalityIDs = uniqueSortedMunicipalityIDs([relationshipId(doc.municipality)])
if (municipalityIDs.length === 0) throw new APIError('Praça da atualização inválida.', 500)

View file

@ -1,4 +1,5 @@
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateDocumentById } from '@/utilities/documents'
import type { CollectionConfig } from 'payload'
@ -25,6 +26,10 @@ export const Petition: CollectionConfig<typeof slug> = {
},
access: {
read: () => true,
readVersions: payloadAdminOnly,
create: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
hooks: {
beforeChange: [

View file

@ -1,3 +1,4 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents'
import { slugify } from '@/utilities/slug'
import type { CollectionConfig } from 'payload'
@ -22,6 +23,10 @@ export const Post: CollectionConfig<typeof slug> = {
},
access: {
read: () => true,
readVersions: payloadAdminOnly,
create: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
hooks: {
afterChange: [

View file

@ -1,3 +1,4 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { signatureContactToCSV, signaturePetitionToCSV } from '@/utilities/signatureExport'
import { CollectionConfig } from 'payload'
@ -10,6 +11,14 @@ export const Signature: CollectionConfig = {
admin: {
group: 'Abaixo-assinados',
},
// Citizen PII. Public petition flows write via the Local API without a user
// (overrideAccess defaults to true), so admin-only access does not affect them.
access: {
create: payloadAdminOnly,
read: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
fields: [
{
type: 'relationship',

View file

@ -1,3 +1,4 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { CollectionConfig } from 'payload'
export const Subscription: CollectionConfig = {
@ -9,6 +10,14 @@ export const Subscription: CollectionConfig = {
admin: {
group: 'Contatos',
},
// Citizen PII. Public opt-in flows write via the Local API without a user
// (overrideAccess defaults to true), so admin-only access does not affect them.
access: {
create: payloadAdminOnly,
read: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
fields: [
{
type: 'relationship',

View file

@ -1,3 +1,4 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents'
import { slugify } from '@/utilities/slug'
import type { CollectionConfig } from 'payload'
@ -16,6 +17,9 @@ export const Tag: CollectionConfig<typeof slug> = {
},
access: {
read: () => true,
create: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
hooks: {
afterChange: [

View file

@ -1,3 +1,4 @@
import { isPayloadAdmin, payloadAdminOnly } from '@/utilities/campaignAccess'
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
@ -11,6 +12,17 @@ export const Users: CollectionConfig = {
useAsTitle: 'email',
},
auth: true,
// Admin accounts are managed only by admin accounts. Without this, Payload's
// "any authenticated user" default would let a campaignUser JWT manage admins
// through /api/users — a privilege-escalation path.
access: {
admin: ({ req }) => isPayloadAdmin(req.user),
create: payloadAdminOnly,
read: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
unlock: payloadAdminOnly,
},
fields: [
// Email added by default
// Add more fields as needed

View file

@ -19,6 +19,8 @@ fbq('track', 'PageView');
`}
</Script>
<noscript>
{/* Meta's noscript tracking pixel must be a plain 1×1 <img>. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
height="1"

View file

@ -6,12 +6,12 @@ import { NameInput } from '@/components/NameInput'
import { PhoneInput } from '@/components/PhoneInput'
import { StateSelect } from '@/components/StateSelect'
import { Button } from '@/components/ui/button'
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend } from '@/components/ui/field'
import { Field, FieldDescription, FieldGroup } from '@/components/ui/field'
import { Textarea } from '@/components/ui/textarea'
import { WhatsAppFormInput, whatsAppFormSchema } from '@/lib/schemas/whatsapp-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form'
import { useEffect, useTransition } from 'react'
import { useTransition } from 'react'
import { submitWhatsapp } from '@/app/(frontend)/actions/submitWhatsapp'
interface WhatsappFormProps {

View file

@ -1,5 +1,6 @@
'use client'
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
import { useRef, useState, useTransition } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
@ -81,7 +82,11 @@ export const ActionPlanFilters = ({
const valuesRef = useRef(initialValues)
const [values, setValues] = useState(initialValues)
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const sharedPending = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
// Prefer the page-level boundary so the results region dims together.
const isPending = sharedPending?.isPending ?? isLocalPending
const startTransition = sharedPending?.startTransition ?? startLocalTransition
const replaceValues = (nextValues: FilterValues) => {
valuesRef.current = nextValues
@ -101,7 +106,8 @@ export const ActionPlanFilters = ({
const updateKind = (kind: string) => replaceValues({ ...valuesRef.current, kind })
const updateStatus = (status: string) => replaceValues({ ...valuesRef.current, status })
const updateMunicipality = (municipality: string) => replaceValues({ ...valuesRef.current, municipality })
const updateMunicipality = (municipality: string) =>
replaceValues({ ...valuesRef.current, municipality })
const clearFilters = () => replaceValues({ kind: '', status: '', municipality: '' })

View file

@ -1,6 +1,6 @@
'use client'
import { startTransition, useOptimistic } from 'react'
import { useOptimistic, useTransition } from 'react'
import { toast } from 'sonner'
import { toggleActionPlanTaskAction } from '@/app/(campaign)/campanha/(app)/planos/[slug]/taskActions'
@ -14,6 +14,7 @@ type ActionPlanTaskChecklistProps = {
}
export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskChecklistProps) => {
const [isPending, startTransition] = useTransition()
const [optimisticTasks, setOptimisticDone] = useOptimistic(
tasks,
(currentTasks, { taskId, done }: { taskId: string; done: boolean }) =>
@ -26,6 +27,8 @@ export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskCheckli
startTransition(async () => {
setOptimisticDone({ taskId, done })
const result = await toggleActionPlanTaskAction(planId, taskId, done)
// On failure the optimistic state rolls back automatically when the
// transition ends (the action does not revalidate on error).
if (!result.ok) toast.error(result.message)
})
}
@ -35,15 +38,17 @@ export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskCheckli
}
return (
<ul role="list" className="flex flex-col gap-2">
<ul role="list" className="flex flex-col gap-2" aria-busy={isPending}>
{optimisticTasks.map((task) => (
<li
key={task.id ?? task.title}
className="flex items-start gap-3 rounded-lg border p-3"
data-done={task.done}
data-pending={isPending || undefined}
>
<Checkbox
checked={task.done}
disabled={isPending}
onCheckedChange={(checked) => toggle(task.id, checked === true)}
aria-label={`Marcar tarefa "${task.title}" como ${task.done ? 'pendente' : 'concluída'}`}
className="mt-0.5"

View file

@ -24,6 +24,7 @@ import {
type LayerStyleContext,
} from '@/lib/bahiaMapStyle'
import { cn } from '@/lib/utils'
import { Spinner } from '@/components/ui/Spinner'
export type { BahiaMapFillMode, ChoroplethValues }
@ -113,7 +114,9 @@ export const BahiaMap = ({
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const layerRef = useRef<L.GeoJSON | null>(null)
const geometryModuleRef = useRef<MunicipalityGeometryModule | TerritoryGeometryModule | null>(null)
const geometryModuleRef = useRef<MunicipalityGeometryModule | TerritoryGeometryModule | null>(
null,
)
const pathByKeyRef = useRef<Map<string, L.Path>>(new Map())
const styleContextRef = useRef<LayerStyleContext | null>(null)
const geometryReadyRef = useRef(false)
@ -386,6 +389,9 @@ export const BahiaMap = ({
useEffect(() => {
if (!interactiveKey || pathByKeyRef.current.size === 0) return
restyleAllPaths()
// Restyle helpers only read refs and are recreated per render — depending
// on them would re-run this effect on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps -- interactiveKey-only restyle
}, [interactiveKey])
useEffect(() => {
@ -425,6 +431,9 @@ export const BahiaMap = ({
if (selectedKey) {
restyleFeature(selectedKey)
}
// Restyle helpers only read refs and are recreated per render — depending
// on them would re-run this effect on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedKey-only O(2) restyle
}, [selectedKey])
return (
@ -439,7 +448,14 @@ export const BahiaMap = ({
heightClassName,
)}
/>
{status === 'loading' ? <p className="sr-only">Carregando mapa…</p> : null}
{status === 'loading' ? (
<div className="absolute inset-0 flex items-center justify-center" role="status">
<span className="flex items-center gap-2 rounded-md bg-background/85 px-3 py-2 text-sm text-muted-foreground shadow-sm">
<Spinner aria-hidden="true" />
Carregando mapa…
</span>
</div>
) : 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

@ -1,20 +1,18 @@
import { ArrowRightIcon } from 'lucide-react'
import Link from 'next/link'
import type { ReactNode } from 'react'
import { CampaignMetricStrip } from '@/components/campaign/CampaignMetricStrip'
import {
CampaignPageShell,
campaignPrioritySurfaceClassName,
} from '@/components/campaign/CampaignPageShell'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/MunicipalityEstimateScenarioContext'
import { MunicipalityMapPanelDynamic } from '@/components/campaign/MunicipalityMapPanelDynamic'
import { RecentlyVisitedCard } from '@/components/campaign/RecentlyVisitedCard'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { formatElectionNumber } from '@/lib/electionInsights'
import type { StaffDashboardView } from '@/utilities/campaignDashboardData'
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
import type { MunicipalityMapBundle } from '@/utilities/municipalityMapData'
import {
formatVoteEstimateEndpointsLabel,
voteEstimateScenarioLabels,
@ -28,13 +26,12 @@ const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
export const CampaignDashboard = ({
view,
userName,
mapBundle = null,
candidateOptions = [],
mapSlot = null,
}: {
view: StaffDashboardView
userName: string
mapBundle?: MunicipalityMapBundle | null
candidateOptions?: FederalCandidateOption[]
/** Server-streamed map section (composition keeps this component map-agnostic). */
mapSlot?: ReactNode
}) => (
<CampaignPageShell>
<header className="flex flex-col gap-1">
@ -46,11 +43,7 @@ export const CampaignDashboard = ({
</p>
</header>
{mapBundle ? (
<MunicipalityEstimateScenarioProvider>
<MunicipalityMapPanelDynamic bundle={mapBundle} candidateOptions={candidateOptions} />
</MunicipalityEstimateScenarioProvider>
) : null}
{mapSlot}
<section
aria-label="Indicadores das Praças"

View file

@ -0,0 +1,77 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState, useTransition, type MouseEvent } from 'react'
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
import { Button } from '@/components/ui/button'
export type CampaignFilterChip = {
href: string
label: string
active: boolean
}
/**
* Status/kind chips that navigate via URL. Optimistic on the chip (the tapped
* one activates at once), honest pending on the results (shared boundary
* transition dims them) — "Feel the action".
*/
export const CampaignFilterChips = ({
ariaLabel,
chips,
}: {
ariaLabel: string
chips: CampaignFilterChip[]
}) => {
const router = useRouter()
const shared = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
const isPending = shared?.isPending ?? isLocalPending
const startTransition = shared?.startTransition ?? startLocalTransition
const [optimisticHref, setOptimisticHref] = useState<string | null>(null)
const activeHref =
isPending && optimisticHref !== null
? optimisticHref
: (chips.find((chip) => chip.active)?.href ?? null)
const interceptNavigation = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
event.preventDefault()
setOptimisticHref(href)
startTransition(() => {
router.push(href)
})
}
return (
<nav aria-label={ariaLabel} aria-busy={isPending} className="flex flex-wrap gap-2">
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
{chips.map((chip) => (
<Button
key={chip.href}
asChild
variant={chip.href === activeHref ? 'default' : 'outline'}
className="min-h-11"
>
<Link href={chip.href} onClick={(event) => interceptNavigation(event, chip.href)}>
{chip.label}
</Link>
</Button>
))}
</nav>
)
}

View file

@ -1,12 +1,14 @@
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
import { Button } from '@/components/ui/button'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/Pagination'
import { cn } from '@/lib/utils'
export const getPaginationPages = (page: number, totalPages: number): number[] => {
const pages = new Set([1, totalPages, page - 1, page, page + 1])
@ -15,6 +17,12 @@ export const getPaginationPages = (page: number, totalPages: number): number[] =
.sort((a, b) => a - b)
}
/**
* Server component: `hrefForPage` never crosses the client boundary. The
* anchors themselves are `CampaignTransitionAnchor` client leaves so page
* changes ride the shared list transition (results dim) instead of a dead
* full-page load.
*/
export const CampaignListPagination = ({
page,
totalPages,
@ -32,35 +40,73 @@ export const CampaignListPagination = ({
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={hrefForPage(Math.max(1, page - 1))}
aria-disabled={page <= 1}
className={page <= 1 ? 'pointer-events-none opacity-50' : undefined}
/>
<Button
asChild
variant="ghost"
size="default"
className={cn(
'min-h-11 min-w-11 pl-1.5!',
page <= 1 ? 'pointer-events-none opacity-50' : undefined,
)}
>
<CampaignTransitionAnchor
href={hrefForPage(Math.max(1, page - 1))}
aria-label="Ir para a página anterior"
aria-disabled={page <= 1}
data-slot="pagination-link"
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">Anterior</span>
</CampaignTransitionAnchor>
</Button>
</PaginationItem>
{visiblePages.map((pageNumber, index) => {
const previousPage = visiblePages[index - 1]
const hasGap = previousPage !== undefined && pageNumber - previousPage > 1
const isActive = pageNumber === page
return (
<PaginationItem key={pageNumber} className="contents">
{hasGap ? <PaginationEllipsis /> : null}
<PaginationLink
href={hrefForPage(pageNumber)}
isActive={pageNumber === page}
aria-label={`Ir para a página ${pageNumber}`}
<Button
asChild
variant={isActive ? 'outline' : 'ghost'}
size="icon"
className="min-h-11 min-w-11"
>
{pageNumber}
</PaginationLink>
<CampaignTransitionAnchor
href={hrefForPage(pageNumber)}
aria-label={`Ir para a página ${pageNumber}`}
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
>
{pageNumber}
</CampaignTransitionAnchor>
</Button>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
href={hrefForPage(Math.min(totalPages, page + 1))}
aria-disabled={page >= totalPages}
className={page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
/>
<Button
asChild
variant="ghost"
size="default"
className={cn(
'min-h-11 min-w-11 pr-1.5!',
page >= totalPages ? 'pointer-events-none opacity-50' : undefined,
)}
>
<CampaignTransitionAnchor
href={hrefForPage(Math.min(totalPages, page + 1))}
aria-label="Ir para a próxima página"
aria-disabled={page >= totalPages}
data-slot="pagination-link"
>
<span className="hidden sm:block">Próxima</span>
<ChevronRightIcon data-icon="inline-end" />
</CampaignTransitionAnchor>
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>

View file

@ -0,0 +1,110 @@
'use client'
import { useRouter } from 'next/navigation'
import {
createContext,
useContext,
useTransition,
type ComponentPropsWithoutRef,
type ReactNode,
type TransitionStartFunction,
} from 'react'
import { cn } from '@/lib/utils'
type CampaignListPendingValue = {
isPending: boolean
startTransition: TransitionStartFunction
}
const CampaignListPendingContext = createContext<CampaignListPendingValue | null>(null)
/**
* Shares one transition between the filter controls (which navigate) and the
* results region (which must dim while the RSC payload loads) — "Feel the
* action": optimistic on the control, honest pending on the result.
*
* Children stay server-rendered (composition via `children`).
*/
export const CampaignListPendingBoundary = ({ children }: { children: ReactNode }) => {
const [isPending, startTransition] = useTransition()
return (
<CampaignListPendingContext.Provider value={{ isPending, startTransition }}>
{children}
</CampaignListPendingContext.Provider>
)
}
/** Filter controls prefer the shared transition so the results dim with them. */
export const useCampaignListPending = (): CampaignListPendingValue | null =>
useContext(CampaignListPendingContext)
/**
* Anchor that navigates through the shared list transition (falling back to a
* local one), so the results region dims instead of a dead full-page load.
* Takes only serializable props — safe to render from server components.
*/
export const CampaignTransitionAnchor = ({
href,
children,
...anchorProps
}: Omit<ComponentPropsWithoutRef<'a'>, 'href' | 'onClick'> & { href: string }) => {
const router = useRouter()
const shared = useContext(CampaignListPendingContext)
const [, startLocalTransition] = useTransition()
const startTransition = shared?.startTransition ?? startLocalTransition
return (
<a
href={href}
{...anchorProps}
onClick={(event) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
event.preventDefault()
startTransition(() => {
router.push(href)
})
}}
>
{children}
</a>
)
}
/** Wraps the server-rendered results; dims while a shared navigation is pending. */
export const CampaignListResults = ({
children,
className,
}: {
children: ReactNode
className?: string
}) => {
const shared = useContext(CampaignListPendingContext)
const isPending = shared?.isPending ?? false
return (
<div
className={cn(
'flex flex-col gap-6 transition-opacity data-[pending=true]:opacity-60',
className,
)}
data-pending={isPending || undefined}
aria-busy={isPending}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
{children}
</div>
)
}

View file

@ -0,0 +1,66 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
import { Button } from '@/components/ui/button'
/**
* Name-search form for simple list pages (lideranças, organizações,
* dobradinhas): keeps the input value locally (optimistic) and navigates in a
* transition so the results region dims instead of a dead full-page wait.
*/
export const CampaignSearchForm = ({
ariaLabel,
placeholder,
initialQuery,
basePath,
}: {
ariaLabel: string
placeholder: string
initialQuery: string
/** List route; the query lands as `?q=` (serializable — no function props across the RSC boundary). */
basePath: string
}) => {
const router = useRouter()
const shared = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
const isPending = shared?.isPending ?? isLocalPending
const startTransition = shared?.startTransition ?? startLocalTransition
const [query, setQuery] = useState(initialQuery)
const hrefForQuery = (value: string) =>
value ? `${basePath}?q=${encodeURIComponent(value)}` : basePath
return (
<form
role="search"
className="flex gap-2 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending || undefined}
aria-busy={isPending}
onSubmit={(event) => {
event.preventDefault()
startTransition(() => {
router.replace(hrefForQuery(query), { scroll: false })
})
}}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
<input
type="search"
name="q"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
aria-label={ariaLabel}
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" disabled={isPending}>
Buscar
</Button>
</form>
)
}

View file

@ -1,6 +1,6 @@
'use client'
import type { FormEvent } from 'react'
import { useState, type FormEvent } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
@ -10,6 +10,7 @@ import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { CampaignUserAvatar } from '@/components/campaign/CampaignUserAvatar'
import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import {
Sidebar,
SidebarContent,
@ -32,11 +33,15 @@ export type CampaignSidebarUser = CampaignUserShellView
export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
const pathname = usePathname()
const { isMobile, setOpenMobile } = useSidebar()
const [isLoggingOut, setIsLoggingOut] = useState(false)
const handleLogout = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (isLoggingOut) return
setIsLoggingOut(true)
clearRecentVisits()
await clearCampaignPwaCaches()
// logoutCampaign redirects — no need to reset the pending flag on success.
await logoutCampaign()
}
@ -53,7 +58,9 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
<SidebarContent>
<SidebarGroup>
<CampaignScopeBadge className="mb-3 w-fit">{campaignRoleLabels[user.role]}</CampaignScopeBadge>
<CampaignScopeBadge className="mb-3 w-fit">
{campaignRoleLabels[user.role]}
</CampaignScopeBadge>
<SidebarGroupContent>
<SidebarMenu>
{getCampaignNav(user.role).map((item) => (
@ -83,7 +90,9 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
>
<CampaignUserAvatar name={user.name} avatarUrl={user.avatarUrl} size="sm" />
<div className="flex min-w-0 flex-col gap-0.5 leading-none">
<span className="truncate text-sm font-medium text-sidebar-foreground">{user.name}</span>
<span className="truncate text-sm font-medium text-sidebar-foreground">
{user.name}
</span>
<span className="truncate text-xs text-muted-foreground">Meu perfil</span>
</div>
</Link>
@ -92,9 +101,11 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
type="submit"
variant="outline"
size="sm"
disabled={isLoggingOut}
className="min-h-11 w-full border-sidebar-border bg-sidebar font-medium text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
Sair
{isLoggingOut ? <Spinner aria-hidden="true" /> : null}
{isLoggingOut ? 'Saindo…' : 'Sair'}
</Button>
</form>
</SidebarFooter>

View file

@ -2,6 +2,7 @@
import { XIcon } from 'lucide-react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useTransition } from 'react'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldLabel } from '@/components/ui/field'
@ -23,6 +24,7 @@ export const CandidateComparePicker = ({
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [isPending, startTransition] = useTransition()
const optionByNumber = new Map(options.map((option) => [option.candidateNumber, option]))
const navigate = (numbers: number[]) => {
@ -30,11 +32,20 @@ export const CandidateComparePicker = ({
params.delete('compare')
for (const number of numbers) params.append('compare', String(number))
params.set('tab', 'elections')
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
})
}
return (
<div className="flex flex-col gap-2">
<div
className="flex flex-col gap-2 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending || undefined}
aria-busy={isPending}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando comparativo…' : ''}
</p>
<Field className="max-w-md">
<FieldLabel htmlFor="candidate-compare-picker">Comparar com outros candidatos</FieldLabel>
<NativeSelect

View file

@ -1,18 +0,0 @@
'use client'
import dynamic from 'next/dynamic'
import type { ComponentType } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
export const createCampaignClientDynamic = <P extends object>(
importModule: () => Promise<Record<string, ComponentType<P>>>,
exportName: string,
skeletonHeightClassName: string,
) =>
dynamic(() => importModule().then((module) => module[exportName]), {
ssr: false,
loading: () => (
<Skeleton className={`${skeletonHeightClassName} w-full rounded-lg`} aria-hidden="true" />
),
})

View file

@ -24,7 +24,8 @@ type FormAction = (
type DemandWorkflowCardProps = {
demandID: number
status: CampaignDemandStatus
isCoordinator: boolean
/** Coordinator or candidate — the roles allowed to decide escalated demands. */
canDecideEscalated: boolean
currentCost: number | null
transitionFormAction: FormAction
costFormAction: FormAction
@ -38,7 +39,7 @@ const transitionVariant = (target: CampaignDemandStatus) =>
export const DemandWorkflowCard = ({
demandID,
status,
isCoordinator,
canDecideEscalated,
currentCost,
transitionFormAction,
costFormAction,
@ -53,7 +54,9 @@ export const DemandWorkflowCard = ({
const availableTransitions = campaignDemandTransitions[status].filter(
(target) =>
isCoordinator || status !== 'escalada' || (target !== 'aprovada' && target !== 'rejeitada'),
canDecideEscalated ||
status !== 'escalada' ||
(target !== 'aprovada' && target !== 'rejeitada'),
)
return (

View file

@ -1,59 +0,0 @@
'use client'
import { AlertCircleIcon } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Spinner } from '@/components/ui/Spinner'
export const LazyDialogModuleFallback = ({
description,
loadingLabel,
onRetry,
status,
title,
}: {
description: string
loadingLabel: string
onRetry: () => void
status: 'idle' | 'loading' | 'error'
title: string
}) => (
<DialogContent showCloseButton={status !== 'error'}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{status === 'error' ? (
<>
<Alert variant="destructive">
<AlertCircleIcon aria-hidden="true" />
<AlertTitle>Conteúdo indisponível</AlertTitle>
<AlertDescription>Não foi possível carregar este conteúdo.</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
<Button type="button" onClick={onRetry}>
Tentar novamente
</Button>
</DialogFooter>
</>
) : (
<div className="flex min-h-24 items-center justify-center" aria-label={loadingLabel}>
<Spinner aria-hidden="true" />
</div>
)}
</DialogContent>
)

View file

@ -0,0 +1,232 @@
'use client'
import { UserPlusIcon } from 'lucide-react'
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
createLeaderSupporterFormAction,
type LeaderSupporterFormState,
} from '@/app/(campaign)/campanha/actions/leaderSupporter'
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { FormattedInput } from '@/components/FormattedInput'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
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 { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
import { municipalityComboboxOptions } from '@/utilities/territoryComboboxOptions'
const errorProps = (fieldErrors: Record<string, string[]> | undefined, field: string) =>
buildErrorProps(fieldErrors, field, 'leader-contact')
export type LeaderContactFormProps = {
municipalityOptions: RelationOption[]
defaultMunicipalityId: number | null
showMunicipalitySelect: boolean
registrationConsentConfigured: boolean
}
export const LeaderContactForm = ({
municipalityOptions,
defaultMunicipalityId,
showMunicipalitySelect,
registrationConsentConfigured,
}: LeaderContactFormProps) => {
const [state, formAction, pending] = useActionState<LeaderSupporterFormState, FormData>(
createLeaderSupporterFormAction,
{},
)
const [city, setCity] = useState<string>((state.values?.city as string | undefined) ?? '')
const values = state.values
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
}, [state.message, state.status])
const phoneField = errorProps(state.fieldErrors, 'phone')
const nameField = errorProps(state.fieldErrors, 'name')
const cityField = errorProps(state.fieldErrors, 'city')
const municipalityField = errorProps(state.fieldErrors, 'municipality')
const consentError = fieldError(state.fieldErrors, 'consentAccepted')
return (
<section
aria-labelledby="leader-contact-form-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex items-center gap-2">
<UserPlusIcon className="size-5 text-muted-foreground" aria-hidden="true" />
<h2 id="leader-contact-form-title" className="text-base font-medium">
Novo contato
</h2>
</div>
<form
key={state.status === 'success' ? `success-${state.revision ?? 0}` : 'draft'}
action={formAction}
className="flex flex-col gap-4"
>
{(state.message || state.fieldErrors?.form) && state.status !== 'success' ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível cadastrar</AlertTitle>
<AlertDescription>
{state.message ? <p>{state.message}</p> : null}
{state.fieldErrors?.form?.map((error) => (
<p key={error}>{error}</p>
))}
</AlertDescription>
</Alert>
) : null}
<FieldGroup>
<Field data-invalid={phoneField.invalid}>
<FieldLabel htmlFor="leader-contact-phone">Celular *</FieldLabel>
<FormattedInput
id="leader-contact-phone"
name="phone"
required
inputMode="tel"
autoComplete="tel"
autoFocus
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
format={formatBrazilianPhoneInput}
sanitize={sanitizeBrazilianPhoneInput}
aria-invalid={phoneField.invalid}
aria-describedby={phoneField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{phoneField.error ? (
<FieldError id="leader-contact-phone-error">{phoneField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={nameField.invalid}>
<FieldLabel htmlFor="leader-contact-name">Nome *</FieldLabel>
<Input
id="leader-contact-name"
name="name"
required
minLength={2}
defaultValue={values?.name}
autoComplete="name"
aria-invalid={nameField.invalid}
aria-describedby={nameField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{nameField.error ? (
<FieldError id="leader-contact-name-error">{nameField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={cityField.invalid}>
<FieldLabel htmlFor="leader-contact-city">Município</FieldLabel>
<input type="hidden" name="city" value={city} />
<StrictCombobox
id="leader-contact-city"
options={municipalityComboboxOptions()}
value={city}
onValueChange={setCity}
error={cityField.error}
/>
{cityField.error ? (
<FieldError id="leader-contact-city-error">{cityField.error}</FieldError>
) : null}
</Field>
{showMunicipalitySelect ? (
<Field data-invalid={municipalityField.invalid}>
<FieldLabel htmlFor="leader-contact-municipality">Município *</FieldLabel>
<NativeSelect
id="leader-contact-municipality"
name="municipality"
required
defaultValue={values?.municipality ?? ''}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Selecione a Praça</NativeSelectOption>
{municipalityOptions.map((municipality) => (
<NativeSelectOption key={municipality.id} value={String(municipality.id)}>
{municipality.name}
</NativeSelectOption>
))}
</NativeSelect>
{municipalityField.error ? (
<FieldError id="leader-contact-municipality-error">
{municipalityField.error}
</FieldError>
) : null}
</Field>
) : defaultMunicipalityId ? (
<input type="hidden" name="municipality" value={String(defaultMunicipalityId)} />
) : null}
</FieldGroup>
<section
className="rounded-[6px] border bg-muted/30 p-4"
aria-labelledby="leader-contact-consent-title"
>
<h3 id="leader-contact-consent-title" className="font-medium">
Consentimento de cadastro
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Confirme que a pessoa autorizou o uso dos dados para o cadastro como apoiador.
</p>
{!registrationConsentConfigured ? (
<Alert variant="destructive" className="mt-3">
<AlertTitle>Consentimento não configurado</AlertTitle>
<AlertDescription>
O texto ainda não foi cadastrado no admin. Não é possível concluir o cadastro.
</AlertDescription>
</Alert>
) : (
<Field orientation="horizontal" className="mt-3" data-invalid={Boolean(consentError)}>
<Checkbox
id="leader-contact-consent"
name="consentAccepted"
value="true"
required
aria-invalid={Boolean(consentError)}
aria-describedby={consentError ? 'leader-contact-consent-error' : undefined}
/>
<FieldContent>
<FieldLabel htmlFor="leader-contact-consent">
A pessoa autorizou o cadastro *
</FieldLabel>
<FieldDescription>
Você confirma que explicou o uso dos dados conforme o texto versionado da
campanha.
</FieldDescription>
{consentError ? (
<FieldError id="leader-contact-consent-error">{consentError}</FieldError>
) : null}
</FieldContent>
</Field>
)}
</section>
<Button
type="submit"
className="min-h-11 w-full sm:w-auto sm:self-end"
disabled={pending || !registrationConsentConfigured}
>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Cadastrando…' : 'Cadastrar contato'}
</Button>
</form>
</section>
)
}

View file

@ -1,31 +1,8 @@
'use client'
import { PhoneIcon } from 'lucide-react'
import { PhoneIcon, UserPlusIcon } from 'lucide-react'
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
createLeaderSupporterFormAction,
type LeaderSupporterFormState,
} from '@/app/(campaign)/campanha/actions/leaderSupporter'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { LeaderContactForm } from '@/components/campaign/LeaderContactForm'
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { FormattedInput } from '@/components/FormattedInput'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
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 {
Table,
TableBody,
@ -34,19 +11,14 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields'
import type { LeaderContactListItem } from '@/utilities/leaderContactsPageData'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
import { municipalityComboboxOptions } from '@/utilities/territoryComboboxOptions'
import { formatBrazilianPhoneInput } from '@/utilities/phone'
const dateFormatter = new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
timeStyle: 'short',
})
const errorProps = (fieldErrors: Record<string, string[]> | undefined, field: string) =>
buildErrorProps(fieldErrors, field, 'leader-contact')
type LeaderContactsPanelProps = {
userName: string
municipalityOptions: RelationOption[]
@ -56,204 +28,6 @@ type LeaderContactsPanelProps = {
contacts: LeaderContactListItem[]
}
const LeaderContactForm = ({
municipalityOptions,
defaultMunicipalityId,
showMunicipalitySelect,
registrationConsentConfigured,
}: Pick<
LeaderContactsPanelProps,
| 'municipalityOptions'
| 'defaultMunicipalityId'
| 'showMunicipalitySelect'
| 'registrationConsentConfigured'
>) => {
const [state, formAction, pending] = useActionState<LeaderSupporterFormState, FormData>(
createLeaderSupporterFormAction,
{},
)
const [city, setCity] = useState<string>((state.values?.city as string | undefined) ?? '')
const values = state.values
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
}, [state.message, state.status])
const phoneField = errorProps(state.fieldErrors, 'phone')
const nameField = errorProps(state.fieldErrors, 'name')
const cityField = errorProps(state.fieldErrors, 'city')
const municipalityField = errorProps(state.fieldErrors, 'municipality')
const consentError = fieldError(state.fieldErrors, 'consentAccepted')
return (
<section
aria-labelledby="leader-contact-form-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex items-center gap-2">
<UserPlusIcon className="size-5 text-muted-foreground" aria-hidden="true" />
<h2 id="leader-contact-form-title" className="text-base font-medium">
Novo contato
</h2>
</div>
<form
key={state.status === 'success' ? `success-${state.revision ?? 0}` : 'draft'}
action={formAction}
className="flex flex-col gap-4"
>
{(state.message || state.fieldErrors?.form) && state.status !== 'success' ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível cadastrar</AlertTitle>
<AlertDescription>
{state.message ? <p>{state.message}</p> : null}
{state.fieldErrors?.form?.map((error) => (
<p key={error}>{error}</p>
))}
</AlertDescription>
</Alert>
) : null}
<FieldGroup>
<Field data-invalid={phoneField.invalid}>
<FieldLabel htmlFor="leader-contact-phone">Celular *</FieldLabel>
<FormattedInput
id="leader-contact-phone"
name="phone"
required
inputMode="tel"
autoComplete="tel"
autoFocus
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
format={formatBrazilianPhoneInput}
sanitize={sanitizeBrazilianPhoneInput}
aria-invalid={phoneField.invalid}
aria-describedby={phoneField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{phoneField.error ? (
<FieldError id="leader-contact-phone-error">{phoneField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={nameField.invalid}>
<FieldLabel htmlFor="leader-contact-name">Nome *</FieldLabel>
<Input
id="leader-contact-name"
name="name"
required
minLength={2}
defaultValue={values?.name}
autoComplete="name"
aria-invalid={nameField.invalid}
aria-describedby={nameField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{nameField.error ? (
<FieldError id="leader-contact-name-error">{nameField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={cityField.invalid}>
<FieldLabel htmlFor="leader-contact-city">Município</FieldLabel>
<input type="hidden" name="city" value={city} />
<StrictCombobox
id="leader-contact-city"
options={municipalityComboboxOptions()}
value={city}
onValueChange={setCity}
error={cityField.error}
/>
{cityField.error ? (
<FieldError id="leader-contact-city-error">{cityField.error}</FieldError>
) : null}
</Field>
{showMunicipalitySelect ? (
<Field data-invalid={municipalityField.invalid}>
<FieldLabel htmlFor="leader-contact-municipality">Município *</FieldLabel>
<NativeSelect
id="leader-contact-municipality"
name="municipality"
required
defaultValue={values?.municipality ?? ''}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Selecione a Praça</NativeSelectOption>
{municipalityOptions.map((municipality) => (
<NativeSelectOption key={municipality.id} value={String(municipality.id)}>
{municipality.name}
</NativeSelectOption>
))}
</NativeSelect>
{municipalityField.error ? (
<FieldError id="leader-contact-municipality-error">
{municipalityField.error}
</FieldError>
) : null}
</Field>
) : defaultMunicipalityId ? (
<input type="hidden" name="municipality" value={String(defaultMunicipalityId)} />
) : null}
</FieldGroup>
<section
className="rounded-[6px] border bg-muted/30 p-4"
aria-labelledby="leader-contact-consent-title"
>
<h3 id="leader-contact-consent-title" className="font-medium">
Consentimento de cadastro
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Confirme que a pessoa autorizou o uso dos dados para o cadastro como apoiador.
</p>
{!registrationConsentConfigured ? (
<Alert variant="destructive" className="mt-3">
<AlertTitle>Consentimento não configurado</AlertTitle>
<AlertDescription>
O texto ainda não foi cadastrado no admin. Não é possível concluir o cadastro.
</AlertDescription>
</Alert>
) : (
<Field orientation="horizontal" className="mt-3" data-invalid={Boolean(consentError)}>
<Checkbox
id="leader-contact-consent"
name="consentAccepted"
value="true"
required
aria-invalid={Boolean(consentError)}
aria-describedby={consentError ? 'leader-contact-consent-error' : undefined}
/>
<FieldContent>
<FieldLabel htmlFor="leader-contact-consent">
A pessoa autorizou o cadastro *
</FieldLabel>
<FieldDescription>
Você confirma que explicou o uso dos dados conforme o texto versionado da
campanha.
</FieldDescription>
{consentError ? (
<FieldError id="leader-contact-consent-error">{consentError}</FieldError>
) : null}
</FieldContent>
</Field>
)}
</section>
<Button
type="submit"
className="min-h-11 w-full sm:w-auto sm:self-end"
disabled={pending || !registrationConsentConfigured}
>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Cadastrando…' : 'Cadastrar contato'}
</Button>
</form>
</section>
)
}
const LeaderContactsList = ({ contacts }: { contacts: LeaderContactListItem[] }) => (
<section
aria-labelledby="leader-contacts-list-title"
@ -312,6 +86,11 @@ const LeaderContactsList = ({ contacts }: { contacts: LeaderContactListItem[] })
</section>
)
/**
* Leader home (lockdown role). Server component: the header and the contacts
* table render on the server; only the registration form hydrates
* (`LeaderContactForm` is the client island).
*/
export const LeaderContactsPanel = ({
userName,
municipalityOptions,

View file

@ -4,7 +4,7 @@ import type { BahiaMapFeatureInfo } from '@/components/campaign/BahiaMap'
import { Button } from '@/components/ui/button'
import { formatElectionNumber } from '@/lib/electionInsights'
import type { MunicipalityMapNavigation } from '@/utilities/municipalityMapNavigation'
import type { MunicipalityMapScaleMode } from '@/utilities/municipalityMapData'
import type { MunicipalityMapScaleMode } from '@/utilities/municipalityMapContract'
type MapFeatureReadoutProps = {
feature: BahiaMapFeatureInfo | null

View file

@ -1,5 +1,6 @@
'use client'
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, useTransition } from 'react'
@ -29,7 +30,11 @@ type MunicipalityFiltersProps = {
export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFiltersProps) => {
const router = useRouter()
const [search, setSearch] = useState(state.q ?? '')
const [isPending, startTransition] = useTransition()
const sharedPending = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
// Prefer the page-level boundary so the results region dims together.
const isPending = sharedPending?.isPending ?? isLocalPending
const startTransition = sharedPending?.startTransition ?? startLocalTransition
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(
@ -54,7 +59,8 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
page: 1,
q: normalizedText(patch.q !== undefined ? patch.q : search),
}
if (buildMunicipalityFiltersKey(merged) === buildMunicipalityFiltersKey({ ...state, page: 1 })) return
if (buildMunicipalityFiltersKey(merged) === buildMunicipalityFiltersKey({ ...state, page: 1 }))
return
startTransition(() => {
router.replace(buildMunicipalityListHref(merged, 1), { scroll: false })
@ -73,12 +79,12 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
const hasActiveFilters = Boolean(
state.q ||
normalizedText(search) ||
state.region ||
state.kind ||
state.coverage ||
state.priority ||
state.trend,
normalizedText(search) ||
state.region ||
state.kind ||
state.coverage ||
state.priority ||
state.trend,
)
return (
@ -140,7 +146,9 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
className="min-h-11 w-full"
>
<NativeSelectOption value="">Todos</NativeSelectOption>
{(Object.keys(municipalityKindLabels) as Array<keyof typeof municipalityKindLabels>).map((kind) => (
{(
Object.keys(municipalityKindLabels) as Array<keyof typeof municipalityKindLabels>
).map((kind) => (
<NativeSelectOption key={kind} value={kind}>
{municipalityKindLabels[kind]}
</NativeSelectOption>
@ -156,7 +164,8 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
value={state.coverage ?? ''}
onChange={(event) =>
commitNavigation({
coverage: (event.target.value || undefined) as MunicipalityListState['coverage'],
coverage: (event.target.value ||
undefined) as MunicipalityListState['coverage'],
})
}
className="min-h-11 w-full"

View file

@ -156,7 +156,6 @@ export const MunicipalityList = ({
</TableHeader>
<TableBody>
{municipalities.map((municipality) => {
const names = advisorNames(municipality)
const hasAdvisor = municipality.advisorIDs.length > 0
return (

View file

@ -2,7 +2,7 @@
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState, useTransition } from 'react'
import { BahiaMap, type BahiaMapFeatureInfo } from '@/components/campaign/BahiaMap'
import { ChoroplethLegend } from '@/components/campaign/ChoroplethLegend'
@ -19,14 +19,14 @@ import { computeValidVoteShares, divergingGradientCss } from '@/lib/choroplethCo
import { formatElectionNumber } from '@/lib/electionInsights'
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
import {
PLAZA_MAP_SCALE_MODES,
PLAZA_MAP_YEARS,
MUNICIPALITY_MAP_SCALE_MODES,
MUNICIPALITY_MAP_YEARS,
municipalityMapScaleModeLabels,
municipalityMapYearLabels,
type MunicipalityMapBundle,
type MunicipalityMapScaleMode,
type MunicipalityMapYear,
} from '@/utilities/municipalityMapData'
} from '@/utilities/municipalityMapContract'
import { resolveMunicipalityMapNavigation } from '@/utilities/municipalityMapNavigation'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
@ -56,8 +56,6 @@ export const MunicipalityMapPanel = ({
)
const estimateScenario = scenarioContext?.scenario ?? localEstimateScenario
const setEstimateScenario = scenarioContext?.setScenario ?? setLocalEstimateScenario
const [selectedFeature, setSelectedFeature] = useState<BahiaMapFeatureInfo | null>(null)
const selectedKeyRef = useRef<string | null>(null)
const comparison = bundle.comparison
const comparisonActive = comparison !== null && year !== 2026
@ -73,7 +71,10 @@ export const MunicipalityMapPanel = ({
return bundle.valuesByYear[String(year)] ?? {}
}, [bundle, comparison, comparisonActive, estimateScenario, year])
const validVotesForYear = bundle.validVotesByYear[String(year)] ?? {}
const validVotesForYear = useMemo(
() => bundle.validVotesByYear[String(year)] ?? {},
[bundle.validVotesByYear, year],
)
const displayValues = useMemo(() => {
if (percentScaleActive) {
@ -87,7 +88,10 @@ export const MunicipalityMapPanel = ({
return computeChoroplethMax(displayValues, fillMode, percentScaleActive ? 1 : undefined)
}, [comparisonActive, displayValues, percentScaleActive])
const scopedKeys = useMemo(() => Object.keys(bundle.municipalitiesByIbgeCode), [bundle.municipalitiesByIbgeCode])
const scopedKeys = useMemo(
() => Object.keys(bundle.municipalitiesByIbgeCode),
[bundle.municipalitiesByIbgeCode],
)
const metricLabel = useMemo(() => {
if (comparisonActive) {
@ -104,54 +108,28 @@ export const MunicipalityMapPanel = ({
return `votos de ${bundle.candidateName} em ${year}`
}, [bundle.candidateName, comparisonActive, estimateScenario, percentScaleActive, year])
// Optimistic on the control, honest pending on the map: the comparison data
// is a server round trip, so the select flips at once and the map region
// dims until the refreshed bundle arrives.
const [isComparePending, startCompareTransition] = useTransition()
const [optimisticCompare, setOptimisticCompare] = useState<string | null>(null)
const compareSelectValue =
isComparePending && optimisticCompare !== null
? optimisticCompare
: comparison
? String(comparison.candidateNumber)
: ''
const setCompare = (candidateNumber: string) => {
setOptimisticCompare(candidateNumber)
const params = new URLSearchParams(searchParams.toString())
if (candidateNumber) params.set('compare', candidateNumber)
else params.delete('compare')
router.replace(`${pathname}${params.size ? `?${params.toString()}` : ''}`, { scroll: false })
startCompareTransition(() => {
router.replace(`${pathname}${params.size ? `?${params.toString()}` : ''}`, { scroll: false })
})
}
const handleFeatureSelect = useCallback((info: BahiaMapFeatureInfo | null) => {
selectedKeyRef.current = info?.key ?? null
setSelectedFeature(info)
}, [])
const handleFeatureActivate = useCallback(
(key: string) => {
if (selectedKeyRef.current !== key) return
const navigation = resolveMunicipalityMapNavigation(key, bundle.municipalitiesByIbgeCode)
if (navigation.kind === 'navigate') {
router.push(`/campanha/municipios/${navigation.slug}`)
return
}
if (navigation.kind === 'zones') {
document.getElementById('municipality-zone-breakdown')?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
},
[bundle.municipalitiesByIbgeCode, router],
)
const selectedMetricValue =
selectedFeature && selectedFeature.key in displayValues
? displayValues[selectedFeature.key]
: undefined
const selectedRawMetricValue =
selectedFeature && selectedFeature.key in rawValues ? rawValues[selectedFeature.key] : undefined
const selectedNavigation = useMemo(
() =>
selectedFeature
? resolveMunicipalityMapNavigation(selectedFeature.key, bundle.municipalitiesByIbgeCode)
: null,
[bundle.municipalitiesByIbgeCode, selectedFeature],
)
return (
<section
aria-labelledby="municipality-map-title"
@ -179,7 +157,7 @@ export const MunicipalityMapPanel = ({
onChange={(event) => setYear(Number(event.target.value) as MunicipalityMapYear)}
className="min-h-11 w-full"
>
{PLAZA_MAP_YEARS.map((entry) => (
{MUNICIPALITY_MAP_YEARS.map((entry) => (
<NativeSelectOption key={entry} value={String(entry)}>
{municipalityMapYearLabels[entry]}
</NativeSelectOption>
@ -195,7 +173,7 @@ export const MunicipalityMapPanel = ({
onChange={(event) => setScaleMode(event.target.value as MunicipalityMapScaleMode)}
className="min-h-11 w-full"
>
{PLAZA_MAP_SCALE_MODES.map((mode) => (
{MUNICIPALITY_MAP_SCALE_MODES.map((mode) => (
<NativeSelectOption key={mode} value={mode}>
{municipalityMapScaleModeLabels[mode]}
</NativeSelectOption>
@ -215,7 +193,7 @@ export const MunicipalityMapPanel = ({
<FieldLabel htmlFor="municipality-map-compare">Comparar com</FieldLabel>
<NativeSelect
id="municipality-map-compare"
value={comparison ? String(comparison.candidateNumber) : ''}
value={compareSelectValue}
onChange={(event) => setCompare(event.target.value)}
className="min-h-11 w-full"
>
@ -234,42 +212,178 @@ export const MunicipalityMapPanel = ({
</div>
</div>
{comparisonActive && comparison ? (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="shrink-0 text-xs text-muted-foreground">
+{comparison.candidateName}
</span>
<div
className="h-2.5 min-w-0 flex-1 rounded-full ring-1 ring-foreground/10"
style={{ background: divergingGradientCss }}
role="img"
aria-label={`Escala divergente: azul onde ${comparison.candidateName} lidera, branco no empate, vermelho onde ${bundle.candidateName} lidera`}
/>
<span className="shrink-0 text-xs text-muted-foreground">+{bundle.candidateName}</span>
</div>
<p className="text-xs text-muted-foreground">
Vermelho: {bundle.candidateName} na frente · Branco: empate · Azul:{' '}
{comparison.candidateName} na frente. Comparação usa diferença absoluta.
</p>
</div>
) : displayMax > 0 ? (
<ChoroplethLegend
max={displayMax}
metricLabel={metricLabel}
formatMax={percentScaleActive ? () => '100%' : undefined}
/>
) : (
<p className="text-sm text-muted-foreground">Sem dados para este ano no seu escopo.</p>
)}
{comparison && year === 2026 ? (
<p className="text-sm text-muted-foreground">
A comparação usa os anos com dados TSE (2014, 2018, 2022). Escolha um destes anos para ver
o mapa comparativo.
<div
className="flex flex-col gap-4 transition-opacity data-[pending=true]:opacity-60"
data-pending={isComparePending || undefined}
aria-busy={isComparePending}
>
<p className="sr-only" aria-live="polite">
{isComparePending ? 'Atualizando comparação…' : ''}
</p>
) : null}
{comparisonActive && comparison ? (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="shrink-0 text-xs text-muted-foreground">
+{comparison.candidateName}
</span>
<div
className="h-2.5 min-w-0 flex-1 rounded-full ring-1 ring-foreground/10"
style={{ background: divergingGradientCss }}
role="img"
aria-label={`Escala divergente: azul onde ${comparison.candidateName} lidera, branco no empate, vermelho onde ${bundle.candidateName} lidera`}
/>
<span className="shrink-0 text-xs text-muted-foreground">
+{bundle.candidateName}
</span>
</div>
<p className="text-xs text-muted-foreground">
Vermelho: {bundle.candidateName} na frente · Branco: empate · Azul:{' '}
{comparison.candidateName} na frente. Comparação usa diferença absoluta.
</p>
</div>
) : displayMax > 0 ? (
<ChoroplethLegend
max={displayMax}
metricLabel={metricLabel}
formatMax={percentScaleActive ? () => '100%' : undefined}
/>
) : (
<p className="text-sm text-muted-foreground">Sem dados para este ano no seu escopo.</p>
)}
{comparison && year === 2026 ? (
<p className="text-sm text-muted-foreground">
A comparação usa os anos com dados TSE (2014, 2018, 2022). Escolha um destes anos para
ver o mapa comparativo.
</p>
) : null}
<MunicipalityMapSelection
displayValues={displayValues}
rawValues={rawValues}
displayMax={displayMax}
scopedKeys={scopedKeys}
metricLabel={metricLabel}
scaleMode={comparisonActive ? 'absolute' : scaleMode}
comparisonActive={comparisonActive}
municipalitiesByIbgeCode={bundle.municipalitiesByIbgeCode}
ariaLabel={
comparisonActive && comparison
? `Mapa comparativo entre ${bundle.candidateName} e ${comparison.candidateName} por município`
: `Mapa da Bahia com ${metricLabel} por município`
}
/>
{/* zone breakdown below */}
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
<div id="municipality-zone-breakdown" className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Praças por zona eleitoral</h3>
<p className="text-sm text-muted-foreground">
Zonas não têm polígono oficial — Salvador e Camaçari aparecem agregadas no mapa e
detalhadas aqui.
</p>
<ul className="grid gap-1 sm:grid-cols-2">
{bundle.zoneBreakdown.map((zone) => (
<li
key={zone.slug}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
>
<Link
href={`/campanha/municipios/${zone.slug}`}
className="min-h-11 content-center text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{zone.name}
</Link>
<span className="text-sm tabular-nums text-muted-foreground">
{formatElectionNumber(
year === 2026
? (zone.votes2026ByScenario[estimateScenario] ?? 0)
: (zone.votesByYear[String(year)] ?? 0),
)}
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
</section>
)
}
/**
* Selection/hover live here so a mouseover re-renders only the map and the
* readout — not the whole panel (controls, legend, zone list). The Leaflet
* paint path itself stays ref-based inside BahiaMap (O(2) restyle).
*/
const MunicipalityMapSelection = ({
displayValues,
rawValues,
displayMax,
scopedKeys,
metricLabel,
scaleMode,
comparisonActive,
municipalitiesByIbgeCode,
ariaLabel,
}: {
displayValues: Record<string, number>
rawValues: Record<string, number>
displayMax: number
scopedKeys: string[]
metricLabel: string
scaleMode: MunicipalityMapScaleMode
comparisonActive: boolean
municipalitiesByIbgeCode: MunicipalityMapBundle['municipalitiesByIbgeCode']
ariaLabel: string
}) => {
const router = useRouter()
const [selectedFeature, setSelectedFeature] = useState<BahiaMapFeatureInfo | null>(null)
const selectedKeyRef = useRef<string | null>(null)
const handleFeatureSelect = useCallback((info: BahiaMapFeatureInfo | null) => {
selectedKeyRef.current = info?.key ?? null
setSelectedFeature(info)
}, [])
const handleFeatureActivate = useCallback(
(key: string) => {
if (selectedKeyRef.current !== key) return
const navigation = resolveMunicipalityMapNavigation(key, municipalitiesByIbgeCode)
if (navigation.kind === 'navigate') {
router.push(`/campanha/municipios/${navigation.slug}`)
return
}
if (navigation.kind === 'zones') {
document.getElementById('municipality-zone-breakdown')?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
},
[municipalitiesByIbgeCode, router],
)
const selectedMetricValue =
selectedFeature && selectedFeature.key in displayValues
? displayValues[selectedFeature.key]
: undefined
const selectedRawMetricValue =
selectedFeature && selectedFeature.key in rawValues ? rawValues[selectedFeature.key] : undefined
const selectedNavigation = useMemo(
() =>
selectedFeature
? resolveMunicipalityMapNavigation(selectedFeature.key, municipalitiesByIbgeCode)
: null,
[municipalitiesByIbgeCode, selectedFeature],
)
return (
<>
<BahiaMap
mode="municipality"
values={displayValues}
@ -280,11 +394,7 @@ export const MunicipalityMapPanel = ({
selectedKey={selectedFeature?.key ?? null}
onFeatureSelect={handleFeatureSelect}
onFeatureActivate={handleFeatureActivate}
ariaLabel={
comparisonActive && comparison
? `Mapa comparativo entre ${bundle.candidateName} e ${comparison.candidateName} por município`
: `Mapa da Bahia com ${metricLabel} por município`
}
ariaLabel={ariaLabel}
/>
<MapFeatureReadout
@ -292,42 +402,10 @@ export const MunicipalityMapPanel = ({
metricValue={selectedMetricValue}
rawMetricValue={selectedRawMetricValue}
metricLabel={metricLabel}
scaleMode={comparisonActive ? 'absolute' : scaleMode}
scaleMode={scaleMode}
comparisonActive={comparisonActive}
navigation={selectedNavigation}
/>
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
<div id="municipality-zone-breakdown" className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Praças por zona eleitoral</h3>
<p className="text-sm text-muted-foreground">
Zonas não têm polígono oficial — Salvador e Camaçari aparecem agregadas no mapa e
detalhadas aqui.
</p>
<ul className="grid gap-1 sm:grid-cols-2">
{bundle.zoneBreakdown.map((zone) => (
<li
key={zone.slug}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
>
<Link
href={`/campanha/municipios/${zone.slug}`}
className="min-h-11 content-center text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{zone.name}
</Link>
<span className="text-sm tabular-nums text-muted-foreground">
{formatElectionNumber(
year === 2026
? (zone.votes2026ByScenario[estimateScenario] ?? 0)
: (zone.votesByYear[String(year)] ?? 0),
)}
</span>
</li>
))}
</ul>
</div>
) : null}
</section>
</>
)
}

View file

@ -3,7 +3,7 @@
import dynamic from 'next/dynamic'
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
import type { MunicipalityMapBundle } from '@/utilities/municipalityMapData'
import type { MunicipalityMapBundle } from '@/utilities/municipalityMapContract'
const MunicipalityMapPanelLazy = dynamic(
() => import('@/components/campaign/MunicipalityMapPanel').then((module) => module.MunicipalityMapPanel),

View file

@ -1,4 +1,4 @@
import { hasAnyEstimate, PledgeEstimateForm } from '@/components/campaign/PledgeEstimateForm'
import { PledgeEstimateForm } from '@/components/campaign/PledgeEstimateForm'
import { Badge } from '@/components/ui/Badge'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
@ -7,6 +7,7 @@ import {
getVoteEstimateForScenario,
} from '@/utilities/voteEstimate'
import { type StaffPledgeRow } from '@/utilities/votePledgeData'
import { hasAnyEstimate } from '@/utilities/voteEstimate'
const voteFormatter = new Intl.NumberFormat('pt-BR')
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
@ -20,7 +21,10 @@ type MunicipalityPledgesPanelProps = {
}
/** Staff-only: declared vs estimated votes per leadership in this municipality. */
export const MunicipalityPledgesPanel = ({ pledges, estimateFormAction }: MunicipalityPledgesPanelProps) => {
export const MunicipalityPledgesPanel = ({
pledges,
estimateFormAction,
}: MunicipalityPledgesPanelProps) => {
const declaredTotal = pledges.reduce((total, pledge) => total + pledge.declaredVotes, 0)
const effectiveTotal = pledges.reduce(
(total, pledge) =>

View file

@ -22,9 +22,6 @@ type PledgeEstimateFormProps = {
) => Promise<CampaignFormActionState>
}
const hasAnyEstimate = (estimates: VoteEstimateScenarioViewModel): boolean =>
estimates.pessimistic != null || estimates.central != null || estimates.optimistic != null
/** Staff-only inline estimate. The leader never sees these fields. */
export const PledgeEstimateForm = ({
pledgeID,
@ -82,5 +79,3 @@ export const PledgeEstimateForm = ({
</form>
)
}
export { hasAnyEstimate }

View file

@ -13,15 +13,19 @@ export const RecentVisitTracker = ({
}: {
entry: Omit<RecentVisitEntry, 'visitedAt'>
}) => {
// Primitive dependencies: the effect must re-run on visit identity changes,
// not on parent re-renders handing down a fresh `entry` object.
const { href, label, kind } = entry
useEffect(() => {
const timer = window.setTimeout(() => {
recordRecentVisit({ ...entry, visitedAt: Date.now() })
recordRecentVisit({ href, label, kind, visitedAt: Date.now() })
}, RECORD_DWELL_MS)
return () => {
window.clearTimeout(timer)
}
}, [entry.href, entry.label, entry.kind])
}, [href, label, kind])
return null
}

View file

@ -1,32 +0,0 @@
'use client'
import { useCallback, useState } from 'react'
import { RecentlyVisitedCard } from '@/components/campaign/RecentlyVisitedCard'
import { UpcomingActionPlansCard } from '@/components/campaign/UpcomingActionPlansCard'
import { cn } from '@/lib/utils'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import { listRecentVisits } from '@/utilities/recentVisits'
type RecentlyVisitedProps = {
now: Date
upcomingPlans: ActionPlanUpcomingPreviewRecord[]
}
export const RecentlyVisited = ({ now, upcomingPlans }: RecentlyVisitedProps) => {
const [hasRecent, setHasRecent] = useState(() => listRecentVisits().length > 0)
const handleVisitCountChange = useCallback((count: number) => {
setHasRecent(count > 0)
}, [])
return (
<div className={cn('grid gap-6', hasRecent ? 'lg:grid-cols-2 lg:items-start' : undefined)}>
<RecentlyVisitedCard
now={now}
clearControl="labeled"
onVisitCountChange={handleVisitCountChange}
/>
<UpcomingActionPlansCard plans={upcomingPlans} />
</div>
)
}

View file

@ -1,5 +1,6 @@
'use client'
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
import { type FormEvent, useRef, useState, useTransition } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
@ -111,7 +112,11 @@ export const SupporterFilters = ({
const valuesRef = useRef(initialValues)
const [values, setValues] = useState(initialValues)
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const sharedPending = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
// Prefer the page-level boundary so the results region dims together.
const isPending = sharedPending?.isPending ?? isLocalPending
const startTransition = sharedPending?.startTransition ?? startLocalTransition
const replaceValues = (nextValues: FilterValues) => {
valuesRef.current = nextValues
@ -138,7 +143,9 @@ export const SupporterFilters = ({
}
const clearFilters = () => {
const cleared = Object.fromEntries(filterNames.map((name) => [name, ''])) as Partial<FilterValues>
const cleared = Object.fromEntries(
filterNames.map((name) => [name, '']),
) as Partial<FilterValues>
replaceValues({ ...valuesRef.current, ...cleared })
}

View file

@ -1,60 +0,0 @@
import { ArrowRightIcon } from 'lucide-react'
import Link from 'next/link'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { cn } from '@/lib/utils'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
export const UpcomingActionPlansCard = ({
plans,
className,
}: {
plans: ActionPlanUpcomingPreviewRecord[]
className?: string
}) => (
<Card className={cn('flex h-full flex-col', className)}>
<CardHeader>
<CardTitle>Próximos eventos</CardTitle>
<CardAction>
<Button asChild variant="ghost" size="sm" className="min-h-11">
<Link href="/campanha/planos">
Ver todos
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</Button>
</CardAction>
</CardHeader>
<CardContent className="flex-1">
{plans.length ? (
<ul className="m-0 flex list-none flex-col gap-2 p-0">
{plans.map((plan) => (
<li
key={plan.id}
className="flex flex-col gap-1 border-b pb-2 last:border-b-0 last:pb-0"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{actionPlanKindLabels[plan.kind]}</Badge>
<span className="text-sm text-muted-foreground">
{formatBahiaDateTimeLabel(plan.startAt)}
{plan.municipalityName ? ` · ${plan.municipalityName}` : ''}
</span>
</div>
<Link
href={`/campanha/planos/${plan.slug}`}
className="text-sm text-primary underline-offset-4 hover:underline"
>
{plan.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhum evento agendado</p>
)}
</CardContent>
</Card>
)

View file

@ -30,7 +30,8 @@ const staffNav: CampaignNavItem[] = [
]
const leaderNav: CampaignNavItem[] = [
{ title: 'Início', href: '/campanha', icon: HomeIcon },
// The leader home IS the contact tool — one entry, one href (duplicate
// hrefs previously produced duplicate React keys in the sidebar).
{ title: 'Meus contatos', href: '/campanha', icon: UsersIcon },
]

View file

@ -1,67 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
type LazyDialogModule<T> = { default: T }
type LazyDialogModuleState<T> =
| { status: 'idle'; component: null }
| { status: 'loading'; component: null }
| { status: 'ready'; component: T }
| { status: 'error'; component: null }
export const useLazyDialogModule = <T,>(
loadModule: () => Promise<LazyDialogModule<T>>,
) => {
const [state, setState] = useState<LazyDialogModuleState<T>>({
status: 'idle',
component: null,
})
const statusRef = useRef<LazyDialogModuleState<T>['status']>('idle')
const attemptRef = useRef(0)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
attemptRef.current += 1
}
}, [])
const updateState = useCallback((nextState: LazyDialogModuleState<T>) => {
statusRef.current = nextState.status
setState(nextState)
}, [])
const load = useCallback(() => {
if (statusRef.current === 'loading' || statusRef.current === 'ready') return
const attempt = ++attemptRef.current
updateState({ status: 'loading', component: null })
void Promise.resolve()
.then(loadModule)
.then(({ default: component }) => {
if (!mountedRef.current || attempt !== attemptRef.current) return
updateState({ status: 'ready', component })
})
.catch(() => {
if (!mountedRef.current || attempt !== attemptRef.current) return
updateState({ status: 'error', component: null })
})
}, [loadModule, updateState])
const resetAfterClose = useCallback(() => {
if (statusRef.current !== 'error') return
attemptRef.current += 1
updateState({ status: 'idle', component: null })
}, [updateState])
return {
component: state.component,
load,
resetAfterClose,
status: state.status,
}
}

View file

@ -1,234 +0,0 @@
'use client'
import * as React from 'react'
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
import { CheckIcon, ChevronRightIcon } from 'lucide-react'
const DropdownMenu = ({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) => (
<DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
)
const DropdownMenuPortal = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) => (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
const DropdownMenuTrigger = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) => (
<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
)
const DropdownMenuContent = ({
className,
align = 'start',
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn(
'z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
const DropdownMenuGroup = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) => (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
const DropdownMenuItem = ({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) => (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
)
const DropdownMenuCheckboxItem = ({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) => (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
const DropdownMenuRadioGroup = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) => (
<DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
)
const DropdownMenuRadioItem = ({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) => (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
const DropdownMenuLabel = ({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) => (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
className,
)}
{...props}
/>
)
const DropdownMenuSeparator = ({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) => (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
const DropdownMenuShortcut = ({ className, ...props }: React.ComponentProps<'span'>) => (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
className,
)}
{...props}
/>
)
const DropdownMenuSub = ({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) => (
<DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
)
const DropdownMenuSubTrigger = ({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) => (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
const DropdownMenuSubContent = ({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) => (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
)
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -1,78 +0,0 @@
'use client'
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Tabs as TabsPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
const Tabs = ({
className,
orientation = 'horizontal',
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) => (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn('group/tabs flex gap-2 data-horizontal:flex-col', className)}
{...props}
/>
)
const tabsListVariants = cva(
'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:max-w-full group-data-horizontal/tabs:justify-start group-data-horizontal/tabs:overflow-x-auto group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none',
{
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
variant: 'default',
},
},
)
const TabsList = ({
className,
variant = 'default',
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) => (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
const TabsTrigger = ({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) => (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex min-h-11 flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
className,
)}
{...props}
/>
)
const TabsContent = ({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) => (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 text-sm outline-none', className)}
{...props}
/>
)
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View file

@ -1,185 +0,0 @@
'use client'
import * as React from 'react'
import { Select as SelectPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react'
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn('scroll-my-1 p-1', className)}
{...props}
/>
)
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-[var(--field-border)] bg-[var(--field-background)] py-2 pr-2 pl-2.5 text-sm whitespace-nowrap text-[var(--field-foreground)] transition-colors outline-none select-none focus-visible:border-primary focus-visible:ring-3 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-[var(--field-placeholder)] data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-[var(--field-icon)]" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = 'item-aligned',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === 'item-aligned'}
className={cn(
'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
'data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)',
position === 'popper' && '',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('px-1.5 py-1 text-xs text-muted-foreground', className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -0,0 +1,44 @@
import artifactJson from '@/lib/electionAggregates/bahia-federal-baseline.json'
/**
* Committed per-municipality election baseline (immutable TSE 2014/2018/2022):
* Solla nominal votes + federal T1 valid votes, pre-cut by the same
* `municipalityCatalog` geography the runtime uses (Salvador split by zone,
* every other municipality whole — Camaçari included).
*
* Generated by `pnpm build:election-aggregates` from the locally seeded TSE
* data — never computed at build or request time (see the script header for
* the rationale). A snapshot test pins artifact keys ↔ catalog slugs.
*/
export type MunicipalityFederalBaseline = {
/** TSE year (as string) → Solla nominal votes inside the municipality geography. */
votesByYear: Record<string, number>
/** TSE year (as string) → federal T1 valid votes inside the municipality geography. */
validVotesByYear: Record<string, number>
}
type FederalBaselineArtifact = {
provenance: string
candidateNumber: number
candidateName: string
years: number[]
municipalities: Record<string, MunicipalityFederalBaseline>
}
const artifact = artifactJson as FederalBaselineArtifact
export const FEDERAL_BASELINE_CANDIDATE_NUMBER = artifact.candidateNumber
export const federalBaselineMunicipalitySlugs = (): string[] =>
Object.keys(artifact.municipalities)
const EMPTY_BASELINE: MunicipalityFederalBaseline = { votesByYear: {}, validVotesByYear: {} }
/**
* Baseline for one catalog municipality slug. Unknown slugs return an empty
* baseline (renders as 0 votes) — the artifact↔catalog snapshot test is the
* guard against silent drift.
*/
export const getMunicipalityFederalBaseline = (slug: string): MunicipalityFederalBaseline =>
artifact.municipalities[slug] ?? EMPTY_BASELINE

File diff suppressed because it is too large Load diff

View file

@ -35,14 +35,14 @@ export type SupportStatus = (typeof leadershipSupportStatuses)[number]
export const isSupportStatus = (value: unknown): value is SupportStatus =>
typeof value === 'string' && leadershipSupportStatuses.some((status) => status === value)
export const MAX_LEADERSHIP_PLAZAS = 30
export const MAX_LEADERSHIP_MUNICIPALITIES = 30
export const MAX_LEADERSHIP_ORGANIZATIONS = 20
export const MAX_LEADERSHIP_STATE_DEPUTIES = 20
const municipalitiesArraySchema = z
.array(positiveRelationshipId)
.min(1, 'Vincule a liderança a pelo menos uma Praça.')
.max(MAX_LEADERSHIP_PLAZAS)
.max(MAX_LEADERSHIP_MUNICIPALITIES)
.transform((ids) => [...new Set(ids)])
const organizationsArraySchema = z

View file

@ -0,0 +1,69 @@
// ---------------------------------------------------------------------------
// Action plans
// ---------------------------------------------------------------------------
import type { Access, FieldAccess, Where } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
getFreshCampaignUser,
isCampaignCoordinator,
isCampaignLeader,
isCampaignStaff,
isCampaignUnrestricted,
isPayloadAdmin,
} from '@/utilities/access/shared'
const canStaffCreateActionPlan: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canCreateActionPlan: Access = canStaffCreateActionPlan
export const canReadActionPlan: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser) return false
if (isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
if (currentUser.role === 'advisor') {
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return {
or: [
{
advisors: {
contains: currentUser.id,
},
},
{
municipality: {
in: municipalityIDs ?? [],
},
},
],
}
}
return false
}
/** Same row scope as read — leader write limits are enforced in `beforeChange`. */
export const canUpdateActionPlan: Access = canReadActionPlan
export const canDeleteActionPlan: Access = ({ req }) => isPayloadAdmin(req.user)
export const canSetActionPlanSystemField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
export const canSetActionPlanStatus: FieldAccess = canStaffCreateActionPlan
export const canCreateActionPlanAdvisors: FieldAccess = canStaffCreateActionPlan
export const canManageActionPlanAdvisors: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignCoordinator(await getFreshCampaignUser(req))
}

View file

@ -0,0 +1,153 @@
// ---------------------------------------------------------------------------
// Campaign users
// ---------------------------------------------------------------------------
import type { Access, FieldAccess, PayloadRequest } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import type { DynamicFind } from '@/utilities/access/shared'
import {
getFreshCampaignUser,
isCampaignCoordinator,
isCampaignLeader,
isCampaignStaff,
isCampaignUnrestricted,
isCampaignUser,
isPayloadAdmin,
} from '@/utilities/access/shared'
const CAMPAIGN_USER_PHONE_ACCESS_CONTEXT_KEY = 'campaignUserPhoneAccess'
export const canManageCampaignUsers: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignUnrestricted(await getFreshCampaignUser(req))
}
export const canManageCampaignUserRole: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignUnrestricted(await getFreshCampaignUser(req))
}
export const canReadCampaignUsers: Access = ({ req }) =>
isPayloadAdmin(req.user) || isCampaignUser(req.user)
export const canReadCampaignUserIdentity: FieldAccess = ({ req, id }) => {
if (isPayloadAdmin(req.user)) return true
if (!isCampaignUser(req.user)) return false
return id !== undefined && String(id) === String(req.user.id)
}
const canSelfOrStaffUpdateCampaignUser = async (
req: PayloadRequest,
id: string | number | undefined,
): Promise<boolean> => {
if (isPayloadAdmin(req.user)) return true
if (isCampaignCoordinator(await getFreshCampaignUser(req))) return true
if (!isCampaignUser(req.user) || id === undefined) return false
return String(id) === String(req.user.id)
}
export const canUpdateCampaignUser: Access = async ({ req, id }) =>
canSelfOrStaffUpdateCampaignUser(req, id)
export const canUpdateCampaignUserAvatar: FieldAccess = async ({ req, id }) =>
canSelfOrStaffUpdateCampaignUser(req, id)
// ---------------------------------------------------------------------------
// Staff-only field access (internal evaluations, estimates, financials)
// ---------------------------------------------------------------------------
export const canReadCampaignStaffField: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canManageCampaignStaffField: FieldAccess = canReadCampaignStaffField
/** System/derived fields are written only via server actions with overrideAccess. */
export const canSetCampaignSystemField: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
// ---------------------------------------------------------------------------
// Campaign user phone visibility
// ---------------------------------------------------------------------------
export const canReadCampaignUserPhone: FieldAccess = async ({ doc, id, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser) return false
if (id !== undefined && String(id) === String(currentUser.id)) return true
if (isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
if (id === undefined) return false
const targetUserID = Number(id)
if (!Number.isInteger(targetUserID) || targetUserID <= 0) return false
const context = req.context as Record<string, unknown>
const cacheKey = `${CAMPAIGN_USER_PHONE_ACCESS_CONTEXT_KEY}:${currentUser.id}:${targetUserID}`
if (typeof context[cacheKey] === 'boolean') return context[cacheKey]
let targetRole = typeof doc === 'object' && doc !== null && 'role' in doc ? doc.role : undefined
if (targetRole === undefined) {
try {
const target = await req.payload.findByID({
collection: 'campaignUser',
id: targetUserID,
depth: 0,
select: { role: true },
overrideAccess: true,
req,
})
targetRole = target.role
} catch {
// Missing target only — fall through to the municipality-scope check.
}
}
if (targetRole === 'coordinator') {
context[cacheKey] = true
return true
}
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
if (!municipalityIDs?.length) {
context[cacheKey] = false
return false
}
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const result = await find({
collection: 'municipality',
depth: 0,
limit: 1,
overrideAccess: true,
pagination: false,
req,
select: { id: true },
where: {
and: [{ id: { in: municipalityIDs } }, { advisors: { contains: targetUserID } }],
},
})
const allowed = result.docs.length > 0
context[cacheKey] = allowed
return allowed
}
export const canCreateCampaignUserPhone: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignCoordinator(await getFreshCampaignUser(req))
}
export const canUpdateCampaignUserPhone: FieldAccess = async ({ id, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser) return false
if (id !== undefined && String(id) === String(currentUser.id)) return true
return isCampaignCoordinator(currentUser)
}

View file

@ -0,0 +1,119 @@
// ---------------------------------------------------------------------------
// Contacts
// ---------------------------------------------------------------------------
import type { Access, PayloadRequest } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import type { CampaignActor, DynamicFind } from '@/utilities/access/shared'
import {
getFreshCampaignUser,
isCampaignUnrestricted,
isCampaignUser,
isPayloadAdmin,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
type ContactID = number
const ACCESSIBLE_CONTACT_IDS_CONTEXT_KEY = 'campaignAccessibleContactIds'
export const getAccessibleContactIds = async (
req: PayloadRequest,
user: CampaignActor = req.user,
): Promise<ContactID[]> => {
const currentUser =
isCampaignUser(user) && user === req.user ? await getFreshCampaignUser(req, user) : user
if (!isCampaignUser(currentUser)) return []
const context = req.context as Record<string, unknown>
const cacheKey = `${ACCESSIBLE_CONTACT_IDS_CONTEXT_KEY}:${currentUser.id}:${currentUser.role}`
const cached = context[cacheKey]
if (Array.isArray(cached)) {
return cached.filter((id): id is number => typeof id === 'number')
}
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
const leadershipWhere =
currentUser.role === 'leader'
? {
and: [{ user: { equals: currentUser.id } }, { supportStatus: { equals: 'engajado' } }],
}
: {
municipalities: {
in: municipalityIDs ?? [],
},
}
const find = req.payload.find.bind(req.payload) as unknown as DynamicFind
const collections = req.payload.collections as Record<string, unknown>
const contactIDs: ContactID[] = []
if (collections.leadership) {
const leadershipResult = await find({
collection: 'leadership',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: leadershipWhere,
})
for (const doc of leadershipResult.docs) {
const id = relationshipId(doc.contact)
if (id !== null) contactIDs.push(id)
}
}
// Advisors read contacts of supporters in their municipalities; leaders read
// contacts of the supporters they created — mirroring `canReadSupporter`.
const supporterWhere =
currentUser.role === 'advisor'
? { municipality: { in: municipalityIDs ?? [] } }
: currentUser.role === 'leader'
? { createdBy: { equals: currentUser.id } }
: null
if (supporterWhere && collections.supporter) {
const supporterResult = await find({
collection: 'supporter',
depth: 0,
limit: 0,
overrideAccess: true,
pagination: false,
req,
select: { contact: true },
where: supporterWhere,
})
for (const doc of supporterResult.docs) {
const id = relationshipId(doc.contact)
if (id !== null) contactIDs.push(id)
}
}
const ids = [...new Set(contactIDs)]
context[cacheKey] = ids
return ids
}
export const canReadContacts: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignUnrestricted(currentUser)) return true
if (!currentUser) return false
const ids = await getAccessibleContactIds(req, currentUser)
return {
id: {
in: ids,
},
}
}
export const canManageContacts: Access = ({ req }) => isPayloadAdmin(req.user)

View file

@ -0,0 +1,53 @@
// ---------------------------------------------------------------------------
// Campaign demands
// ---------------------------------------------------------------------------
import type { Access, Where } from 'payload'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
getFreshCampaignUser,
isCampaignLeader,
isCampaignUnrestricted,
isPayloadAdmin,
} from '@/utilities/access/shared'
import { relationshipId } from '@/utilities/relationship'
export const canCreateCampaignDemand: Access = async ({ data, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (!currentUser || isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
const municipalityID = relationshipId(data?.municipality)
if (!municipalityID) return false
if (currentUser.role !== 'advisor') return false
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return municipalityIDs?.includes(municipalityID) ?? false
}
export const canReadCampaignDemand: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
if (isCampaignLeader(currentUser)) return false
if (isCampaignUnrestricted(currentUser)) return true
if (!currentUser || currentUser.role !== 'advisor') return false
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
return {
municipality: {
in: municipalityIDs ?? [],
},
}
}
/**
* Staff manage demands in their municipalities; leaders have no demand access.
*/
export const canUpdateCampaignDemand: Access = canReadCampaignDemand
export const canDeleteCampaignDemand: Access = ({ req }) => isPayloadAdmin(req.user)

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