mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge branch 'feat/engineering-standards-hardening'
This commit is contained in:
commit
8712ae9181
159 changed files with 11000 additions and 4651 deletions
49
.cursor/rules/engineering-standards.mdc
Normal file
49
.cursor/rules/engineering-standards.mdc
Normal 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
68
.github/workflows/ci.yml
vendored
Normal 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
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
12
AGENTS.md
12
AGENTS.md
|
|
@ -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/*)
|
||||
|
|
|
|||
51
docs/IMPROVE-CODE-QUALITY-PLAN.md
Normal file
51
docs/IMPROVE-CODE-QUALITY-PLAN.md
Normal 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
57
docs/TECH-DEBT.md
Normal 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
32
docs/TESTING.md
Normal 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.
|
||||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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
31
knip.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
package.json
13
package.json
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
1009
pnpm-lock.yaml
1009
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
111
scripts/build-election-aggregates.mjs
Normal file
111
scripts/build-election-aggregates.mjs
Normal 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)
|
||||
|
|
@ -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.')
|
||||
|
|
@ -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'}
|
||||
|
|
|
|||
20
src/app/(campaign)/campanha/(app)/apoiadores/loading.tsx
Normal file
20
src/app/(campaign)/campanha/(app)/apoiadores/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import config from '@payload-config'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
20
src/app/(campaign)/campanha/(app)/municipios/loading.tsx
Normal file
20
src/app/(campaign)/campanha/(app)/municipios/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
357
src/app/(campaign)/campanha/actions/supporterImport.ts
Normal file
357
src/app/(campaign)/campanha/actions/supporterImport.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: '' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
77
src/components/campaign/CampaignFilterChips.tsx
Normal file
77
src/components/campaign/CampaignFilterChips.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
110
src/components/campaign/CampaignListPending.tsx
Normal file
110
src/components/campaign/CampaignListPending.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
src/components/campaign/CampaignSearchForm.tsx
Normal file
66
src/components/campaign/CampaignSearchForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
),
|
||||
})
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
232
src/components/campaign/LeaderContactForm.tsx
Normal file
232
src/components/campaign/LeaderContactForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -156,7 +156,6 @@ export const MunicipalityList = ({
|
|||
</TableHeader>
|
||||
<TableBody>
|
||||
{municipalities.map((municipality) => {
|
||||
const names = advisorNames(municipality)
|
||||
const hasAdvisor = municipality.advisorIDs.length > 0
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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 },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
44
src/lib/bahiaElectionAggregates.ts
Normal file
44
src/lib/bahiaElectionAggregates.ts
Normal 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
|
||||
5232
src/lib/electionAggregates/bahia-federal-baseline.json
Normal file
5232
src/lib/electionAggregates/bahia-federal-baseline.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
69
src/utilities/access/actionPlans.ts
Normal file
69
src/utilities/access/actionPlans.ts
Normal 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))
|
||||
}
|
||||
153
src/utilities/access/campaignUsers.ts
Normal file
153
src/utilities/access/campaignUsers.ts
Normal 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)
|
||||
}
|
||||
119
src/utilities/access/contacts.ts
Normal file
119
src/utilities/access/contacts.ts
Normal 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)
|
||||
53
src/utilities/access/demands.ts
Normal file
53
src/utilities/access/demands.ts
Normal 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
Loading…
Reference in a new issue