diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index d2185b41..a4d9aad9 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -3,10 +3,11 @@ description: Estado do projeto Núcleos Eleitorais alwaysApply: false --- -# Núcleos Eleitorais +# Núcleos Eleitorais → Praças ## Status +- **REMODELAGEM ENTREGUE EM CÓDIGO (2026-07-21; deploy pendente — migração `20260721_020109_remodel_plazas` é destrutiva para a vertical campanha, revisar SQL antes do build de produção). Suítes verdes: unit 187, int 306 (incl. assimetria declarado×estimado, access por papel, workflow de demandas), e2e 9. Critique/polish visual fino registrado como R6 no roadmap.** Feedback da coordenação invalidou o modelo de núcleo abstrato. Decisões travadas: 436 Praças pré-definidas (415 municípios + 19 zonas de Salvador + 2 de Camaçari; zonas compartilhadas cortadas na divisa municipal); código `plaza`; roles renomeados para `coordinator`/`advisor`/`leader` (labels "Coordenador Geral"/"Assessor"/"Liderança"); assessor vê só as Praças que administra; liderança = 1 registro por pessoa com N Praças e N organizações; votos por liderança×Praça (`declaredVotes` visível à liderança, `estimatedVotes` staff-only); demandas com workflow assessor→coordenador geral + comprovantes staff-only; tendência política manual (staff); comparativo multi-candidato/multi-ano por Praça; mapa divergente vermelho↔branco↔azul. Reset dos dados de campanha em produção (site público intocado). Plano-mestre: [`docs/plans/remodelagem-pracas.md`](../../../docs/plans/remodelagem-pracas.md). As entradas abaixo descrevem a era Núcleos — padrões de infra (locks, transações, consent por chave, shells, mapa, dados eleitorais) continuam válidos; superfícies e modelo de Núcleo foram substituídos. - **MVP Ciclo 1 entregue e enviado em `main` (2026-07-18):** ondas 1–8 (auth/RBAC, domínio+UI de Núcleo/Liderança/Inteligência/Atualizações/Convites, dashboard, hardening) + refactors de composição Next.js, primitivos compartilhados, locks PostgreSQL, fixtures E2E e migration consolidada `20260718_010733_consolidate_campaign_schema`. - **Ciclo 2 entregue, mesclado e deployado (2026-07-18):** A1 território multi-município/bairro, A3 baseline TSE 2022 (modelo + import), B1 overview da lista, C1 compartilhar página e D1 PWA — detalhes no item datado abaixo. - **A2 entregue e mesclado em `main` (2026-07-18):** cadastro estático município↔ZE (`bahiaTseZones`, TSE 2024) + chips opt-in no formulário (`NucleusTerritoryAndZonesFields` / `territorySuggestions`); sem migration. Plano: [`docs/plans/zonas-por-municipio.md`](../../../docs/plans/zonas-por-municipio.md). diff --git a/.impeccable/config.json b/.impeccable/config.json new file mode 100644 index 00000000..5fc7123b --- /dev/null +++ b/.impeccable/config.json @@ -0,0 +1,17 @@ +{ + "detector": { + "ignoreRules": [], + "ignoreFiles": [], + "ignoreValues": [ + { + "rule": "design-system-color", + "value": "#111827", + "files": [ + "src/collections/CampaignUser.ts" + ], + "createdAt": "2026-07-21T01:54:11.928Z", + "reason": "Pre-existing inline hex in the password-reset EMAIL HTML template; transactional email cannot use CSS tokens" + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index 646e6cf2..43aee503 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,8 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si **Locked decisions — do not relitigate without a new concrete reason:** -- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `geral`, `coordenador`, and `lideranca`, all on the separate auth collection `campaignUser` (see "Campaign auth" below). +- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), and `leader` ("Liderança"), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the Praças they administer. +- **2026-07-20 remodel (in progress): "Praça" replaces "Núcleo Eleitoral" as the operational unit.** Praças are PREDEFINED (seeded, geography read-only): one per Bahia municipality, except Salvador (19 zone Praças, ZE 1–19) and Camaçari (2 zone Praças, ZE 170/171); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `plaza`. Leaderships are one record per person (`contact` UNIQUE) linked to N plazas and N organizations; vote pledges are per leadership×plaza with `declaredVotes` (leader-visible) vs `estimatedVotes` (staff-only, hidden from the leader). Campaign production data was reset by the remodel migration (public-site data untouched). Master plan: `docs/plans/remodelagem-pracas.md`. Sections below describing "nucleus" surfaces are being rewritten as the remodel lands. - Hosting stays on Vercel for now. No self-host/Coolify migration in progress. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. @@ -98,41 +99,39 @@ The internal `/campanha` area is gated by its own authentication, deliberately k - **Route layout.** The route group's root layout `src/app/(campaign)/layout.tsx` renders the ``/`
` with `data-theme="campaign"`. The public login lives at `src/app/(campaign)/campanha/login/` (`page.tsx` + client `LoginForm.tsx`). Everything gated sits in the `(app)` group, whose layout (`src/app/(campaign)/campanha/(app)/layout.tsx`) calls `getCampaignUser()` and `redirect('/campanha/login')` when there is no session — that layout is the barrier. - **Migrations.** `20260716_010420_add_campaign_user` created the auth collection; the unpublished 2026-07-17 campaign chain was consolidated into `20260718_010733_consolidate_campaign_schema`, which adds role/username auth, contact phone, and the remaining final campaign schema. -## Campaign nuclei MVP +## Campaign Praças model (2026-07-20 remodel) -The first `/campanha` operational cycle is centered on `electoralNucleus`; a Núcleo is the campaign's operating unit and is not a TSE electoral zone. +`/campanha` is organized around `plaza` ("Praça") — the campaign's PREDEFINED operational territory. Master plan: `docs/plans/remodelagem-pracas.md`. -- **Collections:** `electoralNucleus` (territory, coordinators, TSE zone references, intelligence and vote estimates), `leadership` (unique `Contact`↔nucleus join with support status), `nucleusUpdate` (immutable field reports), and `campaignInvite` (single-use WhatsApp invite hashes). All are in admin group `Campanha`. -- **Canonical nucleus URLs:** nuclei use a unique canonical `slug` generated from `name` and route through `/campanha/nucleos/[slug]`. IDs stay internal. Names are immutable after creation so existing shared links remain durable without an alias table. -- **Territory:** Bahia is implicit. Since 2026-07-18 nuclei use `hasMany` arrays `regions` / `cities` / `neighborhoods` (plus `locality` / `territoryNotes`), validated against official Bahia identity territories (`src/lib/bahiaTerritories.ts`): `regions` is derived server-side from the selected cities, and neighborhoods require exactly one city. List filters stay single-select and query array membership with `{ equals }`. Migration `20260718_190559_territorio_multi_municipio_bairro` backfilled the old scalar fields (`docs/plans/territorio-multi-municipio-bairro.md`). **A2 (2026-07-18):** static municipality↔TSE zone map in `src/lib/bahiaTseZones.ts` (`tseZonesForCity` / `tseZonesForTerritory` / `citiesForTseZone`); the create/edit form coordinator `NucleusTerritoryAndZonesFields` offers opt-in `{label} +` chips from `buildTerritorySuggestions` (city/TI → missing zones; TI siblings and zone cities → municipalities). Manual `TseZoneInput` stays free (1–999); server validation does not require `tseZones` to match the official set; an amber client advisory may warn when a typed zone is outside the selected geography. Details: `docs/plans/zonas-por-municipio.md`. **B2 (2026-07-18):** static map geometries — see "Campaign map geometries (B2)" below (Leaflet surfaces are B3). -- **Role scope:** `geral` sees/manages all nuclei, assigns coordinators, and confirms estimates; `coordenador` sees assigned nuclei and manages their leaderships, updates, invites, and estimates; `lideranca` sees only nuclei linked through an engaged leadership record, creates own updates, and suggests estimates. -- **Sensitive fields:** `leadership.supportStatus`, internal notes, consent notes, and strategic nucleus intelligence are staff-only and are excluded from leadership view models. Revoking `engajado` revokes nucleus access immediately. Dashboard aggregates omit Payload-redacted missing statuses and fail closed on invalid enum values. -- **People and consent:** leaderships always reuse `Contact`; multi-collection writes are transactional. Self-service updates accept a strict whitelist derived from the invite token. Campaign consent is looked up only by the stable key `lideranca-autopreenchimento`. -- **Invites:** only SHA-256 token hashes are persisted. Absolute invite URLs require a valid `NEXT_PUBLIC_SITE_URL` (exact HTTPS DNS origin in production). Public invite pages are dynamic, `no-store`, `noindex,nofollow`, `no-referrer`, and have no third-party application scripts. Login invites are allowed only for engaged leaderships. -- **Vote estimates:** proposals never overwrite the confirmed value; each proposal carries a server-generated UUID version compared under advisory lock on confirm. Only a coordinator assigned to the nucleus or `geral` may confirm. -- **List overview (B1, 2026-07-18):** `/campanha/nucleos` renders an aggregate panel between the filters and the list (`NucleusListOverview` + `loadNucleusListOverviewData`): confirmed vote estimate total/%, coordinator coverage, and a 3-update preview, computed over the entire filtered set (`buildNucleusListWhere`, `pagination: false`, `overrideAccess: false`). Hidden when the filtered set is empty; `lideranca` gets a reduced select without pending-suggestion counts. -- **Share dialog (C1, 2026-07-18):** the nucleus detail header has a "Compartilhar" dialog (WhatsApp `wa.me` per recipient + copy link) that sends only the URL — it never creates invites or grants access. Recipients (gerais, assigned coordinators, `engajado` leaderships) are loaded on open via a read-only Server Action; the loader gates on nucleus access (`overrideAccess: false`) then reads name+phone privileged (`overrideAccess: true`) to avoid N+1 field-access checks. `canReadCampaignUserPhone` allows any campaign user to read `geral` phones. -- **PWA (D1, 2026-07-18):** `/campanha` is installable — manifest and service worker are served by route handlers under `/campanha` (script built in `src/utilities/campaignPwa.ts`), scoped to the vertical only (public site and `/admin` have no SW). Cache is named `campanha-@@ -109,18 +109,18 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
{supporter.phoneDisplay || '—'}
{supporter.email ?{supporter.email}
: null}- {supporter.nucleusName ? ( + {supporter.plazaName ? ( <> - Núcleo:{' '} + Praça:{' '} - {supporter.nucleusName} + {supporter.plazaName} > ) : ( - Sem núcleo vinculado + Sem Praça vinculada )}
diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx index f02f11c6..af1ec7e0 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx @@ -5,13 +5,13 @@ import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' import { SupporterImportWizard } from '@/components/campaign/SupporterImportWizard' import { Button } from '@/components/ui/button' import { getCampaignUser } from '@/utilities/campaignAuth' -import { isCampaignGeneral } from '@/utilities/campaignAccess' +import { isCampaignCoordinator } from '@/utilities/campaignAccess' export default async function ImportSupportersPage() { const user = await getCampaignUser() if (!user) redirect('/campanha/login') - if (!isCampaignGeneral(user)) redirect('/campanha/apoiadores') + if (!isCampaignCoordinator(user)) redirect('/campanha/apoiadores') return (
Envie uma planilha, confira a prévia e confirme a importação em lote.
diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts
index faa5a8dd..1a0629fe 100644
--- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts
+++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts
@@ -26,15 +26,15 @@ export type SupporterFormValues = {
phone?: string
email?: string
city?: string
- nucleus?: string
+ plaza?: string
voteIntention?: string
}
const safeActionMessages = [
- 'Esta pessoa já está cadastrada como apoiador neste núcleo.',
+ 'Esta pessoa já está cadastrada como apoiador nesta Praça.',
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.',
- 'Somente a coordenação pode gerenciar apoiadores.',
- 'Somente a coordenação geral pode cadastrar apoiadores sem núcleo.',
+ 'Somente a coordenação e a assessoria podem gerenciar apoiadores.',
+ 'Somente o Coordenador Geral pode cadastrar apoiadores sem Praça.',
'Consentimento de cadastro de apoiador ainda não configurado.',
'Consentimento de intenção de voto ainda não configurado.',
] as const
@@ -65,20 +65,20 @@ export const createSupporterFormAction = async (
phone: optionalFormText(formData, 'phone'),
email: optionalFormText(formData, 'email'),
city: optionalFormText(formData, 'city'),
- nucleus: optionalFormText(formData, 'nucleus'),
+ plaza: optionalFormText(formData, 'plaza'),
voteIntention: optionalFormText(formData, 'voteIntention'),
}
const phone = sanitizeBrazilianPhoneInput(values.phone ?? '')
const voteIntention = values.voteIntention?.trim() || undefined
- const nucleus = nullableRelationshipFormValue(formData, 'nucleus')
+ const plaza = nullableRelationshipFormValue(formData, 'plaza')
const input = supporterCreateSchema.parse({
name: values.name ?? '',
phone,
email: values.email,
city: values.city,
- ...(nucleus ? { nucleus } : {}),
+ ...(plaza ? { plaza } : {}),
...(voteIntention ? { voteIntention } : {}),
consentAccepted: checkboxFormValue(formData, 'consentAccepted') ? true : undefined,
voteIntentionConsentAccepted: voteIntention
diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx
index d1eff1c5..5e71f028 100644
--- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx
@@ -7,7 +7,7 @@ import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoia
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { SupporterForm } from '@/components/campaign/SupporterForm'
import { Button } from '@/components/ui/button'
-import { isCampaignGeneral } from '@/utilities/campaignAccess'
+import { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadSupporterCreatePageData } from '@/utilities/supporterPageData'
import { canAccessSupporterArea } from '@/utilities/supporterUi'
@@ -27,7 +27,7 @@ export default async function NewSupporterPage() {
← Voltar para apoiadores
@@ -37,10 +37,10 @@ export default async function NewSupporterPage() {
- Base nominal de apoio com intenção de voto e vínculo opcional a núcleos. + Base nominal de apoio com intenção de voto e vínculo opcional a Praças.
{view.description}
- ) : ( -Sem descrição.
- )} -Observações do território
-{view.territoryNotes}
-{view.description}
+ ) : ( +Sem descrição.
+ )} +Nenhum coordenador vinculado.
- )} -- {view.taskProgress.done}/{view.taskProgress.total} tarefas concluídas -
- {view.createdByName ? ( -Criado por {view.createdByName}
- ) : null} -Assessores responsáveis
+ {view.advisors.length ? ( +Nenhum assessor vinculado.
+ )} +Organizações apoiadoras
+ {view.organizations.length ? ( +Nenhuma organização vinculada.
+ )} ++ {view.taskProgress.done}/{view.taskProgress.total} tarefas concluídas +
+ {view.createdByName ? ( +Criado por {view.createdByName}
+ ) : null} ++ {[ + view.result.recordedByName + ? `Registrado por ${view.result.recordedByName}` + : 'Registrado', + view.result.recordedAt + ? `em ${formatBahiaDateTimeLabel(view.result.recordedAt)}` + : null, + ] + .filter(Boolean) + .join(' ')} +
+{view.result.summary}
++ A geografia da Praça é pré-definida e não pode ser alterada. Aqui você edita metas, + inteligência e tendência política. +
+{formatPlazaGeographyLabel(view)}
++ {advisorSummaries.length + ? `Assessoria: ${advisorSummaries.map((advisor) => advisor.name).join(', ')}` + : 'Sem assessor designado.'} + {view.lastUpdateAt + ? ` · Última atualização em ${dateFormatter.format(new Date(view.lastUpdateAt))}` + : ''} +
++ As demandas desta Praça aparecem em{' '} + + Demandas + + . +
++ Informe quantos votos você está trazendo. Você pode atualizar o número quando quiser. +
++ Compare a votação de deputados federais nesta Praça através dos anos (fonte TSE, votos + nominais do 1º turno). +
++ As 436 Praças da campanha: um município cada — em Salvador e Camaçari, uma zona eleitoral + cada. +
++ {listData.totalDocs}{' '} + {listData.totalDocs === 1 ? 'Praça encontrada' : 'Praças encontradas'} +
+{recordedLabel}
+ ) : null} +Carregando mapa…
- ) : null} + {status === 'loading' ?Carregando mapa…
: null} {status === 'error' ? (Não foi possível carregar o mapa. Tente recarregar a página. diff --git a/src/components/campaign/CampaignBottomNav.tsx b/src/components/campaign/CampaignBottomNav.tsx index 6b43677e..9c581097 100644 --- a/src/components/campaign/CampaignBottomNav.tsx +++ b/src/components/campaign/CampaignBottomNav.tsx @@ -3,13 +3,13 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' -import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav' +import { getCampaignBottomNav, isCampaignNavActive } from '@/components/campaign/nav' import { cn } from '@/lib/utils' import type { CampaignUser } from '@/payload-types' export const CampaignBottomNav = ({ role }: { role: CampaignUser['role'] }) => { const pathname = usePathname() - const navItems = getCampaignNav(role) + const navItems = getCampaignBottomNav(role) return (
- Escala: intensidade da cor indica {metricLabel.toLowerCase()} no território. + Escala: intensidade da cor indica {metricLabel} no território.
- Sem dados para esta métrica no conjunto atual. -
- )} -{description}
-+ O assessor analisa e decide — ou escala ao Coordenador Geral. Demandas escaladas são + decididas somente por ele. +
+Esta demanda já foi decidida.
+ )} + + + + +- {leadership.sectorNotes} -
-- {leadership.notes || 'Nenhuma observação interna registrada.'} -
-- {leadership.consentNote} -
-{message}
: null} - {formErrors?.map((error) => ( -{error}
- ))} -