Ship /campanha nuclei MVP with RBAC, invites, and vote estimates.
Adds electoral nuclei, leaderships, updates, and WhatsApp invite flows behind campaign auth, plus the consolidated schema migration that Vercel applies on deploy.
This commit is contained in:
parent
198de05c18
commit
bcac665cb5
276 changed files with 45285 additions and 886 deletions
95
.cursor/rules/projects/nucleos-eleitorais.mdc
Normal file
95
.cursor/rules/projects/nucleos-eleitorais.mdc
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
description: Estado do projeto Núcleos Eleitorais
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Núcleos Eleitorais
|
||||
|
||||
## Status
|
||||
|
||||
- Onda 1 (auth/RBAC e fundação visual): concluída e aprovada em 2026-07-16.
|
||||
- Onda 2 (domínio Núcleo): implementada em 2026-07-16; aguardando integração/revisão.
|
||||
- Onda 3B (domínio Liderança): implementada em 2026-07-16; migration aplicada nos bancos locais de desenvolvimento e teste.
|
||||
- Onda 3A (UI Núcleo): concluída e aprovada em 2026-07-16.
|
||||
- Onda 4A (UI Lideranças): implementada em 2026-07-16 com lista responsiva, filtros, ficha/formulário, contato principal e serialização específica por papel.
|
||||
- Onda 4B (Inteligência do Núcleo): implementada em 2026-07-17 com fluxo seguro de sugerir/confirmar estimativas, edição de forças/riscos/perfis/dobrada/contato principal e DTOs mínimos por papel.
|
||||
- Onda 6B (domínio de convites): implementada em 2026-07-17 com Consent por chave estável, convites WhatsApp com hash/expiração/revogação, consumo transacional de uso único, autopreenchimento restrito e criação/recuperação de acesso.
|
||||
- Onda 6A (dashboard e atualizações): implementada e revisada em 2026-07-17 com agregados só de núcleos ativos, semana exata `America/Bahia`, feed paginado por URL e contato contextual da coordenação.
|
||||
- Onda 7 (UI de convites): implementada em 2026-07-17 com ação WhatsApp por liderança, bloqueio por Consent/status e rota pública dinâmica de autopreenchimento/acesso/recuperação.
|
||||
- Onda 8 (hardening): implementada em 2026-07-17 com E2E por papel, negativa de escopo, convite público/WhatsApp sem navegação real, verificação responsiva 360/390/768/1440, correção de alvos de toque no login, documentação e checklist de deploy.
|
||||
- Em 2026-07-17, a listagem de Núcleos passou a aplicar filtros automaticamente pelo estado da URL, sem botão de confirmação, com comboboxes territoriais estritos compartilhados com o formulário.
|
||||
- Em 2026-07-17, o contato principal passou a usar busca remota limitada e escopada no detalhe do núcleo, sem serializar a lista completa de lideranças.
|
||||
- Em 2026-07-17, o feed paginado de atualizações e a prévia das três mais recentes passaram a usar loaders separados por aba, sem consulta de prévia fora da visão geral.
|
||||
- Em 2026-07-17, o painel de lideranças passou a restaurar foco deterministicamente no fechamento de Sheet/Drawer e ganhou E2E real de URL, histórico, responsividade e privacidade de Flight.
|
||||
- Em 2026-07-17, o login de campanha removeu React Hook Form/Zod do bundle cliente em favor de formulário nativo com `useActionState`; `Table` voltou a ser módulo estático sem fronteira cliente.
|
||||
- Em 2026-07-17, os filtros da lista passaram a montar uma única árvore de controles em um disclosure responsivo; o estado canônico vindo do servidor remonta a ilha por chave de query, sem sincronização por `useSearchParams`.
|
||||
- Em 2026-07-17, a composição Phase 1 separou o cartão estático de coordenação no servidor dos controles cliente; opções elegíveis e revisão do Núcleo agora são carregadas somente ao abrir o seletor. Editores pesados de inteligência, estimativa, atualização, liderança e convite passaram a fronteiras lazy locais.
|
||||
- Em 2026-07-17, os shells lazy passaram a manter seus gatilhos Radix montados durante carregamento e edição, com restauração real de foco validada no navegador; o carregamento de coordenação permanece cancelável e só bloqueia fechamento durante a gravação.
|
||||
- Em 2026-07-17, falhas nos chunks lazy de inteligência, atualização, convite e estimativa ganharam estado de erro acessível, fechamento seguro e nova tentativa controlada sem rejeição não tratada ou spinner infinito.
|
||||
- Em 2026-07-17, o fluxo lazy de estimativa passou a transportar uma intenção explícita de foco através do refresh RSC: sugestão foca revisão, confirmação e edição focam o gatilho de editar, com fallback estável no cartão.
|
||||
- Em 2026-07-17, a Refactor Phase 2C centralizou os advisory locks transacionais PostgreSQL e separou namespaces por domínio, com regressões concorrentes validadas.
|
||||
- Em 2026-07-17, o follow-up Phase 2C tornou `contact-phone:<normalizado>` um invariante único para todo create/update de `Contact`; mudanças bloqueiam telefones antigo+novo em ordem determinística, rejeitam outro titular sem merge e preservam o convite em rollback.
|
||||
- Em 2026-07-17, a Refactor Phase 2D reduziu `actions/invite.ts` a uma orquestração de 44 linhas e separou repositório SQL, criação e resgate em módulos `server-only`, preservando 488 testes de integração.
|
||||
- Em 2026-07-17, a Refactor Phase 4A consolidou as dez migrations locais não publicadas de `20260717_*` em `20260718_010733_consolidate_campaign_schema`, mantendo intacta a história rastreada até `20260716_010420_add_campaign_user`; os bancos locais foram reconciliados sem alterar dados de aplicação.
|
||||
- Em 2026-07-18, o follow-up Phase 4C fixou o Playwright em dois workers por padrão, adicionou guardas automáticos de erros do navegador/console/5xx, fortaleceu privacidade HTML+Flight por papel e tornou o login por teclado uma interação real com Enter e cookie verificado.
|
||||
- Em 2026-07-18, a exceção concorrente de `SupportStatusBadge` foi rastreada a projeções em voo nas quais o acesso de campo podia omitir `supportStatus`; o agregado do dashboard agora valida o enum e omite registros incompletos sem inventar um status padrão.
|
||||
|
||||
## Decisões
|
||||
|
||||
- Núcleo Eleitoral é a unidade operacional; Zona TSE é uma referência oficial adicional.
|
||||
- Todo núcleo exige geografia; natureza organizativa e setor são dimensões complementares.
|
||||
- Exclusão no app é substituída por arquivamento.
|
||||
- `lastUpdateAt` existe como placeholder até o domínio de atualizações.
|
||||
- `Contact` tem e-mail opcional no banco, telefone normalizado/indexado e acesso por escopo; os schemas públicos continuam exigindo e-mail.
|
||||
- `Leadership` é a junção única contato↔núcleo; acesso de liderança e contato exige vínculo `engajado`, e avaliação interna não é serializada para a titular.
|
||||
- A UI de liderança usa view models separados: coordenação recebe avaliação interna; papel `lideranca` recebe somente a própria ficha sem `supportStatus`, `notes` ou `consentNote`.
|
||||
- A deduplicação de `Contact` por telefone usa `pg_advisory_xact_lock(hashtextextended(phone, 0))` na própria sessão Drizzle da transação Payload, evitando duplicatas entre instâncias concorrentes.
|
||||
- O DTO da própria liderança contém somente nome, celular, e-mail, setor e confirmação pela pessoa.
|
||||
- “Convidar pelo WhatsApp” cria convite de uso único e abre `wa.me` no navegador; os E2E substituem `window.open` e validam a URL sem navegar para o WhatsApp.
|
||||
- Sugestões de estimativa não alteram o valor confirmado; confirmação exige coordenação geral ou coordenador designado, deriva autoria/data no servidor e exige justificativa quando ajusta a proposta.
|
||||
- Sugestão e confirmação da estimativa usam o mesmo `pg_advisory_xact_lock` por núcleo na sessão exata da transação Payload, serializando concorrência entre instâncias.
|
||||
- Em 2026-07-17, cada sugestão de estimativa passou a receber uma versão UUID derivada no servidor; confirmações vinculam e comparam essa versão sob o mesmo lock, impedindo confirmações ABA obsoletas mesmo quando o valor volta a ser igual.
|
||||
- Em 2026-07-17, links de convite passaram a exigir `NEXT_PUBLIC_SITE_URL` HTTPS e não local em produção; desenvolvimento/teste preferem a origem HTTP real da requisição local, sem confiar em `Host`/`X-Forwarded-*` sobre a configuração de produção.
|
||||
- Em 2026-07-17, a origem de convites foi endurecida: produção ignora headers e exige `NEXT_PUBLIC_SITE_URL` como origem HTTPS exata (sem credenciais, path, query ou fragmento); desenvolvimento/teste só aceita headers para `localhost`, `127.0.0.1` ou `::1` exatos e usa a configuração explícita como único fallback.
|
||||
- O detalhe do núcleo usa selects/DTOs discriminados por papel: liderança recebe apenas identificação, território básico, Zonas TSE e estimativa confirmada; inteligência estratégica e metadados de proposta/auditoria ficam restritos ao staff.
|
||||
- Componentes React novos da Onda 4B usam arquivo PascalCase idêntico ao export principal; a renomeação dos componentes legados fica para a varredura separada.
|
||||
- O consentimento de autopreenchimento é resolvido exclusivamente pela chave `lideranca-autopreenchimento`; ausência bloqueia criação e resgate sem fallback por ID.
|
||||
- Convites persistem somente SHA-256 do token aleatório de 32 bytes, expiram em sete dias e revogam o convite ativo anterior do mesmo tipo/vínculo.
|
||||
- O consumo usa `UPDATE ... WHERE` transacional com estado ativo e expiração, garantindo uso único mesmo sob corrida; falhas posteriores fazem rollback e preservam o convite.
|
||||
- Autopreenchimento altera somente contato público, setor e consentimento; login exige vínculo engajado e só recupera uma conta existente quando `leadership.user` já aponta exatamente para ela. Coincidência de username sem vínculo é rejeitada, e uma conta nova sempre nasce como `lideranca`.
|
||||
- `campaignUser.phone` é o celular opcional de contato; `username` permanece exclusivamente identidade de autenticação e nunca alimenta DTOs de contato. Leitura do telefone exige admin, titular, geral ou vínculo contextual por núcleo acessível.
|
||||
- A ficha de liderança recebe somente `{ configured: boolean }` sobre o Consent; login fica indisponível fora de `engajado`, enquanto Consent ausente desabilita todos os convites com estado explícito.
|
||||
- A rota pública `/campanha/convite/[token]` consulta exclusivamente o hash e serializa nome/celular/e-mail/gênero/setor/texto de Consent; estados inexistente/usado/expirado são indistinguíveis, sem avaliação interna.
|
||||
- A rota pública força renderização dinâmica e no-store, usa `noindex,nofollow,nocache` e `referrer: no-referrer`; o resgate de login define o cookie isolado existente e redireciona apenas para `/campanha`.
|
||||
- O Consent aceito só é preservado sem novo checkbox quando seu ID coincide com o documento atualmente configurado pela chave estável; versão diferente exige aceite explícito antes da troca, e rejeições fazem rollback sem substituir a relação anterior.
|
||||
- O token bruto não é persistido nem enviado em campos de perfil. A verificação E2E mostrou que o Next.js serializa o segmento dinâmico e o argumento ligado da Server Action no HTML/Flight da própria página; a fronteira de segurança comprovada é `no-store`, `noindex`, `no-referrer`, nenhum request de terceiro e links externos com `noreferrer`. O token continua inevitavelmente presente na URL/documento same-origin.
|
||||
- Campos opcionais self-service distinguem ausente (`undefined`, mantém valor) de vazio explícito (`null`, limpa valor), e o texto Lexical do Consent usa o renderer JSX oficial do Payload para preservar estrutura sem HTML injetado.
|
||||
- O login de campanha garante alvos de toque mínimos de 44 px para identificador, senha e submit, com foco visível e ordem de teclado testada.
|
||||
- A validação do login permanece autoritativa no servidor por `campaignLoginSchema`; o cliente usa apenas constraints nativas, metadados de autofill e estado pendente/erro da Server Action.
|
||||
- Em 2026-07-17, toda senha recebida por `FormData` passou por uma fronteira central que rejeita ausência, vazio e `File`, mas preserva exatamente espaços e demais caracteres; apenas identificadores continuam normalizados.
|
||||
- O shell mantém o documento sem overflow horizontal e o conteúdo em scroll interno nas larguras 360, 390, 768 e 1440 px.
|
||||
- Em 2026-07-17, os caminhos internos foram renomeados para inglês: route group `(campaign)` e componentes em `src/components/campaign`; o segmento real `campanha` foi preservado, portanto as URLs continuam `/campanha`.
|
||||
- Em 2026-07-17, Bahia passou a ser implícita no Núcleo. O território usa `region`, `city`, `neighborhood`, `locality` e `territoryNotes`; `state`, `territoryKind`, coordenadas e raio foram removidos. Região/cidade devem seguir a composição oficial vigente dos Territórios de Identidade.
|
||||
- Em 2026-07-17, a composição oficial dos 417 municípios foi incorporada a partir da lista municipal da SECULT Bahia, validada pelos 27 códigos/contagens do mapa SEI 2024 (versão DPA 20/12/2024) e pela cobertura exata de `CitiesByState.BA`. O arquivo documenta as URLs oficiais; o formulário sincroniza município/território e o servidor rejeita pares incompatíveis.
|
||||
- Em 2026-07-17, Núcleos receberam `slug` canônico único e as rotas passaram a `/campanha/nucleos/[slug]`. Para manter links duráveis sem uma tabela de aliases desproporcional neste ciclo, o nome ficou imutável após a criação; IDs permanecem internos nas relações e ações.
|
||||
- Em 2026-07-17, responsáveis por Núcleo passaram a aceitar `campaignUser.role = geral|coordenador`; `lideranca` é rejeitada também em escritas diretas da Local API. Nos formulários, o usuário geral atual aparece primeiro com o rótulo visual “(você)”, sem alterar o nome persistido.
|
||||
- Os filtros de Núcleos usam `router.replace`, reiniciam a paginação, preservam os demais parâmetros e sincronizam novamente a partir da URL em navegação voltar/avançar; a consulta Payload no servidor permanece a fonte dos resultados.
|
||||
- A composição dos filtros usa uma única instância dos comboboxes oficiais. Atualizações rápidas derivam a próxima URL do estado controlado local, e a navegação canônica do servidor remonta a ilha pelo resultado de `buildNucleusFiltersKey`.
|
||||
- O seletor de contato principal fixa o contato atual separadamente e retorna no máximo 99 alternativas quando ele existe (100 itens renderizados no total); a busca autenticada resolve o slug uma vez, restringe ao núcleo acessível e consulta somente lideranças engajadas.
|
||||
- As opções de responsáveis nunca fazem parte do DTO da página do Núcleo: uma Server Action exclusiva de `geral` retorna apenas `{ id, name, isCurrent }` e `expectedUpdatedAt` ao abrir o diálogo; respostas obsoletas após fechamento/reabertura são ignoradas no cliente e a gravação continua protegida pela revisão e pelo lock existentes.
|
||||
- Os diálogos lazy são controlados pelo shell que contém o gatilho persistente. Modos de estimativa mantêm um Root por gatilho para que Escape, overlay e X retornem ao botão exato; após sucesso com revalidação RSC, o modo de origem é transportado até a árvore atualizada para restaurar o foco.
|
||||
- O carregamento dos módulos lazy compartilha somente uma pequena máquina de estados `idle|loading|ready|error`; cada shell conserva seu `import()` dinâmico e, após rejeição, fechar ou tentar novamente invalida a tentativa anterior para permitir um loader novo.
|
||||
- O foco pós-sucesso da estimativa é condicionado aos dados atualizados (`proposedVoteEstimate` ou revisão de confirmação), usa refs dos gatilhos remontados e nunca timeout/RAF; quando o gatilho esperado não existe, o título focável do cartão recebe o foco.
|
||||
- Advisory locks de runtime usam a sessão exata da transação Payload via `postgresTransactionLocks.ts`, com chaves textuais deduplicadas/ordenadas e namespaces independentes (`vote-estimate`, `nucleus-updates`, `primary-contact`, convites, telefone de contato e designação de coordenação). Leases de Consent permanecem exclusivamente em helpers de teste.
|
||||
- A fronteira pública de convites exporta somente as três Server Actions; helpers privilegiados de SQL, autorização, token, consentimento e recuperação ficam em módulos `server-only`, com limites arquiteturais cobertos por teste.
|
||||
- Em 2026-07-17, `Contact.city` passou a opcional porque Núcleos podem representar um Território de Identidade sem município/localidade; os fluxos públicos continuam exigindo cidade em seus próprios schemas. O cadastro de liderança mascara o celular no cliente, normaliza 11 dígitos no servidor e preserva valores/erros após falhas.
|
||||
- Em 2026-07-17, decisão de produto: `Município` e `Bairro` do Núcleo passam a aceitar múltiplas entradas (arrays planos `cities`/`neighborhoods`), e `region` vira `regions` derivado no servidor a partir dos municípios, permitindo cruzar Territórios de Identidade. Bairros exigem município único; filtros da lista mudam de `equals` para `contains`. Modelo A (arrays planos) escolhido em vez de array estruturado por município. Plano: [`docs/plans/territorio-multi-municipio-bairro.md`](../../../docs/plans/territorio-multi-municipio-bairro.md); item do roadmap: "Território multi-município e multi-bairro".
|
||||
- O DTO paginado de atualizações contém somente filtro/paginação/feed; a visão geral chama um loader dedicado que busca `limit: 3` e apenas os autores necessários à prévia.
|
||||
- A lista paginada de lideranças seleciona e serializa somente os campos da linha; avaliação interna e dados completos de contato são carregados apenas para a liderança selecionada. O fechamento usa `onCloseAutoFocus` no Sheet e `finalFocus` no Drawer, focaliza a linha de origem (ou o título do Núcleo) antes de navegar para a URL canônica, sem RAF/timeout.
|
||||
- Fonte: `/Users/francisco.solla/.cursor/plans/núcleos_eleitorais_mvp_7f788d29.plan.md`.
|
||||
|
||||
## Bloqueadores e próximas frentes
|
||||
|
||||
- **Bloqueador obrigatório de produção:** antes de dados reais ou convites, a assessoria jurídica eleitoral deve documentar a base do art. 11 da LGPD, aprovar o texto versionado específico e cadastrar `Consent.key = 'lideranca-autopreenchimento'`. Em 2026-07-17 esse texto entrou num **lote jurídico único** com os demais consents da campanha (`apoiador-cadastro`, `apoiador-intencao-voto`, `campanha-notificacoes-push`) e o Aviso de Privacidade — ver "Onda 0" do roadmap.
|
||||
- Em 2026-07-17 o roadmap foi repriorizado orientado ao calendário eleitoral (Res. TSE 23.760/2026: propaganda a partir de 16/08, 1º turno 04/10), com ordem de execução, trilhas paralelas (A dados eleitorais/território, B superfícies de coordenação, C operação de campo, D plataforma) e grafo de dependências. Decisões de sequenciamento: lançar o MVP antes da migração multi-município; multi-município antes de zonas-por-municipio/eventos/demandas; baseline TSE independente de zonas-por-municipio (dependência rebaixada a suave); WhatsApp Business API movido a fora de escopo (vedação Meta + Res. 23.610 art. 33).
|
||||
- Backlog consolidado do produto (admin RBAC, mapa, eventos, demandas, apoiadores, listas globais, site, white-label): [`docs/roadmap.md`](../../../docs/roadmap.md).
|
||||
|
|
@ -8,6 +8,12 @@ DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo
|
|||
|
||||
PAYLOAD_SECRET=YOUR_SECRET_HERE
|
||||
BLOB_READ_WRITE_TOKEN=YOUR_BLOB_TOKEN_HERE
|
||||
# Canonical public origin used in absolute campaign invite links. In production
|
||||
# this must be an exact HTTPS non-local origin: no credentials, path other than
|
||||
# `/`, query, or fragment. Production ignores request headers and invite
|
||||
# creation fails closed when this value is invalid. Development/test accepts
|
||||
# request-derived origins only for exact localhost, 127.0.0.1, or ::1 hosts and
|
||||
# normalizes them to HTTP; otherwise this configured origin is the only fallback.
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
|
||||
# Shared secret guarding POST /api/revalidate, which busts the `posts` cache tag
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -48,4 +48,5 @@ node_modules/
|
|||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/.playwright-cli/
|
||||
.aider*
|
||||
|
|
|
|||
45
AGENTS.md
45
AGENTS.md
|
|
@ -8,7 +8,7 @@ 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, `(campanha)` internal campaign tool (roles: assessores). No separate Rust service, no separate frontend app for `/campanha` — model it as Payload collections (it already has native `point` fields + `near` queries for the political map). The `(campanha)` route group now EXISTS with an auth barrier — it is NOT built on `users` + roles but on a **separate Payload 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 `geral`, `coordenador`, and `lideranca`, all on the separate auth collection `campaignUser` (see "Campaign auth" below).
|
||||
- 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.
|
||||
|
||||
|
|
@ -32,8 +32,10 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si
|
|||
|
||||
## Known Gaps (as of 2026-07-15 — resolve before relying on this file for onboarding new devs)
|
||||
|
||||
1. **No `roles` field on either auth collection yet.** The RBAC examples below are generic Payload patterns, not yet implemented here — every `users` (admin) user today has full access, and the `/campanha` barrier is currently just "any authenticated `campaignUser` = full access" (see "Campaign auth" below). Roles are deferred until a `/campanha` screen actually needs to gate on them; add `roles` (e.g. `admin`/`editor`/`assessor`) to the relevant collection at that point (and before opening the admin to more people).
|
||||
2. **`Consent` document IDs are hardcoded** in code (e.g. `consent: 2` in `submitWhatsapp.ts`). Fragile — breaks silently if that document is ever recreated with a different ID, and blocks having a reproducible local seed. Consider referencing by a stable slug/key instead. (The column-type drift is fixed; the hardcoded-ID fragility remains.)
|
||||
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.
|
||||
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.
|
||||
|
||||
**Recently resolved (2026-07-15):** local Postgres now runs via `docker-compose.yml` (Postgres 17, matching prod, auto-creating `teqo_test`); Payload migrations are set up with prod baselined; dev/test database guards prevent accidental production access (see the two database skills). The `post`/`tag` collections and the public news/article/listing routes shipped and are deployed (see "Posts & Tags" below).
|
||||
|
|
@ -45,11 +47,11 @@ The public news/publications system is backed by two collections (both in the `P
|
|||
- **`post`** (`src/collections/Post.ts`) — fields: `title`, `slug` (unique, indexed, auto-slugified from `title` when left empty), `type` (select enum `noticia|campanha|artigo|evento`), `category` (required single relationship → `tag`), `tags` (`hasMany` relationship → `tag`), `subtitle`, `coverImage` (upload → `media`), `publishedDate`, `body` (richText). Has drafts/versions (`schedulePublish`, `maxPerDoc: 5`).
|
||||
- **`tag`** (`src/collections/Tag.ts`) — fields: `name`, `slug` (unique, indexed, auto-slugified from `name`), and `hidden` (checkbox, admin label "Esconder", default `false`). Tags are the shared taxonomy for both `post.category` and `post.tags`.
|
||||
|
||||
**Tags are a taxonomy, not a person record.** The `Contact` convention above applies to *people*; `tag` is publication metadata (categories + a visibility control flag), so post/tag relations do not touch `Contact`. Don't fold taxonomy into `Contact` or vice-versa.
|
||||
**Tags are a taxonomy, not a person record.** The `Contact` convention above applies to _people_; `tag` is publication metadata (categories + a visibility control flag), so post/tag relations do not touch `Contact`. Don't fold taxonomy into `Contact` or vice-versa.
|
||||
|
||||
**Electoral visibility control (`hidden` + fail-closed `isPostVisible`).** Marking a tag `hidden` hides every post that references it — as `category` or in `tags` — with a single toggle. This exists so campaign / pre-candidacy content (tagged `eleitoral`) can be pulled from the public site during the electoral period. `isPostVisible(post)` (`src/utilities/posts.ts`) returns true only when the post is `published` AND none of its related tags (`category` + `tags`) is `hidden`. It **fails closed**: an unpopulated relation (a bare numeric id) is treated as hiding, so callers must fetch with `depth >= 1`. Every public read filters through `getVisiblePosts()`, which applies this predicate to the cached published list.
|
||||
|
||||
**URLs and Portuguese SEO values.** The canonical article path is `/[type]/[category]/[slug]` (e.g. `/noticia/saude/<slug>`), with `/[type]` and `/[type]/[category]` listing routes and the home "Últimas notícias" list. The `type` enum values (`noticia|campanha|artigo|evento`) and tag/category slugs (`saude`, `eleitoral`, …) are deliberately Portuguese for SEO and are **data, not identifiers** — never translate them (see the naming-conventions bullet above). The route folders are `[type]/[category]/[slug]` (English param keys); the article route redirects any stale/mismatched URL to the canonical path derived from the post's *current* `type` + category slug. Article pages emit JSON-LD (`Article`) and Open Graph metadata.
|
||||
**URLs and Portuguese SEO values.** The canonical article path is `/[type]/[category]/[slug]` (e.g. `/noticia/saude/<slug>`), with `/[type]` and `/[type]/[category]` listing routes and the home "Últimas notícias" list. The `type` enum values (`noticia|campanha|artigo|evento`) and tag/category slugs (`saude`, `eleitoral`, …) are deliberately Portuguese for SEO and are **data, not identifiers** — never translate them (see the naming-conventions bullet above). The route folders are `[type]/[category]/[slug]` (English param keys); the article route redirects any stale/mismatched URL to the canonical path derived from the post's _current_ `type` + category slug. Article pages emit JSON-LD (`Article`) and Open Graph metadata.
|
||||
|
||||
**Caching (mixed ISR, `posts` tag).** Public reads go through the `unstable_cache` wrappers in `src/utilities/posts.ts` (`getCachedPublishedPosts`, `getCachedPostBySlug`), all tagged `posts`. The routes set `export const dynamicParams = true` and build `generateStaticParams` from `getVisiblePosts()`: known paths are statically generated, unknown ones render on demand, and everything stays cached until the `posts` tag is busted. `revalidatePostsListing()` (`src/utilities/documents.ts`) is `revalidateTag('posts')` (the listing tag is `` `${collection}s` `` → `posts`), so one call busts the home page, every listing route, and every article route at once.
|
||||
|
||||
|
|
@ -77,12 +79,33 @@ curl -X POST "https://<prod-domain>/api/revalidate" \
|
|||
|
||||
The internal `/campanha` area is gated by its own authentication, deliberately kept **isolated from the Payload admin (`/admin`)** so a campaign session and an admin session can coexist in the same browser.
|
||||
|
||||
- **`campaignUser` collection** (`src/collections/CampaignUser.ts`) — a **separate Payload auth collection** (`auth: true`), labels "Usuário"/"Usuários", admin group "Campanha", with a single `name` field on top of the built-in auth fields. It is NOT `users`: `admin.user` in `payload.config.ts` stays `users`, so a `campaignUser` cannot log into `/admin` (it can still be managed there by an admin). This is why `/campanha` is not built on `users` + roles.
|
||||
- **`campaignUser` collection** (`src/collections/CampaignUser.ts`) — a separate Payload auth collection with `name`, optional contact `phone`, and required `role`: `geral`, `coordenador`, or `lideranca` (default and least privilege). Staff may sign in by email; leadership accounts use the normalized 11-digit phone as `username`. It is not `users`: `admin.user` remains `users`, so campaign users cannot log into `/admin`.
|
||||
- **Isolated session cookie `campaign-token`.** The flow sets its own httpOnly cookie named `campaign-token` (constant `CAMPAIGN_TOKEN_COOKIE` in `src/utilities/campaignAuth.ts`), scoped to `path: '/campanha'`, `sameSite: 'lax'`, and `secure` only in production. It is deliberately NOT the default `payload-token` cookie — using a distinct name + path is what lets a campaign session and a Payload admin session live side by side without clobbering each other.
|
||||
- **Session verification — `getCampaignUser()`** (`src/utilities/campaignAuth.ts`). Reads the `campaign-token` cookie, calls `payload.auth({ headers })` with an `Authorization: JWT <token>` header, and returns the user only when `user.collection === 'campaignUser'` (otherwise `null`) — so an admin `payload-token` can never satisfy the campaign barrier.
|
||||
- **Login/logout server actions** (`src/app/(campanha)/campanha/actions/auth.ts`). `loginCampaign` validates input with `campaignLoginSchema` (`src/lib/schemas/campaign-login.ts`), calls `payload.login({ collection: 'campaignUser' })`, and — because the Local API returns a token WITHOUT setting a cookie — sets the `campaign-token` cookie itself (using the collection's `auth.tokenExpiration` as `maxAge`) before redirecting to `/campanha`. `logoutCampaign` clears the cookie (`maxAge: 0`) and redirects to `/campanha/login`.
|
||||
- **Route layout.** The route group's root layout `src/app/(campanha)/layout.tsx` renders the `<html>`/`<body>` with `data-theme="campaign"`. The public login lives at `src/app/(campanha)/campanha/login/` (`page.tsx` + client `LoginForm.tsx`). Everything gated sits in the `(app)` group, whose layout (`src/app/(campanha)/campanha/(app)/layout.tsx`) calls `getCampaignUser()` and `redirect('/campanha/login')` when there is no session — that layout is the barrier.
|
||||
- **Migration.** Adding the collection required migration `20260716_010420_add_campaign_user` (creates `campaign_user` + `campaign_user_sessions` tables and the related `payload_locked_documents_rels` / `payload_preferences_rels` columns).
|
||||
- **Session verification — `getCampaignUser()`** (`src/utilities/campaignAuth.ts`). Reads the `campaign-token` cookie, calls `payload.auth({ headers })`, accepts only `user.collection === 'campaignUser'`, then reloads that document by ID. The fresh read makes role downgrades effective immediately instead of trusting a stale JWT role.
|
||||
- **Login/logout server actions** (`src/app/(campaign)/campanha/actions/auth.ts`). `loginCampaign` validates input with `campaignLoginSchema` (`src/lib/schemas/campaign-login.ts`), calls `payload.login({ collection: 'campaignUser' })`, and — because the Local API returns a token WITHOUT setting a cookie — sets the `campaign-token` cookie itself (using the collection's `auth.tokenExpiration` as `maxAge`) before redirecting to `/campanha`. `logoutCampaign` clears the cookie (`maxAge: 0`) and redirects to `/campanha/login`.
|
||||
- **Route layout.** The route group's root layout `src/app/(campaign)/layout.tsx` renders the `<html>`/`<body>` with `data-theme="campaign"`. The public login lives at `src/app/(campaign)/campanha/login/` (`page.tsx` + client `LoginForm.tsx`). Everything gated sits in the `(app)` group, whose layout (`src/app/(campaign)/campanha/(app)/layout.tsx`) calls `getCampaignUser()` and `redirect('/campanha/login')` when there is no session — that layout is the barrier.
|
||||
- **Migrations.** `20260716_010420_add_campaign_user` created the auth collection; the unpublished 2026-07-17 campaign chain was consolidated into `20260718_010733_consolidate_campaign_schema`, which adds role/username auth, contact phone, and the remaining final campaign schema.
|
||||
|
||||
## Campaign nuclei MVP
|
||||
|
||||
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.
|
||||
|
||||
- **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.
|
||||
- **Role scope:** `geral` sees/manages all nuclei 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.
|
||||
- **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. 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; only a coordinator assigned to the nucleus or `geral` may confirm.
|
||||
|
||||
### Local verification and deploy checklist
|
||||
|
||||
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:int`, `pnpm test:e2e`, and `pnpm build` against the local database.
|
||||
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.
|
||||
|
||||
## Core Principles
|
||||
|
||||
|
|
@ -108,7 +131,7 @@ src/
|
|||
├── app/
|
||||
│ ├── (frontend)/ # Frontend routes + server actions (src/app/(frontend)/actions/*.ts)
|
||||
│ ├── (payload)/ # Payload admin routes
|
||||
│ └── (campanha)/ # Internal campaign-management area (auth barrier, see "Campaign auth")
|
||||
│ └── (campaign)/ # Internal campaign-management area (auth barrier, see "Campaign auth")
|
||||
├── collections/ # Collection configs (Users, CampaignUser, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag)
|
||||
├── globals/ # Global configs (SiteSettings, HomePage, Metadata)
|
||||
├── components/ # Custom React components (incl. components/ui from shadcn)
|
||||
|
|
|
|||
|
|
@ -93,8 +93,6 @@ Then run `pnpm test` (or `pnpm test:int` / `pnpm test:e2e`).
|
|||
- PostgreSQL (via `@payloadcms/db-postgres`)
|
||||
- TypeScript
|
||||
|
||||
## Roadmap Notes
|
||||
## Roadmap
|
||||
|
||||
- Prioritize audience ownership and portability of data.
|
||||
- Keep modules generic enough for reuse across different political contexts.
|
||||
- Default to secure, access-controlled features for campaign and institutional teams.
|
||||
Backlog and future plans live in [`docs/roadmap.md`](docs/roadmap.md) (blockers, public site, `/campanha` next cycles, admin RBAC, white-label).
|
||||
|
|
|
|||
BIN
docs/design-refs/campanha-nucleos/01-login.png
Normal file
BIN
docs/design-refs/campanha-nucleos/01-login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/design-refs/campanha-nucleos/02-inicio-geral.png
Normal file
BIN
docs/design-refs/campanha-nucleos/02-inicio-geral.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
BIN
docs/design-refs/campanha-nucleos/03-inicio-coordenador.png
Normal file
BIN
docs/design-refs/campanha-nucleos/03-inicio-coordenador.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/design-refs/campanha-nucleos/04-lista-nucleos.png
Normal file
BIN
docs/design-refs/campanha-nucleos/04-lista-nucleos.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/design-refs/campanha-nucleos/05-detalhe-nucleo.png
Normal file
BIN
docs/design-refs/campanha-nucleos/05-detalhe-nucleo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/design-refs/campanha-nucleos/06-liderancas.png
Normal file
BIN
docs/design-refs/campanha-nucleos/06-liderancas.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
183
docs/plans/baseline-eleitoral-tse.md
Normal file
183
docs/plans/baseline-eleitoral-tse.md
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
# Baseline eleitoral TSE 2022
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Os resultados das eleições passadas são **dados abertos públicos** do TSE (Portal de Dados Abertos, licença Creative Commons Atribuição), disponíveis por **município e zona eleitoral** (e por seção). Hoje o `/campanha` só tem a estimativa manual de votos (sugerir/confirmar); não há referência histórica. A decisão de produto (2026-07-17) é importar os resultados oficiais de 2022 (presidente, governador, dep. federal, dep. estadual) por município+zona e usá-los como **baseline histórico** em três lugares: no detalhe do núcleo, no overview da lista de núcleos (escopado pelos filtros), e em insights automáticos para coordenadores e lideranças.
|
||||
|
||||
Conjunto TSE usado: ["Resultados - 2022"](https://dadosabertos.tse.jus.br/dataset/resultados-2022) — "Votação nominal por município e zona", "Detalhe da apuração por município e zona" e "Consulta candidatas e candidatos" (esta última traz `DS_SIT_TOT_TURNO`, que diz quem foi **eleito**). Arquivos por UF (BA) para governador/federal/estadual; BR para presidente.
|
||||
|
||||
Candidatos de interesse (destaques na UI): **Solla = dep. federal BA, PT, nº 1313** (128.968 votos, eleito); **Lula = presidente, PT, nº 13**; **Jerônimo = governador BA, PT, nº 13** (venceu no 2º turno).
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Importar o **conjunto completo de candidatos de 2022** para os quatro cargos (não só Solla/Lula/Jerônimo), com flag `elected`/`electedBy` por candidato.
|
||||
- Armazenar no grain **município + zona** (casa com `electoralNucleus.city` + `electoralNucleus.tseZones[]`).
|
||||
- Derivar, por núcleo, o baseline 2022: votos de Solla/Lula/Jerônimo, válidos, brancos, nulos, abstenções, aptos, mais votado local, ranking do Solla.
|
||||
- Mostrar baseline no detalhe do núcleo (aba overview) e no overview da lista (reagindo aos filtros).
|
||||
- Gerar insights automáticos (gap vs 2022, taxa de conversão, classificação territorial, alavancagem da chapa, oportunidade de mobilização, competidor).
|
||||
- Deixar o modelo pronto para, quando o TSE publicar 2026, casar quem concorre de novo (`runningAgain2026`).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Granularidade município + zona** (confirmada com o produto). Seção não tem mapeamento oficial → bairro e não cruzaria com o núcleo; volume proibitivo.
|
||||
- **Todos os candidatos**, não só os de interesse. `electionCandidate` guarda 1 row por candidato com `elected`/`electedBy` derivados de `DS_SIT_TOT_TURNO`.
|
||||
- **Três collections** em admin group `Dados Eleitorais`: `electionTally` (apuração por city+zone+office+turn), `electionCandidateVote` (votos por candidato por city+zone, só `votes > 0`), `electionCandidate` (registro de candidatura + eleito + identidade cross-ano).
|
||||
- **Access control:** dado público TSE. `read` para autenticado em `campaignUser` ou `users` (não aberto ao público). `create/update/delete` admin only. Sem `Consent`, sem transação em runtime. Import via CLI com `overrideAccess: true` fora do runtime deployado.
|
||||
- **Sem PII.** Não persistir CPF nem título eleitoral. A identidade cross-ano usa `identityKey = sha256(normalize(urnaName) + birthCity + birthState + party)` — só dados públicos da consulta de candidaturas.
|
||||
- **Migration** `add_election_results` (push:false). `pnpm build` aplica em prod no deploy. Não esbarra no bloqueador LGPD (dado público, sem liderança real).
|
||||
- **Import idempotente** pela chave única (upsert), com proveniência (URL, data, SHA-256) no cabeçalho do script. Sem step de revalidate (páginas de campanha são dinâmicas com auth, sem ISR/tag).
|
||||
- **Agregação núcleo→baseline é autossuficiente** (revisão 2026-07-17): `citiesForTerritory` **já existe** em `src/lib/bahiaTerritories.ts` (não vem do plano `zonas-por-municipio.md`), e a correspondência cidade↔zona pode ser resolvida pelas próprias rows de `electionTally` (cada par cidade×zona importado é uma row — `where cityName = C` retorna todas as zonas da cidade). `tseZonesForCity` (plano `zonas-por-municipio.md`) é **opcional**: quando existir, vira a fonte preferida por ser validada por fixture. Sem `tseZones` → todas as zonas da(s) cidade(s); só `region` → todas as cidades do território; sem geografia → estado "sem baseline TSE".
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês, strings visíveis em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- Mapeamento `CD_MUNICIPIO` (TSE) → nome canônico em `CitiesByState.BA`: usar `NM_MUNICIPIO` do CSV + validação cruzada.
|
||||
- Limiares de classificação territorial (defesa/ataque/indecisa/perdida) — definir com produto.
|
||||
- Mostrar 1º e 2º turno para presidente/governador, ou só o decisivo? Recomendação: ambos, com destaque ao decisivo.
|
||||
- `lideranca` vê ranking/competidor? Recomendação: sim, é dado público e útil ao engajamento.
|
||||
- `identityKey` por nome de urna + naturalidade + partido pode gerar colisões (homônimos) ou falsos negativos (mudança de partido). Mitigação: revisão manual admin do match 2022→2026 antes de fixar `runningAgain2026=sim|nao`, com `desconhecido` como padrão.
|
||||
|
||||
> Nota: limiares de classificação territorial, taxonomia de alinhamento político (para dobradinha) e limiares de conversão ficam nos planos separados dos respectivos insights no roadmap.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
TSE["Portal Dados Abertos TSE<br/>CSVs 2022 (BA + BR)<br/>resultados + candidaturas"]
|
||||
Script["scripts/seed-tse-results.mjs<br/>+ seed-loader.mjs<br/>guard non-local DB"]
|
||||
Coll1["electionTally<br/>(year, office, turn, state, city, zone)<br/>aptos, comparecimento, abstencoes,<br/>validos, legenda, brancos, nulos,<br/>winnerNumber, winnerName, winnerVotes"]
|
||||
Coll2["electionCandidateVote<br/>(year, office, turn, state, city, zone,<br/>candidateNumber, name, coalition, party,<br/>voteType: nominal|legenda, votes)<br/>somente votes > 0"]
|
||||
Coll3["electionCandidate<br/>(year, office, turn, state,<br/>candidateNumber, urnaName, party, coalition,<br/>elected, electedBy, identityKey,<br/>runningAgain2026?)<br/>1 row por candidato"]
|
||||
Pages["Detalhe do núcleo + Overview lista + Insights"]
|
||||
TSE --> Script --> Coll1
|
||||
Script --> Coll2
|
||||
Script --> Coll3
|
||||
Coll1 --> Pages
|
||||
Coll2 --> Pages
|
||||
Coll3 --> Pages
|
||||
```
|
||||
|
||||
### Collections
|
||||
|
||||
- **`electionTally`** (`src/collections/ElectionTally.ts`): `year`, `office` (`presidente|governador|deputado_federal|deputado_estadual`), `turn` (1|2), `state` (`BA`), `cityCode`, `cityName`, `zoneNumber`, `aptos`, `comparecimento`, `abstencoes`, `votosValidos`, `votosNominaisValidos`, `votosLegenda`, `votosBranco`, `votosNulo`, `votosAnulados`, `winnerCandidateNumber`, `winnerCandidateName`, `winnerVotes`, `winnerCoalition`, `winnerParty`. Único `(year, office, turn, state, cityCode, zoneNumber)`.
|
||||
- **`electionCandidateVote`** (`src/collections/ElectionCandidateVote.ts`): `year`, `office`, `turn`, `state`, `cityCode`, `cityName`, `zoneNumber`, `candidateNumber`, `candidateName`, `coalition`, `party`, `voteType` (`nominal|legenda`), `votes`. Único `(year, office, turn, state, cityCode, zoneNumber, candidateNumber, voteType)`. Só `votes > 0`.
|
||||
- **`electionCandidate`** (`src/collections/ElectionCandidate.ts`): `year`, `office`, `turn`, `state`, `candidateNumber`, `urnaName`, `completeName`, `party`, `coalition`, `candidateStatus`, `elected` (checkbox), `electedBy` (`QP|média|2º turno`), `totalVotesState`, `identityKey` (index), `runningAgain2026` (`sim|nao|desconhecido`, default `desconhecido`). Único `(year, office, turn, state, candidateNumber)`.
|
||||
|
||||
### Agregação núcleo → baseline
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Nuc["electoralNucleus<br/>cities[] + tseZones[]"]
|
||||
Cities["cities efetivas:<br/>nucleus.cities ?? citiesForTerritory(region)"]
|
||||
PerCity["para cada cidade C:<br/>zones = nucleus.tseZones ∩ tseZonesForCity(C)"]
|
||||
Query["electionTally + electionCandidateVote<br/>where city=C, zone in zones, office, turn"]
|
||||
Sum["soma votos por candidato/tipo"]
|
||||
Baseline["NucleusElectoralBaseline<br/>solla/lula/jeronimo/validos/brancos/nulos/winner/rank"]
|
||||
Nuc --> Cities --> PerCity --> Query --> Sum --> Baseline
|
||||
```
|
||||
|
||||
### Fases
|
||||
|
||||
1. **Modelo + import** — collections, migration, `scripts/seed-tse-results.mjs` + `pnpm db:seed:tse`, helpers `src/lib/electionResults.ts` e `src/lib/electionCandidateIdentity.ts`, teste int (totais conhecidos, `elected=true` para Solla/Lula/Jerônimo, idempotência).
|
||||
2. **Detalhe do núcleo** — bloco "Baseline eleitoral 2022" na aba overview; view model `NucleusElectoralBaselineViewModel` em `src/utilities/nucleusViewModels.ts`; componente `src/components/campaign/NucleusElectoralBaseline.tsx`.
|
||||
3. **Overview da lista** — estende `overview-lista-nucleos.md`: somar baselines sobre o conjunto filtrado (`buildNucleusListWhere`); novo bloco no `NucleusListOverview`.
|
||||
4. **Insights automáticos (Gap vs 2022)** — implementa somente o insight "Gap vs 2022" (ver seção "Insights automáticos" abaixo). `src/components/campaign/NucleusInsights.tsx` + `src/lib/electionInsights.ts`. Os demais insights viram itens separados no roadmap com planos próprios.
|
||||
5. **Prontidão 2026** — `identityKey` já em 2022; `scripts/reconcile-running-again.mjs` marca `runningAgain2026` quando 2026 publicar; UI sinaliza quem volta a concorrer. (O insight de dobradinha, que depende deste, é plano separado no roadmap.)
|
||||
|
||||
## Insights automáticos
|
||||
|
||||
Cada insight é uma **derivação de leitura** (sem escrita, sem `Consent`, sem migration) a partir do baseline TSE 2022 + `confirmedVoteEstimate`. Este plano implementa **apenas o insight "Gap vs 2022"** (Fase 4); os demais viram itens separados no roadmap, cada um com plano próprio, porque dependem de limiares/taxonomias a fechar com produto ou de dados de 2026.
|
||||
|
||||
### 1. Gap vs 2022 — IN-SCOPE (Fase 4 deste plano)
|
||||
|
||||
- **Definição:** compara a estimativa confirmada do núcleo com o voto histórico de Solla (dep. federal, nº 1313) em 2022 na mesma geografia. É o insight-cabeça que justifica o import do baseline.
|
||||
- **Fórmula:**
|
||||
- `sollaVotes2022` = soma de `electionCandidateVote.votes` onde `year=2022`, `office=deputado_federal`, `candidateNumber=1313`, `voteType=nominal`, `(city,zone)` ∈ geografia do núcleo (cidades∩zonas, ver agregação).
|
||||
- `gap = confirmedVoteEstimate − sollaVotes2022`.
|
||||
- `ratio = confirmedVoteEstimate / sollaVotes2022` (quando `sollaVotes2022 > 0`).
|
||||
- **Inputs:** `electoralNucleus.confirmedVoteEstimate` + `electionCandidateVote` agregado pela geografia do núcleo.
|
||||
- **Output no detalhe do núcleo:** linha/badge:
|
||||
- `gap < 0` → "Faltam `{abs(gap)}` votos para o patamar de 2022" (cor de atenção).
|
||||
- `gap ≥ 0` → "Já superamos 2022 em `{ratio−1}`%" (cor positiva).
|
||||
- `sollaVotes2022 = 0` → "Solla não recebeu votos aqui em 2022 — território novo a abrir".
|
||||
- `confirmedVoteEstimate = null` → "Sem estimativa confirmada para comparar".
|
||||
- núcleo sem geografia → "Sem baseline TSE (informe território/município)".
|
||||
- **Output no overview da lista:** sobre o conjunto filtrado: `gapTotal = Σ confirmedVoteEstimate − Σ sollaVotes2022`, `nucleiAbove2022` (contagem com `gap ≥ 0`), `nucleiBelow2022` (contagem com `gap < 0`). Renderizado no bloco "Baseline 2022" do `NucleusListOverview`.
|
||||
- **Componente:** `src/components/campaign/NucleusInsights.tsx` (server; começa só com este insight; arquitetura extensível para os demais).
|
||||
- **Helper:** `src/lib/electionInsights.ts` exportando `computeGapVs2022(baseline, confirmedVoteEstimate)` → `{ gap, ratio, status: 'above'|'below'|'noBaseline'|'noEstimate'|'noSolla2022' }`.
|
||||
- **Visibilidade:** `geral`/`coordenador`/`lideranca` (dado público + estimativa já visível ao papel).
|
||||
- **Teste int:** cenários com gap negativo, positivo, `sollaVotes2022=0`, `confirmedVoteEstimate=null` e sem geografia.
|
||||
|
||||
### 2. Taxa de conversão — PLANO SEPARADO no roadmap
|
||||
|
||||
- **Definição:** `confirmedVoteEstimate / aptos` — % do eleitorado apto da zona que a estimativa representa.
|
||||
- **Inputs:** `electionTally.aptos` agregado pela geografia + `confirmedVoteEstimate`.
|
||||
- **Limiares (guia de estratégia):** <15% oportunidade; >40% reduto. A definir com produto.
|
||||
- **Roadmap:** "Insight: taxa de conversão por núcleo".
|
||||
|
||||
### 3. Classificação territorial — PLANO SEPARADO no roadmap
|
||||
|
||||
- **Definição:** classificar o núcleo em defesa/ataque/indecisa/perdida por `sollaPercentValid` (= `sollaVotes / votosValidosFederal`) vs. limiares configuráveis; opcionalmente cruzar com pesquisa de intenção de voto quando existir.
|
||||
- **Inputs:** `electionCandidateVote` (Solla) + `electionTally.votosValidos` agregados.
|
||||
- **Dependência externa:** limiares a definir com produto; possível sobreposição com futuro domínio de pesquisas.
|
||||
- **Roadmap:** "Insight: classificação territorial (defesa/ataque/indecisa/perdida)".
|
||||
|
||||
### 4. Alavancagem da chapa — PLANO SEPARADO no roadmap
|
||||
|
||||
- **Definição:** `confirmedVoteEstimate / lulaVotes` e `/ jeronimoVotes` — % da base de Lula/Jerônimo capturada; o teto natural do PT no local como meta implícita.
|
||||
- **Inputs:** `electionCandidateVote` para presidente (nº 13) e governador (nº 13) agregado + `confirmedVoteEstimate`.
|
||||
- **Roadmap:** "Insight: alavancagem da chapa (Lula/Jerônimo)".
|
||||
|
||||
### 5. Oportunidade de mobilização — PLANO SEPARADO no roadmap
|
||||
|
||||
- **Definição:** `brancos + nulos` e `abstencoes` como potencial não aproveitado (votos que não foram a ninguém / eleitores que não compareceram).
|
||||
- **Inputs:** `electionTally.votosBranco + votosNulo + abstencoes` agregados.
|
||||
- **Roadmap:** "Insight: oportunidade de mobilização (brancos/nulos/abstenções)".
|
||||
|
||||
### 6. Inteligência competitiva — PLANO SEPARADO no roadmap
|
||||
|
||||
- **Definição:** `winnerFederal` (mais votado local para federal em 2022), margem sobre Solla, e `sollaRank` (posição do Solla entre os candidatos no local).
|
||||
- **Inputs:** `electionCandidateVote` agregado por geografia, ordenado por votos; `electionCandidate.elected`.
|
||||
- **Roadmap:** "Insight: inteligência competitiva (mais votado, ranking, margem)".
|
||||
|
||||
### 7. Oportunidades de dobradinha 2026 — PLANO SEPARADO no roadmap
|
||||
|
||||
- **Definição:** candidatos que concorrem de novo em 2026, priorizados por (a) alinhamento político com a chapa PT/Solla e (b) força eleitoral local (votos de 2022 na geografia).
|
||||
- **Inputs:** `electionCandidate.runningAgain2026` + `electionCandidateVote` 2022 + `electionAlliances` (tiers de alinhamento).
|
||||
- **Dependência:** Fase 5 (2026 carregado) + taxonomia de alinhamento (`src/lib/electionAlliances.ts`).
|
||||
- **Roadmap:** "Insight: oportunidades de dobradinha 2026".
|
||||
|
||||
## Arquivos a criar/alterar
|
||||
|
||||
- Criar: `src/collections/ElectionTally.ts`, `src/collections/ElectionCandidateVote.ts`, `src/collections/ElectionCandidate.ts`, `src/lib/electionResults.ts`, `src/lib/electionInsights.ts`, `src/lib/electionCandidateIdentity.ts`, `src/utilities/nucleusElectoralBaseline.ts`, `src/components/campaign/NucleusElectoralBaseline.tsx`, `src/components/campaign/NucleusInsights.tsx`, `scripts/seed-tse-results.mjs`, `scripts/reconcile-running-again.mjs`, migration `add_election_results`, testes int. (Os demais componentes de insight — `electionAlliances.ts`, `DobradinhaOpportunities.tsx` etc. — ficam nos planos separados dos respectivos insights.)
|
||||
- Alterar: `src/payload.config.ts`, `src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx` + `src/utilities/nucleusDetailPageData.ts`, `src/utilities/nucleusPageData.ts`/`nucleusViewModels.ts`, overview da lista, `package.json` (`db:seed:tse`).
|
||||
|
||||
## Dependências
|
||||
|
||||
1. `zonas-por-municipio.md` — **dependência suave, não bloqueante** (revisado em 2026-07-17). A agregação funciona sem ele: `citiesForTerritory` já existe em `bahiaTerritories.ts` e as zonas de cada cidade saem das próprias rows de `electionTally`. O que aquele plano melhora é a **qualidade do input**: com auto-preenchimento, `electoralNucleus.tseZones` deixa de depender de digitação manual (zonas erradas produzem baseline errado; zonas ausentes caem no fallback correto "todas as zonas da cidade"). Recomendado implementá-lo antes ou em paralelo, mas este plano pode ser executado de forma independente.
|
||||
2. `territorio-multi-municipio-bairro.md` (torna `cities[]`) — `getNucleusElectoralBaseline` já desenhado para iterar sobre `cities[]`; funciona igualmente com o modelo single atual (array de 1).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Dados por seção eleitoral (grain escolhido: município+zona).
|
||||
- Outras eleições além de 2022 (extensível via `year`; 2026 entra reusando o mesmo modelo quando o TSE publicar).
|
||||
- Outros estados além da BA (todo núcleo é baiano; `state` fica `BA`).
|
||||
- Previsão estatística de votos (roadmap linha 62) — aqui só baseline histórico + comparação com estimativa manual.
|
||||
- Mapa/PostGIS (roadmap linha 52) — separado.
|
||||
- Substitui o plano `zonas-por-municipio.md` — ele continua válido como melhoria de qualidade do input (dependência suave, ver "Dependências").
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (seção "Campanha → Próximos ciclos")
|
||||
- `docs/plans/zonas-por-municipio.md` — dependência suave (`tseZonesForCity` como fonte preferida de zonas por cidade; `citiesForTerritory` já existe em `bahiaTerritories.ts`)
|
||||
- `docs/plans/overview-lista-nucleos.md` — Fase 3 estende este plano
|
||||
- `src/collections/ElectoralNucleus.ts` — `city`, `region`, `tseZones`
|
||||
- `src/utilities/nucleusViewModels.ts` — onde entra `NucleusElectoralBaselineViewModel`
|
||||
- `scripts/seed-posts.mjs` + `scripts/seed-loader.mjs` — padrão de seed CLI com guard non-local
|
||||
- Portal de Dados Abertos do TSE — https://dadosabertos.tse.jus.br/dataset/resultados-2022
|
||||
- AGENTS.md — naming conventions, padrão de leitura/escrita, "Bahia implícita no Núcleo"
|
||||
154
docs/plans/cadastro-nominal-apoiadores.md
Normal file
154
docs/plans/cadastro-nominal-apoiadores.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# Cadastro nominal de apoiadores em massa
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos", linha 56)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Hoje a campanha só cadastra pessoas via `leadership` — a junção única `Contact`↔núcleo que carrega status de apoio, vínculo com `campaignUser` (acesso ao app) e consentimento. Não existe base nominal de apoiadores "comuns": eleitores que declaram apoio mas não são lideranças engajadas com acesso à plataforma. O roadmap pede explicitamente o cadastro nominal em massa via `Contact` + junção, **sem nunca criar uma collection "pessoa/apoiador" paralela** (decisão travada no AGENTS.md, espelhada por `Signature`/`Subscription`).
|
||||
|
||||
Decisão de produto (2026-07-17): modelo **híbrido** — apoiador é join `Contact`↔campanha com **núcleo opcional**. A base é transversal (pode existir sem núcleo) e, quando vinculada a um núcleo, agrega por território. `leadership` permanece como o subconjunto que tem acesso ao app. A **pre-intenção de votos** (intenção de voto) entra neste plano: é dado sensível (opinião política) e exige consentimento destacado, mas é o ganho de produto central — transforma a estimativa manual de hoje em base nominal real, alimentando os insights de conversão e gap vs. 2022.
|
||||
|
||||
## Marco legal (LGPD + TSE)
|
||||
|
||||
Pesquisa web (2026-07-17). Fontes: TSE (Res. 23.610/2019 alterada pela 23.732/2024), ConJur, Migalhas, OAB Campinas, ANPD.
|
||||
|
||||
- **Opinião política e filiação partidária são dados sensíveis** (art. 11 da LGPD). Não cabe legítimo interesse do controlador; exigem **consentimento específico, expresso e destacado**. A intenção de voto capturada neste plano é dado sensível.
|
||||
- **Princípio da finalidade** (art. 6º LGPD): dado coletado para "cadastro de apoio" ou "lista de presença em evento" não migra livremente para "propaganda eleitoral". Cada finalidade nova precisa ser consentida. O `source` (proveniência) e o `Consent` por finalidade documentam isso.
|
||||
- **Disparo em massa é vedado** no WhatsApp/Telegram (Res. 23.610, art. 33). Permitido só compartilhamento individual/orgânico entre pessoas físicas (art. 33, §2º). A Meta veda o uso do WhatsApp Business API por campanhas políticas.
|
||||
- **Vedada** a cessão, doação e venda de cadastros eletrônicos a candidatas/candidatos/partidos. A base nominal construída aqui é da campanha do Solla e não é compartilhada.
|
||||
- **Obrigações do controlador**: Aviso de Privacidade, Política de Segurança, Canal de Comunicação (confirmação de tratamento, eliminação, descadastramento — art. 18 LGPD), Encarregado de Dados (DPO; dispensado em municípios < 200 mil eleitores), Registro das Operações de Tratamento (art. 33-C da Res. 23.610).
|
||||
- **RIPD**: a Justiça Eleitoral pode exigir Relatório de Impacto à Proteção de Dados quando o tratamento for em larga escala (≥10% do eleitorado apto da circunscrição) e envolver dado sensível ou perfilamento. Avaliação caso a caso pela assessoria jurídica.
|
||||
- **Mensagens** devem trazer identificação completa do remetente e mecanismo de descadastramento/eliminação.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Nova collection `supporter` (join `Contact`↔campanha, núcleo opcional), no admin group `Campanha`.
|
||||
- Import em massa (CSV) com dedup por telefone, transacional, whitelist de colunas e preview de erros por linha.
|
||||
- Captura de **intenção de voto** (dado sensível) com `Consent` por chave estável `apoiador-intencao-voto`, falha fechado se ausente.
|
||||
- Consentimento de apoio declarado por chave `apoiador-cadastro`.
|
||||
- **Mobilização orgânica**: kits de conteúdo compartilhável (links `wa.me` pré-preenchidos, cards, textos) para apoiadores repassarem individualmente em seus círculos sociais — sem disparo em massa pela campanha.
|
||||
- Canal de descadastramento/eliminação (direito LGPD art. 18).
|
||||
- Access control por papel herdando o padrão existente (`getAccessibleNucleusIds`).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Pessoa = `Contact` + junção.** Nova collection `supporter` relaciona `contact` (required) + `nucleus` (opcional) + campos de apoio. Nunca criar "apoiador/pessoa" paralela. (AGENTS.md.)
|
||||
- **Híbrido com núcleo opcional.** Apoiador pode existir sem núcleo (base transversal); quando vinculado a um núcleo, agrega por território. `nucleus` é `relationship` opcional a `electoralNucleus`.
|
||||
- **`leadership` segue separada.** Liderança é o apoiador engajado com acesso ao app; `leadership` permanece responsável pelo `campaignUser` e pelo `supportStatus` interno. Um hook em `supporter` impede o mesmo `contact` ser apoiador e liderança do mesmo núcleo simultaneamente.
|
||||
- **Intenção de voto é dado sensível.** Captura exige `Consent.key = 'apoiador-intencao-voto'` (consentimento destacado, separado do consentimento de cadastro). Falha fechado se a chave não existir — mesmo padrão do convite de liderança (`campaignInvite.ts`).
|
||||
- **Import em massa só por `geral`/admin**, via server action autenticada no `/campanha` (não no `/admin`, que ainda não tem RBAC — AGENTS Known Gap #1). Dedup por telefone com `pg_advisory_xact_lock(hashtextextended(phone, 0))` (mesmo padrão do `Contact`). Tudo transacional com `req: { transactionID }`.
|
||||
- **Sem disparo em massa.** A campanha não envia mensagens em massa a apoiadores. Fornece **kits de compartilhamento** (links `wa.me` pré-preenchidos, cards, textos) que o apoiador/envia individualmente aos seus contatos. Caminho legal (art. 33, §2º).
|
||||
- **Descadastramento.** Server action `unsubscribeSupporter` (por token ou por telefone autenticado) anonimiza/remove o `supporter` e registra o pedido, preservando contagem agregada sem PII.
|
||||
- **Access control.** `geral` vê/gerencia todos; `coordenador` vê apoiadores dos seus núcleos; `lideranca` não gerencia apoiadores (só vê o próprio cadastro se for apoiador). Herda `getAccessibleNucleusIds`.
|
||||
- **i18n e naming.** Identificadores em inglês (`supporter`, `voteIntention`, `unsubscribeSupporter`), strings visíveis em pt-BR. Admin group `Campanha`.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Coexistência `supporter`↔`leadership` no mesmo núcleo.** Recomendação: um contato não pode ser apoiador e liderança do mesmo núcleo simultaneamente (hook valida). Definir com produto.
|
||||
- **Import no `/campanha` vs `/admin`.** Recomendação: import no `/campanha` (server action autenticada, `geral`), com preview de dedup e erros por linha — mantém dentro da ferramenta de campanha e fora do `/admin` sem RBAC. Definir com produto.
|
||||
- **Intenção de voto: enum ou escala.** Recomendação: enum `certo | tende_a_certo | indeciso | outro` + campo de observação. Definir com produto/jurídico.
|
||||
- **RIPD necessário?** Se a base nominal superar 10% do eleitorado apto + dado sensível, TSE pode exigir RIPD. Recomendação: assessoria jurídica avalia; este plano entrega a base, não o RIPD.
|
||||
- **Kit de compartilhamento: onde mora?** Recomendação: novo domínio `shareKit` (cards + texto + link) relacionado a `post`/evento, consumido no detalhe do apoiador e no dashboard. Pode ser fase 2. Definir com produto.
|
||||
- **Anonimização vs eliminação.** LGPD art. 18 permite eliminação. Recomendação: descadastramento anonimiza `Contact.phone`/`email` e remove `supporter`, preservando contagem agregada sem PII. Definir com jurídico.
|
||||
- **Reuso da base na pós-eleição.** Dados coletados em campanha não migram para mandato sem nova base legal. Recomendação: documentar retenção e expiração no Aviso de Privacidade. Definir com jurídico.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Import["Import CSV / cadastro manual<br/>(server action, geral)"]
|
||||
Dedup["pg_advisory_xact_lock<br/>hashtextextended(phone, 0)"]
|
||||
Contact["upsert Contact<br/>(transacional)"]
|
||||
Consent["resolve Consent por chave<br/>apoiador-cadastro / apoiador-intencao-voto"]
|
||||
Supporter["create supporter<br/>contact + nucleus opcional<br/>+ voteIntention + consent"]
|
||||
Agg["agregados por território/setor<br/>+ insights (conversão, gap vs 2022)"]
|
||||
Kit["kit de compartilhamento<br/>wa.me + cards + textos"]
|
||||
Share["apoiador repassa<br/>individualmente (art. 33 §2º)"]
|
||||
Unsub["unsubscribeSupporter<br/>anonimiza/remove"]
|
||||
|
||||
Import --> Dedup
|
||||
Dedup --> Contact
|
||||
Contact --> Consent
|
||||
Consent --> Supporter
|
||||
Supporter --> Agg
|
||||
Supporter --> Kit
|
||||
Kit --> Share
|
||||
Supporter --> Unsub
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/collections/Supporter.ts`** (nova, group `Campanha`):
|
||||
- `contact` (relationship→`contact`, required, index)
|
||||
- `nucleus` (relationship→`electoralNucleus`, opcional, index)
|
||||
- `supportLevel` (select: `apoiador | simpatizante | a_abordar`, default `apoiador`, index)
|
||||
- `voteIntention` (select: `certo | tende_a_certo | indeciso | outro`, index) — **dado sensível**, field access restrito a `geral`/`coordenador`
|
||||
- `consent` (relationship→`consent`) — consentimento de intenção/cadastro
|
||||
- `consentContentHash`, `consentedAt` (mesmo padrão do `leadership`)
|
||||
- `source` (select: `import_csv | manual | convite | evento`) — proveniência para o Registro de Operações (art. 33-C)
|
||||
- `notes` (textarea, internal, field access restrito)
|
||||
- `createdBy` (relationship→`campaignUser`, readOnly, index)
|
||||
- indexes: unique `[contact, nucleus]` quando núcleo definido; index `[contact]` para apoiadores sem núcleo.
|
||||
- access: `canReadSupporter`, `canManageSupporter`, `canCreateSupporter` (em `campaignAccess.ts`).
|
||||
- hook `beforeChange`: valida coexistência com `leadership` (rejeita se o `contact` já é `leadership` do mesmo `nucleus`).
|
||||
- **`Consent` keys** (admin cadastra; app falha fechado se ausente):
|
||||
- `apoiador-cadastro` — apoio declarado (dado não-sensível).
|
||||
- `apoiador-intencao-voto` — intenção de voto (dado sensível, consentimento destacado).
|
||||
- **`src/utilities/supporterConsent.ts`** — resolve `Consent` por chave (mesmo padrão do convite em `campaignInvite.ts`).
|
||||
- **`src/app/(campaign)/campanha/actions/supporter.ts`** — server actions:
|
||||
- `importSupporters` (parse CSV, dedup, transacional, `geral` only, preview de erros por linha).
|
||||
- `setSupporterVoteIntention` (exige consentimento destacado ativo).
|
||||
- `unsubscribeSupporter` (descadastramento/eliminação).
|
||||
- **`src/utilities/supporterDedup.ts`** — reusa o lock `pg_advisory_xact_lock(hashtextextended(phone, 0))`.
|
||||
- **UI**:
|
||||
- `/campanha/apoiadores` — lista paginada com filtros por território/setor/intenção (`geral`/`coordenador`).
|
||||
- Import no `/campanha` com preview de dedup e erros.
|
||||
- Kit de compartilhamento no detalhe do apoiador (fase 2).
|
||||
- **Migrations**: `pnpm migrate:create add_supporter` (+ `add_supporter_consent_keys` se necessário). Tipos: `pnpm generate:types`.
|
||||
|
||||
## O que isso viabiliza
|
||||
|
||||
- **Base nominal por território/setor** para direcionar campo e eventos.
|
||||
- **Intenção de voto nominal** alimentando os insights existentes (taxa de conversão, gap vs. 2022, classificação territorial, dobradinha 2026) com dado real em vez de só estimativa manual — cruzar com o baseline TSE 2022 (plano `baseline-eleitoral-tse.md`).
|
||||
- **Mobilização orgânica**: apoiadores compartilham conteúdo da campanha em seus círculos (caminho legal, sem disparo em massa).
|
||||
- **Dobradinha 2026**: identificar apoiadores de aliados por território.
|
||||
- **Demandas/eventos**: rotear convites e demandas por território (quando esses domínios existirem).
|
||||
- **Agregados no overview da lista de núcleos** (plano `overview-lista-nucleos.md`) passam a incluir contagem nominal de apoiadores.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma bloqueante. Reusa `Contact`, `Consent` (por chave), `campaignAccess` (`getAccessibleNucleusIds`), `normalizeBrazilianPhone`, o lock de dedup e o padrão transacional multi-collection.
|
||||
- **Insights** (taxa de conversão, gap vs. 2022, classificação territorial) se beneficiam, mas não bloqueiam.
|
||||
- **Baseline TSE 2022** (plano separado) habilita o cruzamento nominal vs. voto 2022.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Disparo em massa / WhatsApp Business API — vedado por lei e pela Meta.
|
||||
- Compartilhamento/cessão da base com partido/outras candidaturas — vedado.
|
||||
- RIPD — entregue pela assessoria jurídica, não por este plano.
|
||||
- PWA/push para apoiadores — escopo do plano `notifications.md` (interna à equipe).
|
||||
- Mesclar `leadership` em `supporter` — `leadership` segue responsável pelo acesso ao app.
|
||||
|
||||
## Bloqueador obrigatório de produção
|
||||
|
||||
Antes de importar dados reais ou capturar intenção de voto: a assessoria jurídica eleitoral documenta a base do art. 11 da LGPD para intenção de voto (dado sensível) e dos arts. 7/8 para o cadastro de apoio, aprova os textos versionados específicos, e um admin cadastra `Consent.key = 'apoiador-cadastro'` e `Consent.key = 'apoiador-intencao-voto'`. O app falha fechado se qualquer uma das chaves estiver ausente — mesmo padrão do bloqueador de liderança.
|
||||
|
||||
**Lote jurídico único (decisão de processo 2026-07-17):** esses dois textos devem ser revisados na **mesma rodada jurídica** que o texto de `lideranca-autopreenchimento` (bloqueador do MVP de Núcleos) e o de `campanha-notificacoes-push` ([notifications.md](notifications.md)) — quatro textos, uma rodada. O jurídico é o caminho crítico de produção da vertical inteira; fatiar em rodadas separadas multiplica o lead time externo. A mesma rodada deve cobrir o **Aviso de Privacidade** (página institucional LGPD, bloqueador já listado no roadmap), que é obrigação do controlador antes de coleta em massa.
|
||||
|
||||
**Nota de faseamento:** a engenharia deste plano **não** fica bloqueada pelo jurídico — o app falha fechado por design, então collection, import, UI e testes podem ser construídos e deployados antes das chaves existirem; só o uso com dados reais espera a aprovação.
|
||||
|
||||
## Referências
|
||||
|
||||
- [`docs/roadmap.md`](../roadmap.md) (linha 56)
|
||||
- [`AGENTS.md`](../../AGENTS.md) — Pessoa = `Contact` + junção; `Consent` por chave; transações multi-collection; access control
|
||||
- Res. TSE 23.610/2019 (alterada pela 23.732/2024); LGPD art. 11 (dados sensíveis) e art. 18 (direitos do titular)
|
||||
- [`src/collections/Leadership.ts`](../../src/collections/Leadership.ts) — padrão de junção única `Contact`↔núcleo + consent
|
||||
- [`src/collections/Signature.ts`](../../src/collections/Signature.ts) e [`src/collections/Subscription.ts`](../../src/collections/Subscription.ts) — padrão de join com `Contact`
|
||||
- [`src/collections/Consent.ts`](../../src/collections/Consent.ts) — `key` estável
|
||||
- [`src/collections/Contact.ts`](../../src/collections/Contact.ts) — normalização de telefone
|
||||
- [`src/utilities/campaignAccess.ts`](../../src/utilities/campaignAccess.ts) — `getAccessibleNucleusIds`, padrão de access por papel
|
||||
- [`src/utilities/campaignInvite.ts`](../../src/utilities/campaignInvite.ts) — `Consent` por chave + falha fechada
|
||||
- [`src/app/(frontend)/actions/submitWhatsapp.ts`](<../../src/app/(frontend)/actions/submitWhatsapp.ts>) — padrão transacional multi-collection
|
||||
104
docs/plans/compartilhar-pagina.md
Normal file
104
docs/plans/compartilhar-pagina.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# Compartilhar página do núcleo
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos", linha 51)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Hoje o detalhe do núcleo (`/campanha/nucleos/[slug]`) só tem ações de escrita (Editar, Nova liderança, Nova atualização, Arquivar). Não existe um caminho rápido para um usuário da campanha mandar o link daquele núcleo a outra pessoa do mesmo núcleo. A decisão de produto (2026-07-17) é adicionar um botão **Compartilhar** que oferece quatro destinos: coordenação geral, coordenador, liderança ou copiar link. Para os três primeiros, abre o WhatsApp via `wa.me` com o telefone do destinatário — o mesmo padrão já usado pelos convites, **sem WhatsApp Business API**.
|
||||
|
||||
Importante: compartilhar **não concede acesso**. O link aponta para `/campanha/nucleos/[slug]`, rota autenticada que já filtra por escopo (`canReadElectoralNucleus` em `src/utilities/campaignAccess.ts`). O destinatário só vê o núcleo se tiver sessão de campanha válida e o núcleo no seu escopo. O compartilhamento é só conveniência — envia o link, não cria convite, não gera token, não toca em `Consent`.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Botão de ação "Compartilhar" no header do detalhe do núcleo, ao lado das ações existentes.
|
||||
- Quatro destinos: coordenação geral, coordenador, liderança, copiar link.
|
||||
- Para os três primeiros, abrir `wa.me` com o telefone do destinatário e uma mensagem pronta (remetente + nome do núcleo + link).
|
||||
- Reusar o padrão `wa.me` dos convites (`buildWhatsAppUrl` em `src/utilities/phone.ts` e `buildCampaignInviteWhatsAppLink` em `src/utilities/campaignInvite.ts` como referência de texto).
|
||||
- Manter access control existente: nenhum dado sensível (telefone) é exposto a quem já não pudesse lê-lo. Sem server action de escrita, sem collection nova, sem `Consent`.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Escopo: só o detalhe do núcleo.** O botão vive no header de `src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx`. A listagem de núcleos (`NucleusCard`) fica fora deste ciclo (ver "Não escopo").
|
||||
- **Compartilhar não é convite.** Não cria `campaignInvite`, não gera token, não expira, não revoga. Reusa o padrão `wa.me` apenas como canal (mesma decisão do notebook: "Convidar pelo WhatsApp" cria convite; aqui não).
|
||||
- **Sem WhatsApp Business API.** Mesma justificativa do roadmap linha 57: o remetente envia pelo seu próprio WhatsApp, abrindo `wa.me` em nova aba com `noopener,noreferrer` (igual ao `LeadershipInviteDialog`).
|
||||
- **Sem server action.** A URL `wa.me` é montada no cliente a partir do telefone do destinatário (já carregado no server) + mensagem com remetente/nome do núcleo/link. Nenhuma escrita, nenhuma transação, nenhum `req`.
|
||||
- **Access control por `overrideAccess: false`.** Os telefones dos destinatários vêm de queries com `user` + `overrideAccess: false`, então o field access existente (`canReadCampaignUserPhone`, `canReadContacts`) filtra naturalmente quem pode ver qual telefone. O plano não inventa regra nova.
|
||||
- **Link compartilhado = URL canônica do núcleo.** `${NEXT_PUBLIC_SITE_URL}/campanha/nucleos/${slug}` — reusa `getCampaignInviteBaseURL()` de `src/utilities/campaignInvite.ts` para derivar a origem (mesmo guard de produção/localhost).
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`ShareNucleusDialog`, `getNucleusShareRecipients`), strings visíveis em pt-BR.
|
||||
- **`lideranca` compartilha, mas sem "Com liderança".** `lideranca` vê "Com coordenação geral", "Com coordenador" e "Copiar link". A seção "Com liderança" fica oculta para esse papel: o escopo de `canReadLeadership` para `lideranca` é `user = self`, então só retornaria a própria liderança, o que é inútil.
|
||||
- **`lideranca` lendo telefone de coordenador/geral.** `canReadCampaignUserPhone` já aprova leitura quando o alvo é coordenador de um núcleo acessível ao usuário, e `lideranca` tem núcleos acessíveis via liderança engajada. Mantém-se a política atual do app; se produto quiser bloquear, é mudar `canReadCampaignUserPhone`, não este plano.
|
||||
- **`geral` é destinatário sim.** Além de coordenador e liderança, a coordenação geral (`campaignUser.role = 'geral'`) também é destinatária — própria seção "Com coordenação geral", listando todos os usuários `geral` (name+telefone, `overrideAccess: false`). Qualquer `geral` é destinatário válido de qualquer núcleo porque vê todos.
|
||||
- **Loader dedicado.** `getNucleusShareRecipients` seleciona só nome+telefone de `geral`, coordenadores e lideranças do núcleo, `overrideAccess: false`, sem paginação (volume por núcleo é baixo). Evita acoplar ao estado de filtro/paginação da aba de lideranças e evita vazar telefone de quem o ator não pode ver.
|
||||
- **Destinatário sem telefone é ocultado.** Sem telefone não há `wa.me`; o destinatário simplesmente não aparece na lista (em vez de aparecer desabilitado).
|
||||
- **Núcleo arquivado também compartilha.** O link funciona para quem tem acesso; o botão permanece visível mesmo com `status = 'arquivado'`.
|
||||
- **UI: Dialog.** Dialog central (como `LeadershipInviteDialog`), reusando `src/components/ui/dialog` e o padrão visual do `LeadershipInviteDialog` — dá espaço para listar destinatários em mobile.
|
||||
- **Texto da mensagem (pt-BR).** Template fixo: `Oi {destinatário}, aqui é {remetente} da campanha do Solla. Veja o núcleo {nome}: {link}`.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Page["NucleusDetailPage (server)<br/>getNucleusShareRecipients<br/>overrideAccess:false"]
|
||||
Header["Header de ações<br/>botão Compartilhar"]
|
||||
Dialog["ShareNucleusDialog (client)<br/>4 destinos"]
|
||||
Geral["Com coordenação geral<br/>lista {name,phone}<br/>buildWhatsAppUrl"]
|
||||
Coord["Com coordenador<br/>lista {name,phone}<br/>buildWhatsAppUrl"]
|
||||
Lider["Com liderança<br/>lista {name,phone}<br/>buildWhatsAppUrl"]
|
||||
Copy["Copiar link<br/>navigator.clipboard"]
|
||||
Wa["wa.me<br/>window.open noopener,noreferrer"]
|
||||
Recipient["Destinatário<br/>abre /campanha/nucleos/slug<br/>auth + escopo existente"]
|
||||
|
||||
Page --> Header
|
||||
Header --> Dialog
|
||||
Dialog --> Geral
|
||||
Dialog --> Coord
|
||||
Dialog --> Lider
|
||||
Dialog --> Copy
|
||||
Geral --> Wa
|
||||
Coord --> Wa
|
||||
Lider --> Wa
|
||||
Wa --> Recipient
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`getNucleusShareRecipients`** (em `src/utilities/nucleusShareRecipients.ts`): recebe `payload`, `user`, `nucleusSlug`. Carrega o núcleo por slug (`overrideAccess: false`, `depth: 0`, `select: { coordinators: true }`) para confirmar acesso e obter IDs dos coordenadores. Depois:
|
||||
- Coordenação geral: `payload.find({ collection: 'campaignUser', where: { role: { equals: 'geral' } }, select: { name: true, phone: true }, overrideAccess: false, user })`.
|
||||
- Coordenadores: `payload.find({ collection: 'campaignUser', where: { and: [{ id: { in: coordinatorIds } }, { role: { equals: 'coordenador' } }] }, select: { name: true, phone: true }, overrideAccess: false, user })` (filtra `coordenador` para não duplicar `geral` já listado; `geral` eventualmente presente em `coordinators` aparece só na seção de coordenação geral).
|
||||
- Lideranças: `payload.find({ collection: 'leadership', where: { nucleus: { equals: nucleusId } }, depth: 1, select: { contact: true }, overrideAccess: false, user })` → extrai `{ id, name, phone }` do `contact` populado.
|
||||
- Retorna `{ general: Recipient[], coordinators: Recipient[], leaderships: Recipient[], nucleusUrl }`, onde `Recipient = { id, name, phone }`. Telefones que o ator não pode ler chegam como `null`/ausente e são filtrados pelo componente. Para `lideranca`, a query de lideranças já retorna só a própria (escopo do access), e o componente oculta a seção.
|
||||
- **`ShareNucleusDialog`** (client, em `src/components/campaign/`): botão "Compartilhar" (`ShareIcon`) que abre `Dialog`. Dentro, quatro seções:
|
||||
- **Com coordenação geral** — lista de usuários `geral` com botão "Abrir WhatsApp" cada.
|
||||
- **Com coordenador** — lista de coordenadores do núcleo. Oculta quando vazia.
|
||||
- **Com liderança** — lista de lideranças do núcleo. Oculta para `lideranca` (e quando vazia).
|
||||
- **Copiar link** — `navigator.clipboard.writeText(nucleusUrl)` com estado "copiado" (reusar o padrão de `copied` do `LeadershipInviteDialog`).
|
||||
- Cada ação de WhatsApp monta `buildWhatsAppUrl(phone, message)` com `message = "Oi {name}, aqui é {senderName} da campanha do Solla. Veja o núcleo {nucleusName}: {nucleusUrl}"` e abre com `window.open(url, '_blank', 'noopener,noreferrer')`.
|
||||
- Reusa `<Dialog>`/`<DialogContent>`/`<Button>`/`<Alert>` e ícones `lucide-react` (`ShareIcon`, `MessageCircleIcon`, `CopyIcon`, `CheckIcon`, `ExternalLinkIcon`), igual ao `LeadershipInviteDialog`.
|
||||
- **Integração na página** (`src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx`): chamar `getNucleusShareRecipients` no `Promise.all` já existente (junto com as demais `*PageData`), e renderizar `<ShareNucleusDialog>` no bloco de botões do header (ao lado de Editar/Nova liderança/Nova atualização). Passar `recipients`, `nucleusUrl`, `nucleusName`, `senderName={user.name}`, `role={user.role}`.
|
||||
- **Sem migration, sem collection, sem server action.** Todo o fluxo é leitura no server + montagem de URL no cliente.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma de outro plano. Reusa `buildWhatsAppUrl` (`src/utilities/phone.ts`), `getCampaignInviteBaseURL` (`src/utilities/campaignInvite.ts`), access control existente (`src/utilities/campaignAccess.ts`) e UI `Dialog`/`Alert` (`src/components/ui/`).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Compartilhar a partir da listagem de núcleos (`NucleusCard` actions) — só o detalhe neste ciclo.
|
||||
- Compartilhar outras páginas da campanha (dashboard, lideranças) — o padrão é extensível, mas fica para depois.
|
||||
- WhatsApp Business API (item separado, roadmap linha 57).
|
||||
- Criar convite/acesso — compartilhar não concede nada; o destinatário já precisa ter acesso.
|
||||
- Compartilhar por canal que não WhatsApp (e-mail, SMS, push) — só `wa.me` + copiar link.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (linha 51)
|
||||
- `src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx` — header onde o botão entra
|
||||
- `src/utilities/phone.ts` — `buildWhatsAppUrl` (`wa.me`, normalização 11 dígitos)
|
||||
- `src/utilities/campaignInvite.ts` — `buildCampaignInviteWhatsAppLink` (referência de texto), `getCampaignInviteBaseURL`
|
||||
- `src/components/campaign/LeadershipInviteDialog.tsx` — padrão visual de Dialog + `window.open` + copiar link
|
||||
- `src/utilities/campaignAccess.ts` — `canReadCampaignUserPhone`, `canReadContacts`, `canReadElectoralNucleus` (escopo que protege o link)
|
||||
- `src/utilities/leadershipViewModels.ts`, `src/utilities/nucleusCoordinatorAssignmentPageData.ts` — formato dos destinatários
|
||||
- AGENTS.md — Campaign auth, naming conventions, padrão `wa.me` dos convites
|
||||
135
docs/plans/demandas-campanha.md
Normal file
135
docs/plans/demandas-campanha.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Demandas de campanha
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos", linha 55)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Hoje a `/campanha` só modela território (`electoralNucleus`) e reporte (`nucleusUpdate`). Não há como registrar, priorizar e acompanhar necessidades operacionais da campanha — material, transporte, alimentação, espaço, equipamento, pessoal de apoio — nem seu custo e comprovante de pagamento. Essas demandas hoje se perdem em WhatsApp e cadernos.
|
||||
|
||||
A decisão de produto (2026-07-17) é modelar o domínio "Demandas" como **uma entidade única `campaignDemand`**, que alimenta o bloco "Demandas" já reservado no overview de `/campanha/nucleos` ([overview-lista-nucleos.md](overview-lista-nucleos.md)).
|
||||
|
||||
A pesquisa de CRM político (O Assessor, Conecta Gabinete, FiscalNote, foreAction) indica os pilares de organização de demandas: classificação (tema/tipo/urgência/competência/território), priorização, status de ciclo (recebida → em andamento → encaminhada → resolvida), responsáveis, prazos, SLA/tempo de resolução e relatórios/heatmap por região.
|
||||
|
||||
### Aspecto legal (prestação de contas eleitoral)
|
||||
|
||||
A prestação de contas eleitoral é regulada pela Lei 9.504/1997 e pela Resolução TSE 23.604/2019, e é feita no sistema oficial do TSE (SPCE). Os gastos eleitorais devem ser comprovados com documento fiscal idôneo (data, descrição detalhada, valor, identificação de emitente/destinatário com CPF/CNPJ e endereço), sem emendas/rasuras, pagos via transação bancária ou cheque nominativo cruzado.
|
||||
|
||||
**Este app NÃO é o SPCE e não processa pagamentos nem doações** (roadmap: fora de escopo). Os campos `cost` e `paymentReceipt` deste domínio são **apenas controle interno de gastos da campanha**, para a coordenação acompanhar necessidades e comprovantes operacionais. Eles **não substituem** a prestação de contas oficial e não têm valor fiscal. A exportação fiscal para o SPCE fica fora do MVP (ver Não escopo).
|
||||
|
||||
### LGPD
|
||||
|
||||
Dados financeiros não são tecnicamente "sensíveis" pela LGPD (art. 5º II, art. 11), mas a jurisprudência e orientações da ANPD recomendam tratá-los com proteção equiparada, pelos princípios da necessidade/minimização, segurança, accountability e acesso restrito need-to-know (art. 6º, art. 46). A base legal do tratamento no MVP é o **cumprimento de obrigação legal/regulatória** (prestação de contas eleitoral), independentemente de consentimento. O controle de acesso por associação (ver Decisões travadas) materializa o need-to-know.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Escopo financeiro = controle interno only.** Manter `cost` + `paymentReceipt` no MVP como controle interno de gastos da campanha, sem substituir o SPCE/TSE. Disclaimer jurídico no admin e na UI. (Resposta do usuário 2026-07-17.)
|
||||
- **Requerente = `Contact`, MVP interno.** Demandas são registradas só por staff logado; base legal LGPD = obrigação legal. "Requerente visualiza" só se aplica quando o `Contact` tem `campaignUser` (liderança) vinculado. Sem `Consent` novo, sem rota pública self-service. (Resposta do usuário 2026-07-17.)
|
||||
- **Entidade única `campaignDemand`**, admin group `Campanha`, `useAsTitle: title`.
|
||||
- **Três modos de associação territorial (mutuamente exclusivos em prática):**
|
||||
- `nucleus` setado → herda território e coordenadores do núcleo; alimenta o bloco "Demandas" do overview de `/campanha/nucleos`.
|
||||
- `actionPlan` setado → herda território, `coordinators` e `leadership` do plano ([eventos-agenda-mobilizacao.md](eventos-agenda-mobilizacao.md)); não alimenta o bloco do overview de núcleos (overview é escopado por filtros de núcleo).
|
||||
- nenhum dos dois → **demanda avulsa** com `territory` próprio (validações Bahia reusadas); não alimenta o bloco do overview de núcleos.
|
||||
- Validação server-side: se `nucleus` ou `actionPlan` setado, `territory` é ignorado/limpo (origem do território é derivada, não duplicada). Pelo menos uma das três origens deve estar presente (núcleo, actionPlan ou território próprio).
|
||||
- **`nucleus` é opcional** (confirmado pelo usuário 2026-07-17). Permite demanda não associada a um núcleo.
|
||||
- **Pessoas = `Contact` + `campaignUser`, nunca cadastro paralelo** (convenção AGENTS.md). `requester` → `Contact` (quem tem a necessidade); `responsible` → `Contact` (executor em campo, como `actionPlan.responsible`); `coordinator` → `campaignUser` (opcional, para avulsas; vinculada a núcleo herda `coordinators`, vinculada a actionPlan herda `coordinators` do plano); `createdBy` → `campaignUser` (derivado no servidor).
|
||||
- **`kind` enum** de tipos operacionais de campanha: `material`, `servico`, `transporte`, `alimentacao`, `infraestrutura`, `espaco`, `equipamento`, `pessoal_apoio`, `outro`. Valores em pt-BR (são dados/labels, não identificadores).
|
||||
- **`category` texto livre opcional** (tema: saúde, educação, segurança...) — secundário, não enum, para não travar taxonomia no MVP.
|
||||
- **`priority` enum**: `baixa`, `media`, `alta`, `emergencial`. Indexada.
|
||||
- **`status` enum** de ciclo: `rascunho`, `recebida`, `em_andamento`, `encaminhada`, `resolvida`, `nao_resolvida`, `cancelada`. Transições para `cancelada`/`resolvida`/`nao_resolvida` restritas a `geral` ou `coordinator` (espelha `canManageNucleusLifecycle`).
|
||||
- **`paymentStatus` enum** (eixo financeiro separado do status): `nao_pago`, `pago`, `reembolsado`, `cancelado`. `paidAt` (date) e `dueAt` (date, prazo de pagamento, distinto de `deadline` da demanda).
|
||||
- **`cost` number** min 0, 2 casas decimais, BRL implícita. Sensível.
|
||||
- **`paymentReceipt` upload → `media`.** Documento fiscal com CPF/CNPJ — muito sensível. Field-level read restrito.
|
||||
- **`slug` canônico imutável após criação** (mesmo padrão `setCanonicalNucleusSlug`): derivado de `title`, único, indexado, readOnly. Rotas em `/campanha/demandas/[slug]` (segmento `demandas` é dado/SEO em pt-BR, não identificador).
|
||||
- **`statusHistory` array append-only no MVP** (`{ author→campaignUser, status, note, createdAt }`, com `author`/`createdAt` derivados no servidor). Collection dedicada `demandUpdate` (espelhando `nucleusUpdate`, feed imutável paginado) fica como follow-up.
|
||||
- **Access control por associação (usuário):**
|
||||
- `geral` vê todas.
|
||||
- `coordenador` vê as onde é `coordinator` **ou** vinculadas a núcleo onde é coordenador **ou** vinculadas a actionPlan onde está em `coordinators`.
|
||||
- `lideranca` vê só as onde é `requester`/`responsible` (via `Contact`↔`campaignUser`↔`leadership` engajada) **ou** vinculadas a actionPlan cuja `leadership` é uma das suas engajadas.
|
||||
- Field-level read de `cost`/`paymentReceipt`/`paymentStatus`/`paidAt`/`dueAt` restrito ao mesmo escopo + `geral`.
|
||||
- Honra a regra do produto: "somente o coordenador geral vê todas; requerente, responsável e coordenador vê só as suas".
|
||||
- **Sem `Consent` novo; sem rota pública no MVP.** Base legal = obrigação legal.
|
||||
- **O bloco de território (demanda avulsa) nasce no modelo vigente do núcleo** (nota de sequenciamento 2026-07-17). Se [`territorio-multi-municipio-bairro.md`](territorio-multi-municipio-bairro.md) já tiver sido implementado (ordem recomendada no roadmap), `campaignDemand.territory` nasce com `regions[]`/`cities[]`/`neighborhoods[]` e as mesmas regras de validação — nunca com o modelo single antigo.
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`campaignDemand`, `CampaignDemand`, `CampaignDemandForm`, `loadCampaignDemandListPageData`), labels admin e strings visíveis em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- ~~**Ordem de implementação vs. `actionPlan`.**~~ **Resolvida no roadmap (2026-07-17): opção (b).** `campaignDemand` pode ser implementada sem o campo `actionPlan`; a relação é adicionável depois (migration aditiva simples) e só ganha UI quando `actionPlan` existir. Na prática o roadmap prioriza Eventos antes de Demandas (agenda de mobilização é mais crítica para 16/08), então o campo provavelmente já nasce junto — mas Demandas **não bloqueia** em Eventos.
|
||||
- **`lideranca` vê demandas?** Recomendação: só se for `requester`/`responsible` ou via actionPlan leadership. Confirmar com produto.
|
||||
- **`responsible` aceita qualquer `Contact` ou só acessíveis?** Recomendação: só contatos no escopo do ator (`getAccessibleContactIds`), espelhando o contato principal do núcleo. Definir com produto.
|
||||
- **Bloco "Demandas" no overview mostra custo?** Custo é sensível. Recomendação: só contagens (por status, emergenciais, próximas do prazo); custo só visível para `geral`. Confirmar com produto.
|
||||
- **Dashboard geral ganha bloco de demandas?** Recomendação: sim, agregando todos os três modos (núcleo, actionPlan e avulsa), sem filtros de lista.
|
||||
- **Demandas avulsas e actionPlan-vinculadas aparecem em algum agregado de território?** Recomendação: dashboard geral, não no overview de núcleos (que é escopado por filtros de núcleo).
|
||||
- **`demandUpdate` dedicado.** Migrar o array `statusHistory` para collection separada (imutável, paginada, autor derivado) em follow-up se o histórico crescer.
|
||||
- **Exportação fiscal para o SPCE.** Exportar demandas pagas + comprovantes para alimentar o SPCE? Fora do MVP, mas a arquitetura (comprovante em `media`, campos de pagamento) deve permitir no futuro.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User["campaignUser autenticado"]
|
||||
Gen["geral: todas as demandas"]
|
||||
Coord["coordenador: coordinator proprio<br/>OU nucleo onde e coordenador<br/>OU actionPlan em coordinators"]
|
||||
Lid["lideranca: requester/responsible proprio<br/>OU actionPlan com sua leadership engajada"]
|
||||
Read["canReadCampaignDemand -> Where"]
|
||||
Demand["campaignDemand<br/>title, kind, category, priority, status, deadline<br/>requester, responsible, coordinator, createdBy<br/>nucleus? OU actionPlan? OU territory proprio<br/>cost, paymentStatus, dueAt, paidAt, paymentReceipt<br/>statusHistory[]"]
|
||||
|
||||
User --> Gen
|
||||
User --> Coord
|
||||
User --> Lid
|
||||
Gen --> Read
|
||||
Coord --> Read
|
||||
Lid --> Read
|
||||
Read --> Demand
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/collections/CampaignDemand.ts`** (novo): collection em admin group `Campanha`, `useAsTitle: title`. Campos conforme "Decisões travadas". Hooks `beforeValidate` (slug canônico + validação dos três modos de território: herda de núcleo/actionPlan ou exige `territory` próprio + validações Bahia reusadas de `ElectoralNucleus`) e `beforeChange` (derivar `createdBy` em create, derivar `statusHistory.author`/`createdAt`, limpar `territory` quando `nucleus`/`actionPlan` setado).
|
||||
- **`src/utilities/campaignAccess.ts`** (extensão): `canCreateCampaignDemand`, `canReadCampaignDemand` (retorna `Where` por papel), `canUpdateCampaignDemand`, `canDeleteCampaignDemand`, `canSetCampaignDemandSystemField`, `canSetCampaignDemandStatus`, `canReadCampaignDemandFinancial` (field-level para `cost`/`paymentReceipt`/`paymentStatus`/`paidAt`/`dueAt`), e `getAccessibleDemandIds` (cache em `req.context`, espelha `getAccessibleNucleusIds` — resolve IDs por `coordinator`, por núcleo acessível e por actionPlan acessível).
|
||||
- **`src/app/(campaign)/campanha/(app)/demandas/page.tsx`**: lista paginada com filtros (kind, status, priority, paymentStatus, território, janela `deadline`), reusando `NucleusFilters`/`NucleusList`/`NucleusPagination` como referência de layout e `components/ui/*`.
|
||||
- **`src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx`**: detalhe com `NucleusTabNav`-style (Visão geral, Histórico de status).
|
||||
- **`src/app/(campaign)/campanha/(app)/demandas/novo/page.tsx`** e **`demandas/[slug]/editar/page.tsx`**: form reusando `NucleusForm`/`NucleusTerritoryFields` como referência.
|
||||
- **`demandas/formActions.ts`**, **`demandas/[slug]/campaignDemandFormActions.ts`**: server actions; escritas multi-collection (criar demanda + entrada de `statusHistory`; transição de status + append de `statusHistory`; registrar pagamento + anexar comprovante) envolvem `payload.db.beginTransaction/commitTransaction/rollbackTransaction` com `req: { transactionID }` (padrão AGENTS.md).
|
||||
- **`src/components/campaign/CampaignBottomNav.tsx`**: adicionar entrada "Demandas".
|
||||
- **Bloco "Demandas"**: ativar no overview de `/campanha/nucleos` (já desenhado em [overview-lista-nucleos.md](overview-lista-nucleos.md)) consumindo `campaignDemand` com `nucleus` no conjunto filtrado, agrupado por `status`/`priority`, sem expor `cost` (só `geral`). No dashboard `/campanha`, bloco agregando os três modos.
|
||||
|
||||
## Ondas (lean, espelhando o ciclo de Núcleos)
|
||||
|
||||
1. Domínio: collection `CampaignDemand.ts`, hooks, access control em `campaignAccess.ts`, migration `add_campaign_demand`, `pnpm generate:types`, `pnpm generate:importmap`, `pnpm exec tsc --noEmit`, lint.
|
||||
2. Lista `/campanha/demandas` + filtros + ativação do bloco "Demandas" no overview de `/campanha/nucleos`.
|
||||
3. Detalhe `/campanha/demandas/[slug]` com tabs (Visão geral, Histórico).
|
||||
4. Forms novo/editar + server actions transacionais + transição de status + registrar pagamento/comprovante.
|
||||
5. Dashboard "Demandas" + E2E por papel + responsividade 360/390/768/1440 + hardening + Aikido por arquivo editado.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **[`territorio-multi-municipio-bairro.md`](territorio-multi-municipio-bairro.md) — pré-requisito de sequenciamento** (2026-07-17): o bloco de território da demanda avulsa nasce no modelo de arrays. Ver "Decisões travadas".
|
||||
- **Soft dependency em `actionPlan`** ([eventos-agenda-mobilizacao.md](eventos-agenda-mobilizacao.md)): a relação `actionPlan` só é viável quando a collection `actionPlan` existir. Decidido (opção b): Demandas não bloqueia em Eventos; o campo é adicionável depois (ver Questões em aberto).
|
||||
- Reusa validações Bahia (`src/lib/bahiaTerritories.ts`, `src/lib/cities.ts`), access control existente (`src/utilities/campaignAccess.ts`), `slugify` (`src/utilities/slug.ts`), UI `components/ui/*` e padrões de `NucleusForm`/`NucleusFilters`/`NucleusList`/`NucleusTabNav`.
|
||||
- Consumidor direto: bloco "Demandas" do overview de `/campanha/nucleos` ([overview-lista-nucleos.md](overview-lista-nucleos.md)) — só agrega demandas vinculadas a núcleo.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- SPCE/prestação de contas oficial — não substitui o TSE; os campos financeiros são controle interno.
|
||||
- Processar pagamentos ou doações dentro deste app (roadmap fora de escopo).
|
||||
- Self-service público de requerente + `Consent` — fora do MVP.
|
||||
- Collection dedicada `demandUpdate` (feed imutável paginado) — follow-up; MVP usa array `statusHistory`.
|
||||
- Exportação fiscal oficial para o SPCE — fora do MVP.
|
||||
- Previsão/estimativa estatística de custo.
|
||||
- Notificações/lembretes de demanda (item separado do roadmap, [notifications.md](notifications.md)).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (linha 55)
|
||||
- `docs/plans/overview-lista-nucleos.md` — bloco "Demandas" já desenhado
|
||||
- `docs/plans/eventos-agenda-mobilizacao.md` — entidade `actionPlan` (relação opcional)
|
||||
- `src/collections/ElectoralNucleus.ts` — padrão de collection, hooks de slug/território, campos derivados
|
||||
- `src/collections/NucleusUpdate.ts` — padrão de feed imutável, locks por núcleo, autor derivado
|
||||
- `src/utilities/campaignAccess.ts` — `getAccessibleNucleusIds`, `getFreshCampaignUser`, `getAccessibleContactIds`, padrões de access por papel
|
||||
- `src/lib/bahiaTerritories.ts`, `src/lib/cities.ts` — validação de território Bahia
|
||||
- `src/utilities/slug.ts` — `slugify`
|
||||
- AGENTS.md — Campaign auth, naming conventions, transações multi-collection, workflow de migrations, LGPD/Consent
|
||||
- Pesquisa: CRM político (O Assessor, Conecta Gabinete, FiscalNote, foreAction); TSE Res. 23.604/2019, Lei 9.504/1997; LGPD Lei 13.709/2018 art. 6º/7º/11/46
|
||||
107
docs/plans/eventos-agenda-mobilizacao.md
Normal file
107
docs/plans/eventos-agenda-mobilizacao.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# Eventos / presença e agenda de mobilização
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos", linha 54)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
O roadmap lista "Eventos / presença e agenda de mobilização" como próximo ciclo da vertical `/campanha` (plano-arquitetura §4). Hoje a campanha só modela território (núcleo) e reporte (atualização); não há como planejar, escalar e acompanhar ações de campo — caminhadas, comícios, carreatas, panfletagens, reuniões de apoio, lançamentos, convenções, atos, entrevistas — nem ver o que vem a seguir.
|
||||
|
||||
A pesquisa de jargão de marketing político brasileiro separa três conceitos que o item do roadmap agrupa:
|
||||
|
||||
1. **Gestão de agenda do candidato** — o tempo do candidato é o recurso mais escasso; a agenda aloca esse tempo entre eventos de mobilização, mídia, encontros com lideranças, gravações e descanso. Agenda é instrumento estratégico, não lista de compromissos.
|
||||
2. **Tipos de ação de mobilização** — caminhadas, carreatas, comícios, panfletagens, porta a porta, reuniões de apoio (lideranças/categorias profissionais/grupos religiosos/sindicais), eventos programados (lançamentos, convenções, atos), entrevistas/aparições. Distinção entre alta intensidade (rua) e baixa intensidade (digital/WhatsApp).
|
||||
3. **Plano de ação de mobilização** — planejamento antecipado, coordenação centralizada, divisão clara de tarefas com responsáveis, prazos e KPIs, supervisor monitorando execução e conformidade eleitoral.
|
||||
|
||||
A decisão de produto (2026-07-17) é modelar isso como **uma única entidade "Plano de Ação"** (`actionPlan`), onde cada documento é uma ação/evento calendarizável, com checklist de tarefas e feed de atualizações — enxuto o suficiente para um MVP e compatível com o bloco "Próximos eventos" já reservado no overview da lista de núcleos ([overview-lista-nucleos.md](overview-lista-nucleos.md)).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Granularidade: entidade única `actionPlan`.** Cada documento É uma ação/evento (caminhada, comício, etc.), não um guarda-chuva de várias ocorrências. Isso mantém o MVP enxuto (uma collection, uma migration, um CRUD) e torna a entidade calendar-addressable via `startAt`. (Decisão de produto 2026-07-17.)
|
||||
- **Território próprio, sem link a `electoralNucleus`.** O plano carrega seu próprio bloco de território, reusando as mesmas validações Bahia do núcleo. O escopo de acesso vem dos campos explícitos (`coordinators`, `leadership`), não de um núcleo vinculado. (Decisão de produto 2026-07-17.) Planos podem cobrir um território sem ser de um núcleo específico.
|
||||
- **O bloco de território nasce no modelo vigente do núcleo** (nota de sequenciamento 2026-07-17). Se [`territorio-multi-municipio-bairro.md`](territorio-multi-municipio-bairro.md) já tiver sido implementado (ordem recomendada no roadmap), `actionPlan` nasce com `regions[]`/`cities[]`/`neighborhoods[]` e as mesmas 6 regras de validação — nunca com o modelo single antigo, que criaria migração dupla. O bloco "Próximos eventos" no overview filtra por território com a mesma semântica da lista de núcleos (`contains` sobre arrays).
|
||||
- **`startAt` obrigatório + `endAt` opcional.** Sem data/hora de início não há agenda nem bloco "Próximos eventos". `deadline` (prazo de conclusão do plano) é distinto e opcional.
|
||||
- **Pessoas = `Contact` + `campaignUser`, nunca cadastro paralelo.** `coordinators` → `campaignUser` (hasMany, só `geral`/`coordenador`, mesmo `filterOptions` do núcleo). `responsible` → `Contact` (pessoa responsável em campo, não precisa ser usuário nem liderança). `leadership` → `leadership` (relação opcional à junção `Contact`↔núcleo, para marcar a liderança que conduz a ação). Honra os três campos propostos pelo produto sem inventar "pessoa" paralela (convenção AGENTS.md).
|
||||
- **`kind` como enum de tipos de mobilização.** `caminhada`, `comicio`, `carreatas`, `panfletagem`, `porta_a_porta`, `reuniao_apoio`, `lancamento`, `convencao`, `ato`, `entrevista`, `producao_conteudo`, `digital`, `outro`. Valores em pt-BR (são dados/labels, não identificadores).
|
||||
- **`status` como enum de ciclo de vida.** `rascunho`, `planejado`, `confirmado`, `realizado`, `cancelado`. Transições para `cancelado`/`realizado` restritas a `geral` ou coordenador atribuído (espelha `canManageNucleusLifecycle`).
|
||||
- **`slug` canônico imutável após criação.** Mesmo padrão de `setCanonicalNucleusSlug`: derivado de `title`, único, indexado, readOnly. Rotas em `/campanha/planos/[slug]` (segmento `planos` é dado/SEO em pt-BR, não identificador).
|
||||
- **`tasks` e `updates` como arrays no MVP.** `tasks`: `{ title, responsible→Contact, due, done, doneAt }`. `updates`: append-only `{ author→campaignUser, body, createdAt }`, com `author`/`createdAt`/`doneAt` derivados no servidor. Uma collection dedicada `actionUpdate` (espelhando `nucleusUpdate`, feed imutável paginado) fica como follow-up.
|
||||
- **Access control por campos explícitos.** `geral` vê/edita tudo; `coordenador` vê planos onde está em `coordinators`; `lideranca` vê planos onde `leadership` é uma das suas lideranças engajadas (IDs calculados e cacheados em `req.context`, mesmo padrão de `getAccessibleNucleusIds`). Escrita de `lideranca` restrita a toggle de `tasks.done` e append de `updates` no próprio plano (escopo estreito).
|
||||
- **Sem `Consent` para o plano.** Plano é dado interno de staff, não coleta de PII pública. Captura nominal de presentes/apoiadores (tocaria `Contact` + `Consent`) fica fora do MVP.
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`actionPlan`, `ActionPlan`, `ActionPlanForm`, `loadActionPlanListPageData`), labels admin e strings visíveis em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **`lideranca` edita tarefas?** Recomendação: sim, só toggle `done` (e append de `update`), sem editar título/datas/responsáveis. Confirmar com produto.
|
||||
- **`tasks.responsible` aceita qualquer `Contact` ou só acessíveis?** Recomendação: só contatos no escopo do ator (`getAccessibleContactIds`), espelhando o contato principal do núcleo. Definir com produto.
|
||||
- **Filtro de janela de data na lista.** Recomendação: filtro de "próximos" (`startAt >= now`), "passados" (`startAt < now`) e intervalo livre. Default: próximos, ordenado por `startAt` asc.
|
||||
- **Bloco "Próximos eventos" no dashboard `/campanha`.** O overview de `/campanha/nucleos` já reserva o bloco; o dashboard geral também ganha? Recomendação: sim, mesmo recorte (todos os planos não cancelados com `startAt >= now`), sem filtros de lista.
|
||||
- **Repetição/recorrência.** Eventos recorrentes (ex.: panfletagem toda sábado) ficam fora do MVP — cada ocorrência é um plano. Reavaliar quando o volume justificar.
|
||||
- **`actionUpdate` dedicado.** Migrar o array `updates` para collection separada (imutável, paginada, autor derivado) em follow-up se o feed crescer.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User["campaignUser autenticado"]
|
||||
Gen["geral: todos os planos"]
|
||||
Coord["coordenador: coordinators contains user.id"]
|
||||
Lid["lideranca: leadership in engagedLeaderships(user)"]
|
||||
Read["canReadActionPlan -> Where"]
|
||||
Plan["actionPlan<br/>title, kind, startAt/endAt, deadline, status<br/>coordinators, leadership, responsible<br/>territorio proprio, location, notes<br/>tasks[], updates[]"]
|
||||
|
||||
User --> Gen
|
||||
User --> Coord
|
||||
User --> Lid
|
||||
Gen --> Read
|
||||
Coord --> Read
|
||||
Lid --> Read
|
||||
Read --> Plan
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/collections/ActionPlan.ts`** (novo): collection em admin group `Campanha`, `useAsTitle: title`. Campos conforme "Decisões travadas". Hooks `beforeValidate` (slug canonico + validação de territorio Bahia + `startAt < endAt`) e `beforeChange` (derivar `createdBy`, `tasks.doneAt`, `updates.author/createdAt`).
|
||||
- **`src/utilities/campaignAccess.ts`** (extensão): `canCreateActionPlan`, `canReadActionPlan` (retorna `Where` por papel), `canUpdateActionPlan`, `canDeleteActionPlan`, `canSetActionPlanSystemField`, `canSetActionPlanStatus`, e `getAccessibleLeadershipIds` (cache em `req.context`, espelha `getAccessibleNucleusIds`).
|
||||
- **`src/app/(campaign)/campanha/(app)/planos/page.tsx`**: lista paginada com filtros (kind, status, territorio, janela `startAt`), reusando `NucleusFilters`/`NucleusList`/`NucleusPagination` como referência de layout e `components/ui/*`.
|
||||
- **`src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx`**: detalhe com `NucleusTabNav`-style (Visão geral, Tarefas, Atualizações).
|
||||
- **`src/app/(campaign)/campanha/(app)/planos/novo/page.tsx`** e **`planos/[slug]/editar/page.tsx`**: form reusando `NucleusForm`/`NucleusTerritoryFields` como referência.
|
||||
- **`planos/formActions.ts`**, **`planos/[slug]/actionPlanFormActions.ts`**: server actions; escritas multi-collection (criar plano + update inicial; toggle tarefa + append update) envolvem `payload.db.beginTransaction/commitTransaction/rollbackTransaction` com `req: { transactionID }` (padrão AGENTS.md).
|
||||
- **`src/components/campaign/CampaignBottomNav.tsx`**: adicionar entrada "Planos".
|
||||
- **Bloco "Próximos eventos"**: ativar no overview de `/campanha/nucleos` (já desenhado em [overview-lista-nucleos.md](overview-lista-nucleos.md)) e no dashboard `/campanha`, consumindo `actionPlan` com `startAt >= now`, `status in [planejado, confirmado]`, ordenado por `startAt` asc, respeitando os mesmos filtros de territorio da página.
|
||||
|
||||
## Ondas (lean, espelhando o ciclo de Núcleos)
|
||||
|
||||
1. Domínio: collection `ActionPlan.ts`, hooks, access control em `campaignAccess.ts`, migration `add_action_plan`, `pnpm generate:types`, `pnpm generate:importmap`, `tsc --noEmit`, lint.
|
||||
2. Lista `/campanha/planos` + filtros + ativação do bloco "Próximos eventos" no overview de `/campanha/nucleos`.
|
||||
3. Detalhe `/campanha/planos/[slug]` com tabs (Visão geral, Tarefas, Atualizações).
|
||||
4. Forms novo/editar + server actions transacionais + toggle de tarefa + append de update.
|
||||
5. Dashboard "Próximos eventos" + E2E por papel + responsividade 360/390/768/1440 + hardening + Aikido por arquivo editado.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **[`territorio-multi-municipio-bairro.md`](territorio-multi-municipio-bairro.md) — pré-requisito de sequenciamento** (2026-07-17): o bloco de território de `actionPlan` nasce no modelo de arrays. Ver "Decisões travadas".
|
||||
- Fora isso, nenhuma de outro plano. Reusa validações Bahia (`src/lib/bahiaTerritories.ts`, `src/lib/cities.ts`), access control existente (`src/utilities/campaignAccess.ts`), `slugify` (`src/utilities/slug.ts`), UI `components/ui/*` e padrões de `NucleusForm`/`NucleusFilters`/`NucleusList`/`NucleusTabNav`.
|
||||
- O bloco "Próximos eventos" do overview de `/campanha/nucleos` ([overview-lista-nucleos.md](overview-lista-nucleos.md)) é consumidor direto e só ganha UI quando esta collection existir.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Collection dedicada `actionUpdate` (feed imutável paginado) — follow-up; MVP usa array.
|
||||
- Captura nominal de presentes/apoiadores (tocaria `Contact` + `Consent`) — fora do MVP.
|
||||
- Visão de calendário/mapa (depende do item mapa/PostGIS do roadmap).
|
||||
- Link a `electoralNucleus` (decisão: território próprio).
|
||||
- Recorrência de eventos — cada ocorrência é um plano no MVP.
|
||||
- Estimativa estatística de votos em eventos.
|
||||
- Notificações/lembretes de evento (item separado do roadmap, [notifications.md](notifications.md)).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (linha 54)
|
||||
- `docs/plans/overview-lista-nucleos.md` — bloco "Próximos eventos" já desenhado
|
||||
- `src/collections/ElectoralNucleus.ts` — padrão de collection, hooks de slug/território, campos derivados
|
||||
- `src/utilities/campaignAccess.ts` — `getAccessibleNucleusIds`, `getFreshCampaignUser`, padrões de access por papel
|
||||
- `src/lib/bahiaTerritories.ts`, `src/lib/cities.ts` — validação de território Bahia
|
||||
- `src/utilities/slug.ts` — `slugify`
|
||||
- AGENTS.md — Campaign auth, naming conventions, transações multi-collection, workflow de migrations
|
||||
65
docs/plans/insight-alavancagem-chapa.md
Normal file
65
docs/plans/insight-alavancagem-chapa.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Insight: alavancagem da chapa (Lula/Jerônimo)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Solla apoia a chapa principal do PT (Lula para presidente, Jerônimo para governador, ambos nº 13). Os votos que Lula e Jerônimo receberam em 2022 numa geografia são o **teto natural do PT** no local — um piso de simpatia partidária que a candidatura de Solla pode capturar como dobradinha. Com o baseline TSE 2022 (ver [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)), podemos medir quanto da base da chapa a estimativa do núcleo já captura e quanto ainda falta — meta implícita de alavancagem.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Computar `lulaVotes` (presidente, nº 13, 1º e 2º turno) e `jeronimoVotes` (governador, nº 13, 1º e 2º turno) por geografia do núcleo.
|
||||
- Calcular `ticketLeverage = confirmedVoteEstimate / lulaVotes` (e `/ jeronimoVotes`) — % da base da chapa capturada.
|
||||
- Exibir no detalhe do núcleo: "% da base de Lula capturada", "teto da chapa: X votos de Lula / Y de Jerônimo", e gap `lulaVotes − confirmedVoteEstimate` como potencial de alavancagem.
|
||||
- No overview da lista: soma de `lulaVotes` e `jeronimoVotes` sobre o conjunto filtrado, e `Σ confirmedVoteEstimate / Σ lulaVotes`.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Leitura derivada** — sem escrita, sem `Consent`, sem migration.
|
||||
- **Reusa** `getNucleusElectoralBaseline` (já expõe `lulaVotes1t/2t`, `jeronimoVotes1t/2t` no view model).
|
||||
- **Turno decisivo** como referência principal (presidente 2º turno; governador 2º turno), com 1º turno como secundário.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- Usar Lula ou Jerônimo como teto principal? Recomendação: ambos, com Lula (presidente) como teto mais alto e Jerônimo (governador) como referência estadual.
|
||||
- `lideranca` vê? Recomendação: sim.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Base["getNucleusElectoralBaseline<br/>lulaVotes + jeronimoVotes"]
|
||||
Est["confirmedVoteEstimate"]
|
||||
Calc["computeTicketLeverage(lulaVotes, jeronimoVotes, estimate)<br/>% capturada + gap de alavancagem"]
|
||||
Detail["Detalhe: '% da base de Lula capturada'<br/>+ teto da chapa + gap"]
|
||||
Overview["Overview: Σ estimate / Σ lulaVotes<br/>sobre o filtro"]
|
||||
Base --> Calc
|
||||
Est --> Calc
|
||||
Calc --> Detail
|
||||
Calc --> Overview
|
||||
```
|
||||
|
||||
- **Helper** `src/lib/electionInsights.ts`: `computeTicketLeverage(lulaVotes, jeronimoVotes, confirmedVoteEstimate)` → `{ lulaLeverage, jeronimoLeverage, lulaGap, jeronimoGap, status }`.
|
||||
- **Componente** `src/components/campaign/NucleusTicketLeverage.tsx` (server). Overview: bloco agregado no `NucleusListOverview`.
|
||||
- **Teste int** cenários: `lulaVotes=0`, `confirmedVoteEstimate=null`, `estimate > lulaVotes` (superou a chapa).
|
||||
|
||||
## Arquivos a criar/alterar
|
||||
|
||||
- Criar: `src/components/campaign/NucleusTicketLeverage.tsx`.
|
||||
- Alterar: `src/lib/electionInsights.ts`, `nucleos/[slug]/page.tsx` + `nucleusDetailPageData.ts`, overview da lista.
|
||||
|
||||
## Dependências
|
||||
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) (votos de presidente/governador no baseline).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Dobradinha efetiva (plano [insight-dobradinha-2026.md](insight-dobradinha-2026.md)) — aqui só medimos alavancagem contra a chapa, não sugerimos parceiros.
|
||||
|
||||
## Referências
|
||||
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)
|
||||
- AGENTS.md
|
||||
67
docs/plans/insight-classificacao-territorial.md
Normal file
67
docs/plans/insight-classificacao-territorial.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Insight: classificação territorial (defesa/ataque/indecisa/perdida)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
A literatura de territorialização eleitoral (Politipédia AVM, OPUS, Seja Eleito) classifica cada território em quatro zonas operacionais — **defesa** (voto histórico favorável, meta: manter), **ataque** (desfavorável mas relevante, meta: virar/reduzir margem), **indecisa** (pulverizada, baixa rejeição, meta: consolidar) e **perdida** (desfavorável e elástica, meta: minimizar perda) — para alocar esforço de forma proporcional ao retorno. Hoje o `/campanha` não classifica núcleos; com o baseline TSE 2022 (ver [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)) podemos derivar uma primeira classificação a partir do histórico.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Computar `sollaPercentValid = sollaVotes2022 / votosValidosFederal2022` por núcleo (soma sobre cidades∩zonas).
|
||||
- Classificar o núcleo em defesa/ataque/indecisa/perdida por `sollaPercentValid` vs. limiares configuráveis.
|
||||
- Exibir badge no detalhe do núcleo e distribuição por classe no overview da lista (sobre o conjunto filtrado).
|
||||
- (Opcional, quando o domínio existir) refinar a classe cruzando com pesquisa de intenção de voto atual.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Leitura derivada** — sem escrita, sem `Consent`, sem migration.
|
||||
- **Limiares versionados** em `src/lib/electionInsights.ts` (constantes); decisão de produto por limiar. A classe é **sugestão automática**, não rótulo definitivo — coordenador pode discordar.
|
||||
- **Reusa** `getNucleusElectoralBaseline` (Solla + válidos federal) do plano baseline.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- Limiares exatos por classe — definir com produto (referência: alta % válidos → defesa; baixa + relevante → ataque; pulverizada → indecisa; baixa + alta rejeição → perdida).
|
||||
- Como incorporar rejeição (não temos dado de rejeição por geografia hoje) — adiar até domínio de pesquisas.
|
||||
- `lideranca` vê a classe do próprio núcleo? Recomendação: sim.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Base["getNucleusElectoralBaseline<br/>sollaVotes + votosValidosFederal"]
|
||||
Calc["computeTerritorialClass(sollaPercentValid)<br/>classe + confiança"]
|
||||
Detail["Detalhe: badge da classe<br/>+ % válidos de Solla"]
|
||||
Overview["Overview: distribuição<br/>defesa/ataque/indecisa/perdida<br/>sobre o filtro"]
|
||||
Base --> Calc
|
||||
Calc --> Detail
|
||||
Calc --> Overview
|
||||
```
|
||||
|
||||
- **Helper** `src/lib/electionInsights.ts`: `computeTerritorialClass(sollaVotes, votosValidosFederal)` → `{ percentValid, class: 'defesa'|'ataque'|'indecisa'|'perdida'|'semBaseline' }`.
|
||||
- **Componente** `src/components/campaign/NucleusTerritorialClassification.tsx` (server). Overview: bloco com contagem por classe.
|
||||
- **Teste int** cenários limítrofes e `votosValidosFederal=0`.
|
||||
|
||||
## Arquivos a criar/alterar
|
||||
|
||||
- Criar: `src/components/campaign/NucleusTerritorialClassification.tsx`.
|
||||
- Alterar: `src/lib/electionInsights.ts`, `nucleos/[slug]/page.tsx` + `nucleusDetailPageData.ts`, overview da lista.
|
||||
|
||||
## Dependências
|
||||
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) — **única dependência dura**.
|
||||
- [zonas-por-municipio.md](zonas-por-municipio.md) — dependência suave herdada do baseline (melhora a qualidade de `tseZones`; ver revisão 2026-07-17 no plano baseline).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Mapa/PostGIS com a classe pintada por território (roadmap linha 52).
|
||||
- Pesquisa de intenção/rejeição (domínio inexistente; refinamento futuro).
|
||||
|
||||
## Referências
|
||||
|
||||
- Politipédia AVM — "Territorialização eleitoral em campanha"
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)
|
||||
- AGENTS.md
|
||||
74
docs/plans/insight-dobradinha-2026.md
Normal file
74
docs/plans/insight-dobradinha-2026.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# Insight: oportunidades de dobradinha 2026
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
"Dobradinha" é a campanha conjunta entre dois candidatos que somam estrutura e votos num mesmo território. Sabendo, via baseline TSE 2022 + Fase 5 (ver [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)), **quais candidatos de 2022 voltam a concorrer em 2026** (`runningAgain2026`), podemos sugerir oportunidades de dobradinha por núcleo/território — priorizando por (a) **alinhamento político** com a chapa PT/Solla e (b) **força eleitoral local** (votos recebidos ali em 2022). É o insight mais estratégico do conjunto e depende de dados de 2026, por isso é plano separado.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Por núcleo, listar candidatos que concorrem de novo em 2026 (`runningAgain2026=sim`) com votos 2022 na geografia, partido/coligação e tier de alinhamento.
|
||||
- Ranquear por score combinando alinhamento (peso a definir) e força eleitoral local (votos 2022).
|
||||
- Exibir no detalhe do núcleo (aba overview) a lista ranqueada de potenciais parceiros de dobradinha; no overview, agregar top parceiros por território filtrado.
|
||||
- Habilitar só quando 2026 estiver carregado; enquanto não, mostrar "indisponível até a candidatura de 2026".
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Leitura derivada** — sem escrita, sem `Consent`, sem migration.
|
||||
- **Depende** do baseline TSE 2022 (Fase 1) **e** da Fase 5 (`runningAgain2026` populado).
|
||||
- **Sem PII** — matching cross-ano via `identityKey` pública (já no plano baseline).
|
||||
- **i18n/naming** seguem o AGENTS.md.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Taxonomia de alinhamento político**: como classificar partidos/coligações em tiers (aliado / aliado histórico / neutro / adversário) relativos à chapa PT/Solla. Recomendação: mapeamento estático versionado em `src/lib/electionAlliances.ts` (partido→tier), derivado das federações/coligações oficiais de 2022 (FE Brasil da Esperança, Pela Bahia Pelo Brasil) e revalidado para 2026. Decisão de produto por tiers.
|
||||
- **Fórmula do score**: como combinar alinhamento (ordinal) com força eleitoral (votos). Recomendação: score = `wAlign * tierWeight + wVotes * normalizedVotes`, com pesos `wAlign`/`wVotes` a definir com produto.
|
||||
- Considerar só candidatos do mesmo cargo (dep. federal + estadual) ou também majoritários? Recomendação: só proporcionais (federal/estadual) para dobradinha com Solla.
|
||||
- `lideranca` vê? Recomendação: sim, mas sem expor o tier de adversário (mostrar só aliados/neutros).
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Cand["electionCandidate<br/>runningAgain2026=sim"]
|
||||
Votes["electionCandidateVote 2022<br/>votos por geografia"]
|
||||
Align["electionAlliances.ts<br/>partido -> tier"]
|
||||
Score["computeDobradinhaOpportunities(...)"]
|
||||
Detail["Detalhe: lista ranqueada<br/>nome, votos 2022, partido, tier"]
|
||||
Overview["Overview: top parceiros<br/>por território filtrado"]
|
||||
Cand --> Score
|
||||
Votes --> Score
|
||||
Align --> Score
|
||||
Score --> Detail
|
||||
Score --> Overview
|
||||
```
|
||||
|
||||
- **Helper** `src/lib/electionAlliances.ts`: mapeamento partido→tier (versionado, com proveniência das coligações 2022/2026).
|
||||
- **Helper** `src/lib/electionInsights.ts`: `computeDobradinhaOpportunities(candidates2026, votes2022, alliances, nucleusGeography)` → `Array<{ candidate, party, coalition, tier, votes2022, score }>` ordenado por score.
|
||||
- **Componente** `src/components/campaign/DobradinhaOpportunities.tsx` (server). Overview: bloco agregado no `NucleusListOverview`.
|
||||
- **Teste int** cenários: sem 2026 carregado (`desconhecido` → estado indisponível); só adversários; misto.
|
||||
|
||||
## Arquivos a criar/alterar
|
||||
|
||||
- Criar: `src/lib/electionAlliances.ts`, `src/components/campaign/DobradinhaOpportunities.tsx`.
|
||||
- Alterar: `src/lib/electionInsights.ts` (nova função), `nucleos/[slug]/page.tsx` + `nucleusDetailPageData.ts`, overview da lista.
|
||||
|
||||
## Dependências
|
||||
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) — Fase 1 (collections + `electionCandidateVote`) **e** Fase 5 (`runningAgain2026` + `identityKey`).
|
||||
- 2026 publicado pelo TSE (externo; fora do nosso controle).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Fechar dobradinhas (decisão política humana) — só sugerimos oportunidades, não fechamos alianças.
|
||||
- Cruzar com pesquisa de intenção (domínio inexistente).
|
||||
|
||||
## Referências
|
||||
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) — baseline + Fase 5 (2026)
|
||||
- [insight-inteligencia-competitiva.md](insight-inteligencia-competitiva.md) — fornece o quadro competitivo base
|
||||
- AGENTS.md — naming, "Bahia implícita no Núcleo"
|
||||
66
docs/plans/insight-inteligencia-competitiva.md
Normal file
66
docs/plans/insight-inteligencia-competitiva.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Insight: inteligência competitiva (mais votado, ranking, margem)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Saber onde Solla foi bem é só metade; a outra é saber **quem o superou localmente** e por quanto. O baseline TSE 2022 (ver [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)) guarda o conjunto completo de candidatos por geografia, com flag de eleito — podemos dizer, por núcleo, quem foi o mais votado para deputado federal ali em 2022, qual a margem sobre Solla, e em que colocação Solla ficou. É inteligência competitiva: onde há adversário forte a desmontar e onde Solla já é o referencial.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Por núcleo, listar o top-N candidatos a dep. federal 2022 por votos nominais na geografia, com `elected` e partido/coligação.
|
||||
- Destacar `winnerFederal` (mais votado), `sollaRank` (colocação de Solla) e `marginToWinner = winnerVotes − sollaVotes` (e `marginToElectable` quando aplicável).
|
||||
- Exibir no detalhe do núcleo (aba overview) uma tabela compacta; no overview, agregar "núcleos onde Solla foi o mais votado" / "onde há adversário à frente".
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Leitura derivada** — sem escrita, sem `Consent`, sem migration.
|
||||
- **Reusa** `electionCandidateVote` + `electionCandidate` (e `elected`).
|
||||
- **Top-N configurável** (default 5) em `src/lib/electionInsights.ts`.
|
||||
- Cargo principal: **deputado federal** (cargo do Solla). Estadual como secundário opcional.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- Mostrar só candidatos com `elected=true` no top, ou todos os mais votados localmente? Recomendação: todos os mais votados localmente, com flag de eleito destacada.
|
||||
- `lideranca` vê o competidor? Recomendação: sim (dado público).
|
||||
- Cruzar com `runningAgain2026` (plano [insight-dobradinha-2026.md](insight-dobradinha-2026.md)) para marcar quem volta — adiar ao plano de dobradinha.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Base["electionCandidateVote + electionCandidate<br/>agregados por geografia"]
|
||||
Rank["ordenar por votos nominais<br/>+ flag elected"]
|
||||
Calc["computeCompetitiveIntel(...)"]
|
||||
Detail["Detalhe: tabela top-N<br/>+ winnerFederal + sollaRank + margem"]
|
||||
Overview["Overview: núcleos onde Solla<br/>é o mais votado vs adversário à frente"]
|
||||
Base --> Rank
|
||||
Rank --> Calc
|
||||
Calc --> Detail
|
||||
Calc --> Overview
|
||||
```
|
||||
|
||||
- **Helper** `src/lib/electionInsights.ts`: `computeCompetitiveIntel(candidateVotes, sollaNumber=1313, topN)` → `{ winner, sollaRank, marginToWinner, topCandidates[] }`.
|
||||
- **Componente** `src/components/campaign/NucleusCompetitiveIntel.tsx` (server). Overview: bloco agregado no `NucleusListOverview`.
|
||||
- **Teste int** cenários: Solla é o mais votado; Solla fora do top-N; `sollaVotes=0`.
|
||||
|
||||
## Arquivos a criar/alterar
|
||||
|
||||
- Criar: `src/components/campaign/NucleusCompetitiveIntel.tsx`.
|
||||
- Alterar: `src/lib/electionInsights.ts`, `nucleos/[slug]/page.tsx` + `nucleusDetailPageData.ts`, overview da lista.
|
||||
|
||||
## Dependências
|
||||
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) (`electionCandidateVote` + `electionCandidate` com `elected`).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Sugerir dobradinha (plano [insight-dobradinha-2026.md](insight-dobradinha-2026.md)) — aqui só exibimos o quadro competitivo, não sugerimos parceiros.
|
||||
|
||||
## Referências
|
||||
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)
|
||||
- AGENTS.md
|
||||
63
docs/plans/insight-mobilizacao-brancos-nulos.md
Normal file
63
docs/plans/insight-mobilizacao-brancos-nulos.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Insight: oportunidade de mobilização (brancos/nulos/abstenções)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Em 2022, parte significativa dos eleitores aptos de cada geografia não gerou voto útil: **abstenções** (não compareceu), **votos em branco** e **votos nulos** (compareceu mas não votou em candidato válido). Esses eleitores são potencial de mobilização — gente que, bem engajada, pode virar voto. O baseline TSE 2022 (ver [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)) traz `electionTally.abstencoes`, `votosBranco` e `votosNulo` por município+zona; hoje o `/campanha` não os usa.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Computar `mobilizationPotential = abstencoes + votosBranco + votosNulo` por geografia do núcleo.
|
||||
- Expressar como % de `aptos` (potencial relativo) e como absoluto (votos "deixados na mesa").
|
||||
- Exibir no detalhe do núcleo: "X votos em branco/nulo + Y abstenções (Z% do eleitorado apto) — potencial de mobilização".
|
||||
- No overview da lista: soma do potencial sobre o conjunto filtrado, ordenado para priorizar onde mobilizar.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Leitura derivada** — sem escrita, sem `Consent`, sem migration.
|
||||
- **Reusa** `getNucleusElectoralBaseline` (já expõe `abstencoes`, `brancos`, `nulos`, `aptos`).
|
||||
- **Não somar anulados** (votos anulados por motivo de urna, não são mobilizáveis da mesma forma) — só brancos + nulos + abstenções.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- Separar abstenção de brancos/nulos na UI (são naturezas diferentes: abstenção = não foi; branco/nulo = foi mas não votou válido)? Recomendação: sim, com dois subtotais.
|
||||
- `lideranca` vê? Recomendação: sim.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Base["getNucleusElectoralBaseline<br/>abstencoes + brancos + nulos + aptos"]
|
||||
Calc["computeMobilizationOpportunity(...)"]
|
||||
Detail["Detalhe: 'X votos não aproveitados<br/>(Y% do eleitorado apto)'"]
|
||||
Overview["Overview: Σ potencial sobre o filtro<br/>+ ranking de onde mobilizar"]
|
||||
Base --> Calc
|
||||
Calc --> Detail
|
||||
Calc --> Overview
|
||||
```
|
||||
|
||||
- **Helper** `src/lib/electionInsights.ts`: `computeMobilizationOpportunity(abstencoes, brancos, nulos, aptos)` → `{ absolute, relative, abstencoes, brancosNulos, status }`.
|
||||
- **Componente** `src/components/campaign/NucleusMobilizationOpportunity.tsx` (server). Overview: bloco agregado + ranking.
|
||||
- **Teste int** cenários: `aptos=0`, tudo zero.
|
||||
|
||||
## Arquivos a criar/alterar
|
||||
|
||||
- Criar: `src/components/campaign/NucleusMobilizationOpportunity.tsx`.
|
||||
- Alterar: `src/lib/electionInsights.ts`, `nucleos/[slug]/page.tsx` + `nucleusDetailPageData.ts`, overview da lista.
|
||||
|
||||
## Dependências
|
||||
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) (apuração por município+zona).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Disparar campanhas de mobilização efetivas (WhatsApp, eventos) — só o insight, não a ação.
|
||||
|
||||
## Referências
|
||||
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)
|
||||
- AGENTS.md
|
||||
69
docs/plans/insight-taxa-conversao.md
Normal file
69
docs/plans/insight-taxa-conversao.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Insight: taxa de conversão por núcleo
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Com o baseline TSE 2022 importado (ver [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md)), temos `electionTally.aptos` (eleitores aptos) por município+zona. A estimativa confirmada do núcleo (`confirmedVoteEstimate`) hoje só é comparada com o voto histórico de Solla (Gap vs 2022). Falta medir a estimativa contra o **tamanho do eleitorado apto** da geografia — ou seja, qual % do eleitorado potencial da zona a estimativa representa. É o indicador que separa "reduto consolidado" de "oportunidade de crescimento", segundo a literatura de estratégia eleitoral.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Computar `conversionRate = confirmedVoteEstimate / aptos` por núcleo (soma de aptos nas cidades∩zonas do núcleo).
|
||||
- Classificar o núcleo em faixas (limiares a definir com produto; referência da literatura: <15% oportunidade, >40% reduto).
|
||||
- Exibir no detalhe do núcleo (aba overview) e como agregado no overview da lista (média de conversão e distribuição por faixa sobre o conjunto filtrado).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Leitura derivada** — sem escrita, sem `Consent`, sem migration. Reusa as collections e helpers do plano baseline.
|
||||
- **Mesma agregação geográfica** do baseline (cidades∩zonas, via `getNucleusElectoralBaseline`).
|
||||
- **Limiares versionados** em `src/lib/electionInsights.ts` como constantes (decisão de produto por limiar).
|
||||
- **i18n/naming** seguem o AGENTS.md.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- Limiares exatos das faixas (15% / 40% são referência da literatura, não decisão travada) — definir com produto.
|
||||
- Mostrar conversão contra `aptos` ou contra `comparecimento` (eleitores que efetivamente votaram)? Recomendação: ambos, com `aptos` como principal.
|
||||
- `lideranca` vê este insight? Recomendação: sim.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Base["getNucleusElectoralBaseline<br/>aptos agregados"]
|
||||
Est["electoralNucleus.confirmedVoteEstimate"]
|
||||
Calc["computeConversionRate(aptos, estimate)<br/>rate + faixa"]
|
||||
Detail["Detalhe: badge + faixa<br/>'X% do eleitorado apto'"]
|
||||
Overview["Overview: média + distribuição<br/>por faixa sobre o filtro"]
|
||||
Base --> Calc
|
||||
Est --> Calc
|
||||
Calc --> Detail
|
||||
Calc --> Overview
|
||||
```
|
||||
|
||||
- **Helper** `src/lib/electionInsights.ts`: `computeConversionRate(aptos, confirmedVoteEstimate)` → `{ rate, band: 'reduto'|'consolidado'|'oportunidade'|'semEstimativa'|'semBaseline' }`.
|
||||
- **Componente** `src/components/campaign/NucleusConversionRate.tsx` (server). No overview, bloco agregado no `NucleusListOverview`.
|
||||
- **Teste int** cenários: `aptos=0`, `confirmedVoteEstimate=null`, faixas limítrofes.
|
||||
|
||||
## Arquivos a criar/alterar
|
||||
|
||||
- Criar: `src/components/campaign/NucleusConversionRate.tsx`.
|
||||
- Alterar: `src/lib/electionInsights.ts` (nova função), `src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx` + `nucleusDetailPageData.ts` (detalhe), overview da lista.
|
||||
|
||||
## Dependências
|
||||
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) (fornece `electionTally` + `getNucleusElectoralBaseline`) — **única dependência dura**.
|
||||
- [zonas-por-municipio.md](zonas-por-municipio.md) — dependência suave herdada do baseline (melhora a qualidade de `tseZones`; a agregação funciona sem ele — ver revisão 2026-07-17 no plano baseline).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Previsão estatística de votos (roadmap separado).
|
||||
- Cruzamento com pesquisa de intenção de voto (domínio inexistente).
|
||||
|
||||
## Referências
|
||||
|
||||
- [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) — baseline e agregação
|
||||
- `src/utilities/nucleusViewModels.ts` — view models do núcleo
|
||||
- AGENTS.md — naming, padrão de leitura
|
||||
103
docs/plans/mapa-bahia-geometrias.md
Normal file
103
docs/plans/mapa-bahia-geometrias.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Mapa da Bahia na campanha — geometrias estáticas + Leaflet
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
O roadmap (linha 52) prevê "Mapa / PostGIS: Leaflet, geometria/point, imagem Docker PostGIS; âncora `/mapa` do design-ux adiada". Este plano destrincha esse item para a vertical `/campanha`: preparar o webapp para representar o mapa da Bahia (municípios + Territórios de Identidade) e usar esse mapa para visualizar agregados da campanha de forma mais clara.
|
||||
|
||||
Hoje o `electoralNucleus` (`src/collections/ElectoralNucleus.ts`) indexa território por texto (`region`/`city`/`neighborhood`/`tseZones`) e as agregações saem de queries Payload por essas chaves (`src/utilities/campaignDashboardPageData.ts`, `src/utilities/nucleusViewModels.ts`). Não há nenhuma geometria. A decisão de produto (2026-07-17) é adotar **geometrias estáticas versionadas no repo** (mesmo padrão de `src/lib/bahiaTerritories.ts`), sem PostGIS, e integrar Leaflet nas superfícies já existentes — a âncora `/mapa` do design-ux permanece adiada.
|
||||
|
||||
## Decisões travadas (confirmadas com o usuário)
|
||||
|
||||
- **GeoJSON/TopoJSON estático versionado no repo**, sem PostGIS. Mesmo padrão de `src/lib/bahiaTerritories.ts` (dado versionado com proveniência + fixture + teste int).
|
||||
- **v1 cobre municípios (IBGE) + Territórios de Identidade (IDE Bahia).** Zonas TSE como camada ficam para um ciclo seguinte (dependem de `zonas-por-municipio`).
|
||||
- **`/mapa` dedicada do design-ux permanece adiada.** v1 incorpora o mapa em superfícies já existentes: overview da lista de núcleos, detalhe do núcleo e dashboard `/campanha`.
|
||||
- **Zona segue como granularidade eleitoral de referência** do núcleo — não mapear núcleo a seções (ver "Granularidade seção vs. zona").
|
||||
- **Sem migration, sem collection, sem Consent, sem server action nova.** Só dado estático + componentes cliente + agregações já existentes.
|
||||
|
||||
## Por que estático e não PostGIS
|
||||
|
||||
O `electoralNucleus` já indexa território por texto e as agregações saem de queries Payload por essas chaves — não há query espacial (point-in-polygon, raio, geocodificação) no horizonte curto. PostGIS adicionaria imagem Docker PostGIS local, extensão no Neon, colunas `geometry` + migrations e um endpoint de serving, sem benefício imediato. PostGIS só volta à mesa se surgir query espacial real (ex.: geocodificar endereços de apoiadores, busca por raio). Movido para "Fora de escopo por enquanto" no roadmap.
|
||||
|
||||
## Fontes oficiais (pesquisa)
|
||||
|
||||
- **Municípios (BA):** IBGE Malha Municipal 2024. GeoJSON/TopoJSON via API `https://servicodados.ibge.gov.br/api/v3/malhas/estados/29?formato=application/vnd.geo+json&intrarregiao=municipio` (resolução 1–2 ideal para web; `codarea` = código IBGE de 7 dígitos). Shapefile em `geoftp.ibge.gov.br/.../municipio_2024/UFs/BA/`. CRS SIRGAS 2000 (EPSG:4674) ≈ WGS84 para web (diferença sub-metro, irrelevante em escala municipal).
|
||||
- **Territórios de Identidade:** IDE Bahia / SEI publica polígono vetorial oficial 1:100.000 (2019, EPSG:4674): `https://metadados.ide.ba.gov.br/geonetwork/srv/api/records/90b140bf-17df-496f-b048-5783fdf02864` (Shapefile + KML). Configuração vigente desde 2016 (a mesma de `bahiaTerritories.ts`). Não precisamos dissolver.
|
||||
- **Zonas TSE:** o TSE **não** publica polígonos — só o cadastro tabular "Eleitorado por município e zona" (composição município→zona) e os endereços de locais de votação/seções. Polígonos só por construção derivada (aproximação; padrão da literatura). Fica fora da v1.
|
||||
|
||||
## Granularidade seção vs. zona
|
||||
|
||||
Hierarquia TSE: município → (1+) zona → (muitas) seção; a relação município↔zona é muitos-para-muitos (uma cidade pode ter várias zonas; uma zona pode cobrir vários municípios pequenos). Decisão (2026-07-17): **manter zona como granularidade eleitoral de referência** do núcleo, não seções, porque:
|
||||
|
||||
1. O núcleo é unidade operacional da campanha, não eleitoral — o coordenador pensa em bairro/município/território.
|
||||
2. O cruzamento com a baseline TSE 2022 (`baseline-eleitoral-tse`) é por zona.
|
||||
3. Não existe mapeamento oficial seção↔bairro — não dá para auto-derivar as seções do bairro do núcleo; seria manual e frágil, e números de seção mudam entre eleições.
|
||||
4. Caso sub-zona (núcleo menor que a zona) é o único em que seção traria precisão; hoje a baseline da zona sobre-cobre o núcleo (aproximação aceitável, documentada como limitação).
|
||||
|
||||
## Modelagem de dados
|
||||
|
||||
- **Arquivos de geometria** (TopoJSON, menor que GeoJSON) commitados no repo:
|
||||
- `src/lib/geometries/bahia-municipalities.topojson` — uma Feature por município, `properties: { codarea, name }`.
|
||||
- `src/lib/geometries/bahia-identity-territories.topojson` — uma Feature por território, `properties: { code, name }` (`code` = "01".."27", já existe em `bahiaIdentityTerritoryRecords`).
|
||||
- **Join por chave estável:**
|
||||
- Municípios: o núcleo guarda `city` (nome). Precisamos de `name → codarea`. Criar `src/lib/bahiaMunicipalityCodes.ts` (record nome→código IBGE de 7 dígitos) com proveniência + fixture `tests/fixtures/bahia-municipality-codes.official.json` + teste `tests/int/bahiaMunicipalityCodes.int.spec.ts` (espelha `bahiaTerritories`). Necessário porque nomes IBGE ≠ nomes SECULT canônicos (acentos, "Dias D'Ávila", etc.) — a tabela reconcilia.
|
||||
- Territórios: join por `code` (01–27) já presente em `bahiaIdentityTerritoryRecords`; sem tabela extra.
|
||||
- **Proveniência e versionamento:** cada arquivo `.topojson` e cada tabela estática com cabeçalho/documentação de proveniência (URL, versão/ano, SHA-256 do download de origem), no estilo de `bahiaTerritories.ts`.
|
||||
|
||||
## Script de geração (one-off, re-executável)
|
||||
|
||||
- `scripts/build-bahia-geometries.mjs`: baixa IBGE (API) + IDE Bahia (shapefile), converte/simplifica com `topojson-server` + `topojson-simplify` (ou `@turf/simplify`), emite os `.topojson` e a tabela `bahiaMunicipalityCodes.ts`. Não roda em build/dev — só quando se atualiza a versão de origem. Mesmo guard de não-produção dos outros scripts (não toca prod).
|
||||
|
||||
## Leaflet na campanha
|
||||
|
||||
- Dependência: `leaflet` + `topojson-client` (cliente). `react-leaflet` é opcional; preferir wrapper cliente fino com `leaflet` direto e `dynamic(() => import(...), { ssr: false })` para evitar SSR.
|
||||
- **Base tiles:** OpenStreetMap raster (ou CartoDB Positron) com atribuição obrigatória; ou fundo neutro sem tiles (só coroplético) como opção mais leve. Decidir na implementação; default OSM.
|
||||
- **Componentes novos (todos `src/components/campaign/`, PascalCase):**
|
||||
- `BahiaMap.tsx` — cliente, recebe `geometry` (TopoJSON) + `values: Record<string, number>` (chave→métrica) + `mode: 'municipality' | 'territory'` + `colorScale`; renderiza coroplético Leaflet.
|
||||
- `NucleusOverviewMap.tsx` — mapa no overview de `/campanha/nucleos`, escopado pelos mesmos filtros da lista, com toggle município/território e escolha da métrica (estimativa confirmada, nº de núcleos, baseline 2022 Solla quando disponível).
|
||||
- `NucleusDetailMap.tsx` — mapa pequeno no detalhe do núcleo destacando seus municípios/território (+ marcadores ponto das zonas TSE, quando a camada de zonas existir).
|
||||
- `DashboardMap.tsx` — coroplético por território no dashboard `/campanha`.
|
||||
- **Agregação:** servidor continua a fonte. Reusar `campaignDashboardPageData` / `nucleusViewModels` e expor agregados por `codarea`/`code` de território; o cliente junta com a geometria. Sem query espacial.
|
||||
|
||||
## Phasing
|
||||
|
||||
- **Fase 1 — Fundação de geometrias:** `bahiaMunicipalityCodes.ts` + fixture + teste; `.topojson` de municípios e territórios; script de geração; helpers `getMunicipalityFeature(codarea)`, `getTerritoryFeature(code)`.
|
||||
- **Fase 2 — Leaflet nas superfícies:** `BahiaMap` + `NucleusOverviewMap` + `NucleusDetailMap` + `DashboardMap`, com agregados existentes.
|
||||
|
||||
**Sequenciamento (2026-07-17):** a Fase 1 é independente e paralelizável a qualquer momento. A Fase 2 rende mais **depois** de [overview-lista-nucleos.md](overview-lista-nucleos.md) (o `NucleusOverviewMap` é um bloco daquele painel) e ganha muito valor com [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) implementado (coroplético de baseline 2022 e da classificação territorial é onde o mapa vira instrumento de decisão, não só visual). Nenhum dos dois é bloqueante — o mapa funciona só com estimativa/nº de núcleos — mas a ordem recomendada no roadmap coloca overview e baseline antes.
|
||||
|
||||
## Ciclo seguinte — Zonas TSE como camada
|
||||
|
||||
Após `zonas-por-municipio`. Polígono por dissolução dos municípios membros (abordagem A, default) ou por geocodificação dos endereços das seções → casco convexo/côncavo (abordagem B, questão em aberto). Ponto representativo por centroide da zona. Item separado no roadmap.
|
||||
|
||||
- **(A) Dissolução dos municípios membros:** o TSE publica quais municípios compõem cada zona; como a v1 já terá os polígonos municipais (IBGE), basta dissolver os municípios de cada zona. Limpo, alinhado às divisas oficiais, sem geocodificação, sem serviço externo. Mais fiel porque usa a composição oficial.
|
||||
- **(B) Geocodificar endereços das seções/locais de votação → casco:** pegar os locais de votação de cada zona, geocodificar (OSM Nominatim com rate-limit, IBGE logradouros, ou geocoder pago) e gerar o polígono. Independente do mapeamento município→zona, mas: milhares de endereços para geocodificar em batch offline, locais de votação se concentram em centros urbanos (casco irregular, áreas rurais cobertas mal), e pode divergir da composição oficial do TSE.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- PostGIS (adiado; só se surgir query espacial real).
|
||||
- `/mapa` dedicada do design-ux (adiada).
|
||||
- Camada de Zonas TSE — polígono ou ponto (ciclo seguinte; depende de `zonas-por-municipio`).
|
||||
- Mapear núcleo a seções eleitorais (zona segue como referência).
|
||||
- Geocodificação de endereços de apoiadores.
|
||||
- Outros estados (todo núcleo é BA).
|
||||
|
||||
## Dependências
|
||||
|
||||
- Reusa `bahiaIdentityTerritoryRecords`/`bahiaMunicipalities` (`src/lib/bahiaTerritories.ts`), `CitiesByState.BA` (`src/lib/cities`), `campaignDashboardPageData` / `nucleusViewModels` (agregados existentes), e o padrão de fixture/teste de `bahiaTerritories`.
|
||||
- **Camada de Zonas** depende de `docs/plans/zonas-por-municipio.md`.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (item "Mapa / PostGIS", linha 52; "Import do cadastro oficial de zonas TSE", linha 59)
|
||||
- `src/lib/bahiaTerritories.ts` + `tests/fixtures/bahia-identity-territories.official.json` + `tests/int/bahiaTerritories.int.spec.ts` (padrão a espelhar)
|
||||
- `src/collections/ElectoralNucleus.ts`, `src/utilities/campaignDashboardPageData.ts`, `src/utilities/nucleusViewModels.ts` (agregados a reusar)
|
||||
- `docs/plans/zonas-por-municipio.md` (dependência da camada de Zonas)
|
||||
- `docs/plans/baseline-eleitoral-tse.md` (baseline por zona, consumidor do mapa)
|
||||
- AGENTS.md (naming, "Bahia implícita", padrão de dado estático versionado)
|
||||
- IBGE — API de Malhas: https://servicodados.ibge.gov.br/api/docs/malhas
|
||||
- IDE Bahia / SEI — Territórios de Identidade 1:100.000: https://metadados.ide.ba.gov.br/geonetwork/srv/api/records/90b140bf-17df-496f-b048-5783fdf02864
|
||||
77
docs/plans/notifications.md
Normal file
77
docs/plans/notifications.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Notificações (push PWA + sino in-app)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Coordenadores e lideranças precisam ser avisados de eventos da campanha (convite aceito, novo `nucleusUpdate`, estimativa aguardando confirmação) sem depender só do canal de WhatsApp. Este plano cobre dois canais de notificação dentro da vertical `/campanha`:
|
||||
|
||||
- **Push web** via Push API + Service Worker + VAPID (entrega em segundo plano, inclusive com o app fechado).
|
||||
- **Sino de notificações in-app** dentro do `/campanha` (central de avisos lidos/não lidos).
|
||||
|
||||
Notificações por WhatsApp Business API ficam **fora** deste plano — já existem como item separado no roadmap (linha 57), exigem Meta + templates e serão reavaliadas quando o volume justificar.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Entregar avisos da campanha por push e por sino, com opt-in explícito (LGPD).
|
||||
- Manter o padrão arquitetural do projeto: pessoas como `Contact`, transações em writes multi-collection, `Consent` resolvido por chave estável, access control por papel.
|
||||
- Limitar push ao escopo `/campanha` (alinhado à decisão de PWA só na vertical de campo).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- Push só em `/campanha` (alinhado ao item PWA do roadmap; ver [plans/pwa-campanha.md](./pwa-campanha.md)); site público e `/admin` não recebem push.
|
||||
- Consentimento LGPD obrigatório antes de qualquer push; reusar a collection `Consent` com a chave estável **`campanha-notificacoes-push`** (falha fechado se ausente, mesmo padrão do fluxo de liderança), não inventar mecanismo novo. O texto deste consentimento entra no **lote jurídico único** de textos de `Consent` da campanha (junto com `lideranca-autopreenchimento`, `apoiador-cadastro` e `apoiador-intencao-voto`) — ver roadmap "Onda 0"; uma única rodada com a assessoria jurídica eleitoral cobre os quatro.
|
||||
- **Este plano supera o item "Notificações WhatsApp Business API"** (removido do roadmap em 2026-07-17): a Meta veda o uso do WhatsApp Business API por campanhas políticas no Brasil e a Res. TSE 23.610 (art. 33) veda disparo em massa — pesquisa registrada em [cadastro-nominal-apoiadores.md](cadastro-nominal-apoiadores.md). Push web + sino cobrem a mesma necessidade (lembrete de reporte, avisos da coordenação) sem risco legal/de plataforma.
|
||||
- Pessoas continuam sendo `Contact`; a subscription de push é dado de dispositivo/sessão ligado a `campaignUser`, não a `Contact`.
|
||||
- iOS só recebe push com o PWA instalado na tela inicial — documentar essa limitação para o usuário no fluxo de opt-in.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- Unificar numa única collection `Notification` (log de eventos) que alimenta tanto o sino quanto o push, ou manter dois caminhos separados? **Recomendação:** unificar — um registro por evento, com canais de entrega como atributo.
|
||||
- Quais eventos disparam notificação, e qual canal cada evento usa (ex.: estimativa aguardando confirmação → push para `geral`/coordenador; novo update → sino para coordenador do núcleo).
|
||||
- Políticas de agrupamento/dedupe (ex.: `tag` do Push API por núcleo) e expurgo de notificações antigas.
|
||||
- Onde guardar as VAPID keys (Vercel env) e política de rotação.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Hook["afterChange hook<br/>leadership / nucleusUpdate /<br/>campaignInvite / voteEstimate"]
|
||||
Notif["Cria registro Notification<br/>(com req, dentro da transação)"]
|
||||
Bell["Sino in-app<br/>(lê Notification por campaignUser)"]
|
||||
Push["Push via Service Worker<br/>(VAPID)"]
|
||||
Sub["PushSubscription<br/>(campaignUser)"]
|
||||
Consent{"Consent opt-in<br/>chave estável?"}
|
||||
Device["Dispositivo<br/>(Android: sempre;<br/>iOS: só PWA instalado)"]
|
||||
|
||||
Hook --> Notif
|
||||
Notif --> Bell
|
||||
Notif --> Push
|
||||
Sub --> Push
|
||||
Consent --> Push
|
||||
Push --> Device
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **Collection `Notification`** (admin group `Campanha`): `recipient` (rel → `campaignUser`), `type` (enum de evento), `payload` (json/richtext leve), `readAt`, `nucleus` (rel opcional). Access por `recipient = req.user.id`.
|
||||
- **Collection `PushSubscription`** (admin group `Campanha`): `user` (rel → `campaignUser`), `endpoint`, `keys.p256dh`, `keys.auth`, `expirationTime`. Várias subscriptions por usuário (vários dispositivos).
|
||||
- **Service Worker** `public/sw.js` com escopo `/campanha`, handler de `push` e `notificationclick` (abre a rota certa via `data.url`).
|
||||
- **Server action** de subscribe/unsubscribe + pedido de permissão, com opt-in gravando `Consent`.
|
||||
- **Hooks `afterChange`** nos collections de evento criando o `Notification` dentro da transação (`req: { transactionID }`) e enfileirando o push; usar `context` para evitar loops (padrão do AGENTS.md).
|
||||
- **Sino** no layout `(app)` lendo `Notification` não lidas do `getCampaignUser()`.
|
||||
|
||||
## Dependências
|
||||
|
||||
- PWA de `/campanha` — push exige o service worker registrado. Ver [plans/pwa-campanha.md](./pwa-campanha.md). **O sino in-app não depende do PWA** e pode ser entregue antes, se fizer sentido faseá-lo.
|
||||
- `Consent.key = 'campanha-notificacoes-push'` criada por admin após aprovação jurídica (mesmo padrão fail-closed do `lideranca-autopreenchimento`; texto no lote jurídico único do roadmap).
|
||||
- Migration para as collections `Notification` e `PushSubscription` (`pnpm migrate:create`).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (linhas 57, 60, 61)
|
||||
- `AGENTS.md` — Campaign auth, Campaign nuclei MVP, transações, `Consent` por chave
|
||||
- `.cursor/rules/projects/nucleos-eleitorais.mdc`
|
||||
114
docs/plans/overview-lista-nucleos.md
Normal file
114
docs/plans/overview-lista-nucleos.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Overview na lista de núcleos
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos", linha 50)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Hoje `/campanha/nucleos` é só cabeçalho + filtros + lista paginada (25/página). Não há agregados: o usuário precisa varrer a lista (ou ir ao dashboard `/campanha`) para sentir quanto daquela seleção tem estimativa confirmada, quantos votos somam, ou quais foram os últimos reportes. O dashboard geral já calcula esses agregados, mas para **todos** os núcleos ativos — não respeita os filtros de território/cobertura/estimativa da lista.
|
||||
|
||||
A decisão de produto (2026-07-17) é adicionar um **painel de overview acima da lista**, com blocos como "Estimativa de votos" e "Últimas atualizações", e espaço reservado para "Próximos eventos" e "Demandas" (que só ganham UI quando os domínios correspondentes existirem). O ponto-chave: **o overview usa os mesmos filtros da página** — se o usuário filtra por um território, os agregados são só daquele território. O overview é, portanto, um recorte do dashboard reagindo aos filtros da lista.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Painel de overview renderizado entre os filtros e a lista em `src/app/(campaign)/campanha/(app)/nucleos/page.tsx`.
|
||||
- Bloco **Estimativa de votos**: soma de `confirmedVoteEstimate`, % com estimativa confirmada e contagem de sugestões pendentes — todos calculados sobre o conjunto **filtrado** (não só a página atual).
|
||||
- Bloco **Últimas atualizações**: preview das N atualizações mais recentes (`nucleusUpdate`) entre os núcleos filtrados, com link para o detalhe do núcleo.
|
||||
- Blocos **Próximos eventos** e **Demandas**: arquitetura extensível, mas sem UI órfã — só aparecem quando os domínios existirem.
|
||||
- Reusar `NucleusListState` / `buildNucleusListWhere(state)` para que o overview sempre concorde com a lista visível.
|
||||
- Manter access control existente: todas as queries com `user` + `overrideAccess: false`. Sem collection nova, sem migration, sem server action, sem `Consent`.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Mesmos filtros da página.** O overview é computado a partir do mesmo `NucleusListState` já parseado em `loadNucleusListPageData` (`parseNucleusListParams` + `buildNucleusListWhere`). Não existe filtro "do overview" separado do filtro "da lista". (Decisão de produto 2026-07-17; roadmap linha 50.)
|
||||
- **Agregados sobre o conjunto inteiro filtrado, não só a página atual.** A lista é paginada em 25, mas o overview precisa de totais — logo exige uma query adicional com `pagination: false` selecionando só os campos de agregação. Mesmo padrão já usado por `getCampaignDashboardPageData` (que carrega todos os núcleos ativos com `pagination: false`).
|
||||
- **Sem collection/migration/server action.** Tudo é leitura no server component. Nenhuma escrita, nenhuma transação, nenhum `Consent`.
|
||||
- **Access control por `overrideAccess: false`.** O overview herda naturalmente o escopo de papel: `geral` vê tudo, `coordenador` vê só os seus núcleos, `lideranca` vê só os núcleos com liderança engajada — exatamente como a lista já faz. Para "Últimas atualizações", `canReadNucleusUpdate` já restringe `lideranca` ao próprio autor; o preview de `lideranca` mostra só os próprios reportes nos núcleos filtrados. O plano não inventa regra nova.
|
||||
- **Ordem na página: header → filtros → overview → lista.** O overview fica **abaixo** dos filtros (para refletir a seleção que o usuário acabou de fazer) e **acima** da lista. "Acima da lista" (roadmap) é respeitado; colocar abaixo dos filtros é deliberado para que os agregados correspondam à seleção visível.
|
||||
- **Overview oculto quando não há resultados.** Se `result.totalDocs === 0`, o overview não renderiza — a página já mostra o `Empty` de "Nenhum núcleo encontrado". Evita agregados zerados sem sentido.
|
||||
- **Eventos e Demandas não ganham UI placeholder.** "Quando o domínio existir" significa: o bloco só é implementado quando a collection existir. Não renderizar cards "em breve" — UI órfã confunde mais do que informa. A arquitetura (view model + componente) fica pronta para plugar.
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`NucleusListOverview`, `loadNucleusListOverviewData`, `buildNucleusListOverviewViewModel`), strings visíveis em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **`lideranca` vê o overview?** A lista de núcleos é visível para os três papéis, e o roadmap não exclui `lideranca`. **Recomendação:** sim, mostrar — os agregados de estimativa e o preview das próprias atualizações são úteis e já respeitam o escopo. Se produto quiser esconder, é só não montar o componente para esse papel.
|
||||
- **Quantas atualizações no preview?** O detalhe do núcleo usa 3 (`getNucleusUpdatesPageData` preview). **Recomendação:** 3 ou 4 no overview, suficiente para dar um "pulso" sem virar feed. Definir com produto.
|
||||
- **"Estimativa de votos" mostra também `proposedVoteEstimate`?** Hoje a lista marca "Sugestão pendente". **Recomendação:** o bloco mostra soma das confirmadas + contagem de pendentes (não soma das propostas — proposta não é voto). Soma de propostas pode confundir com confirmado.
|
||||
- **Agregados por território/cobertura extras?** Ex.: % com coordenador no conjunto filtrado (já existe no dashboard geral). **Recomendação:** incluir como KPI secundário no bloco de estimativa ou num terceiro bloco "Cobertura" — alinhado com o dashboard, mas reagindo a filtros.
|
||||
- **Custo da segunda query.** Para `geral` sem filtros, o overview carrega todos os núcleos ativos (`pagination: false`) só para agregar — duplica o trabalho da listagem paginada. **Recomendação:** aceitar (o dashboard já faz isso) e selecionar só `{ id, slug, name, coordinators, confirmedVoteEstimate, proposedVoteEstimate, lastUpdateAt }`. Se virar gargalo, otimizar com `count` por cláusula depois.
|
||||
- **Cache/ISR.** A página já é dinâmica (lê `searchParams`). O overview é computado por request com dados frescos — sem `unstable_cache`, sem tag nova. Confirmar se produto aceita custo por request.
|
||||
- **Eventos: gatilho de ativação.** Quando o domínio de eventos existir, qual collection o bloco lê? **Recomendação:** deixar a decisão para o plano de Eventos; aqui só fixamos que o bloco consome `nucleus` + `startAt`/`status` e respeita os mesmos filtros de território.
|
||||
- **Demandas: idem.** Mesma recomendação — arquitetura aberta, decisão no plano de Demandas.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Page["NucleiPage (server)<br/>loadNucleusListPageData<br/>+ loadNucleusListOverviewData"]
|
||||
State["NucleusListState<br/>(mesmos filtros)"]
|
||||
Where["buildNucleusListWhere(state)"]
|
||||
AggNuclei["find electoralNucleus<br/>pagination:false<br/>overrideAccess:false<br/>select agregados"]
|
||||
Updates["find nucleusUpdate<br/>nucleus in ids<br/>sort -createdAt limit N<br/>overrideAccess:false"]
|
||||
VM["buildNucleusListOverviewViewModel<br/>(estimativa + cobertura<br/>+ preview atualizações)"]
|
||||
Comp["NucleusListOverview (server)<br/>grid de blocos"]
|
||||
Events["Bloco Eventos<br/>(futuro)"]
|
||||
Demandas["Bloco Demandas<br/>(futuro)"]
|
||||
|
||||
Page --> State
|
||||
State --> Where
|
||||
Where --> AggNuclei
|
||||
AggNuclei --> Updates
|
||||
AggNuclei --> VM
|
||||
Updates --> VM
|
||||
VM --> Comp
|
||||
Comp -.extensível.-> Events
|
||||
Comp -.extensível.-> Demandas
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`loadNucleusListOverviewData`** (em `src/utilities/nucleusListOverviewPageData.ts`): recebe `payload`, `user`, `state: NucleusListState`. Roda em paralelo com `loadNucleusListPageData` (mesma `state`):
|
||||
- `payload.find({ collection: 'electoralNucleus', where: buildNucleusListWhere(state), depth: 0, pagination: false, sort: 'name', select: { id, slug, name, coordinators, confirmedVoteEstimate, proposedVoteEstimate, lastUpdateAt }, user, overrideAccess: false })` → `overviewNuclei`.
|
||||
- Se `overviewNuclei.length === 0`, retorna `null` (oculta o overview).
|
||||
- `payload.find({ collection: 'nucleusUpdate', where: { nucleus: { in: overviewNuclei.map(n => n.id) } }, depth: 0, limit: previewLimit, page: 1, sort: '-createdAt', select: { author: true, kind: true, body: true, createdAt: true, nucleus: true }, user, overrideAccess: false })` → `recentUpdates`. Para `lideranca`, o access control já filtra só próprios; para `geral`/`coordenador`, vem o escopo de núcleos acessíveis.
|
||||
- Resolve nomes de autores e de núcleos via um `payload.find({ collection: 'campaignUser', where: { id: { in: authorIds } }, select: { name: true, role: true }, overrideAccess: false, user })` (mesmo padrão de `getNucleusUpdatesPageData`).
|
||||
- Retorna `{ nuclei: overviewNuclei, recentUpdates }` (tipo `NucleusListOverviewInput`).
|
||||
- **`buildNucleusListOverviewViewModel`** (em `src/utilities/nucleusListOverviewViewModels.ts`): recebe o input + `now` e produz `NucleusListOverviewViewModel`:
|
||||
- `estimate: { confirmedTotal: number, confirmedPercent: number, pendingCount: number }` — soma de `confirmedVoteEstimate`, % de núcleos com `confirmedVoteEstimate != null`, contagem de `proposedVoteEstimate != null` (com `confirmedVoteEstimate == null` ou não — definir; recomendação: pendente = `proposedVoteEstimate != null`, independente de confirmada, espelhando o dashboard `pendingEstimate`).
|
||||
- `coverage: { coordinatedCount, total, percent }` — % com `coordinators.length > 0` (opcional, ver questões em aberto).
|
||||
- `recentUpdates: Array<{ id, nucleusSlug, nucleusName, authorName, kind, body, createdAt }>` — já truncado pelo `limit`.
|
||||
- Reusa `percentage` e helpers de formatação no estilo de `campaignDashboardViewModels.ts`.
|
||||
- **`NucleusListOverview`** (server component, em `src/components/campaign/`): grid responsivo de blocos (`Card`/`CardHeader`/`CardContent`, reusando `src/components/ui/card`):
|
||||
- Bloco **Estimativa de votos** — total confirmado (`Intl.NumberFormat('pt-BR')` + "votos"), `% com estimativa confirmada` com `<Progress>`, e badge "N sugestões pendentes".
|
||||
- Bloco **Últimas atualizações** — lista compacta (autor, tipo, data relativa, link para `/campanha/nucleos/{slug}?tab=updates`). Reusa o `relativeFormatter` do `CampaignDashboard`. Vazio: "Nenhuma atualização recente".
|
||||
- Espaço reservado (sem UI) para **Próximos eventos** e **Demandas** — comentário no componente marcando onde plugar.
|
||||
- **Integração na página** (`src/app/(campaign)/campanha/(app)/nucleos/page.tsx`): chamar `loadNucleusListPageData` e `loadNucleusListOverviewData` em paralelo (ambas usam a mesma `state` já resolvida por `resolveNucleusListUrl`). Renderizar `<NucleusFilters>` e, se `overview` não for `null` e `result.docs.length > 0`, `<NucleusListOverview view={overview} now={now} />` antes de `<NucleusList>`.
|
||||
- **Sem migration, sem collection, sem server action.** Todo o fluxo é leitura no server.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma de outro plano. Reusa `NucleusListState`, `buildNucleusListWhere`, `parseNucleusListParams` (`src/utilities/nucleusUi.ts`), `getBahiaWeekRange` (`src/utilities/campaignTime.ts`) se precisar de janela semanal, access control existente (`src/utilities/campaignAccess.ts`) e UI `Card`/`Progress`/`Badge` (`src/components/ui/`).
|
||||
- **Eventos** e **Demandas** dependem dos respectivos domínios serem modelados (itens separados do roadmap). Este plano entrega só a fundação + os dois blocos ativos.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Criar as collections Eventos ou Demandas — domínios separados no roadmap.
|
||||
- Alterar os filtros existentes ou a paginação da lista — o overview é aditivo e reage ao estado já existente.
|
||||
- Substituir o dashboard `/campanha` — o dashboard continua o recorte global (todos os ativos, sem filtros de lista); o overview é o recorte filtrado da lista.
|
||||
- Estimativa estatística/previsão de votos (roadmap linha 60) — aqui só somamos `confirmedVoteEstimate`.
|
||||
- Novo access control ou novo `Consent` — herda o que existe.
|
||||
- Compartilhar núcleo a partir da lista (`NucleusCard` actions) — escopo do plano `compartilhar-pagina.md` (só o detalhe neste ciclo).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (linha 50, e linhas 54–55 para Eventos/Demandas)
|
||||
- `src/app/(campaign)/campanha/(app)/nucleos/page.tsx` — página onde o overview entra
|
||||
- `src/utilities/nucleusPageData.ts` — `loadNucleusListPageData` (mesma `state`)
|
||||
- `src/utilities/nucleusUi.ts` — `NucleusListState`, `parseNucleusListParams`, `buildNucleusListWhere`
|
||||
- `src/utilities/campaignDashboardPageData.ts` e `src/utilities/campaignDashboardViewModels.ts` — padrão de agregação e KPIs a espelhar
|
||||
- `src/utilities/nucleusUpdatePageData.ts` — padrão do preview de atualizações (limit 3, resolve autores)
|
||||
- `src/components/campaign/CampaignDashboard.tsx` — `KpiCard`, `Progress`, `relativeFormatter`, layout de grid
|
||||
- `src/utilities/campaignAccess.ts` — `canReadElectoralNucleus`, `canReadNucleusUpdate` (escopo por papel)
|
||||
- AGENTS.md — Campaign auth, naming conventions, padrão de leitura com `overrideAccess: false`
|
||||
137
docs/plans/pwa-campanha.md
Normal file
137
docs/plans/pwa-campanha.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# PWA / offline só em `/campanha`
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos", linha 62)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
A vertical `/campanha` é a ferramenta de campo da campanha: coordenadores e lideranças acessam o app pelo celular, muitas vezes em locais com conexão instável. Transformar `/campanha` em um PWA instalável (com ícone na tela inicial, splash e capacidade offline básica) melhora a experiência de campo e desbloqueia o push web no iOS (que só recebe push com o PWA instalado — ver [plans/notifications.md](./notifications.md)).
|
||||
|
||||
A decisão de produto (2026-07-17) é deliberadamente restritiva: **só `/campanha` será instalável**. O site público `(frontend)` e o admin Payload `(payload)` ficam de fora — eles continuam apps web normais. Isso mantém o PWA como uma vertical de campo e evita acoplar o site institucional a um service worker.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Tornar `/campanha` instalável (Add to Home Screen) no Android (Chrome) e no iOS (Safari).
|
||||
- Garantir um shell básico offline: abertura do app e navegação para as rotas já visitadas funcionam sem rede.
|
||||
- Manter o padrão arquitetural do projeto: auth isolada por cookie `campaign-token`, access control por papel, transações em writes multi-collection, `Consent` por chave estável.
|
||||
- Servir de fundação para o push web do plano de notificações (o service worker aqui é o mesmo que o push usará).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Escopo restrito a `/campanha`.** `scope` e `start_url` apontam para `/campanha/`; o service worker tem `scope: '/campanha/'`. Site público e `/admin` não são instaláveis e não têm SW. (Decisão de produto 2026-07-17; roadmap linha 81.)
|
||||
- **Um único service worker para a vertical**, compartilhado com o push de notificações — não criar um segundo SW só para push.
|
||||
- **Auth continua via cookie `campaign-token`** (httpOnly, `path: '/campanha'`). O SW roda same-origin e envia cookies automaticamente nos `fetch`; nenhum token extra é exposto ao SW.
|
||||
- **Offline é best-effort, não garantido.** O app continua server-renderizado com auth; o SW faz cache de runtime (stale-while-revalidate) das respostas já vistas e um precache mínimo do shell. Não há sincronização em segundo plano de escritas neste ciclo (write-back offline fica fora de escopo).
|
||||
- **Sem alterar rotas existentes.** O PWA é aditivo: manifest + SW + metadados no layout `(campaign)`. As rotas `/campanha/login`, `/campanha/nucleos`, etc. continuam iguais.
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês, strings visíveis em pt-BR.
|
||||
- **Toast de recomendação de instalação** aparece sempre que o usuário inicia sessão em mobile e **fora** do PWA; pode ser fechado e só volta na próxima sessão. Detalhes na seção "Toast de instalação" abaixo.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Onde servir o SW e o manifest.** Opções:
|
||||
- (a) Route handlers Next.js em `src/app/(campaign)/campanha/sw.js/route.ts` e `src/app/(campaign)/campanha/manifest.webmanifest/route.ts` — permite controlar headers (`Content-Type`, `Cache-Control`, `Service-Worker-Allowed`) e manter tudo dentro do route group `(campaign)`. **Recomendação:** esta.
|
||||
- (b) Arquivos estáticos em `public/campanha/` — mais simples, mas `public/` é servido na raiz e mistura escopos; exigiria reescrever path ou aceitar SW na raiz com `Service-Worker-Allowed: /campanha`.
|
||||
- **Estratégia de cache:**
|
||||
- Precache do shell: quais rotas mínimas (login, dashboard `/campanha`, lista de núcleos)? O login é público; as demais exigem cookie de sessão — cacheá-las no SW precisa cuidar para não vazar HTML autenticado entre usuários no mesmo dispositivo (na prática, mesmo usuário; ainda assim, invalidar cache no logout).
|
||||
- Runtime cache: `stale-while-revalidate` para navegação (HTML) e `network-first` para dados de Server Actions/fetch. Definir limites (entries / tamanho) e expurgo.
|
||||
- Invalidação no logout: `logoutCampaign` precisa avisar o SW para limpar caches (postMessage ou `caches.delete` no cliente).
|
||||
- **Ícones:** quais assets usar (logo `LOGO_SOLLA_BRANCO.svg` existe em `public/`, mas PWA exige PNG 192/512 + maskable). Definir origem dos ícones e `background_color`/`theme_color` coerentes com `data-theme="campaign"`.
|
||||
- **`display`**: `standalone` (recomendado) vs `minimal-ui`. iOS usa `apple-mobile-web-app-capable` + `apple-mobile-web-app-status-bar-style`.
|
||||
- **Atualização do SW:** política de `skipWaiting` + `clients.claim` vs aguardar fechamento de abas. Definir UX de "nova versão disponível".
|
||||
- **Métricas de adoção:** rastrear instalações / sessões PWA (analytics leve, respeitando LGPD) ou adiar.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Layout["Layout (campaign)<br/>meta link rel=manifest<br/>theme-color + apple-touch-icon"]
|
||||
Manifest["GET /campanha/manifest.webmanifest<br/>scope/start_url = /campanha/"]
|
||||
SW["GET /campanha/sw.js<br/>install: precache shell<br/>fetch: SWR navegação<br/>push: handler (futuro)"]
|
||||
Register["Cliente registra SW<br/>navigator.serviceWorker.register<br/>('/campanha/sw.js', {scope: '/campanha/'})"]
|
||||
Offline["Offline<br/>shell + rotas visitadas"]
|
||||
Push["Push (futuro)<br/>ver notifications.md"]
|
||||
AppLayout["Layout (app) autenticado"]
|
||||
Toast["InstallPwaToast<br/>mobile + fora do PWA<br/>sonner persistente"]
|
||||
BIP["beforeinstallprompt<br/>(Android)"]
|
||||
IOS["Passo a passo iOS<br/>(Drawer)"]
|
||||
Session["sessionStorage<br/>dismissed"]
|
||||
|
||||
Layout --> Manifest
|
||||
Layout --> Register
|
||||
Register --> SW
|
||||
SW --> Offline
|
||||
SW --> Push
|
||||
AppLayout --> Toast
|
||||
BIP --> Toast
|
||||
Toast --> IOS
|
||||
Toast --> Session
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **Route handler do manifest** (`src/app/(campaign)/campanha/manifest.webmanifest/route.ts`): retorna JSON com `name`, `short_name`, `start_url: '/campanha/'`, `scope: '/campanha/'`, `display: 'standalone'`, `theme_color`, `background_color`, `icons` (192/512/maskable), `lang: 'pt-BR'`. `Content-Type: application/manifest+json`, `Cache-Control: no-cache`.
|
||||
- **Route handler do SW** (`src/app/(campaign)/campanha/sw.js/route.ts`): serve o script JS com `Content-Type: text/javascript` e `Cache-Control: no-cache` (para o navegador sempre checar atualizações). `Service-Worker-Allowed: /campanha` se necessário. Script com handlers `install` (precache do shell), `activate` (limpeza de caches antigos), `fetch` (SWR para navegação same-origin sob `/campanha`), e placeholder `push`/`notificationclick` para o plano de notificações.
|
||||
- **Registro no cliente**: um pequeno Client Component montado no `src/app/(campaign)/layout.tsx` (root da vertical) chama `navigator.serviceWorker.register('/campanha/sw.js', { scope: '/campanha/' })` em `useEffect`, só em produção (ou com feature flag), com try/catch silencioso.
|
||||
- **Metadados no layout `(campaign)`**: adicionar `manifest`, `themeColor`, `apple-touch-icon`, `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style`, `apple-mobile-web-app-title` no `metadata`/`viewport` do `src/app/(campaign)/layout.tsx`. Manter `robots: { index: false, follow: false }` já existente.
|
||||
- **Logout invalida cache**: `logoutCampaign` (`src/app/(campaign)/campanha/actions/auth.ts`) emite sinal para o SW limpar caches (postMessage ao SW ativo, ou o cliente chama `caches.delete` após redirecionar).
|
||||
- **Sem collections novas neste plano.** Push subscription e `Notification` ficam no plano de notificações; aqui só a fundação do SW.
|
||||
|
||||
## Toast de instalação
|
||||
|
||||
Recomendação de instalação do `/campanha` como PWA, exibida no início da sessão em mobile. Reusa o `<Toaster>` (sonner) já montado no layout `(app)` (`src/app/(campaign)/campanha/(app)/layout.tsx`).
|
||||
|
||||
**Gatilho (todas as condições, avaliadas no cliente):**
|
||||
|
||||
- É mobile: `matchMedia('(pointer: coarse)')` e/ou largura de viewport típica de celular (alinhado às larguras já testadas 360/390 px). Não usar só `navigator.userAgent` (fragil).
|
||||
- Está **fora** do PWA: `matchMedia('(display-mode: standalone)')` é `false` **e** (iOS) `navigator.standalone !== true`.
|
||||
- Usuário autenticado: o toast vive no layout `(app)`, então só aparece após o login (sessão iniciada).
|
||||
- Ainda não foi fechado nesta sessão: flag em `sessionStorage` (chave sugerida `pwa-install-toast-dismissed`) ausente.
|
||||
|
||||
**Comportamento:**
|
||||
|
||||
- Toast **persistente** (`duration: Infinity` no sonner), com botão de fechar (X) explícito.
|
||||
- Botão de ação primária depende da plataforma:
|
||||
- **Android/Chrome** (capturou `beforeinstallprompt`): botão **"Instalar"** chama `deferredPrompt.prompt()` e, no sucesso, fecha o toast e marca a flag.
|
||||
- **iOS/Safari** (sem `beforeinstallprompt`): botão **"Como instalar"** abre um pequeno sheet/drawer com o passo a passo (Compartilhar → Adicionar à Tela de Início).
|
||||
- Fechar (X) grava `sessionStorage['pwa-install-toast-dismissed'] = '1'` — **não** reaparece na mesma sessão, mas volta na próxima (nova sessão/login), porque `sessionStorage` é esvaziado ao fechar a aba/navegador.
|
||||
- Se o usuário instalar (passa a `display-mode: standalone`), o toast não aparece mais — a condição "fora do PWA" já falha.
|
||||
|
||||
**Componentes:**
|
||||
|
||||
- **Client Component `InstallPwaToast`** (em `src/components/campaign/`): registra listener de `beforeinstallprompt` (guarda o `DeferredPrompt`), avalia as condições no mount e dispara `toast.message(...)` do sonner com `action` e `cancel`. Reusa o `<Toaster>` global; não monta um segundo.
|
||||
- **Montagem**: inserir `<InstallPwaToast />` no `src/app/(campaign)/campanha/(app)/layout.tsx` (shell autenticado), ao lado do `<Toaster position="top-center" />` já existente.
|
||||
- **Sem server action**: lógica toda no cliente; nenhum dado sensível, sem escrita em collection, sem `Consent` (a instalação é decisão do dispositivo, não tratamento de PII).
|
||||
|
||||
**Questões a fechar neste plano:**
|
||||
|
||||
- Confirmação de `sessionStorage` (escopo aba/sessão) vs `localStorage` (persistente entre sessões). **Decisão:** `sessionStorage` — atende "só abre novamente na próxima sessão" e evita acúmulo. Se o produto quiser "nunca mais mostrar após fechar", aí troca para `localStorage`.
|
||||
- Texto do toast e do passo a passo iOS (pt-BR), e se o passo a passo abre um `<Drawer>` (`src/components/ui/Drawer.tsx`) ou um `<Sheet>`.
|
||||
- Cadência caso o usuário nunca instale: manter uma vez por sessão é o acordado; não fazer re-display dentro da mesma sessão.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma de outro plano — este é pré-requisito do push web em [plans/notifications.md](./notifications.md).
|
||||
- Assets de ícone PNG (192/512/maskable) a definir.
|
||||
- Definição de `theme_color`/`background_color` da vertical campaign (hoje o tema vem de `data-theme="campaign"` em `styles.css`).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- PWA do site público `(frontend)` ou do `/admin` (decisão de produto; roadmap linha 81).
|
||||
- Write-back offline de atualizações/estimativas — escritas continuam exigindo rede e transação Payload.
|
||||
- Push web em si — fica em [plans/notifications.md](./notifications.md); aqui só o placeholder do handler.
|
||||
- Sincronização em segundo plano (Background Sync API).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (linhas 58, 62, 81)
|
||||
- `docs/plans/notifications.md` — push depende deste plano
|
||||
- `AGENTS.md` — Campaign auth, naming conventions, transações
|
||||
- `.cursor/rules/projects/nucleos-eleitorais.mdc`
|
||||
- `src/app/(campaign)/layout.tsx` — root layout da vertical (onde os metadados entram)
|
||||
- `src/app/(campaign)/campanha/(app)/layout.tsx` — shell autenticado (monta o `<InstallPwaToast />`)
|
||||
- `src/components/ui/Toaster.tsx` — sonner já montado; reusar, não duplicar
|
||||
- `src/components/ui/Drawer.tsx` — passo a passo de instalação no iOS
|
||||
- `src/utilities/campaignAuth.ts` — cookie `campaign-token`
|
||||
- `src/app/(campaign)/campanha/actions/auth.ts` — `logoutCampaign` (invalidação de cache)
|
||||
143
docs/plans/territorio-multi-municipio-bairro.md
Normal file
143
docs/plans/territorio-multi-municipio-bairro.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Território multi-município e multi-bairro
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Hoje o território do Núcleo Eleitoral é modelado com campos **single-valued** em `src/collections/ElectoralNucleus.ts`:
|
||||
|
||||
- `region` (Território de Identidade, `text`, único, indexado);
|
||||
- `city` (Município, `text`, único, indexado);
|
||||
- `neighborhood` (Bairro, `text`, único);
|
||||
- `locality` (Localidade, `text`, livre);
|
||||
- `territoryNotes` (`textarea`).
|
||||
|
||||
As regras atuais (em `validateNucleusTerritoryAndZones` no collection e em `validateGeographyAndZones` no Zod `src/lib/schemas/nucleus.ts`, com parsing em `parseSharedNucleusFormData` em `src/utilities/nucleusUi.ts`) são:
|
||||
|
||||
- `region` deve ser território válido da Bahia; `city` deve ser município válido da Bahia;
|
||||
- se `region` + `city`, então `city` deve pertencer a `region` (`territoryForCity(city) === region`);
|
||||
- `neighborhood` exige `city`;
|
||||
- pelo menos um de `region`/`city`/`locality`.
|
||||
|
||||
Isso não cobre dois casos reais da operação: um núcleo que abrange **mais de um município** (sem ser um Território de Identidade inteiro) e um núcleo que abrange **mais de um bairro** (sem ser um município inteiro). Decisão de produto (2026-07-17): `Município` e `Bairro` passam a aceitar múltiplas entradas, e um núcleo pode **cruzar Territórios de Identidade** quando abrange municípios de territórios distintos.
|
||||
|
||||
## Decisão de modelagem (Modelo A + cross-territory)
|
||||
|
||||
Escolhido o **Modelo A** (arrays planos) em vez de array estruturado por município, porque o caso combinado "vários municípios **com** bairros em cada" não é realista para a operação — bairros só fazem sentido escopados a um único município.
|
||||
|
||||
- `region` → **`regions`** (`text[]`): array de Territórios de Identidade. **Derivado no servidor** a partir dos municípios selecionados quando `cities` não vazio; definível manualmente só quando `cities` vazio (caso "núcleo = território inteiro").
|
||||
- `city` → **`cities`** (`text[]`): múltiplos municípios, podem pertencer a territórios distintos (cross-territory).
|
||||
- `neighborhood` → **`neighborhoods`** (`text[]`): múltiplos bairros (texto livre, como hoje).
|
||||
- `locality` e `territoryNotes`: inalterados.
|
||||
|
||||
### Regras de validação (substituem as atuais)
|
||||
|
||||
1. Pelo menos um de: `regions`, `cities`, `locality`.
|
||||
2. Cada `city` deve ser município válido da Bahia; cada `region` deve ser território válido.
|
||||
3. Se `cities` não vazio → `regions` é **exatamente** o conjunto (deduplicado) dos territórios desses municípios. O servidor **ignora** o valor de `regions` enviado e rederiva (mesmo padrão de `setCanonicalNucleusSlug`).
|
||||
4. Se `cities` vazio → `regions` é o que o usuário escolher (1+ territórios inteiros).
|
||||
5. Se `neighborhoods` não vazio → `cities` deve ter **exatamente 1** elemento. Bairros só fazem sentido num único município.
|
||||
6. Bairros: trim, dedup, não vazios.
|
||||
|
||||
### Diagrama
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start[Formulário Núcleo] --> HasCities{cities vazio?}
|
||||
HasCities -->|não| Derive["regions = territórios das cities (servidor)"]
|
||||
HasCities -->|sim| Manual["regions = escolha manual (1+)"]
|
||||
Derive --> CheckNh{neighborhoods não vazio?}
|
||||
Manual --> CheckNh
|
||||
CheckNh -->|sim| RequireOne["exigir cities.length = 1"]
|
||||
CheckNh -->|não| Save[persistir]
|
||||
RequireOne --> Save
|
||||
```
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Schema do `electoralNucleus` com `regions`/`cities`/`neighborhoods` como arrays, com migration que preserva dados existentes.
|
||||
- Validação server-side autoritativa (hook `beforeValidate` + Zod) com as 6 regras acima; o cliente apenas reflete o estado derivado.
|
||||
- Formulário de criar/editar núcleo (`NucleusTerritoryFields`) com município multi-seleção (chips), bairro como tag input habilitado só com 1 município, e território como exibição derivada + seleção manual quando sem municípios.
|
||||
- Filtros da lista de núcleos (`NucleusFilters`) continuam single-select por território e por município; a consulta muda de `equals` para `contains` (núcleo aparece quando toca aquele território/município).
|
||||
- View models e DTOs discriminados por papel atualizados para arrays; renderização em lista/detalhe apresenta arrays como badges ou lista separada por vírgula.
|
||||
- Testes int cobrindo: multi-município, cross-territory, multi-bairro, regra do município único, derivação de `regions`, e filtro `contains`.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Modelo A (arrays planos).** Sem array estruturado por município; sem tabela aninhada. Bairros exigem município único (regra 5).
|
||||
- **Cross-territory permitido.** Múltiplos municípios podem pertencer a territórios distintos; `regions` reflete o conjunto exato.
|
||||
- **`regions` derivado no servidor quando há `cities`.** O cliente envia `cities` (e opcionalmente `regions` só para o caso sem municípios); o hook rederiva `regions` a partir de `cities` e ignora o valor enviado quando `cities` não vazio. Mesmo princípio do `slug` canônico.
|
||||
- **Renomear campos para o plural** (`region`→`regions`, `city`→`cities`, `neighborhood`→`neighborhoods`) para clareza semântica e consistência com identificadores em inglês (AGENTS.md).
|
||||
- **Migration in-place com backfill.** Dados existentes já cumprem `city ∈ region` (validação atual), então `regions = ARRAY[region]`, `cities = ARRAY[city]`, `neighborhoods = ARRAY[neighborhood]` é correto. Down migration recria colunas single a partir do primeiro elemento e `RAISE EXCEPTION` se algum array tiver >1 elemento (proteção contra perda).
|
||||
- **Índices GIN em `regions` e `cities`** para o operador `contains` dos filtros.
|
||||
- **Filtros continuam single-select.** `NucleusFilters` não vira multi-select; só a consulta (`buildNucleusListWhere`) muda de `equals` para `contains`. O estado da URL (`NucleusListState`) mantém `region`/`city` single.
|
||||
- **Sem `Consent`, sem server action nova, sem collection nova.** Só schema + UI + validação + migration.
|
||||
- **i18n e naming** seguem AGENTS.md: identificadores em inglês, strings visíveis em pt-BR.
|
||||
- **Executar cedo, antes do volume de dados reais e antes de Eventos/Demandas** (decisão de sequenciamento 2026-07-17). Três razões: (1) a migration in-place com backfill é trivial e de risco mínimo enquanto há poucos núcleos — cada semana de dados reais aumenta o custo de validação; (2) [`zonas-por-municipio.md`](zonas-por-municipio.md) nasce contra `cities[]` e depende desta ordem; (3) as collections `actionPlan` ([eventos-agenda-mobilizacao.md](eventos-agenda-mobilizacao.md)) e `campaignDemand` ([demandas-campanha.md](demandas-campanha.md)) copiam o bloco de território do núcleo — se nascerem antes desta mudança, herdam o modelo single e criam dívida imediata de migração dupla.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Tipo do campo Payload.** Para `cities`/`neighborhoods` (texto livre repetido), usar `array` de subcampo `text` (cria tabela `electoral_nucleus_cities`/`electoral_nucleus_neighborhoods`) ou `select` com `hasMany`? `select` não serve para bairros (lista não é fixa). Recomendação: `array` de `text` para `cities` e `neighborhoods`; para `regions` (lista fixa de 27), `select` com `hasMany` ou `array` de `text` com validação — decidir na implementação pelo equilíbrio entre UX no admin Payload e complexidade da migration.
|
||||
- **Admin Payload UI.** O formulário da campanha é custom React; o admin Payload também edita núcleos. Para `array` de `text`, o admin mostra repeatable text inputs (sem combobox). Aceitável para staff; o combobox bonito fica só no `/campanha`. Confirmar com produto se é suficiente.
|
||||
- **Detecção de "tseZones alterado" em update** (interação com o plano [Zonas TSE por município](zonas-por-municipio.md)): com múltiplos municípios, o auto-preenchimento de zonas passa a ser a **união** das zonas dos municípios selecionados. Esse plano atualiza a premissa do outro.
|
||||
- **Limite máximo de municípios/bairros.** Definir um teto razoável (ex.: 27 municípios, 30 bairros) para evitar abuso e limitar o tamanho do array no form/URL. A definir na implementação.
|
||||
- **Núcleo cross-territory no filtro por território.** Confirmar com produto que um núcleo com municípios em 2 territórios deve aparecer ao filtrar por **qualquer um** dos dois (comportamento esperado do `contains`).
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
Arquivos:
|
||||
|
||||
- **`src/collections/ElectoralNucleus.ts`**: renomear campos para `regions`/`cities`/`neighborhoods` como arrays; reescrever `validateNucleusTerritoryAndZones` com as 6 regras (incluindo derivação de `regions` a partir de `cities`); atualizar `defaultColumns` para `regions`/`cities`.
|
||||
- **`src/lib/schemas/nucleus.ts`**: Zod com `regions`/`cities`/`neighborhoods` como arrays (`z.array(z.string())`); `superRefine` com as 6 regras, espelhando o hook.
|
||||
- **`src/lib/bahiaTerritories.ts`**: adicionar `territoriesForCities(cities: string[]): BahiaIdentityTerritory[]` (conjunto único e ordenado dos territórios dos municípios), reusando `territoryForCity`.
|
||||
- **`src/utilities/nucleusUi.ts`**: `parseSharedNucleusFormData` lê `cities`/`neighborhoods` repetidos do FormData e deriva `regions` (quando `cities` não vazio); `buildNucleusListWhere` passa a `regions: { contains: state.region }` e `cities: { contains: state.city }`; `NucleusListState` mantém `region`/`city` single (filtro).
|
||||
- **`src/utilities/nucleusViewModels.ts`**: todos os view models (`NucleusListViewModel`, `NucleusFormViewModel`, `Staff/LeadershipNucleusTabsViewModel`, `NucleusDetailBaseViewModel`) passam a `regions: string[]`, `cities: string[]`, `neighborhoods: string[]`; atualizar mappers (`toNucleusListViewModel`, `toNucleusFormViewModel`, `toNucleusDetailBaseViewModel`, `toStaffNucleusTabsViewModel`, `toLeadershipNucleusTabsViewModel`) e os `select` (`nucleusFormSelect`, `nucleusListSelect`, `nucleusStaffDetailSelect`, `nucleusLeadershipDetailSelect`, `nucleusContextSelect`).
|
||||
- **`src/utilities/nucleusPageData.ts`**: atualizar `nucleusContextSelect` e os selects por tab para os novos nomes.
|
||||
- **`src/components/campaign/NucleusTerritoryFields.tsx`**: município → combobox multi-seleção (chips); bairro → tag input habilitado só quando `cities.length === 1` (limpar bairros ao passar para >1); território → exibição derivada (badges dos territórios das cities) + seleção manual só quando sem municípios; hidden inputs emitem `regions`/`cities`/`neighborhoods` repetidos.
|
||||
- **`src/components/campaign/NucleusFilters.tsx`**: sem mudança de UI; só consome o novo `buildNucleusListWhere`.
|
||||
- **Componentes de renderização** (`NucleusCard`, `NucleusList`, detalhe overview/territory tab): apresentar `regions`/`cities`/`neighborhoods` como badges ou lista separada por vírgula.
|
||||
- **`src/payload-types.ts`**: regenerar com `pnpm generate:types`.
|
||||
- **Migration** `src/migrations/<ts>_territorio_multi_municipio_bairro.ts` (+ `.json` + `index.ts`): ver seção Migration.
|
||||
|
||||
## Migration
|
||||
|
||||
`pnpm migrate:create territorio_multi_municipio_bairro` e editar à mão (skill `payload-migrations` permite data migration):
|
||||
|
||||
- adicionar colunas `regions text[]`, `cities text[]`, `neighborhoods text[]` em `electoral_nucleus`;
|
||||
- backfill:
|
||||
- `regions = CASE WHEN region IS NOT NULL THEN ARRAY[region] ELSE ARRAY[]::text[] END`;
|
||||
- `cities = CASE WHEN city IS NOT NULL THEN ARRAY[city] ELSE ARRAY[]::text[] END`;
|
||||
- `neighborhoods = CASE WHEN neighborhood IS NOT NULL THEN ARRAY[neighborhood] ELSE ARRAY[]::text[] END`;
|
||||
- (dados existentes já cumprem `city ∈ region`, então `regions = ARRAY[region]` está correto);
|
||||
- drop colunas antigas `region`/`city`/`neighborhood`;
|
||||
- drop índices antigos `electoral_nucleus_region_idx` e criar GIN em `regions` e `cities` para o operador `contains`;
|
||||
- **down**: recriar colunas single `region`/`city`/`neighborhood` a partir do primeiro elemento de cada array, recriar índice btree, e `RAISE EXCEPTION` se algum array tiver `cardinality > 1` (proteção contra perda de dados multi-município/bairro).
|
||||
|
||||
## Dependências
|
||||
|
||||
- Reusa `territoryForCity`/`citiesForTerritory`/`bahiaMunicipalities` (`src/lib/bahiaTerritories.ts`), `CitiesByState.BA` (`src/lib/cities`), `StrictCombobox` (`src/components/campaign/StrictCombobox`), `municipalityComboboxOptions`/`territoryComboboxOptions` (`src/utilities/territoryComboboxOptions`).
|
||||
- **Interage com o plano [Zonas TSE por município](zonas-por-municipio.md)**: o auto-preenchimento de zonas passa a ser a união das zonas dos municípios selecionados (em vez de "a zona do município"). Atualizar a premissa daquele plano quando ambos forem implementados.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Array estruturado por município (`areas: [{ city, neighborhoods }]`) — caso combinado "vários municípios com bairros em cada" fica fora.
|
||||
- Mapa / PostGIS / polígonos GeoJSON (roadmap "Mapa / PostGIS").
|
||||
- Polígonos de zonas TSE — só o cadastro tabular (ver plano [Zonas TSE por município](zonas-por-municipio.md)).
|
||||
- Multi-tenant / white-label.
|
||||
- Alterar `Contact.city` (que virou opcional para representar território sem município) — escopo de `Contact`, não do núcleo.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (item novo; seção "Campanha → Próximos ciclos")
|
||||
- `.cursor/rules/projects/nucleos-eleitorais.mdc` (decisão a registrar)
|
||||
- `src/collections/ElectoralNucleus.ts` — campos e `validateNucleusTerritoryAndZones`
|
||||
- `src/lib/schemas/nucleus.ts` — `validateGeographyAndZones`
|
||||
- `src/utilities/nucleusUi.ts` — `parseSharedNucleusFormData`, `buildNucleusListWhere`
|
||||
- `src/utilities/nucleusViewModels.ts` — view models e selects
|
||||
- `src/components/campaign/NucleusTerritoryFields.tsx`, `NucleusFilters.tsx` — UI
|
||||
- `src/lib/bahiaTerritories.ts` — `territoryForCity`, `citiesForTerritory`
|
||||
- `docs/plans/zonas-por-municipio.md` — premissa de auto-preenchimento de zonas a atualizar
|
||||
- AGENTS.md — naming conventions, padrão de migration, "Bahia implícita no Núcleo"
|
||||
127
docs/plans/visitados-recentemente.md
Normal file
127
docs/plans/visitados-recentemente.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Visitados recentemente
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Hoje o dashboard `/campanha` (Início) mostra agregados globais e o feed de atualizações, mas não oferece um atalho para os lugares que o usuário acabou de visitar. Um coordenador que volta da ficha de um núcleo ou de uma listagem filtrada precisa refazer a navegação manualmente — reabrir Núcleos, reaplicar o filtro, reencontrar o núcleo. A decisão de produto (2026-07-17) é adicionar uma seção **"Visitados recentemente"** que lista os últimos lugares acessados dentro de `/campanha`: um núcleo específico (`/campanha/nucleos/[slug]`) ou uma listagem de núcleos com filtro específico (`/campanha/nucleos?...`).
|
||||
|
||||
O requisito explícito é **não criar um modelo complexo**: nada de collection no servidor, migration, server action de escrita ou `Consent`. O histórico é mantido no próprio navegador do usuário.
|
||||
|
||||
### Realidade técnica do "histórico do browser"
|
||||
|
||||
A `window.history` **não expõe** a lista de entradas (URLs) da stack por motivo de privacidade — só `history.length` (um número) e `history.state` (estado da entrada atual). Não é possível enumerar o back/forward. Portanto "usar o histórico do browser" é interpretado como: **registrar a navegação do usuário client-side, em `localStorage`**, conforme ele acessa páginas "interessantes" da campanha. Isso cumpre o objetivo (nenhum modelo no servidor) e deriva da navegação real no navegador do usuário. A limitação (por dispositivo, não sincronizado entre dispositivos/navegadores) é aceita como trade-off e flagada abaixo.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Painel **"Visitados recentemente"** no dashboard `/campanha`, listando os últimos lugares acessados como links clicáveis.
|
||||
- Registrar automaticamente, sem ação do usuário, visitas a:
|
||||
- núcleo específico (`/campanha/nucleos/[slug]`) — label = nome do núcleo;
|
||||
- listagem de núcleos com filtro específico (`/campanha/nucleos?...` com ao menos um filtro ativo) — label derivada do estado de filtro.
|
||||
- Histórico **client-side** em `localStorage`: sem collection, sem migration, sem server action, sem `Consent`, sem escrita no servidor.
|
||||
- Deduplicação por URL (pathname + search): revisitar o mesmo lugar atualiza o timestamp e sobe para o topo, sem duplicar.
|
||||
- **Tempo mínimo de permanência (dwell)** antes de gravar, para não registrar páginas intermediárias enquanto o usuário monta um filtro.
|
||||
- Lista limitada a 8 entradas, ordenadas da mais recente à mais antiga.
|
||||
- Hidratação segura: `localStorage` é browser-only, então a lista só é renderizada após montagem no cliente (sem mismatch de hidratação).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Sem modelo no servidor.** Nenhuma collection, nenhum campo em `campaignUser`, nenhuma migration, nenhuma server action de escrita, nenhum `Consent`. Todo o estado vive em `localStorage` no dispositivo do usuário. (Decisão de produto 2026-07-17; roadmap "Campanha → Próximos ciclos".)
|
||||
- **`localStorage`, não `sessionStorage`.** A intenção é "voltar aonde eu estava" entre sessões; `sessionStorage` morre ao fechar a aba. `localStorage` persiste por origem+dispositivo até limpeza. Chave estável: `teqo:campaign:recent-visits`.
|
||||
- **Por dispositivo, sem sync.** O histórico é local ao navegador. Não há sincronização entre dispositivos do mesmo usuário (isso exigiria modelo no servidor — fora de escopo). Aceito como trade-off do "sem modelo".
|
||||
- **Registro client-side derivado da navegação real.** Como `window.history` não enumera entradas, um componente cliente invisível (`RecentVisitTracker`) registra cada visita "interessante" no `localStorage`. A entrada é produzida no servidor (label) e gravada no cliente (storage) — sem round-trip de escrita.
|
||||
- **O que conta como "lugar".** Apenas: (a) detalhe de núcleo e (b) listagem de núcleos **com filtro ativo**. A listagem sem filtro (`/campanha/nucleos` puro) e o próprio dashboard (`/campanha`) **não** são registrados — são sempre um clique de distância e inundariam a lista. Outras rotas (convite público, login) ficam fora.
|
||||
- **Dedup por `href` (pathname + search normalizado).** Revisitar atualiza `visitedAt` e reposiciona no topo. Filtros equivalentes com ordem de querystring diferente são considerados o mesmo lugar após normalização (reusar `resolveNucleusListUrl`/`NucleusListState` para canonicalizar).
|
||||
- **Tempo mínimo de permanência (dwell) antes de registrar.** A visita só é gravada depois que o usuário permanece naquela URL por um tempo mínimo (default 2000 ms). Enquanto o usuário monta um filtro, a listagem atualiza a URL com `router.replace` **sem remontar** a página — cada mudança de `searchParams` muda o `href` e **re-arma o timer**, descartando os estados intermediários; só o filtro final, em que o usuário repousou pelo dwell, é registrado. Se o usuário sai antes do dwell, nada é gravado. Isso evita o acúmulo de páginas intermediárias e é o motivo de o tracker ser por-`href` com timer, e não gravação imediata no mount. (Decisão de produto 2026-07-17.)
|
||||
- **Lista limitada a 8 entradas.** Suficiente para "voltar aonde estava" sem virar histórico completo. (`MAX_ENTRIES = 8`.)
|
||||
- **Access control herdado, sem validação no render.** Entradas são só links para rotas autenticadas; a rota de destino continua aplicando seu escopo (`canReadElectoralNucleus` etc.). Se o usuário perdeu acesso a um núcleo (ex.: liderança desengajada), o link pode continuar aparecendo no histórico local, mas o destino negará acesso. Não há limpeza reativa — best-effort. (Ver "Questões em aberto".)
|
||||
- **Sem `Consent`.** É o histórico de navegação do próprio usuário no próprio dispositivo, análogo ao histórico do browser — não há dado de terceiros nem transmissão ao servidor. Não configura tratamento de PII de terceiros sob LGPD. Se futuramente houver sync servidor, aí sim vira modelo + `Consent` (fora de escopo).
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`RecentVisitTracker`, `RecentlyVisited`, `recordRecentVisit`, `listRecentVisits`, `buildNucleusListVisitLabel`), strings visíveis em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Registrar listagem só com filtro ativo?** **Recomendação:** sim — a listagem sem filtro é o destino padrão do menu e não agrega. Definir com produto se faz sentido registrar também buscas (`q=`) isoladas.
|
||||
- **Valor exato do dwell.** Default proposto: 2000 ms. Muito curto registra estados intermediários; muito longo perde visitas rápidas legítimas (ex.: abrir um núcleo só para checar um dado). **Recomendação:** 2000 ms como ponto de partida, ajustar com uso. Definir com produto.
|
||||
- **Formato da label do filtro.** Ex.: `Núcleos · Região X`, `Núcleos · Cidade Y · Com estimativa pendente`, `Núcleos · Busca "solla"`. **Recomendação:** label composta curta a partir do `NucleusListState` (território + cobertura + busca), truncada. Definir os rótulos exatos com produto.
|
||||
- **Onde mais mostrar, além do dashboard?** **Recomendação:** só no dashboard `/campanha` neste ciclo (é o "Início", landing natural). Repetir no topo da listagem de núcleos é opcional e pode poluir — deixar para depois.
|
||||
- **Limpeza de entradas obsoletas.** Hoje best-effort (link existe, destino pode negar). **Recomendação:** não limpar reativamente neste ciclo; o usuário pode limpar manualmente via botão "Limpar" no painel. Se virar confusão, adicionar poda ao clicar em entrada que resulta em acesso negado.
|
||||
- **Dispositivo compartilhado.** `localStorage` é por origem; em dispositivo compartilhado, um usuário vê histórico de outro (mesma origem). Não há PII de terceiros, mas nomes de núcleo são internos da campanha. **Recomendação:** aceitar (mesma superfície de qualquer app web nesta origem); se produto exigir isolamento, limpar ao `logoutCampaign` (já é client-side e barato).
|
||||
- **Limpar ao deslogar?** **Recomendação:** sim — chamar `clearRecentVisits()` em `logoutCampaign` para não vazar histórico local na troca de usuário em dispositivo compartilhado. Confirmar com produto.
|
||||
- **PWA/offline.** O painel lê `localStorage`, então funciona offline (depende do item PWA do roadmap estar em pé para o modo offline; sem PWA, funciona online normalmente). Sem dependência dura neste plano.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Sub["Páginas interessantes (server)"]
|
||||
Detail["Detalhe do núcleo<br/>entry: {href, label=name, kind=nucleus}"]
|
||||
List["Listagem com filtro<br/>buildNucleusListVisitLabel(state)<br/>entry: {href, label, kind=nucleusList}"]
|
||||
Tracker["RecentVisitTracker (client)<br/>invisível · on mount"]
|
||||
Store["recentVisits.ts (client-only)<br/>localStorage<br/>teqo:campaign:recent-visits"]
|
||||
Panel["RecentlyVisited (client)<br/>painel no dashboard"]
|
||||
Links["Links → /campanha/nucleos/...<br/>access control herdado"]
|
||||
Logout["logoutCampaign<br/>clearRecentVisits()"]
|
||||
|
||||
Sub --> Detail
|
||||
Sub --> List
|
||||
Detail --> Tracker
|
||||
List --> Tracker
|
||||
Tracker --> Store
|
||||
Store --> Panel
|
||||
Panel --> Links
|
||||
Store -.limpa.-> Logout
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/utilities/recentVisits.ts`** (client-only, guarda `typeof window === 'undefined'`):
|
||||
- `STORAGE_KEY = 'teqo:campaign:recent-visits'`, `MAX_ENTRIES = 8`.
|
||||
- `type RecentVisitKind = 'nucleus' | 'nucleusList'`.
|
||||
- `type RecentVisitEntry = { href: string, label: string, kind: RecentVisitKind, visitedAt: number }`.
|
||||
- `recordRecentVisit(entry)`: lê a lista, remove entrada com mesmo `href` (dedup), insere `entry` no topo, trunca em `MAX_ENTRIES`, grava. No-op fora do browser.
|
||||
- `listRecentVisits(): RecentVisitEntry[]`: lê e parseia; retorna `[]` se ausente/inválido. No-op fora do browser.
|
||||
- `clearRecentVisits()`: remove a chave. Usado no logout.
|
||||
- Normalização de `href` antes de gravar/dedupar: para `nucleusList`, reusar a canonicalização de `resolveNucleusListUrl`/`NucleusListState` para que filtros equivalentes colidam.
|
||||
- **`RecentVisitTracker`** (client, em `src/components/campaign/`, renderiza `null`):
|
||||
- Props: `{ entry: RecentVisitEntry }`.
|
||||
- `useEffect` dependente de `entry.href`: arma um `setTimeout(RECORD_DWELL_MS)` (default 2000). Quando dispara, chama `recordRecentVisit(entry)`. Se `entry.href` muda antes do disparo (usuário ainda montando filtro) ou o componente desmonta (saiu da página), o cleanup cancela o timer — nada é gravado. Sem UI.
|
||||
- Renderizado no detalhe do núcleo e na listagem (com filtro ativo).
|
||||
- **`RecentlyVisited`** (client, em `src/components/campaign/`):
|
||||
- `useState` inicial `[]`; `useEffect` no mount popula com `listRecentVisits()`. Evita mismatch de hidratação (servidor renderiza vazio, cliente preenche).
|
||||
- Se vazio, renderiza `null` (painel some) ou um estado vazio curto — definir com produto (recomendação: `null`, sem UI órfã).
|
||||
- Caso contrário, painel com título "Visitados recentemente" e lista horizontal/vertical de links (label + ícone por `kind` + data relativa). Reusa `Card`/`Button`/ícones `lucide-react` e o `relativeFormatter` do `CampaignDashboard`.
|
||||
- Botão "Limpar" chama `clearRecentVisits()` e reseta o estado local.
|
||||
- **`buildNucleusListVisitLabel(state: NucleusListState): string | null`** (em `src/utilities/nucleusUi.ts`):
|
||||
- Retorna `null` quando **nenhum** filtro está ativo (listagem nua não é registrada).
|
||||
- Caso contrário, compõe label curta a partir de `state` (território + cobertura + `q`), truncada. Formato exato a definir com produto (ver questões em aberto).
|
||||
- **Integração no dashboard** (`src/app/(campaign)/campanha/(app)/page.tsx` + `CampaignDashboard.tsx`): renderizar `<RecentlyVisited />` como um painel (recomendação: no topo, antes dos agregados, como atalho de "voltar aonde estava"; confirmar com produto). Como é client-only, o servidor renderiza o placeholder vazio e o cliente preenche.
|
||||
- **Integração no detalhe do núcleo** (`src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx`): renderizar `<RecentVisitTracker entry={{ href: \`/campanha/nucleos/${slug}\`, label: nucleus.name, kind: 'nucleus' }} />` (invisível).
|
||||
- **Integração na listagem** (`src/app/(campaign)/campanha/(app)/nucleos/page.tsx`): computar `label = buildNucleusListVisitLabel(state)`; se `label` não for `null`, renderizar `<RecentVisitTracker entry={{ href: canonicalUrl.href, label, kind: 'nucleusList' }} />` (invisível).
|
||||
- **Logout** (`src/app/(campaign)/campanha/actions/auth.ts`): chamar `clearRecentVisits()` em `logoutCampaign` (se confirmado) — protege dispositivo compartilhado.
|
||||
- **Sem migration, sem collection, sem server action de escrita.** Tudo é leitura no servidor + estado client-side.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma de outro plano. Reusa `NucleusListState`, `resolveNucleusListUrl` (`src/utilities/nucleusUi.ts`), `CampaignDashboard`/`relativeFormatter` (`src/components/campaign/CampaignDashboard.tsx`) e UI `Card`/`Button` (`src/components/ui/`).
|
||||
- Funciona online desde já; o modo offline depende do item PWA do roadmap (não é bloqueador deste plano).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Sincronização do histórico entre dispositivos — exigiria modelo no servidor + `Consent`, exatamente o que este plano evita.
|
||||
- Histórico de outras rotas além de detalhe de núcleo e listagem filtrada (dashboard, convite público, login).
|
||||
- Validação reativa de acesso (poda de entradas cujo destino deixou de ser acessível) — best-effort neste ciclo.
|
||||
- Registro de telemetria/analytics — este é histórico de navegação local do usuário, não rastreamento para a campanha.
|
||||
- Substituir a navegação principal (sidebar/bottom nav) — o painel é atalho complementar.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (seção "Campanha → Próximos ciclos", item "Visitados recentemente")
|
||||
- `src/app/(campaign)/campanha/(app)/page.tsx` e `src/components/campaign/CampaignDashboard.tsx` — onde o painel entra
|
||||
- `src/app/(campaign)/campanha/(app)/nucleos/page.tsx` — listagem (registro de filtro)
|
||||
- `src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx` — detalhe (registro de núcleo)
|
||||
- `src/utilities/nucleusUi.ts` — `NucleusListState`, `resolveNucleusListUrl` (canonicalização do `href`/label)
|
||||
- `src/app/(campaign)/campanha/actions/auth.ts` — `logoutCampaign` (limpeza opcional)
|
||||
- AGENTS.md — Campaign auth, naming conventions (identificadores em inglês, strings em pt-BR)
|
||||
116
docs/plans/zonas-por-municipio.md
Normal file
116
docs/plans/zonas-por-municipio.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Zonas TSE por município (auto-preenchimento)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-17
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Hoje o campo **Zonas TSE** do formulário do núcleo (`TseZoneInput` em `src/components/campaign/TseZoneInput.tsx`) é sempre de preenchimento manual livre: o usuário digita números de 1 a 999, separados por vírgula/espaço/Enter, e o servidor só valida unicidade e intervalo (`parseTseZoneNumbers` em `src/utilities/nucleusUi.ts`, `validateNucleusTerritoryAndZones` em `src/collections/ElectoralNucleus.ts`, `validateGeographyAndZones` em `src/lib/schemas/nucleus.ts`). Não há nenhuma relação entre o município selecionado e as zonas informadas — o usuário precisa saber (ou adivinhar) quais zonas cobrem aquele município.
|
||||
|
||||
O TSE publica, como dado aberto, a correspondência oficial entre município e zona eleitoral (cada par município×zona aparece no conjunto "Eleitorado por município e zona", por UF). A decisão de produto (2026-07-17) é usar esse cadastro oficial para **preencher automaticamente** as Zonas TSE do núcleo a partir do município selecionado, sem edição manual nesse caso. O território de identidade já é auto-selecionado hoje quando o município muda (`territoryForCity` em `src/lib/bahiaTerritories.ts`, já wired em `NucleusTerritoryFields`); este plano preserva esse comportamento e o documenta como parte do mesmo fluxo.
|
||||
|
||||
O cadastro de zonas é relativamente estável, mas pode mudar entre eleições; por isso o mapeamento entra como **dado estático versionado com proveniência**, no mesmo estilo de `src/lib/bahiaTerritories.ts` e seu fixture `tests/fixtures/bahia-identity-territories.official.json`.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Mapeamento estático **município (BA) → lista de Zonas TSE** com proveniência oficial TSE, em novo arquivo `src/lib/bahiaTseZones.ts`, validado por fixture independente `tests/fixtures/bahia-tse-zones.official.json` e teste `tests/int/bahiaTseZones.int.spec.ts` (mesmo padrão de `bahiaTerritories`).
|
||||
- No formulário do núcleo (criação e edição), quatro modos para o campo Zonas TSE, regidos por uma regra única: **se a geografia selecionada define o conjunto exato de zonas que o núcleo cobre, auto-preencher em somente leitura; se define um superconjunto (zonas permitidas, mas o núcleo pode cobrir um subconjunto), editar restrito; se não define nada, livre**.
|
||||
1. **Município selecionado e bairro vazio** → o núcleo cobre o município inteiro, conjunto exato conhecido → auto-preencher com todas as zonas oficiais daquele município, em ordem crescente, **somente leitura** (sem adicionar/remover).
|
||||
2. **Bairro selecionado** (implica município selecionado) → o núcleo cobre um subconjunto do município (não sabemos qual zona cobre qual bairro) → editável, **restrito às zonas do município**.
|
||||
3. **Apenas território de identidade selecionado** (sem município) → o núcleo cobre um subconjunto do território → editável, **restrito à união das zonas dos municípios daquele território** (`citiesForTerritory` ∘ `tseZonesForCity`).
|
||||
4. **Sem território e sem município** → preenchimento manual livre (1–999), comportamento atual.
|
||||
- Ao selecionar o município, o **território de identidade** continua sendo auto-selecionado (já existe; manter e documentar). Ao selecionar só o território, o município permanece vazio (modo 3).
|
||||
- Validação **server-side** espelhando exatamente os quatro modos, para que o estado read-only do cliente não seja a única barreira (hook `validateNucleusTerritoryAndZones` + zod `validateGeographyAndZones`).
|
||||
- Sem nova collection, sem alteração de schema, sem migration, sem server action nova, sem `Consent`. O campo `tseZones` (array de `{ zoneNumber, label }`) já existe.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Mapeamento como dado estático versionado.** Novo `src/lib/bahiaTseZones.ts` derivado do conjunto "Eleitorado por município e zona" do TSE (UF BA), com cabeçalho documentando URL oficial, versão/data e SHA-256 do download — espelhando o estilo de `src/lib/bahiaTerritories.ts`. O fixture `tests/fixtures/bahia-tse-zones.official.json` é gerado de forma independente e serve de evidência para o teste, nunca lendo `bahiaTseZones.ts`.
|
||||
- **Sem migration.** O campo `tseZones` já é `array<{ zoneNumber, label }>`. Toda a mudança é UI + dado estático + validação. Nenhuma escrita nova além do que já existe.
|
||||
- **Regra de auto-preenchimento é autoritativa no servidor (em criação).** O cliente mostra read-only/construído, mas o servidor revalida em **create**: (1) cidade + sem bairro → zonas enviadas devem ser exatamente o conjunto oficial do município; (2) cidade + bairro → cada zona enviada deve pertencer ao conjunto do município; (3) só território → cada zona enviada deve pertencer à união das zonas dos municípios do território; (4) sem território/cidade → livre 1–999. Divergência = `APIError` 400 / issue zod, igual ao padrão atual de território incompatível. Em **update**, a regra só se aplica quando o campo `tseZones` é efetivamente alterado na requisição — para preservar zonas manuais legadas de núcleos existentes (ver "Questões em aberto → núcleos existentes").
|
||||
- **Território de identidade auto-selecionado já existe.** `NucleusTerritoryFields` já chama `territoryForCity(nextCity)` no `onValueChange` do município. Este plano **não** reimplementa isso; só garante que a subida de estado do novo fluxo não quebre esse comportamento e o documenta como parte do mesmo conjunto de regras.
|
||||
- **Estado compartilhado entre território e zonas.** Hoje `NucleusTerritoryFields` e `TseZoneInput` são siblings com estado independente em `NucleusFormFields`. Para reagir à mudança de município/bairro, o estado de `region/city/neighborhood/tseZones` precisa ser **levantado** para um único componente cliente que os coordene e emita os hidden inputs. Reusar os componentes existentes como filhos controlados.
|
||||
- **Read-only não significa oculto.** Nos modos read-only (1), as zonas auto-preenchidas continuam visíveis como badges (mesmo visual do `TseZoneInput`), mas sem o input de texto e sem o botão de remover. Indicar `aria-readonly` e um texto de ajuda explicando que as zonas derivam do município.
|
||||
- **Transição modo 1 → modo 2 (digitar bairro).** Mantém todas as zonas do município selecionadas como ponto de partida, agora editáveis (o usuário pode remover as que não se aplicam ao bairro). Decisão de produto 2026-07-17.
|
||||
- **Restrição por território (modo 3).** `tseZonesForTerritory(territory)` = união ordenada e deduplicada de `tseZonesForCity(m)` para cada `m` em `citiesForTerritory(territory)`. O input aceita somente zonas desse conjunto; zonas fora são rejeitadas com a mesma mensagem de "Zona TSE inválida" já usada por `parseTseZoneNumbers`.
|
||||
- **Subcampo `label` das zonas.** No auto-preenchimento, `label` fica vazio (é opcional). No modo (3) manual, continua sem label (comportamento atual). Não inventar descrição automática.
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`bahiaTseZones`, `tseZonesForCity`, `NucleusTerritoryAndZonesFields` ou similar), strings visíveis em pt-BR.
|
||||
- **Bahia implícita.** O mapeamento é só para BA (todo núcleo é baiano). Sem UF no dado.
|
||||
- **Ordem de execução: depois de [`territorio-multi-municipio-bairro.md`](territorio-multi-municipio-bairro.md)** (decisão de sequenciamento 2026-07-17). Aquele plano muda `city` → `cities[]`; implementar este plano antes exigiria retrabalho imediato dos quatro modos. Este plano deve nascer contra o modelo de arrays: os modos abaixo leem "município" como "conjunto de municípios selecionados" e o auto-preenchimento passa a ser a **união** de `tseZonesForCity(m)` para cada `m` em `cities`. Modo 1 = `cities` não vazio e sem bairro (união read-only); modo 2 = bairro selecionado (implica `cities.length === 1`, editável restrito às zonas desse município); modo 3 = só território(s) (editável restrito à união das zonas dos municípios dos territórios); modo 4 = sem geografia (livre). A validação server-side espelha a mesma leitura.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Versão do cadastro TSE.** Decidido: usar a versão mais recente do cadastro TSE validada em 2026 (conjunto "Eleitorado por município e zona", UF BA), com data e SHA-256 documentados no cabeçalho de `bahiaTseZones.ts` e no fixture. Reavaliar a cada eleição.
|
||||
- **Núcleos existentes com zonas manuais divergentes.** Decidido: **só aplicar a novos**. Sem migration de dados; núcleos já criados mantêm suas zonas manuais. No formulário de edição, ao carregar um núcleo existente, pré-preencher com as **zonas armazenadas** (não as oficiais); o auto-preenchimento só dispara quando o usuário **altera** o município/território. A validação server-side estrita (zonas == oficiais do município, etc.) vale em **create**; em **update**, só revalidar quando `tseZones` vier alterado na requisição — aceitar zonas legadas divergentes caso contrário. Sub-questão a fechar na implementação: como detectar "tseZones alterado" de forma robusta (comparar com `originalDoc.tseZones`).
|
||||
- **Cidade com zero zonas no dataset?** Não deveria ocorrer, mas o helper deve falhar fechado: se `tseZonesForCity(city)` retornar vazio para uma cidade válida, tratar como dado faltante e permitir modo manual com aviso, em vez de travar o formulário. O mesmo vale para `tseZonesForTerritory(territory)`.
|
||||
- **Zona que cobre vários municípios pequenos.** Isso é normal (uma zona, vários municípios). O mapeamento é município→suas zonas, então cada município lista a zona compartilhada; não há ambiguidade para o auto-preenchimento. No modo 3 (território), a união deduplica naturalmente zonas compartilhadas entre municípios do território. Confirmar com o fixture.
|
||||
- **Ordem e dedup.** `tseZonesForCity` e `tseZonesForTerritory` retornam números únicos em ordem crescente (mesmo padrão de `parseTseZoneNumbers`).
|
||||
- **Edição de bairro após auto-preenchimento.** Decidido: manter todas as zonas do município selecionadas como ponto de partida (modo 1 → modo 2), agora editáveis.
|
||||
- **Cache/ISR.** O dado é estático no bundle; sem `unstable_cache`, sem tag. Confirmar com produto.
|
||||
- **Relação com o item "Import do cadastro oficial de zonas TSE e/ou polígonos GeoJSON" (roadmap linha 59).** Este plano entrega o mapeamento município→zonas e território→zonas (cadastro tabular). Polígonos GeoJSON para o mapa continuam no item da linha 59. Definir se este plano substitui a parte "cadastro" daquele item ou se mantém ambos — recomendação: este plano cobre o cadastro tabular; o item da linha 59 fica restrito a polígonos/GeoJSON.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Form["NucleusFormFields (client)<br/>estado: region/city/neighborhood/tseZones"]
|
||||
Geo["Geografia selecionada"]
|
||||
Auto1["Modo 1: cidade, sem bairro<br/>tseZonesForCity → read-only"]
|
||||
Auto2["Modo 2: cidade + bairro<br/>restrito a tseZonesForCity (editável)"]
|
||||
Auto3["Modo 3: só território<br/>restrito a tseZonesForTerritory (editável)"]
|
||||
Auto4["Modo 4: sem território/cidade<br/>manual livre 1-999"]
|
||||
Region["territoryForCity(city)<br/>→ região auto (se cidade)"]
|
||||
Hidden["hidden inputs:<br/>region, city, neighborhood, tseZones"]
|
||||
Server["validateNucleusTerritoryAndZones<br/>+ validateGeographyAndZones<br/>revalida os 4 modos (create)"]
|
||||
|
||||
Form --> Geo
|
||||
Geo --> Auto1
|
||||
Geo --> Auto2
|
||||
Geo --> Auto3
|
||||
Geo --> Auto4
|
||||
Form --> Region
|
||||
Auto1 --> Hidden
|
||||
Auto2 --> Hidden
|
||||
Auto3 --> Hidden
|
||||
Auto4 --> Hidden
|
||||
Hidden --> Server
|
||||
```
|
||||
|
||||
Arquivos:
|
||||
|
||||
- **`src/lib/bahiaTseZones.ts`** (novo): export `bahiaMunicipalityTseZones` (record/map de cidade→`number[]` ordenado e único), `tseZonesForCity(city): number[]`, `tseZonesForTerritory(territory): number[]` (união deduplicada e ordenada de `tseZonesForCity` sobre `citiesForTerritory(territory)`), e `isTseZoneOfCity`/`isTseZoneOfTerritory` para validação server-side. Cabeçalho com proveniência (URL, versão validada em 2026, SHA-256), no estilo de `bahiaTerritories.ts`.
|
||||
- **`tests/fixtures/bahia-tse-zones.official.json`** (novo): evidência independente (não gerada a partir de `bahiaTseZones.ts`) com `provenance` (URL do "Eleitorado por município e zona" BA, data, SHA-256 do download) e a tabela município→zonas. Espelha `bahia-identity-territories.official.json`.
|
||||
- **`tests/int/bahiaTseZones.int.spec.ts`** (novo): valida que `bahiaTseZones.ts` cobre exatamente os 417 municípios de `CitiesByState.BA`, que cada zona está em 1–999, que `tseZonesForTerritory` bate com a união manual sobre `citiesForTerritory`, e que o conteúdo bate com o fixture. Espelha `bahiaTerritories.int.spec.ts`.
|
||||
- **`src/components/campaign/NucleusForm.tsx` / `NucleusTerritoryFields.tsx` / `TseZoneInput.tsx`**: subir o estado de `region/city/neighborhood/tseZones` para um componente cliente coordenador (ex.: `NucleusTerritoryFields` passar a ser controlado, ou um wrapper novo em `NucleusFormFields`). `TseZoneInput` ganha um modo `readOnly` que renderiza só os badges (sem input/remover) e um modo `allowedZones` que restringe a entrada aos números permitidos (do município ou do território). Seleção de cidade dispara `tseZonesForCity` + `territoryForCity`; seleção de só território dispara `tseZonesForTerritory`; limpar cidade/território reverte o modo conforme a regra. Ao carregar núcleo existente, pré-preencher com as **zonas armazenadas** (não as oficiais).
|
||||
- **`src/lib/schemas/nucleus.ts`** (`validateGeographyAndZones`) e **`src/collections/ElectoralNucleus.ts`** (`validateNucleusTerritoryAndZones`): adicionar a regra de consistência geografia↔zonas usando `tseZonesForCity`/`isTseZoneOfCity` e `tseZonesForTerritory`/`isTseZoneOfTerritory`, conforme os quatro modos. Em create, validar tudo; em update, só quando `tseZones` vier alterado (preservar zonas legadas). Mesma mensagem em pt-BR e mesmo padrão de `APIError` 400 já usado para território incompatível.
|
||||
- **`src/utilities/nucleusUi.ts`** (`parseTseZoneNumbers` / `parseSharedNucleusFormData`): quando aplicável, validar no parse que as zonas enviadas respeitam o modo (defesa em profundidade antes do zod/hook), usando o conjunto permitido derivado da geografia enviada (cidade ou território).
|
||||
- **Sem migration, sem collection, sem server action.** Todo o fluxo é leitura/escrita no formulário existente + dado estático.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **[`territorio-multi-municipio-bairro.md`](territorio-multi-municipio-bairro.md) — pré-requisito de sequenciamento** (decisão 2026-07-17): este plano nasce contra `cities[]` (união de zonas), evitando retrabalho. Ver "Decisões travadas".
|
||||
- Fora isso, reusa `territoryForCity`/`citiesForTerritory` (`src/lib/bahiaTerritories.ts`), `CitiesByState.BA` (`src/lib/cities`), `parseTseZoneNumbers` (`src/utilities/nucleusUi.ts`), `NucleusTerritoryFields`/`TseZoneInput` existentes, e o padrão de fixture/teste de `bahiaTerritories`.
|
||||
- O plano [`baseline-eleitoral-tse.md`](baseline-eleitoral-tse.md) **não** depende deste para funcionar (resolve zonas pelas rows de `electionTally`), mas se beneficia da qualidade do input: zonas auto-preenchidas eliminam erro de digitação que distorceria o baseline por núcleo.
|
||||
- **Polígonos GeoJSON / mapa** (roadmap linha 59) continuam fora deste plano.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Importar polígonos/GeoJSON de zonas ou integrar o mapa (roadmap linha 59 e "Mapa / PostGIS" linha 52).
|
||||
- Alterar o schema `tseZones` ou criar migration — o campo já existe.
|
||||
- Mapear bairro→zona (dado não existe oficialmente no TSE; continua ambíguo por design).
|
||||
- Estender o mapeamento para outros estados (todo núcleo é BA).
|
||||
- Previsão estatística de votos por zona (roadmap linha 60) — aqui só preenchemos o cadastro.
|
||||
- Alterar a lista de núcleos, filtros ou dashboard — só o formulário de criar/editar núcleo.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (item novo; linha 59 para o relacionado "Import do cadastro oficial de zonas TSE")
|
||||
- `src/components/campaign/NucleusForm.tsx`, `NucleusTerritoryFields.tsx`, `TseZoneInput.tsx` — formulário a alterar
|
||||
- `src/lib/bahiaTerritories.ts` e `tests/fixtures/bahia-identity-territories.official.json`, `tests/int/bahiaTerritories.int.spec.ts` — padrão de dado estático + fixture + teste a espelhar
|
||||
- `src/collections/ElectoralNucleus.ts` — `validateNucleusTerritoryAndZones`
|
||||
- `src/lib/schemas/nucleus.ts` — `validateGeographyAndZones`
|
||||
- `src/utilities/nucleusUi.ts` — `parseTseZoneNumbers`, `parseSharedNucleusFormData`
|
||||
- Portal de Dados Abertos do TSE — conjunto "Eleitorado por município e zona" (UF BA): https://dadosabertos.tse.jus.br/
|
||||
- AGENTS.md — naming conventions, padrão de leitura/escrita, "Bahia implícita no Núcleo"
|
||||
226
docs/roadmap.md
Normal file
226
docs/roadmap.md
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-17 (revisão de priorização orientada ao calendário eleitoral)
|
||||
|
||||
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
|
||||
|
||||
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
|
||||
|
||||
| Data | Marco | Consequência para o produto |
|
||||
| ----------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 20/07–05/08 | Convenções partidárias | Estrutura de núcleos/coordenadores deve estar operando **agora** |
|
||||
| 15/08 | Prazo final de registro de candidaturas | TSE publica candidaturas 2026 na sequência → destrava o insight de dobradinha |
|
||||
| 16/08 | Início da propaganda eleitoral (rua e internet) | "Quem chega em 16/08 com base de dados estruturada tem vantagem operacional" (Politipédia) — base nominal, baseline e agenda precisam estar em produção **antes** desta data |
|
||||
| 09–23/10 | Propaganda gratuita rádio/TV | Reta final; congelar mudanças arriscadas no app |
|
||||
| 04/10 | 1º turno | Operação do dia D (GOTV) — confirmação de comparecimento da base nominal |
|
||||
| 25/10 | Eventual 2º turno | Chapa majoritária (Lula/Jerônimo); Solla decidido no 1º turno |
|
||||
|
||||
## Princípios e decisões travadas
|
||||
|
||||
- Priorizar ownership de audiência e portabilidade de dados. _(README)_
|
||||
- Manter módulos genéricos o bastante para reuso em outros contextos políticos. _(README)_
|
||||
- Default seguro e com controle de acesso para equipes de campanha e institucionais. _(README)_
|
||||
- Um único app Next.js com três áreas: site público `(frontend)`, admin Payload `(payload)`, ferramenta interna `(campaign)` em `/campanha`. Sem serviço Rust separado. _(AGENTS.md)_
|
||||
- Hospedagem na Vercel por enquanto; sem migração self-host/Coolify em andamento. _(AGENTS.md)_
|
||||
- Doações **não** entram neste app — só CTA/link para QueroApoiar (`apoiar.me/jorgesolla`, homologado TSE). _(AGENTS.md)_
|
||||
- Pessoa = `Contact` + collections de junção; nunca criar cadastro paralelo de "apoiador/pessoa". _(AGENTS.md)_
|
||||
- Núcleo Eleitoral é unidade operacional da campanha; Zona TSE é referência oficial distinta. _(notebook Núcleos)_
|
||||
- Sem disparo em massa por WhatsApp: vedado pela Res. TSE 23.610 (art. 33) e pela política da Meta; mobilização é orgânica (kits de compartilhamento individuais). _(plano cadastro-nominal, pesquisa 2026-07-17)_
|
||||
|
||||
## Onda 0 — Caminho crítico para `/campanha` em produção
|
||||
|
||||
O MVP de Núcleos está **code-complete** (ondas 1–8). O que separa a vertical da produção não é engenharia — é jurídico + operação de deploy. Estes itens começam **imediatamente** e correm em paralelo a qualquer trilha de engenharia:
|
||||
|
||||
1. **Lote jurídico único de LGPD/Consent** _(externo — assessoria jurídica eleitoral; é o caminho crítico da vertical inteira)_. Uma única rodada cobrindo:
|
||||
- Base do art. 11 da LGPD + texto versionado de `Consent.key = 'lideranca-autopreenchimento'` (bloqueador do MVP de Núcleos; o app falha fechado sem a chave).
|
||||
- Textos de `apoiador-cadastro` e `apoiador-intencao-voto` (bloqueadores do cadastro nominal — [detalhes](plans/cadastro-nominal-apoiadores.md)).
|
||||
- Texto de `campanha-notificacoes-push` (opt-in de push — [detalhes](plans/notifications.md)).
|
||||
- **Aviso de Privacidade / política de privacidade institucional** (obrigação do controlador antes de coleta em massa; também é item do site público).
|
||||
- Avaliação de necessidade de RIPD (tratamento em larga escala + dado sensível).
|
||||
- Racional: fatiar em rodadas separadas multiplica o lead time externo; quatro textos + aviso numa rodada só.
|
||||
2. **Checklist de verificação e deploy** do AGENTS.md (migrations `Ran: Yes`, types, lint, testes int/E2E, build local, envs Vercel — incl. `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL` HTTPS).
|
||||
3. **Lançamento do MVP assim que (1) liberar a chave de liderança + (2) fechar.** Decisão de sequenciamento: **não** esperar a migração multi-município para lançar. Prós de lançar antes: coordenadores estruturam núcleos reais durante as convenções (20/07–05/08), cada semana vale capacidade de campo; a migration multi-município é in-place com backfill seguro e pode rodar com dados reais pequenos. Contras: uma migration a mais pós-lançamento. O contra é barato; o pró é tempo de campanha, que não volta.
|
||||
4. **Onboarding do time real** (usuários `geral`/`coordenador`, primeiros núcleos, treinamento básico de campo).
|
||||
|
||||
## Campanha (`/campanha`)
|
||||
|
||||
### Ciclo 1 — Núcleos (entregue)
|
||||
|
||||
MVP de território + reporte implementado (ondas 1–8): auth isolada `campaignUser` (`geral` / `coordenador` / `lideranca`), núcleos com slug canônico, lideranças, estimativas sugerir/confirmar, atualizações semanais, convites WhatsApp (`wa.me`, hash, uso único), dashboard e hardening. Detalhes: [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
|
||||
|
||||
### Grafo de dependências
|
||||
|
||||
Setas cheias = dependência dura; tracejadas = dependência suave (melhora, não bloqueia).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Onda0["Onda 0 — produção"]
|
||||
JUR["Lote jurídico único<br/>(4 Consent keys + aviso privacidade)"]
|
||||
DEPLOY["Checklist deploy + lançamento MVP"]
|
||||
end
|
||||
|
||||
subgraph TrilhaA["Trilha A — dados eleitorais e território"]
|
||||
A1["A1 Território multi-município/bairro<br/>(migration)"]
|
||||
A2["A2 Zonas TSE por município<br/>(auto-preenchimento)"]
|
||||
A3["A3 Baseline TSE 2022 — Fase 1<br/>(import, collections)"]
|
||||
A4["A4 Baseline no produto + Gap vs 2022<br/>(detalhe, overview)"]
|
||||
A5["A5 Insights: conversão, classificação,<br/>alavancagem, mobilização, competitiva"]
|
||||
A6["A6 Dobradinha 2026"]
|
||||
end
|
||||
|
||||
subgraph TrilhaB["Trilha B — superfícies de coordenação"]
|
||||
B1["B1 Overview da lista de núcleos"]
|
||||
B2["B2 Mapa Fase 1 (geometrias)"]
|
||||
B3["B3 Mapa Fase 2 (Leaflet nas superfícies)"]
|
||||
B4["B4 Camada de zonas TSE no mapa"]
|
||||
end
|
||||
|
||||
subgraph TrilhaC["Trilha C — operação de campo"]
|
||||
C1["C1 Compartilhar página"]
|
||||
C2["C2 Cadastro nominal de apoiadores"]
|
||||
C3["C3 Eventos / agenda de mobilização"]
|
||||
C4["C4 Demandas"]
|
||||
C5["C5 Operação dia D / GOTV<br/>(proposto, a validar)"]
|
||||
end
|
||||
|
||||
subgraph TrilhaD["Trilha D — plataforma e engajamento"]
|
||||
D1["D1 PWA /campanha"]
|
||||
D2["D2 Notificações (push + sino)"]
|
||||
end
|
||||
|
||||
A1 --> A2
|
||||
A3 --> A4
|
||||
B1 --> A4
|
||||
A2 -.qualidade de tseZones.-> A4
|
||||
A4 --> A5
|
||||
A4 --> A6
|
||||
TSE2026["TSE publica candidaturas 2026<br/>(externo, após 15/08)"] --> A6
|
||||
B1 --> B3
|
||||
B2 --> B3
|
||||
A4 -.métricas do coroplético.-> B3
|
||||
A2 --> B4
|
||||
B3 --> B4
|
||||
A1 --> C3
|
||||
A1 --> C4
|
||||
C3 -.relação actionPlan opcional.-> C4
|
||||
JUR --> C2prod["C2 em produção<br/>(dados reais)"]
|
||||
C2 --> C2prod
|
||||
C2 --> C5
|
||||
D1 --> D2
|
||||
JUR -.chave de push.-> D2
|
||||
```
|
||||
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): A1, A3, B1, B2, C1, C2 (engenharia — produção espera o jurídico), D1, além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase).
|
||||
|
||||
### Sequência de execução por janela do calendário
|
||||
|
||||
**Janela 1 — agora → 05/08 (convenções): colocar a vertical em produção e consertar a fundação de dados.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | ----------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ----------------- |
|
||||
| 1 | Onda 0 (jurídico em lote + deploy + lançamento) | — | externo | tudo |
|
||||
| 2 | A1 Território multi-município/bairro | [detalhes](plans/territorio-multi-municipio-bairro.md) | — (executar cedo: migration barata antes do volume de dados; pré-requisito de A2, C3, C4) | B1, C1, A3 |
|
||||
| 3 | A2 Zonas TSE por município (auto-preenchimento) | [detalhes](plans/zonas-por-municipio.md) | A1 (nasce contra `cities[]`) | B1, C1, A3 |
|
||||
| 4 | B1 Overview da lista de núcleos | [detalhes](plans/overview-lista-nucleos.md) | — | A1, A2, C1 |
|
||||
| 5 | C1 Compartilhar página (quick win de campo) | [detalhes](plans/compartilhar-pagina.md) | — | tudo |
|
||||
| 6 | A3 Baseline TSE 2022 — Fase 1 (import) | [detalhes](plans/baseline-eleitoral-tse.md) | — (dado público; sem bloqueador LGPD) | tudo |
|
||||
|
||||
**Janela 2 — 05/08 → 16/08 (pré-propaganda): base nominal + inteligência + agenda prontas para o arranque.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- |
|
||||
| 7 | C2 Cadastro nominal de apoiadores (a peça central pré-16/08; engenharia não espera o jurídico — o app falha fechado) | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3 |
|
||||
| 8 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3 |
|
||||
| 9 | C3 Eventos / agenda de mobilização (`actionPlan`) | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4 |
|
||||
|
||||
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------- |
|
||||
| 10 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B2/B3, C4, D1 |
|
||||
| 11 | B2 + B3 Mapa da Bahia (geometrias + Leaflet) | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1+B2 (suave: A4 para coroplético de baseline/classe) | A5, C4, D1 |
|
||||
| 12 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, D1 |
|
||||
| 13 | D1 PWA `/campanha` | [detalhes](plans/pwa-campanha.md) | — | tudo |
|
||||
| 14 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, C4 |
|
||||
|
||||
**Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| 15 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
|
||||
| 16 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
|
||||
| 17 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | — | C2 |
|
||||
| 18 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — |
|
||||
|
||||
**Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):**
|
||||
|
||||
- **Visitados recentemente** (client-side, `localStorage`). → [detalhes](plans/visitados-recentemente.md)
|
||||
- **Listas globais** (lideranças, atualizações, territórios no nível raiz). _(plano MVP; sem plano detalhado ainda)_
|
||||
- **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_
|
||||
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
|
||||
|
||||
**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B3 mapa Leaflet, C4 demandas, D2 push (mantendo o sino), D1 PWA, fill-ins. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
|
||||
|
||||
### Itens consolidados/removidos nesta revisão (2026-07-17)
|
||||
|
||||
- **"Insight: Gap vs 2022" como item separado** — removido: é a Fase 4 do plano de [baseline TSE 2022](plans/baseline-eleitoral-tse.md) (item A4), não um item próprio.
|
||||
- **"Import do cadastro oficial de zonas TSE e/ou polígonos GeoJSON"** — absorvido: o cadastro tabular é o plano [zonas-por-municipio](plans/zonas-por-municipio.md) (A2); os polígonos são a camada de zonas do [plano do mapa](plans/mapa-bahia-geometrias.md) (B4).
|
||||
- **"Notificações WhatsApp Business API"** — movido para fora de escopo: a Meta veda o WhatsApp Business API para campanhas políticas no Brasil e a Res. TSE 23.610 (art. 33) veda disparo em massa (pesquisa em [cadastro-nominal-apoiadores.md](plans/cadastro-nominal-apoiadores.md)). Push + sino ([notifications.md](plans/notifications.md)) cobrem a necessidade.
|
||||
- **"Previsão estatística de votos"** — mantido, mas explicitamente **fora do horizonte deste ciclo eleitoral**: sem dado acumulado suficiente antes de 04/10; o baseline TSE + estimativa manual + insights são o "mínimo honesto" (design-ux §4.5). Reavaliar pós-eleição.
|
||||
- **Dependência baseline → zonas-por-municipio** — rebaixada de dura para suave: `citiesForTerritory` já existe e as rows de `electionTally` resolvem cidade↔zona (ver revisão nos planos).
|
||||
|
||||
## Bloqueadores atuais
|
||||
|
||||
| Item | Status | Fonte |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Lote jurídico único LGPD (4 textos de `Consent` + Aviso de Privacidade + avaliação de RIPD) — ver Onda 0. O app falha fechado sem as chaves | **caminho crítico; iniciar já** | notebook Núcleos; AGENTS checklist; planos cadastro-nominal e notifications |
|
||||
| RBAC em `users` — todo usuário do admin Payload tem acesso total; necessário antes de abrir `/admin` a equipe maior. Nota: o import de apoiadores foi desenhado no `/campanha` justamente para não depender disso | pendente | AGENTS Known Gap #1 |
|
||||
| Fluxos públicos ainda hardcodam ID de Consent (ex. `consent: 2` em `submitWhatsapp.ts`); migrar para chave estável como na campanha | pendente | AGENTS Known Gap #2; plano-arquitetura |
|
||||
| Collection `Pages` inexistente; hero/copy da home ainda hardcoded — bio, propostas e páginas institucionais | pendente (não bloqueia `/campanha`) | AGENTS Known Gap #3 |
|
||||
|
||||
## Site público
|
||||
|
||||
**Já entregue:** sistema de notícias/publicações (`post`/`tag`), listagens e artigos, seed a partir de jorgesolla.com.br, cache com tag `posts`.
|
||||
|
||||
**Próximos** (não bloqueiam `/campanha`, exceto a política de privacidade, que entra no lote jurídico da Onda 0):
|
||||
|
||||
- Página de política de privacidade institucional (LGPD) — **prioridade alta**, sai do lote jurídico da Onda 0. _(plano-arquitetura §3.1)_
|
||||
- Modelar e popular `Pages` para conteúdo institucional (biografia, mandato, propostas). _(AGENTS Known Gap #3; plano-arquitetura §3)_
|
||||
- Tornar editáveis título/subtítulo (e demais textos) da home via global/`Pages`. _(AGENTS Known Gap #3)_
|
||||
- Agenda pública e multimídia: preferir links oficiais (Câmara, YouTube, Flickr). _(plano-arquitetura §3.1)_
|
||||
- Garantir superfície clara de CTA "Doar" → QueroApoiar. _(AGENTS.md; plano-arquitetura)_
|
||||
- Migrar Consent dos fluxos públicos (WhatsApp, petições) para resolução por chave estável. _(AGENTS Known Gap #2)_
|
||||
|
||||
## Admin Payload
|
||||
|
||||
- Introduzir `roles` em `users` e access control real antes de ampliar quem entra em `/admin`. _(AGENTS Known Gap #1)_
|
||||
- Seed reproduzível de documentos `Consent` resolvidos por chave (não por ID numérico). _(plano-arquitetura §2.3)_
|
||||
|
||||
## Plataforma white-label
|
||||
|
||||
- Fase 2 do README: multi-tenant, customização de marca/conteúdo por mandato, módulos compartilhados de comunicação e engajamento.
|
||||
- **Depois da eleição** — monorepo e white-label deliberadamente fora de escopo até lá. _(README Phase 2; plano-arquitetura §6)_
|
||||
|
||||
## Fora de escopo (por enquanto)
|
||||
|
||||
- Serviço Rust (ou outro backend) separado para `/campanha`. _(AGENTS.md; plano-arquitetura)_
|
||||
- Migração self-host / Coolify enquanto a Vercel atender. _(AGENTS.md)_
|
||||
- Processamento de pagamentos ou doações dentro deste app. _(AGENTS.md)_
|
||||
- Tratar Núcleo Eleitoral como sinônimo de Zona Eleitoral do TSE. _(notebook; plano MVP)_
|
||||
- PWA do site público ou do `/admin` — só a vertical `/campanha` será instalável. _(decisão de produto 2026-07-17)_
|
||||
- **PostGIS** até surgir necessidade de query espacial real; v1 do mapa usa TopoJSON estático versionado no repo. _(decisão de produto 2026-07-17)_
|
||||
- **WhatsApp Business API** — vedado para campanhas políticas pela Meta e sem caminho legal para disparo em massa (Res. TSE 23.610 art. 33); superado por push + sino. _(revisão 2026-07-17)_
|
||||
- **Disparo em massa de mensagens** em qualquer canal — mobilização é orgânica (kits de compartilhamento individuais, art. 33 §2º). _(plano cadastro-nominal)_
|
||||
- Previsão estatística de votos neste ciclo eleitoral. _(revisão 2026-07-17; reavaliar pós-eleição)_
|
||||
|
||||
## Fontes
|
||||
|
||||
- [`AGENTS.md`](../AGENTS.md) — decisões travadas, Known Gaps, checklist de campanha
|
||||
- [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc) — status e decisões do MVP de Núcleos
|
||||
- [`README.md`](../README.md) — missão e direção de produto
|
||||
- [`docs/plans/*.md`](plans/) — planos detalhados por item
|
||||
- Plano Cursor `núcleos_eleitorais_mvp_*.plan.md` (fora do repo; workspace local)
|
||||
- `plano-arquitetura-campanha-2026.md` e `design-ux-campanha.md` (Cowork / pasta irmã; fora do repo)
|
||||
- Res. TSE 23.760/2026 (calendário eleitoral) — https://www.tse.jus.br/legislacao/compilada/res/2026/resolucao-no-23-760-de-2-de-marco-de-2026
|
||||
- Res. TSE 23.610/2019 (propaganda; art. 33 — disparo em massa) e Lei 9.504/1997
|
||||
- Politipédia AVM — planejamento de campanha, base de dados, territorialização, operação do dia da eleição — https://politipedia.wiki.br
|
||||
124
eslint-local-rules.mjs
Normal file
124
eslint-local-rules.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
const isUppercaseName = (node) =>
|
||||
node.type === 'Identifier' && /^[A-Z][A-Za-z0-9]*$/.test(node.name)
|
||||
|
||||
const isJsx = (node) => node?.type === 'JSXElement' || node?.type === 'JSXFragment'
|
||||
|
||||
const noComponentDefaultExport = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Require ordinary React components to use named exports',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
defaultExport: 'React component files must not use default exports.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
ExportDefaultDeclaration(node) {
|
||||
context.report({ node, messageId: 'defaultExport' })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const frameworkDefaultExport = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Require Next framework components to use inline named function defaults',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
invalidDefault:
|
||||
'Next framework components must default-export an inline named function declaration.',
|
||||
missingDefault: 'Next framework components must have a default export.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
let hasDefaultExport = false
|
||||
|
||||
return {
|
||||
ExportDefaultDeclaration(node) {
|
||||
hasDefaultExport = true
|
||||
const declaration = node.declaration
|
||||
const isNamedFunction =
|
||||
declaration.type === 'FunctionDeclaration' && declaration.id !== null
|
||||
|
||||
if (!isNamedFunction) {
|
||||
context.report({ node, messageId: 'invalidDefault' })
|
||||
}
|
||||
},
|
||||
'Program:exit'(node) {
|
||||
if (!hasDefaultExport) {
|
||||
context.report({ node, messageId: 'missingDefault' })
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const componentArrowConventions = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Require const component arrows and implicit returns for pure JSX bodies',
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
messages: {
|
||||
useConst: 'Uppercase component arrow declarations must use const.',
|
||||
implicitReturn: 'Pure JSX component arrow bodies must use an implicit return.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode
|
||||
|
||||
return {
|
||||
VariableDeclarator(node) {
|
||||
if (!isUppercaseName(node.id) || node.init?.type !== 'ArrowFunctionExpression') {
|
||||
return
|
||||
}
|
||||
|
||||
const declaration = node.parent
|
||||
|
||||
if (declaration.type === 'VariableDeclaration' && declaration.kind !== 'const') {
|
||||
context.report({ node: node.id, messageId: 'useConst' })
|
||||
}
|
||||
|
||||
const body = node.init.body
|
||||
|
||||
if (
|
||||
body.type !== 'BlockStatement' ||
|
||||
body.body.length !== 1 ||
|
||||
body.body[0].type !== 'ReturnStatement' ||
|
||||
!isJsx(body.body[0].argument)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const returnedJsx = sourceCode.getText(body.body[0].argument)
|
||||
const hasComments = sourceCode.getCommentsInside(body).length > 0
|
||||
|
||||
context.report({
|
||||
node: body,
|
||||
messageId: 'implicitReturn',
|
||||
...(hasComments
|
||||
? {}
|
||||
: {
|
||||
fix: (fixer) => fixer.replaceText(body, `(${returnedJsx})`),
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const localRules = {
|
||||
rules: {
|
||||
'component-arrow-conventions': componentArrowConventions,
|
||||
'framework-default-export': frameworkDefaultExport,
|
||||
'no-component-default-export': noComponentDefaultExport,
|
||||
},
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import { fileURLToPath } from 'url'
|
|||
import { FlatCompat } from '@eslint/eslintrc'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import prettier from 'eslint-config-prettier/flat'
|
||||
import checkFile from 'eslint-plugin-check-file'
|
||||
import { localRules } from './eslint-local-rules.mjs'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
|
@ -11,8 +13,124 @@ const __dirname = dirname(__filename)
|
|||
// FlatCompat to use them from this flat config with ESLint 9.
|
||||
const compat = new FlatCompat({ baseDirectory: __dirname })
|
||||
|
||||
const frameworkComponentFiles = [
|
||||
'src/app/**/{page,layout,loading,error,not-found,template,default,global-error}.tsx',
|
||||
]
|
||||
|
||||
// These predate the naming convention. Keep them linted by Next's rules while
|
||||
// requiring new component files to use PascalCase.
|
||||
const legacyComponentFilenameIgnores = [
|
||||
'src/components/campaign/campaign-logo.tsx',
|
||||
'src/components/socialIcons.tsx',
|
||||
'src/components/socialLinks.tsx',
|
||||
'src/components/ui/breadcrumb.tsx',
|
||||
'src/components/ui/button.tsx',
|
||||
'src/components/ui/card.tsx',
|
||||
'src/components/ui/combobox.tsx',
|
||||
'src/components/ui/dialog.tsx',
|
||||
'src/components/ui/field.tsx',
|
||||
'src/components/ui/input-group.tsx',
|
||||
'src/components/ui/input.tsx',
|
||||
'src/components/ui/label.tsx',
|
||||
'src/components/ui/native-select.tsx',
|
||||
'src/components/ui/select.tsx',
|
||||
'src/components/ui/separator.tsx',
|
||||
'src/components/ui/skeleton.tsx',
|
||||
'src/components/ui/textarea.tsx',
|
||||
'src/components/ui/tooltip.tsx',
|
||||
]
|
||||
|
||||
const legacyFrameworkExportIgnores = [
|
||||
'src/app/(payload)/admin/\\[\\[...segments\\]\\]/not-found.tsx',
|
||||
'src/app/(payload)/admin/\\[\\[...segments\\]\\]/page.tsx',
|
||||
'src/app/(payload)/layout.tsx',
|
||||
]
|
||||
|
||||
const legacyComponentSyntaxIgnores = [
|
||||
'src/components/StateSelect.tsx',
|
||||
'src/components/ThemeProvider.tsx',
|
||||
]
|
||||
|
||||
const legacyCamelCaseFilenameIgnores = [
|
||||
'src/lib/schemas/campaign-login.ts',
|
||||
'src/lib/schemas/petition-form.ts',
|
||||
'src/lib/schemas/whatsapp-form.ts',
|
||||
'tests/int/petition-page-layout.int.spec.ts',
|
||||
]
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
{
|
||||
files: ['src/**/*.tsx'],
|
||||
ignores: [
|
||||
...frameworkComponentFiles,
|
||||
...legacyComponentFilenameIgnores,
|
||||
...legacyFrameworkExportIgnores,
|
||||
...legacyComponentSyntaxIgnores,
|
||||
],
|
||||
plugins: {
|
||||
'check-file': checkFile,
|
||||
local: localRules,
|
||||
},
|
||||
rules: {
|
||||
'check-file/filename-naming-convention': [
|
||||
'error',
|
||||
{
|
||||
'**/*.tsx': 'PASCAL_CASE',
|
||||
},
|
||||
],
|
||||
'local/component-arrow-conventions': 'error',
|
||||
'local/no-component-default-export': 'error',
|
||||
'react/function-component-definition': [
|
||||
'error',
|
||||
{
|
||||
namedComponents: 'arrow-function',
|
||||
unnamedComponents: 'arrow-function',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: frameworkComponentFiles,
|
||||
ignores: legacyFrameworkExportIgnores,
|
||||
plugins: {
|
||||
'check-file': checkFile,
|
||||
local: localRules,
|
||||
},
|
||||
rules: {
|
||||
'check-file/filename-naming-convention': [
|
||||
'error',
|
||||
{
|
||||
'**/*.tsx': '@(page|layout|loading|error|not-found|template|default|global-error)',
|
||||
},
|
||||
],
|
||||
'local/framework-default-export': 'error',
|
||||
'react/function-component-definition': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/components/**/*.{js,ts}',
|
||||
'src/lib/**/*.{js,ts}',
|
||||
'src/utilities/**/*.{js,ts}',
|
||||
'tests/**/*.{js,jsx,ts,tsx}',
|
||||
],
|
||||
ignores: legacyCamelCaseFilenameIgnores,
|
||||
plugins: {
|
||||
'check-file': checkFile,
|
||||
},
|
||||
rules: {
|
||||
'check-file/filename-naming-convention': [
|
||||
'error',
|
||||
{
|
||||
'**/*.{js,jsx,ts,tsx}': 'CAMEL_CASE',
|
||||
},
|
||||
{
|
||||
ignoreMiddleExtensions: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { withPayload } from '@payloadcms/next/withPayload'
|
|||
const allowedImageHost = process.env.NEXT_PUBLIC_SITE_URL || 'https://jorgesolla.com.br'
|
||||
|
||||
const nextConfig = {
|
||||
distDir: process.env.NEXT_DIST_DIR || '.next',
|
||||
images: {
|
||||
remotePatterns: [new URL(`${allowedImageHost}/**`)],
|
||||
},
|
||||
|
|
|
|||
26
package.json
26
package.json
|
|
@ -21,25 +21,27 @@
|
|||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_OPTIONS=--no-deprecation payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
|
||||
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
|
||||
"db:start": "docker compose up -d postgres",
|
||||
"db:stop": "docker compose down",
|
||||
"db:pull": "node scripts/db-pull.mjs",
|
||||
"db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs",
|
||||
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
|
||||
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
|
||||
"migrate": "cross-env NODE_OPTIONS=--no-deprecation payload migrate",
|
||||
"migrate:create": "cross-env NODE_OPTIONS=--no-deprecation payload migrate:create",
|
||||
"migrate:status": "cross-env NODE_OPTIONS=--no-deprecation payload migrate:status",
|
||||
"generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap",
|
||||
"generate:types": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:types",
|
||||
"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/",
|
||||
"lint:fix": "cross-env NODE_OPTIONS=--no-deprecation eslint --fix",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"payload": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
||||
"test": "pnpm run test:int && pnpm run test:e2e",
|
||||
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" playwright test --config=playwright.config.ts",
|
||||
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
|
||||
"test": "pnpm run test:unit && pnpm run test:int",
|
||||
"test:all": "pnpm run test && pnpm run test:e2e",
|
||||
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server --import=tsx/esm\" playwright test --config=playwright.config.ts",
|
||||
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts",
|
||||
"test:unit": "cross-env NODE_OPTIONS=--no-deprecation DATABASE_URL=postgresql://invalid:invalid@127.0.0.1:1/unit_tests_must_not_connect vitest run --config ./vitest.unit.config.mts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
|
|
@ -58,6 +60,7 @@
|
|||
"@vercel/speed-insights": "^1.3.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
"graphql": "^16.8.1",
|
||||
|
|
@ -69,8 +72,10 @@
|
|||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.0.3",
|
||||
"sharp": "0.34.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
|
@ -89,6 +94,7 @@
|
|||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "15.4.11",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-check-file": "^3.3.1",
|
||||
"jsdom": "28.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.4.2",
|
||||
|
|
|
|||
|
|
@ -10,31 +10,58 @@ import { assertTestDatabase } from './tests/helpers/assertTestDatabase'
|
|||
*/
|
||||
loadEnv({ path: '.env.test', override: true })
|
||||
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')
|
||||
const workersOverride = process.env.PLAYWRIGHT_WORKERS
|
||||
const workers = workersOverride === undefined ? 2 : Number(workersOverride)
|
||||
if (!Number.isInteger(workers) || workers < 2) {
|
||||
throw new Error('PLAYWRIGHT_WORKERS must be an integer of at least 2.')
|
||||
}
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
workers,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
baseURL,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
name: 'setup',
|
||||
testMatch: /setup\.e2e\.spec\.ts/,
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'campaign',
|
||||
testMatch: /campaign.*\.e2e\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'frontend',
|
||||
testMatch: /frontend\.e2e\.spec\.ts/,
|
||||
dependencies: ['campaign'],
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'admin',
|
||||
testMatch: /admin\.e2e\.spec\.ts/,
|
||||
dependencies: ['frontend'],
|
||||
fullyParallel: false,
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
],
|
||||
|
|
@ -45,12 +72,16 @@ export default defineConfig({
|
|||
* production database — always start a fresh one bound to the test DB.
|
||||
*/
|
||||
reuseExistingServer: false,
|
||||
url: 'http://localhost:3000',
|
||||
url: baseURL,
|
||||
/* Force the isolated test database into the server process. */
|
||||
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}`,
|
||||
PAYLOAD_SECRET: process.env.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production',
|
||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000',
|
||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL ?? baseURL,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ importers:
|
|||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
cross-env:
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3
|
||||
|
|
@ -89,12 +92,18 @@ importers:
|
|||
react-hook-form:
|
||||
specifier: ^7.71.1
|
||||
version: 7.71.1(react@19.2.4)
|
||||
server-only:
|
||||
specifier: ^0.0.1
|
||||
version: 0.0.1
|
||||
shadcn:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3(@types/node@22.19.9)(babel-plugin-macros@3.1.0)(typescript@5.7.3)
|
||||
sharp:
|
||||
specifier: 0.34.2
|
||||
version: 0.34.2
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
tailwind-merge:
|
||||
specifier: ^3.4.1
|
||||
version: 3.4.1
|
||||
|
|
@ -144,6 +153,9 @@ importers:
|
|||
eslint-config-prettier:
|
||||
specifier: ^10.1.8
|
||||
version: 10.1.8(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-check-file:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1(eslint@9.39.2(jiti@2.6.1))
|
||||
jsdom:
|
||||
specifier: 28.0.0
|
||||
version: 28.0.0(@noble/hashes@1.8.0)
|
||||
|
|
@ -3362,6 +3374,12 @@ packages:
|
|||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cmdk@1.1.1:
|
||||
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
code-block-writer@13.0.3:
|
||||
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
|
||||
|
||||
|
|
@ -3883,6 +3901,12 @@ packages:
|
|||
eslint-import-resolver-webpack:
|
||||
optional: true
|
||||
|
||||
eslint-plugin-check-file@3.3.1:
|
||||
resolution: {integrity: sha512-b7fDkp8Y0T9vloTYSrkMnb5Cqpk1bc6+jDQdgKIhzdwb47NmLAvMFYo3SzHOPF8u4SEsOVhmrNSbo6Sp633syg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
eslint: '>=9.0.0'
|
||||
|
||||
eslint-plugin-import@2.32.0:
|
||||
resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -5669,6 +5693,9 @@ packages:
|
|||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -5748,6 +5775,12 @@ packages:
|
|||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -9603,6 +9636,18 @@ snapshots:
|
|||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
code-block-writer@13.0.3: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
|
|
@ -10135,6 +10180,12 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-check-file@3.3.1(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
is-glob: 4.0.3
|
||||
micromatch: 4.0.8
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
|
|
@ -12263,6 +12314,8 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
|
|
@ -12448,6 +12501,11 @@ snapshots:
|
|||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
|
|
|
|||
29
src/app/(campaign)/campanha/(app)/error.tsx
Normal file
29
src/app/(campaign)/campanha/(app)/error.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
'use client'
|
||||
|
||||
import { AlertTriangleIcon, RotateCcwIcon } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function CampaignDashboardError({ reset }: { reset: () => void }) {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-72 w-full max-w-2xl items-center">
|
||||
<Alert variant="destructive" aria-live="assertive">
|
||||
<AlertTriangleIcon aria-hidden="true" />
|
||||
<AlertTitle>Não foi possível carregar o painel</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-4">
|
||||
<p>Verifique sua conexão e tente novamente. Seus dados não foram alterados.</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 w-fit"
|
||||
onClick={() => reset()}
|
||||
>
|
||||
<RotateCcwIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
src/app/(campaign)/campanha/(app)/layout.tsx
Normal file
44
src/app/(campaign)/campanha/(app)/layout.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
import React from 'react'
|
||||
|
||||
import { CampaignBottomNav } from '@/components/campaign/CampaignBottomNav'
|
||||
import { CampaignSidebar } from '@/components/campaign/CampaignSidebar'
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/Sidebar'
|
||||
import { Toaster } from '@/components/ui/Toaster'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
|
||||
export default async function CampaignAppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await getCampaignUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/campanha/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider className="h-svh min-h-0 overflow-hidden">
|
||||
<CampaignSidebar user={{ name: user.name, email: user.email, role: user.role }} />
|
||||
<SidebarInset className="h-svh min-h-0 overflow-hidden">
|
||||
<header className="flex min-h-14 shrink-0 items-center gap-3 bg-primary px-4 text-primary-foreground md:hidden">
|
||||
<SidebarTrigger
|
||||
label="Abrir ou fechar menu da campanha"
|
||||
className="text-primary-foreground"
|
||||
/>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<span className="block truncate text-sm font-semibold">Jorge Solla</span>
|
||||
<span className="block truncate text-xs text-primary-foreground/80">
|
||||
Campanha · Bahia
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
data-slot="campaign-content-scroll"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-24 md:p-6"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<CampaignBottomNav role={user.role} />
|
||||
<Toaster position="top-center" />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
27
src/app/(campaign)/campanha/(app)/loading.tsx
Normal file
27
src/app/(campaign)/campanha/(app)/loading.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function CampaignDashboardLoading() {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-screen-2xl flex-col gap-6"
|
||||
aria-label="Carregando painel da campanha"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-6 w-80 max-w-full" />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-28" />
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-64" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
'use server'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
import { assignNucleusCoordinators } from '@/app/(campaign)/campanha/actions/coordinatorAssignment'
|
||||
import {
|
||||
FormDataBoundaryError,
|
||||
repeatedRelationshipFormValues,
|
||||
validationFieldErrors,
|
||||
} from '@/lib/formData'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions'
|
||||
|
||||
export type CoordinatorAssignmentFormState = {
|
||||
status?: 'success'
|
||||
message?: string
|
||||
fieldErrors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export type CoordinatorAssignmentOptionsResult = {
|
||||
expectedUpdatedAt: string
|
||||
options: Array<{ id: number; name: string; isCurrent: boolean }>
|
||||
}
|
||||
|
||||
const knownMessages = new Set([
|
||||
'Somente a coordenação geral pode alterar coordenadores.',
|
||||
'Núcleo não encontrado ou sem acesso.',
|
||||
'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.',
|
||||
'Uma ou mais pessoas selecionadas não são mais elegíveis para coordenação.',
|
||||
])
|
||||
|
||||
const errorState = (error: unknown): CoordinatorAssignmentFormState => {
|
||||
if (error instanceof FormDataBoundaryError) {
|
||||
return { fieldErrors: { [error.field]: [error.message] } }
|
||||
}
|
||||
if (error instanceof ZodError) return { fieldErrors: validationFieldErrors(error) }
|
||||
if (error instanceof Error && knownMessages.has(error.message)) {
|
||||
return { message: error.message }
|
||||
}
|
||||
return { message: 'Não foi possível salvar a coordenação. Atualize a página e tente novamente.' }
|
||||
}
|
||||
|
||||
export const assignNucleusCoordinatorsFormAction = async (
|
||||
slug: string,
|
||||
_state: CoordinatorAssignmentFormState,
|
||||
formData: FormData,
|
||||
): Promise<CoordinatorAssignmentFormState> => {
|
||||
try {
|
||||
const expectedUpdatedAt = formData.get('expectedUpdatedAt')
|
||||
if (typeof expectedUpdatedAt !== 'string' || !expectedUpdatedAt) {
|
||||
return { message: 'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.' }
|
||||
}
|
||||
await assignNucleusCoordinators({
|
||||
slug,
|
||||
expectedUpdatedAt,
|
||||
coordinatorIds: repeatedRelationshipFormValues(formData, 'coordinators'),
|
||||
})
|
||||
revalidatePath(`/campanha/nucleos/${slug}`)
|
||||
revalidatePath('/campanha')
|
||||
return { status: 'success', message: 'Coordenação atualizada.' }
|
||||
} catch (error) {
|
||||
return errorState(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const loadCoordinatorAssignmentOptions = async (
|
||||
slug: string,
|
||||
): Promise<CoordinatorAssignmentOptionsResult> => {
|
||||
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
|
||||
if (!user || user.role !== 'geral') {
|
||||
throw new Error('Somente a coordenação geral pode alterar coordenadores.')
|
||||
}
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'electoralNucleus',
|
||||
where: { slug: { equals: slug } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
select: { updatedAt: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const nucleus = result.docs[0]
|
||||
if (!nucleus) throw new Error('Núcleo não encontrado ou sem acesso.')
|
||||
|
||||
return {
|
||||
expectedUpdatedAt: nucleus.updatedAt,
|
||||
options: await getEligibleNucleusCoordinatorOptions(payload, user),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
import { updateNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { NucleusForm } from '@/components/campaign/NucleusForm'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { getNucleusEditPageData } from '@/utilities/nucleusPageData'
|
||||
|
||||
type EditNucleusPageProps = {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
export default async function EditNucleusPage({ params }: EditNucleusPageProps) {
|
||||
const [{ slug }, user, payload] = await Promise.all([
|
||||
params,
|
||||
getCampaignUser(),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
if (!user) redirect('/campanha/login')
|
||||
if (user.role === 'lideranca') redirect('/campanha/nucleos')
|
||||
|
||||
if (!slug) notFound()
|
||||
const view = await getNucleusEditPageData(payload, user, slug).catch(() => notFound())
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<CampaignScopeBadge>
|
||||
{user.role === 'geral'
|
||||
? 'Edição · Coordenação geral'
|
||||
: 'Edição · Núcleo sob sua coordenação'}
|
||||
</CampaignScopeBadge>
|
||||
<p className="text-sm font-medium text-primary">Núcleos Eleitorais</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Editar {view.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Atualize o território sem alterar o nome ou as estimativas confirmadas.
|
||||
</p>
|
||||
</header>
|
||||
<NucleusForm
|
||||
action={updateNucleusFormAction}
|
||||
nucleus={view}
|
||||
submitLabel="Salvar alterações"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
import {
|
||||
createLeadership,
|
||||
updateLeadershipInternal,
|
||||
} from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus'
|
||||
import { leadershipInternalUpdateSchema } from '@/lib/schemas/leadership'
|
||||
import {
|
||||
FormDataBoundaryError,
|
||||
nullableFormText,
|
||||
optionalFormText,
|
||||
requiredRelationshipFormValue,
|
||||
validationFieldErrors,
|
||||
} from '@/lib/formData'
|
||||
|
||||
export type LeadershipFormState = {
|
||||
status?: 'success'
|
||||
message?: string
|
||||
fieldErrors?: Record<string, string[]>
|
||||
leadershipId?: number
|
||||
contactReused?: boolean
|
||||
values?: LeadershipFormValues
|
||||
revision?: number
|
||||
}
|
||||
|
||||
export type LeadershipFormValues = {
|
||||
name?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
gender?: string
|
||||
sector?: string
|
||||
sectorNotes?: string
|
||||
supportStatus?: string
|
||||
notes?: string
|
||||
consentNote?: string
|
||||
}
|
||||
|
||||
const safeActionMessages = [
|
||||
'Esta pessoa já está cadastrada como liderança neste núcleo.',
|
||||
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.',
|
||||
'Escolha outro contato principal antes de alterar o status desta liderança.',
|
||||
'Somente a coordenação pode gerenciar lideranças.',
|
||||
] as const
|
||||
|
||||
const getLeadershipFormError = (
|
||||
error: unknown,
|
||||
values?: LeadershipFormValues,
|
||||
revision?: number,
|
||||
): LeadershipFormState => {
|
||||
if (error instanceof FormDataBoundaryError) {
|
||||
const message =
|
||||
error.field === 'contact'
|
||||
? 'Não foi possível identificar a liderança. Atualize a página e tente novamente.'
|
||||
: error.field === 'id' || error.field === 'nucleus'
|
||||
? 'Não foi possível identificar o registro. Atualize a página e tente novamente.'
|
||||
: error.message
|
||||
return { fieldErrors: { [error.field]: [message] }, values, revision }
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return { fieldErrors: validationFieldErrors(error), values, revision }
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
safeActionMessages.includes(error.message as (typeof safeActionMessages)[number])
|
||||
) {
|
||||
return { message: error.message, values, revision }
|
||||
}
|
||||
return {
|
||||
message: 'Não foi possível salvar a liderança. Verifique os dados e tente novamente.',
|
||||
values,
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
export const createLeadershipFormAction = async (
|
||||
state: LeadershipFormState,
|
||||
formData: FormData,
|
||||
): Promise<LeadershipFormState> => {
|
||||
let values: LeadershipFormValues | undefined
|
||||
const revision = (state.revision ?? 0) + 1
|
||||
|
||||
try {
|
||||
values = {
|
||||
name: optionalFormText(formData, 'name'),
|
||||
phone: optionalFormText(formData, 'phone'),
|
||||
email: optionalFormText(formData, 'email'),
|
||||
gender: optionalFormText(formData, 'gender'),
|
||||
sector: optionalFormText(formData, 'sector'),
|
||||
sectorNotes: optionalFormText(formData, 'sectorNotes'),
|
||||
supportStatus: optionalFormText(formData, 'supportStatus'),
|
||||
notes: optionalFormText(formData, 'notes'),
|
||||
consentNote: optionalFormText(formData, 'consentNote'),
|
||||
}
|
||||
const input = {
|
||||
nucleus: requiredRelationshipFormValue(formData, 'nucleus'),
|
||||
...values,
|
||||
name: values.name ?? '',
|
||||
phone: values.phone ?? '',
|
||||
}
|
||||
const leadership = await createLeadership(input)
|
||||
revalidatePath('/campanha/nucleos/[slug]', 'page')
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
message: leadership.contactReused
|
||||
? 'Liderança cadastrada. O contato existente com este celular foi reutilizado.'
|
||||
: 'Liderança cadastrada com sucesso.',
|
||||
leadershipId: leadership.id,
|
||||
contactReused: leadership.contactReused,
|
||||
}
|
||||
} catch (error) {
|
||||
return getLeadershipFormError(error, values, revision)
|
||||
}
|
||||
}
|
||||
|
||||
export const updateLeadershipFormAction = async (
|
||||
_state: LeadershipFormState,
|
||||
formData: FormData,
|
||||
): Promise<LeadershipFormState> => {
|
||||
try {
|
||||
const input = leadershipInternalUpdateSchema.parse({
|
||||
id: requiredRelationshipFormValue(formData, 'id'),
|
||||
sector: nullableFormText(formData, 'sector'),
|
||||
sectorNotes: nullableFormText(formData, 'sectorNotes'),
|
||||
supportStatus: optionalFormText(formData, 'supportStatus'),
|
||||
notes: nullableFormText(formData, 'notes'),
|
||||
consentNote: nullableFormText(formData, 'consentNote'),
|
||||
})
|
||||
const leadership = await updateLeadershipInternal(input)
|
||||
revalidatePath('/campanha/nucleos/[slug]', 'page')
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Liderança atualizada com sucesso.',
|
||||
leadershipId: leadership.id,
|
||||
}
|
||||
} catch (error) {
|
||||
return getLeadershipFormError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const setPrimaryContactFormAction = async (
|
||||
_state: LeadershipFormState,
|
||||
formData: FormData,
|
||||
): Promise<LeadershipFormState> => {
|
||||
try {
|
||||
await updateNucleus({
|
||||
id: requiredRelationshipFormValue(formData, 'nucleus'),
|
||||
primaryContact: requiredRelationshipFormValue(formData, 'contact'),
|
||||
})
|
||||
revalidatePath('/campanha/nucleos/[slug]', 'page')
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Contato principal atualizado.',
|
||||
}
|
||||
} catch (error) {
|
||||
return getLeadershipFormError(error)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import Link from 'next/link'
|
||||
import { MapPinOffIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
|
||||
export default function NucleusNotFound() {
|
||||
return (
|
||||
<Empty className="mx-auto min-h-80 max-w-2xl border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<MapPinOffIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Núcleo não encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ele pode não existir ou estar fora do seu escopo de acesso.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/nucleos">Voltar aos núcleos</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
import { updateNucleus } from '@/app/(campaign)/campanha/actions/nucleus'
|
||||
import { FormDataBoundaryError, validationFieldErrors } from '@/lib/formData'
|
||||
import { parseNucleusIntelligenceFormData } from '@/utilities/nucleusIntelligenceUi'
|
||||
|
||||
export type NucleusIntelligenceFormState = {
|
||||
status?: 'success'
|
||||
message?: string
|
||||
fieldErrors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export const updateNucleusIntelligenceFormAction = async (
|
||||
_state: NucleusIntelligenceFormState,
|
||||
formData: FormData,
|
||||
): Promise<NucleusIntelligenceFormState> => {
|
||||
try {
|
||||
const input = parseNucleusIntelligenceFormData(formData)
|
||||
await updateNucleus(input)
|
||||
revalidatePath('/campanha/nucleos/[slug]', 'page')
|
||||
return { status: 'success', message: 'Inteligência do núcleo atualizada.' }
|
||||
} catch (error) {
|
||||
if (error instanceof FormDataBoundaryError) {
|
||||
return { fieldErrors: { [error.field]: [error.message] } }
|
||||
}
|
||||
if (error instanceof ZodError) return { fieldErrors: validationFieldErrors(error) }
|
||||
return {
|
||||
message: 'Não foi possível atualizar a inteligência. Verifique os dados e tente novamente.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
'use server'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { getPayload, type Payload } from 'payload'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
import { createNucleusUpdateRecord } from '@/app/(campaign)/campanha/actions/nucleusUpdate'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { FormDataBoundaryError, validationFieldErrors } from '@/lib/formData'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { parseNucleusUpdateFormData } from '@/utilities/nucleusUpdateUi'
|
||||
import { requireRelationshipId } from '@/utilities/relationship'
|
||||
|
||||
export type NucleusUpdateFormState = {
|
||||
status?: 'success'
|
||||
message?: string
|
||||
fieldErrors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export const createNucleusUpdateFormRecordAction = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
formData: FormData,
|
||||
): Promise<NucleusUpdateFormState & { nucleusId?: number }> => {
|
||||
try {
|
||||
const input = parseNucleusUpdateFormData(formData)
|
||||
const created = await createNucleusUpdateRecord(payload, actor, input)
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Atualização enviada com sucesso.',
|
||||
nucleusId: requireRelationshipId(created.nucleus),
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof FormDataBoundaryError) {
|
||||
return { fieldErrors: { [error.field]: [error.message] } }
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return { fieldErrors: validationFieldErrors(error) }
|
||||
}
|
||||
return {
|
||||
message: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const createNucleusUpdateFormAction = async (
|
||||
_state: NucleusUpdateFormState,
|
||||
formData: FormData,
|
||||
): Promise<NucleusUpdateFormState> => {
|
||||
const [payload, actor] = await Promise.all([getPayload({ config }), getCampaignUser()])
|
||||
if (!actor) {
|
||||
return {
|
||||
message: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
|
||||
}
|
||||
}
|
||||
|
||||
const { nucleusId, ...state } = await createNucleusUpdateFormRecordAction(
|
||||
payload,
|
||||
actor,
|
||||
formData,
|
||||
)
|
||||
if (state.status === 'success' && nucleusId) {
|
||||
revalidatePath('/campanha')
|
||||
revalidatePath('/campanha/nucleos/[slug]', 'page')
|
||||
}
|
||||
return state
|
||||
}
|
||||
213
src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx
Normal file
213
src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import config from '@payload-config'
|
||||
import { PencilIcon, PlusIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
import {
|
||||
assignNucleusCoordinatorsFormAction,
|
||||
loadCoordinatorAssignmentOptions,
|
||||
} from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions'
|
||||
import { confirmVoteEstimateFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions'
|
||||
import { ArchiveNucleusDialog } from '@/components/campaign/ArchiveNucleusDialog'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { CoordinatorAssignmentCard } from '@/components/campaign/CoordinatorAssignmentCard'
|
||||
import { CoordinatorAssignmentDialog } from '@/components/campaign/CoordinatorAssignmentDialog'
|
||||
import { NucleusActiveTab, NucleusActiveTabLoading } from '@/components/campaign/NucleusActiveTab'
|
||||
import { NucleusTabNav } from '@/components/campaign/NucleusTabNav'
|
||||
import { TseZoneBadge } from '@/components/campaign/TseZoneBadge'
|
||||
import { VoteEstimateCard } from '@/components/campaign/VoteEstimateCard'
|
||||
import { VoteEstimateDialogShell } from '@/components/campaign/VoteEstimateDialogShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import {
|
||||
buildLeadershipPanelHref,
|
||||
nucleusDetailFocusFallbackId,
|
||||
parseLeadershipFilterState,
|
||||
} from '@/utilities/leadershipUi'
|
||||
import { loadNucleusDetailPageData } from '@/utilities/nucleusDetailPageData'
|
||||
import {
|
||||
getNucleusDetailTabRedirect,
|
||||
resolveNucleusDetailTab,
|
||||
} from '@/utilities/nucleusDetailTabUi'
|
||||
import { NucleusNotFoundError } from '@/utilities/nucleusPageData'
|
||||
import { organizationKindLabels } from '@/utilities/nucleusUi'
|
||||
|
||||
type NucleusDetailPageProps = {
|
||||
params: Promise<{ slug: string }>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
const detailScopeLabels = {
|
||||
geral: 'Visível para você · Coordenação geral',
|
||||
coordenador: 'Visível para você · Coordenador',
|
||||
lideranca: 'Visível para você · Liderança',
|
||||
} as const
|
||||
|
||||
export default async function NucleusDetailPage({ params, searchParams }: NucleusDetailPageProps) {
|
||||
const [{ slug }, query, user, payload] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
getCampaignUser(),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
if (!user) redirect('/campanha/login')
|
||||
|
||||
if (!slug) notFound()
|
||||
const nucleusKind = user.role === 'lideranca' ? 'leadership' : 'staff'
|
||||
const activeTab = resolveNucleusDetailTab(query, nucleusKind)
|
||||
const { context, view, coordinatorAssignment } = await loadNucleusDetailPageData(
|
||||
payload,
|
||||
user,
|
||||
slug,
|
||||
activeTab,
|
||||
).catch((error) => {
|
||||
if (error instanceof NucleusNotFoundError) notFound()
|
||||
throw error
|
||||
})
|
||||
const canonicalTabRedirect = getNucleusDetailTabRedirect(view.slug, query, view.kind)
|
||||
if (canonicalTabRedirect) redirect(canonicalTabRedirect)
|
||||
|
||||
const leadershipFilters = parseLeadershipFilterState(query)
|
||||
const canEdit = user.role === 'geral' || user.role === 'coordenador'
|
||||
const isStaffView = view.kind === 'staff'
|
||||
const confirmVoteEstimateAction = confirmVoteEstimateFormAction.bind(
|
||||
null,
|
||||
isStaffView ? view.proposedVoteEstimateVersion : null,
|
||||
)
|
||||
const territory = [view.neighborhood, view.locality, view.city, view.region]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
const voteEstimateFocusTargetId = `vote-estimate-${view.id}-focus-target`
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-4">
|
||||
<CampaignScopeBadge>{detailScopeLabels[user.role]}</CampaignScopeBadge>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1
|
||||
id={nucleusDetailFocusFallbackId}
|
||||
tabIndex={-1}
|
||||
className="text-2xl font-bold tracking-tight focus-visible:outline-none"
|
||||
>
|
||||
{view.name}
|
||||
</h1>
|
||||
{view.status === 'arquivado' ? <Badge variant="secondary">Arquivado</Badge> : null}
|
||||
</div>
|
||||
<span className="block text-muted-foreground">
|
||||
{view.organizationLabel ?? organizationKindLabels[view.organizationKind]}
|
||||
{' · '}
|
||||
{territory}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{view.tseZones.length ? (
|
||||
view.tseZones.map((zoneNumber) => (
|
||||
<TseZoneBadge key={zoneNumber} zoneNumber={zoneNumber} />
|
||||
))
|
||||
) : (
|
||||
<Badge variant="tse">Sem Zona TSE</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canEdit ? (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={`/campanha/nucleos/${view.slug}/editar`}>
|
||||
<PencilIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Editar
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{user.role !== 'lideranca' ? (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link
|
||||
href={buildLeadershipPanelHref(view.slug, leadershipFilters, { mode: 'create' })}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova liderança
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={`/campanha/nucleos/${view.slug}?tab=updates&newUpdate=1`}>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova atualização
|
||||
</Link>
|
||||
</Button>
|
||||
{user.role === 'geral' && view.status === 'ativo' ? (
|
||||
<ArchiveNucleusDialog nucleusId={view.id} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section
|
||||
aria-label="Resumo operacional"
|
||||
className="grid min-w-0 grid-cols-1 items-stretch gap-4 lg:grid-cols-2 *:min-w-0"
|
||||
>
|
||||
<CoordinatorAssignmentCard coordinators={coordinatorAssignment.coordinators}>
|
||||
{coordinatorAssignment.canManage ? (
|
||||
<CoordinatorAssignmentDialog
|
||||
coordinators={coordinatorAssignment.coordinators}
|
||||
action={assignNucleusCoordinatorsFormAction.bind(null, view.slug)}
|
||||
loadOptions={loadCoordinatorAssignmentOptions.bind(null, view.slug)}
|
||||
initialOpen={query.assignCoordinators === '1'}
|
||||
/>
|
||||
) : null}
|
||||
</CoordinatorAssignmentCard>
|
||||
|
||||
<VoteEstimateCard
|
||||
focusTargetId={voteEstimateFocusTargetId}
|
||||
confirmedEstimate={view.confirmedVoteEstimate}
|
||||
confirmedBy={isStaffView ? (view.confirmedVoteEstimateBy ?? undefined) : undefined}
|
||||
confirmedAt={
|
||||
isStaffView && view.confirmedVoteEstimateAt
|
||||
? new Intl.DateTimeFormat('pt-BR').format(new Date(view.confirmedVoteEstimateAt))
|
||||
: undefined
|
||||
}
|
||||
proposedEstimate={isStaffView ? view.proposedVoteEstimate : null}
|
||||
proposedBy={isStaffView ? (view.proposedVoteEstimateBy ?? undefined) : undefined}
|
||||
proposedAt={
|
||||
isStaffView && view.proposedVoteEstimateAt
|
||||
? new Intl.DateTimeFormat('pt-BR').format(new Date(view.proposedVoteEstimateAt))
|
||||
: undefined
|
||||
}
|
||||
action={
|
||||
<VoteEstimateDialogShell
|
||||
key={`${view.confirmedVoteEstimate ?? 'none'}-${isStaffView ? (view.proposedVoteEstimateVersion ?? 'none') : 'private'}`}
|
||||
nucleusId={view.id}
|
||||
role={user.role}
|
||||
confirmedEstimate={view.confirmedVoteEstimate}
|
||||
confirmedEstimateRevision={isStaffView ? view.confirmedVoteEstimateAt : null}
|
||||
fallbackFocusId={voteEstimateFocusTargetId}
|
||||
proposedEstimate={isStaffView ? view.proposedVoteEstimate : null}
|
||||
confirmAction={confirmVoteEstimateAction}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<NucleusTabNav
|
||||
activeTab={activeTab}
|
||||
nucleusKind={view.kind}
|
||||
nucleusSlug={view.slug}
|
||||
searchParams={query}
|
||||
/>
|
||||
<Suspense fallback={<NucleusActiveTabLoading />}>
|
||||
<NucleusActiveTab
|
||||
activeTab={activeTab}
|
||||
context={context}
|
||||
payload={payload}
|
||||
searchParams={query}
|
||||
user={user}
|
||||
view={view}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
'use server'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { resolveAccessibleNucleusContext } from '@/utilities/nucleusPageData'
|
||||
import {
|
||||
searchNucleusPrimaryContactOptions,
|
||||
type NucleusPrimaryContactPageData,
|
||||
} from '@/utilities/primaryContactPageData'
|
||||
|
||||
export const searchPrimaryContactOptionsFormAction = async (
|
||||
nucleusSlug: string,
|
||||
query: string,
|
||||
): Promise<NucleusPrimaryContactPageData> => {
|
||||
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
|
||||
if (!user || user.role === 'lideranca') {
|
||||
throw new Error('Somente a coordenação pode buscar contatos principais.')
|
||||
}
|
||||
|
||||
const context = await resolveAccessibleNucleusContext(payload, user, nucleusSlug)
|
||||
return searchNucleusPrimaryContactOptions(
|
||||
payload,
|
||||
user,
|
||||
context,
|
||||
typeof query === 'string' ? query : '',
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
import {
|
||||
confirmVoteEstimate,
|
||||
suggestVoteEstimate,
|
||||
} from '@/app/(campaign)/campanha/actions/voteEstimate'
|
||||
import {
|
||||
FormDataBoundaryError,
|
||||
optionalFormText,
|
||||
requiredIntegerFormValue,
|
||||
requiredRelationshipFormValue,
|
||||
validationFieldErrors,
|
||||
} from '@/lib/formData'
|
||||
|
||||
export type VoteEstimateFormState = {
|
||||
status?: 'success'
|
||||
message?: string
|
||||
fieldErrors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
const errorState = (error: unknown): VoteEstimateFormState => {
|
||||
if (error instanceof FormDataBoundaryError) {
|
||||
return { fieldErrors: { [error.field]: [error.message] } }
|
||||
}
|
||||
if (error instanceof ZodError) return { fieldErrors: validationFieldErrors(error) }
|
||||
if (
|
||||
error instanceof Error &&
|
||||
[
|
||||
'A liderança precisa de vínculo engajado com este núcleo.',
|
||||
'Somente a coordenação pode confirmar estimativas.',
|
||||
'A sugestão foi alterada. Atualize a página antes de confirmar.',
|
||||
'Informe uma justificativa para ajustar a estimativa.',
|
||||
].includes(error.message)
|
||||
) {
|
||||
return { message: error.message }
|
||||
}
|
||||
return { message: 'Não foi possível salvar a estimativa. Tente novamente.' }
|
||||
}
|
||||
|
||||
export const suggestVoteEstimateFormAction = async (
|
||||
_state: VoteEstimateFormState,
|
||||
formData: FormData,
|
||||
): Promise<VoteEstimateFormState> => {
|
||||
try {
|
||||
const updated = await suggestVoteEstimate({
|
||||
nucleus: requiredRelationshipFormValue(formData, 'nucleus'),
|
||||
estimate: requiredIntegerFormValue(formData, 'estimate', {
|
||||
maximum: 100_000_000,
|
||||
}),
|
||||
})
|
||||
revalidatePath(`/campanha/nucleos/${updated.slug}`)
|
||||
return { status: 'success', message: 'Sugestão enviada para revisão.' }
|
||||
} catch (error) {
|
||||
return errorState(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const confirmVoteEstimateFormAction = async (
|
||||
expectedProposedVoteEstimateVersion: string | null,
|
||||
_state: VoteEstimateFormState,
|
||||
formData: FormData,
|
||||
): Promise<VoteEstimateFormState> => {
|
||||
try {
|
||||
const updated = await confirmVoteEstimate({
|
||||
nucleus: requiredRelationshipFormValue(formData, 'nucleus'),
|
||||
estimate: requiredIntegerFormValue(formData, 'estimate', {
|
||||
maximum: 100_000_000,
|
||||
}),
|
||||
expectedProposedVoteEstimateVersion,
|
||||
confirmationNote: optionalFormText(formData, 'confirmationNote'),
|
||||
})
|
||||
revalidatePath(`/campanha/nucleos/${updated.slug}`)
|
||||
return { status: 'success', message: 'Estimativa confirmada.' }
|
||||
} catch (error) {
|
||||
return errorState(error)
|
||||
}
|
||||
}
|
||||
121
src/app/(campaign)/campanha/(app)/nucleos/formActions.ts
Normal file
121
src/app/(campaign)/campanha/(app)/nucleos/formActions.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
'use server'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
import {
|
||||
archiveNucleus,
|
||||
createNucleus,
|
||||
updateNucleus,
|
||||
} from '@/app/(campaign)/campanha/actions/nucleus'
|
||||
import type { NucleusFormState } from '@/components/campaign/NucleusForm'
|
||||
import {
|
||||
FormDataBoundaryError,
|
||||
optionalFormText,
|
||||
requiredRelationshipFormValue,
|
||||
validationFieldErrors,
|
||||
} from '@/lib/formData'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import {
|
||||
parseNucleusCreateFormData,
|
||||
parseNucleusUpdateFormData,
|
||||
} from '@/utilities/nucleusFormData'
|
||||
import { slugify } from '@/utilities/slug'
|
||||
|
||||
const getActionError = (error: unknown): NucleusFormState => {
|
||||
if (error instanceof FormDataBoundaryError) {
|
||||
return { fieldErrors: { [error.field]: [error.message] } }
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return { fieldErrors: validationFieldErrors(error) }
|
||||
}
|
||||
|
||||
return { message: 'Não foi possível concluir a ação. Atualize a página e tente novamente.' }
|
||||
}
|
||||
|
||||
const existingNucleusState = async (formData: FormData): Promise<NucleusFormState | null> => {
|
||||
const name = optionalFormText(formData, 'name')
|
||||
const slug = name ? slugify(name) : ''
|
||||
if (!slug) return null
|
||||
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
|
||||
if (!user || user.role !== 'geral') return null
|
||||
const result = await payload.find({
|
||||
collection: 'electoralNucleus',
|
||||
where: { slug: { equals: slug } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: { slug: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const existing = result.docs[0]
|
||||
return existing
|
||||
? {
|
||||
fieldErrors: { name: ['Já existe um núcleo com este nome.'] },
|
||||
existingHref: `/campanha/nucleos/${existing.slug}`,
|
||||
submittedName: name,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
export const createNucleusFormAction = async (
|
||||
_state: NucleusFormState,
|
||||
formData: FormData,
|
||||
): Promise<NucleusFormState> => {
|
||||
let nucleusSlug: string
|
||||
|
||||
try {
|
||||
const nucleus = await createNucleus(parseNucleusCreateFormData(formData))
|
||||
nucleusSlug = nucleus.slug
|
||||
} catch (error) {
|
||||
const existing = await existingNucleusState(formData)
|
||||
if (existing) return existing
|
||||
return getActionError(error)
|
||||
}
|
||||
|
||||
redirect(`/campanha/nucleos/${nucleusSlug}`)
|
||||
}
|
||||
|
||||
export const updateNucleusFormAction = async (
|
||||
_state: NucleusFormState,
|
||||
formData: FormData,
|
||||
): Promise<NucleusFormState> => {
|
||||
let nucleusSlug: string
|
||||
|
||||
try {
|
||||
const input = parseNucleusUpdateFormData(formData)
|
||||
const nucleus = await updateNucleus(input)
|
||||
nucleusSlug = nucleus.slug
|
||||
} catch (error) {
|
||||
return getActionError(error)
|
||||
}
|
||||
|
||||
redirect(`/campanha/nucleos/${nucleusSlug}`)
|
||||
}
|
||||
|
||||
export const archiveNucleusFormAction = async (
|
||||
_state: NucleusFormState,
|
||||
formData: FormData,
|
||||
): Promise<NucleusFormState> => {
|
||||
let nucleusId: number
|
||||
try {
|
||||
nucleusId = requiredRelationshipFormValue(formData, 'id')
|
||||
} catch {
|
||||
return {
|
||||
message: 'Não foi possível identificar o núcleo. Atualize a página e tente novamente.',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await archiveNucleus(nucleusId)
|
||||
} catch {
|
||||
return {
|
||||
message:
|
||||
'Não foi possível arquivar este núcleo. Verifique seu acesso ou atualize a página e tente novamente.',
|
||||
}
|
||||
}
|
||||
redirect('/campanha/nucleos')
|
||||
}
|
||||
18
src/app/(campaign)/campanha/(app)/nucleos/loading.tsx
Normal file
18
src/app/(campaign)/campanha/(app)/nucleos/loading.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function NucleiLoading() {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-screen-2xl flex-col gap-6"
|
||||
aria-busy="true"
|
||||
aria-label="Carregando núcleos"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-6 w-80 max-w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/app/(campaign)/campanha/(app)/nucleos/novo/page.tsx
Normal file
36
src/app/(campaign)/campanha/(app)/nucleos/novo/page.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
import { createNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { NucleusForm } from '@/components/campaign/NucleusForm'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions'
|
||||
|
||||
export default async function NewNucleusPage() {
|
||||
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
|
||||
|
||||
if (!user) redirect('/campanha/login')
|
||||
if (user.role !== 'geral') redirect('/campanha/nucleos')
|
||||
|
||||
const coordinators = await getEligibleNucleusCoordinatorOptions(payload, user)
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<CampaignScopeBadge>Criação · Coordenação geral</CampaignScopeBadge>
|
||||
<p className="text-sm font-medium text-primary">Núcleos Eleitorais</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Novo núcleo</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Defina a operação, o território obrigatório e quem responde por ele.
|
||||
</p>
|
||||
</header>
|
||||
<NucleusForm
|
||||
action={createNucleusFormAction}
|
||||
coordinators={coordinators}
|
||||
submitLabel="Criar núcleo"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
src/app/(campaign)/campanha/(app)/nucleos/page.tsx
Normal file
108
src/app/(campaign)/campanha/(app)/nucleos/page.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type { ElectoralNucleus } from '@/payload-types'
|
||||
import config from '@payload-config'
|
||||
import { PlusIcon, SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { NucleusFilters } from '@/components/campaign/NucleusFilters'
|
||||
import { NucleusList } from '@/components/campaign/NucleusList'
|
||||
import { NucleusPagination } from '@/components/campaign/NucleusPagination'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { loadNucleusListPageData } from '@/utilities/nucleusPageData'
|
||||
import {
|
||||
buildNucleusFiltersKey,
|
||||
getCampaignScopeLabel,
|
||||
resolveNucleusListUrl,
|
||||
} from '@/utilities/nucleusUi'
|
||||
import { toNucleusListViewModel } from '@/utilities/nucleusViewModels'
|
||||
|
||||
type NucleiPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
export default async function NucleiPage({ searchParams }: NucleiPageProps) {
|
||||
const rawSearchParams = await searchParams
|
||||
const canonicalUrl = resolveNucleusListUrl(rawSearchParams)
|
||||
if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref)
|
||||
|
||||
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const { result, scope } = await loadNucleusListPageData(payload, user, rawSearchParams)
|
||||
const resolvedUrl = resolveNucleusListUrl(rawSearchParams, result.totalPages)
|
||||
|
||||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
const { state } = resolvedUrl
|
||||
|
||||
return (
|
||||
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Núcleos Eleitorais</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Núcleo é a operação da campanha; Zona TSE é a circunscrição oficial.
|
||||
</p>
|
||||
<CampaignScopeBadge>
|
||||
{getCampaignScopeLabel(user.role, scope.totalDocs)}
|
||||
</CampaignScopeBadge>
|
||||
</div>
|
||||
{user.role === 'geral' ? (
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/nucleos/novo">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Novo núcleo
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<NucleusFilters key={buildNucleusFiltersKey(state)} state={state} />
|
||||
|
||||
{result.docs.length ? (
|
||||
<>
|
||||
<NucleusList
|
||||
nuclei={result.docs.map((nucleus) =>
|
||||
toNucleusListViewModel(nucleus as ElectoralNucleus),
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.totalDocs}{' '}
|
||||
{result.totalDocs === 1 ? 'núcleo encontrado' : 'núcleos encontrados'}
|
||||
</p>
|
||||
<NucleusPagination state={state} totalPages={result.totalPages} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum núcleo encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste a busca ou os filtros. Você só vê núcleos dentro do seu escopo.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/nucleos">Limpar busca e filtros</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
src/app/(campaign)/campanha/(app)/page.tsx
Normal file
18
src/app/(campaign)/campanha/(app)/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
import { CampaignDashboard } from '@/components/campaign/CampaignDashboard'
|
||||
import { getCampaignDashboardPageData } from '@/utilities/campaignDashboardPageData'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function CampaignHomePage() {
|
||||
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
|
||||
if (!user) redirect('/campanha/login')
|
||||
|
||||
const now = new Date()
|
||||
const view = await getCampaignDashboardPageData(payload, user, now)
|
||||
return <CampaignDashboard view={view} now={now} />
|
||||
}
|
||||
|
|
@ -5,13 +5,15 @@ import { cookies } from 'next/headers'
|
|||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { requiredFormSecret, requiredFormText } from '@/lib/formData'
|
||||
import { campaignLoginSchema, type CampaignLoginInput } from '@/lib/schemas/campaign-login'
|
||||
import { CAMPAIGN_TOKEN_COOKIE } from '@/utilities/campaignAuth'
|
||||
import {
|
||||
CAMPAIGN_COOKIE_PATH,
|
||||
CAMPAIGN_TOKEN_COOKIE,
|
||||
setCampaignAuthCookie,
|
||||
} from '@/utilities/campaignAuth'
|
||||
|
||||
const COOKIE_PATH = '/campanha'
|
||||
const DEFAULT_TOKEN_EXPIRATION = 7200
|
||||
|
||||
export type LoginResult = { error: string }
|
||||
export type LoginResult = { error?: string }
|
||||
|
||||
export const loginCampaign = async (input: CampaignLoginInput): Promise<LoginResult> => {
|
||||
const parsed = campaignLoginSchema.safeParse(input)
|
||||
|
|
@ -25,36 +27,44 @@ export const loginCampaign = async (input: CampaignLoginInput): Promise<LoginRes
|
|||
let token: string | undefined
|
||||
|
||||
try {
|
||||
const { identifier, password } = parsed.data
|
||||
const credentials = identifier.includes('@')
|
||||
? { email: identifier, password }
|
||||
: { username: identifier, password }
|
||||
|
||||
const result = await payload.login({
|
||||
collection: 'campaignUser',
|
||||
data: parsed.data,
|
||||
data: credentials,
|
||||
})
|
||||
|
||||
token = result.token
|
||||
} catch {
|
||||
return { error: 'Email ou senha inválidos.' }
|
||||
return { error: 'E-mail, celular ou senha inválidos.' }
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return { error: 'Email ou senha inválidos.' }
|
||||
return { error: 'E-mail, celular ou senha inválidos.' }
|
||||
}
|
||||
|
||||
const tokenExpiration =
|
||||
payload.collections.campaignUser?.config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION
|
||||
|
||||
const cookieStore = await cookies()
|
||||
|
||||
cookieStore.set(CAMPAIGN_TOKEN_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: COOKIE_PATH,
|
||||
maxAge: tokenExpiration,
|
||||
})
|
||||
await setCampaignAuthCookie(token, payload)
|
||||
|
||||
redirect('/campanha')
|
||||
}
|
||||
|
||||
export const loginCampaignFormAction = async (
|
||||
_previousState: LoginResult,
|
||||
formData: FormData,
|
||||
): Promise<LoginResult> => {
|
||||
try {
|
||||
return loginCampaign({
|
||||
identifier: requiredFormText(formData, 'identifier'),
|
||||
password: requiredFormSecret(formData, 'password'),
|
||||
})
|
||||
} catch {
|
||||
return { error: 'Dados inválidos.' }
|
||||
}
|
||||
}
|
||||
|
||||
export const logoutCampaign = async (): Promise<void> => {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
|
|
@ -62,7 +72,7 @@ export const logoutCampaign = async (): Promise<void> => {
|
|||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: COOKIE_PATH,
|
||||
path: CAMPAIGN_COOKIE_PATH,
|
||||
maxAge: 0,
|
||||
})
|
||||
|
||||
110
src/app/(campaign)/campanha/actions/coordinatorAssignment.ts
Normal file
110
src/app/(campaign)/campanha/actions/coordinatorAssignment.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
'use server'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
|
||||
import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions'
|
||||
import { acquireNucleusRowLocks } from '@/utilities/nucleusRowLocks'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
|
||||
const coordinatorAssignmentSchema = z.object({
|
||||
slug: z.string().trim().min(1).max(200),
|
||||
coordinatorIds: z
|
||||
.array(z.number().int().positive())
|
||||
.max(100)
|
||||
.transform((ids) => [...new Set(ids)]),
|
||||
expectedUpdatedAt: z.iso.datetime(),
|
||||
})
|
||||
|
||||
export type CoordinatorAssignmentInput = z.infer<typeof coordinatorAssignmentSchema>
|
||||
|
||||
export const assignNucleusCoordinatorsRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: CoordinatorAssignmentInput,
|
||||
) => {
|
||||
const data = coordinatorAssignmentSchema.parse(input)
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadCampaignActor(payload, actor, req)
|
||||
if (currentActor.role !== 'geral') {
|
||||
throw new Error('Somente a coordenação geral pode alterar coordenadores.')
|
||||
}
|
||||
|
||||
const scopedResult = await payload.find({
|
||||
collection: 'electoralNucleus',
|
||||
where: { slug: { equals: data.slug } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: { updatedAt: true },
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
const scopedNucleus = scopedResult.docs[0]
|
||||
if (!scopedNucleus) {
|
||||
throw new Error('Núcleo não encontrado ou sem acesso.')
|
||||
}
|
||||
|
||||
await acquireTextAdvisoryLocks(payload, req, [
|
||||
`coordinator-assignment:${scopedNucleus.id}`,
|
||||
])
|
||||
await acquireNucleusRowLocks({ payload, ...req } as never, [scopedNucleus.id])
|
||||
|
||||
// Intentional admin bypass: the actor is freshly confirmed as geral and the scoped nucleus
|
||||
// is locked before revision, eligibility, and assignment checks.
|
||||
const currentNucleus = await payload.findByID({
|
||||
collection: 'electoralNucleus',
|
||||
id: scopedNucleus.id,
|
||||
depth: 0,
|
||||
select: { updatedAt: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (currentNucleus.updatedAt !== data.expectedUpdatedAt) {
|
||||
throw new Error(
|
||||
'A coordenação deste núcleo foi alterada. Atualize a página e tente novamente.',
|
||||
)
|
||||
}
|
||||
|
||||
if (data.coordinatorIds.length > 0) {
|
||||
const eligible = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: {
|
||||
and: [{ id: { in: data.coordinatorIds } }, eligibleNucleusCoordinatorWhere],
|
||||
},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: { name: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (eligible.docs.length !== data.coordinatorIds.length) {
|
||||
throw new Error(
|
||||
'Uma ou mais pessoas selecionadas não são mais elegíveis para coordenação.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return payload.update({
|
||||
collection: 'electoralNucleus',
|
||||
id: currentNucleus.id,
|
||||
data: { coordinators: data.coordinatorIds },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a transação da designação.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const assignNucleusCoordinators = async (input: CoordinatorAssignmentInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return assignNucleusCoordinatorsRecord(payload, actor, input)
|
||||
}
|
||||
44
src/app/(campaign)/campanha/actions/invite.ts
Normal file
44
src/app/(campaign)/campanha/actions/invite.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use server'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { headers } from 'next/headers'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import type {
|
||||
CampaignInviteAutofillInput,
|
||||
CampaignInviteCreateInput,
|
||||
CampaignInviteLoginInput,
|
||||
} from '@/lib/schemas/invite'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import { setCampaignAuthCookie } from '@/utilities/campaignAuth'
|
||||
import { createCampaignInviteForActor } from '@/utilities/campaignInviteCreation'
|
||||
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
|
||||
import {
|
||||
redeemCampaignInviteAutofillRecord,
|
||||
redeemCampaignInviteLoginRecord,
|
||||
} from '@/utilities/campaignInviteRedemption'
|
||||
|
||||
export const createCampaignInvite = async (input: CampaignInviteCreateInput) => {
|
||||
const [{ payload, actor }, requestHeaders] = await Promise.all([
|
||||
getCampaignActionContext(),
|
||||
headers(),
|
||||
])
|
||||
const inviteBaseURL = getCampaignInviteBaseURL({
|
||||
requestOrigin: requestHeaders.get('origin'),
|
||||
forwardedHost: requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host'),
|
||||
forwardedProto: requestHeaders.get('x-forwarded-proto'),
|
||||
})
|
||||
return createCampaignInviteForActor(payload, actor, input, inviteBaseURL)
|
||||
}
|
||||
|
||||
export const redeemCampaignInviteAutofill = async (input: CampaignInviteAutofillInput) => {
|
||||
const payload = await getPayload({ config })
|
||||
return redeemCampaignInviteAutofillRecord(payload, input)
|
||||
}
|
||||
|
||||
export const redeemCampaignInviteLogin = async (input: CampaignInviteLoginInput) => {
|
||||
const payload = await getPayload({ config })
|
||||
const result = await redeemCampaignInviteLoginRecord(payload, input)
|
||||
await setCampaignAuthCookie(result.token, payload)
|
||||
return { ok: true as const }
|
||||
}
|
||||
202
src/app/(campaign)/campanha/actions/leadership.ts
Normal file
202
src/app/(campaign)/campanha/actions/leadership.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
'use server'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import {
|
||||
leadershipCreateSchema,
|
||||
leadershipInternalUpdateSchema,
|
||||
type LeadershipInternalUpdateInput,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import type { CampaignUser, Contact } from '@/payload-types'
|
||||
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
|
||||
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { requireRelationshipId } from '@/utilities/relationship'
|
||||
|
||||
const getFreshStaffActor = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
req?: PayloadTransactionRequest,
|
||||
): Promise<CampaignUser> => {
|
||||
const currentActor = await reloadCampaignActor(payload, actor, req)
|
||||
|
||||
if (currentActor.role !== 'geral' && currentActor.role !== 'coordenador') {
|
||||
throw new Error('Somente a coordenação pode gerenciar lideranças.')
|
||||
}
|
||||
|
||||
return currentActor
|
||||
}
|
||||
|
||||
const assertNucleusManagement = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
nucleusID: number,
|
||||
req?: PayloadTransactionRequest,
|
||||
) =>
|
||||
payload.findByID({
|
||||
collection: 'electoralNucleus',
|
||||
id: nucleusID,
|
||||
depth: 0,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
|
||||
const isUniqueLeadershipConflict = (error: unknown): boolean => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (/leadership.*contact.*nucleus|leadership_contact_nucleus|duplicate key/i.test(message)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!(error instanceof Error) || error.name !== 'ValidationError') return false
|
||||
|
||||
const details = JSON.stringify(error)
|
||||
return /contact(?:_id)?/i.test(details) && /nucleus(?:_id)?/i.test(details)
|
||||
}
|
||||
|
||||
type LeadershipCreateData = ReturnType<typeof leadershipCreateSchema.parse>
|
||||
|
||||
const createValidatedLeadershipRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
data: LeadershipCreateData,
|
||||
) => {
|
||||
try {
|
||||
return await withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await getFreshStaffActor(payload, actor, req)
|
||||
const nucleus = await assertNucleusManagement(payload, currentActor, data.nucleus, req)
|
||||
if (payload.db.name !== 'postgres') {
|
||||
throw new Error('O bloqueio de deduplicação exige o adaptador PostgreSQL.')
|
||||
}
|
||||
await acquireContactPhoneLocks(payload, req, [data.phone])
|
||||
// Intentional admin bypass: staff scope was freshly checked; these internal reads and
|
||||
// writes atomically maintain the normalized Contact ↔ Leadership join.
|
||||
const contacts = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { equals: data.phone } },
|
||||
depth: 0,
|
||||
limit: 2,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
if (contacts.totalDocs > 1) {
|
||||
throw new Error(
|
||||
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.',
|
||||
)
|
||||
}
|
||||
|
||||
let contactID = contacts.docs[0]?.id
|
||||
const contactReused = Boolean(contactID)
|
||||
|
||||
if (!contactID) {
|
||||
const city = nucleus.city ?? nucleus.locality
|
||||
const contact = await payload.create({
|
||||
collection: 'contact',
|
||||
data: {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
gender: data.gender,
|
||||
state: 'BA' as Contact['state'],
|
||||
city,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
contactID = contact.id
|
||||
}
|
||||
|
||||
const leadership = await payload.create({
|
||||
collection: 'leadership',
|
||||
data: {
|
||||
contact: contactID,
|
||||
nucleus: data.nucleus,
|
||||
sector: data.sector,
|
||||
sectorNotes: data.sectorNotes,
|
||||
supportStatus: data.supportStatus,
|
||||
notes: data.notes,
|
||||
consentNote: data.consentNote,
|
||||
createdBy: currentActor.id,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
return { ...leadership, contactReused }
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a transação de cadastro da liderança.' },
|
||||
)
|
||||
} catch (error) {
|
||||
if (isUniqueLeadershipConflict(error)) {
|
||||
throw new Error('Esta pessoa já está cadastrada como liderança neste núcleo.')
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const createLeadershipRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: unknown,
|
||||
) => createValidatedLeadershipRecord(payload, actor, leadershipCreateSchema.parse(input))
|
||||
|
||||
export const updateLeadershipInternalRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: LeadershipInternalUpdateInput,
|
||||
) => {
|
||||
const { id, ...data } = leadershipInternalUpdateSchema.parse(input)
|
||||
const currentActor = await getFreshStaffActor(payload, actor)
|
||||
const current = await payload.findByID({
|
||||
collection: 'leadership',
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
await assertNucleusManagement(payload, currentActor, requireRelationshipId(current.nucleus))
|
||||
|
||||
return payload.update({
|
||||
collection: 'leadership',
|
||||
id,
|
||||
data,
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
}
|
||||
|
||||
export const listNucleusLeaderships = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
nucleusID: number,
|
||||
) => {
|
||||
const currentActor = await getFreshStaffActor(payload, actor)
|
||||
await assertNucleusManagement(payload, currentActor, nucleusID)
|
||||
|
||||
return payload.find({
|
||||
collection: 'leadership',
|
||||
where: { nucleus: { equals: nucleusID } },
|
||||
depth: 1,
|
||||
sort: 'createdAt',
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
}
|
||||
|
||||
export const createLeadership = async (input: unknown) => {
|
||||
const data = leadershipCreateSchema.parse(input)
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return createValidatedLeadershipRecord(payload, actor, data)
|
||||
}
|
||||
|
||||
export const updateLeadershipInternal = async (input: LeadershipInternalUpdateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return updateLeadershipInternalRecord(payload, actor, input)
|
||||
}
|
||||
94
src/app/(campaign)/campanha/actions/nucleus.ts
Normal file
94
src/app/(campaign)/campanha/actions/nucleus.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
'use server'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import {
|
||||
nucleusCreateSchema,
|
||||
nucleusUpdateSchema,
|
||||
type NucleusCreateInput,
|
||||
type NucleusUpdateInput,
|
||||
} from '@/lib/schemas/nucleus'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
|
||||
import { acquireNucleusRowLocks } from '@/utilities/nucleusRowLocks'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
||||
export const createElectoralNucleus = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: NucleusCreateInput,
|
||||
) => {
|
||||
const data = nucleusCreateSchema.parse(input)
|
||||
|
||||
return payload.create({
|
||||
collection: 'electoralNucleus',
|
||||
data: data as never,
|
||||
depth: 0,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
}
|
||||
|
||||
export const updateElectoralNucleus = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: NucleusUpdateInput,
|
||||
) => {
|
||||
const { id, ...data } = nucleusUpdateSchema.parse(input)
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadCampaignActor(payload, actor, req)
|
||||
await acquireNucleusRowLocks({ payload, ...req } as never, [id])
|
||||
|
||||
return payload.update({
|
||||
collection: 'electoralNucleus',
|
||||
id,
|
||||
data,
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a atualização do núcleo.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const archiveElectoralNucleus = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
id: number,
|
||||
) => {
|
||||
const currentActor = await reloadCampaignActor(payload, actor)
|
||||
|
||||
if (currentActor.role !== 'geral') {
|
||||
throw new Error('Somente a coordenação geral pode arquivar núcleos.')
|
||||
}
|
||||
|
||||
return payload.update({
|
||||
collection: 'electoralNucleus',
|
||||
id,
|
||||
data: {
|
||||
status: 'arquivado',
|
||||
},
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
}
|
||||
|
||||
export const createNucleus = async (input: NucleusCreateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return createElectoralNucleus(payload, actor, input)
|
||||
}
|
||||
|
||||
export const updateNucleus = async (input: NucleusUpdateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return updateElectoralNucleus(payload, actor, input)
|
||||
}
|
||||
|
||||
export const archiveNucleus = async (id: number) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return archiveElectoralNucleus(payload, actor, id)
|
||||
}
|
||||
63
src/app/(campaign)/campanha/actions/nucleusUpdate.ts
Normal file
63
src/app/(campaign)/campanha/actions/nucleusUpdate.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
'use server'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import {
|
||||
nucleusUpdateCreateSchema,
|
||||
type NucleusUpdateCreateInput,
|
||||
} from '@/lib/schemas/nucleusUpdate'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
||||
export const createNucleusUpdateRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: NucleusUpdateCreateInput,
|
||||
) => {
|
||||
const data = nucleusUpdateCreateSchema.parse(input)
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadCampaignActor(payload, actor, req)
|
||||
return payload.create({
|
||||
collection: 'nucleusUpdate',
|
||||
data: data as never,
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a transação da atualização.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const listNucleusUpdates = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
nucleus: number,
|
||||
) => {
|
||||
const currentActor = await reloadCampaignActor(payload, actor)
|
||||
await payload.findByID({
|
||||
collection: 'electoralNucleus',
|
||||
id: nucleus,
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return payload.find({
|
||||
collection: 'nucleusUpdate',
|
||||
where: { nucleus: { equals: nucleus } },
|
||||
depth: 1,
|
||||
sort: '-createdAt',
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
}
|
||||
|
||||
export const createNucleusUpdate = async (input: NucleusUpdateCreateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return createNucleusUpdateRecord(payload, actor, input)
|
||||
}
|
||||
179
src/app/(campaign)/campanha/actions/voteEstimate.ts
Normal file
179
src/app/(campaign)/campanha/actions/voteEstimate.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
'use server'
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import {
|
||||
confirmVoteEstimateSchema,
|
||||
suggestVoteEstimateSchema,
|
||||
type ConfirmVoteEstimateInput,
|
||||
type SuggestVoteEstimateInput,
|
||||
} from '@/lib/schemas/voteEstimate'
|
||||
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
|
||||
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
|
||||
const assertSuggestionScope = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
nucleus: number,
|
||||
req: PayloadTransactionRequest,
|
||||
): Promise<void> => {
|
||||
if (actor.role === 'geral') return
|
||||
|
||||
if (actor.role === 'coordenador') {
|
||||
await payload.findByID({
|
||||
collection: 'electoralNucleus',
|
||||
id: nucleus,
|
||||
depth: 0,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const links = await payload.find({
|
||||
collection: 'leadership',
|
||||
where: {
|
||||
and: [
|
||||
{ nucleus: { equals: nucleus } },
|
||||
{ user: { equals: actor.id } },
|
||||
{ supportStatus: { equals: 'engajado' } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
if (links.totalDocs === 0) {
|
||||
throw new Error('A liderança precisa de vínculo engajado com este núcleo.')
|
||||
}
|
||||
}
|
||||
|
||||
const getConfirmableNucleus = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
nucleus: number,
|
||||
req: PayloadTransactionRequest,
|
||||
): Promise<ElectoralNucleus> => {
|
||||
if (actor.role !== 'geral' && actor.role !== 'coordenador') {
|
||||
throw new Error('Somente a coordenação pode confirmar estimativas.')
|
||||
}
|
||||
|
||||
return payload.findByID({
|
||||
collection: 'electoralNucleus',
|
||||
id: nucleus,
|
||||
depth: 0,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
}
|
||||
|
||||
export const suggestVoteEstimateRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: SuggestVoteEstimateInput,
|
||||
) => {
|
||||
const data = suggestVoteEstimateSchema.parse(input)
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
if (payload.db.name !== 'postgres') {
|
||||
throw new Error('O bloqueio de estimativa exige o adaptador PostgreSQL.')
|
||||
}
|
||||
await acquireTextAdvisoryLocks(payload, req, [`vote-estimate:${data.nucleus}`])
|
||||
const currentActor = await reloadCampaignActor(payload, actor, req)
|
||||
await assertSuggestionScope(payload, currentActor, data.nucleus, req)
|
||||
|
||||
// Intentional admin bypass: scope was checked under the nucleus transaction lock; audit
|
||||
// fields and proposal version are derived exclusively on the server.
|
||||
return payload.update({
|
||||
collection: 'electoralNucleus',
|
||||
id: data.nucleus,
|
||||
data: {
|
||||
proposedVoteEstimate: data.estimate,
|
||||
proposedVoteEstimateAt: new Date().toISOString(),
|
||||
proposedVoteEstimateBy: currentActor.id,
|
||||
proposedVoteEstimateVersion: randomUUID(),
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a transação da estimativa.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const confirmVoteEstimateRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: ConfirmVoteEstimateInput,
|
||||
) => {
|
||||
const data = confirmVoteEstimateSchema.parse(input)
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
if (payload.db.name !== 'postgres') {
|
||||
throw new Error('O bloqueio de estimativa exige o adaptador PostgreSQL.')
|
||||
}
|
||||
await acquireTextAdvisoryLocks(payload, req, [`vote-estimate:${data.nucleus}`])
|
||||
const currentActor = await reloadCampaignActor(payload, actor, req)
|
||||
const nucleus = await getConfirmableNucleus(payload, currentActor, data.nucleus, req)
|
||||
const proposal = nucleus.proposedVoteEstimate
|
||||
const proposalVersion = nucleus.proposedVoteEstimateVersion ?? null
|
||||
|
||||
if (proposal == null) {
|
||||
if (proposalVersion !== null || data.expectedProposedVoteEstimateVersion !== null) {
|
||||
throw new Error('A sugestão foi alterada. Atualize a página antes de confirmar.')
|
||||
}
|
||||
} else if (
|
||||
proposalVersion === null ||
|
||||
proposalVersion !== data.expectedProposedVoteEstimateVersion
|
||||
) {
|
||||
throw new Error('A sugestão foi alterada. Atualize a página antes de confirmar.')
|
||||
}
|
||||
if ((proposal == null || data.estimate !== proposal) && !data.confirmationNote) {
|
||||
throw new Error('Informe uma justificativa para ajustar a estimativa.')
|
||||
}
|
||||
|
||||
// Intentional admin bypass: confirmation role/scope and proposal revision were checked
|
||||
// under the same transaction lock; all audit fields are server-derived.
|
||||
return payload.update({
|
||||
collection: 'electoralNucleus',
|
||||
id: data.nucleus,
|
||||
data: {
|
||||
confirmedVoteEstimate: data.estimate,
|
||||
confirmedVoteEstimateAt: new Date().toISOString(),
|
||||
confirmedVoteEstimateBy: currentActor.id,
|
||||
confirmationNote: data.confirmationNote ?? null,
|
||||
proposedVoteEstimate: null,
|
||||
proposedVoteEstimateAt: null,
|
||||
proposedVoteEstimateBy: null,
|
||||
proposedVoteEstimateVersion: null,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a transação da estimativa.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const suggestVoteEstimate = async (input: SuggestVoteEstimateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return suggestVoteEstimateRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
export const confirmVoteEstimate = async (input: ConfirmVoteEstimateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return confirmVoteEstimateRecord(payload, actor, input)
|
||||
}
|
||||
104
src/app/(campaign)/campanha/convite/[token]/formActions.ts
Normal file
104
src/app/(campaign)/campanha/convite/[token]/formActions.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
'use server'
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
import {
|
||||
redeemCampaignInviteAutofill,
|
||||
redeemCampaignInviteLogin,
|
||||
} from '@/app/(campaign)/campanha/actions/invite'
|
||||
import { campaignInviteAutofillSchema, campaignInviteLoginSchema } from '@/lib/schemas/invite'
|
||||
import {
|
||||
checkboxFormValue,
|
||||
FormDataBoundaryError,
|
||||
nullableFormText,
|
||||
requiredFormSecret,
|
||||
requiredFormText,
|
||||
validationFieldErrors,
|
||||
} from '@/lib/formData'
|
||||
|
||||
export type CampaignInviteFormState = {
|
||||
status?: 'success'
|
||||
message?: string
|
||||
fieldErrors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export type BoundCampaignInviteFormAction = (
|
||||
state: CampaignInviteFormState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignInviteFormState>
|
||||
|
||||
const profileFromForm = (formData: FormData) => ({
|
||||
name: requiredFormText(formData, 'name'),
|
||||
phone: requiredFormText(formData, 'phone'),
|
||||
email: nullableFormText(formData, 'email'),
|
||||
gender: nullableFormText(formData, 'gender'),
|
||||
sector: nullableFormText(formData, 'sector'),
|
||||
sectorNotes: nullableFormText(formData, 'sectorNotes'),
|
||||
})
|
||||
|
||||
const inviteFormError = (error: unknown): CampaignInviteFormState => {
|
||||
if (error instanceof FormDataBoundaryError) {
|
||||
return { fieldErrors: { [error.field]: [error.message] } }
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return { fieldErrors: validationFieldErrors(error) }
|
||||
}
|
||||
return {
|
||||
message: 'Este convite não está disponível. Peça um novo convite à pessoa que falou com você.',
|
||||
}
|
||||
}
|
||||
|
||||
export const redeemCampaignInviteAutofillFormAction = async (
|
||||
token: string,
|
||||
_state: CampaignInviteFormState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignInviteFormState> => {
|
||||
try {
|
||||
const input = campaignInviteAutofillSchema.parse({
|
||||
token,
|
||||
...profileFromForm(formData),
|
||||
consentAccepted: checkboxFormValue(formData, 'consentAccepted'),
|
||||
})
|
||||
await redeemCampaignInviteAutofill(input)
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Seus dados foram confirmados com sucesso.',
|
||||
}
|
||||
} catch (error) {
|
||||
return inviteFormError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const redeemCampaignInviteLoginFormAction = async (
|
||||
token: string,
|
||||
_state: CampaignInviteFormState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignInviteFormState> => {
|
||||
let password: string
|
||||
try {
|
||||
password = requiredFormSecret(formData, 'password')
|
||||
if (password !== requiredFormSecret(formData, 'passwordConfirmation')) {
|
||||
return {
|
||||
fieldErrors: {
|
||||
passwordConfirmation: ['As senhas não coincidem.'],
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return inviteFormError(error)
|
||||
}
|
||||
try {
|
||||
const input = campaignInviteLoginSchema.parse({
|
||||
token,
|
||||
...profileFromForm(formData),
|
||||
password,
|
||||
consentAccepted: checkboxFormValue(formData, 'consentAccepted'),
|
||||
})
|
||||
await redeemCampaignInviteLogin(input)
|
||||
} catch (error) {
|
||||
return inviteFormError(error)
|
||||
}
|
||||
|
||||
redirect('/campanha')
|
||||
}
|
||||
65
src/app/(campaign)/campanha/convite/[token]/page.tsx
Normal file
65
src/app/(campaign)/campanha/convite/[token]/page.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import config from '@payload-config'
|
||||
import type { Metadata } from 'next'
|
||||
import { unstable_noStore as noStore } from 'next/cache'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import {
|
||||
redeemCampaignInviteAutofillFormAction,
|
||||
redeemCampaignInviteLoginFormAction,
|
||||
} from '@/app/(campaign)/campanha/convite/[token]/formActions'
|
||||
import { CampaignInviteForm } from '@/components/campaign/CampaignInviteForm'
|
||||
import { ConsentText } from '@/components/campaign/ConsentText'
|
||||
import { InvalidCampaignInvite } from '@/components/campaign/InvalidCampaignInvite'
|
||||
import { getCampaignInvitePageData } from '@/utilities/campaignInvitePageData'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const fetchCache = 'force-no-store'
|
||||
export const revalidate = 0
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Confirme seus dados | Campanha do Solla',
|
||||
description: 'Página segura para confirmar seus dados com a campanha do Solla.',
|
||||
referrer: 'no-referrer',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
nocache: true,
|
||||
},
|
||||
}
|
||||
|
||||
type CampaignInvitePageProps = {
|
||||
params: Promise<{ token: string }>
|
||||
}
|
||||
|
||||
export default async function CampaignInvitePage({ params }: CampaignInvitePageProps) {
|
||||
noStore()
|
||||
const [{ token }, payload] = await Promise.all([params, getPayload({ config })])
|
||||
const preview = await getCampaignInvitePageData(payload, token)
|
||||
|
||||
if (preview.status === 'invalid') {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center p-4 sm:p-6">
|
||||
<InvalidCampaignInvite />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const action =
|
||||
preview.kind === 'login'
|
||||
? redeemCampaignInviteLoginFormAction.bind(null, token)
|
||||
: redeemCampaignInviteAutofillFormAction.bind(null, token)
|
||||
const consentText = preview.requiresConsent ? ConsentText({ data: preview.consentData }) : undefined
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center p-4 sm:p-6">
|
||||
<CampaignInviteForm
|
||||
action={action}
|
||||
kind={preview.kind}
|
||||
profile={preview.profile}
|
||||
requiresConsent={preview.requiresConsent}
|
||||
>
|
||||
{consentText}
|
||||
</CampaignInviteForm>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
62
src/app/(campaign)/campanha/login/LoginForm.tsx
Normal file
62
src/app/(campaign)/campanha/login/LoginForm.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
|
||||
import { loginCampaignFormAction } from '@/app/(campaign)/campanha/actions/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
|
||||
export const LoginForm = () => {
|
||||
const [state, formAction, pending] = useActionState(loginCampaignFormAction, {})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Acessar painel</CardTitle>
|
||||
<CardDescription>Entre com seu e-mail ou celular para continuar.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={formAction}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="identifier">E-mail ou celular</FieldLabel>
|
||||
<Input
|
||||
id="identifier"
|
||||
name="identifier"
|
||||
type="text"
|
||||
inputMode="email"
|
||||
autoComplete="username"
|
||||
enterKeyHint="next"
|
||||
placeholder="voce@exemplo.com ou (71) 99999-1234"
|
||||
required
|
||||
className="min-h-11"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="current-password">Senha</FieldLabel>
|
||||
<Input
|
||||
id="current-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
enterKeyHint="done"
|
||||
required
|
||||
className="min-h-11"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
{state.error ? <FieldError>{state.error}</FieldError> : null}
|
||||
<Button type="submit" className="min-h-11" disabled={pending}>
|
||||
{pending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
<span aria-live="polite">{pending ? 'Entrando...' : 'Entrar'}</span>
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import type { Metadata } from 'next'
|
|||
import { Inter } from 'next/font/google'
|
||||
import React from 'react'
|
||||
|
||||
import { VoteEstimateFocusProvider } from '@/components/campaign/VoteEstimateFocusProvider'
|
||||
|
||||
import '../(frontend)/styles.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' })
|
||||
|
|
@ -22,7 +24,9 @@ export default function CampanhaRootLayout({ children }: { children: React.React
|
|||
className={inter.variable}
|
||||
style={{ colorScheme: 'light' }}
|
||||
>
|
||||
<body className="bg-background bg-none text-foreground antialiased">{children}</body>
|
||||
<body className="bg-background bg-none text-foreground antialiased">
|
||||
<VoteEstimateFocusProvider>{children}</VoteEstimateFocusProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
import React from 'react'
|
||||
|
||||
import { CampaignSidebar } from '@/components/campanha/campaign-sidebar'
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
|
||||
export default async function CampaignAppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await getCampaignUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/campanha/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<CampaignSidebar user={{ name: user.name, email: user.email }} />
|
||||
<SidebarInset>
|
||||
<header className="flex h-12 items-center border-b px-4 md:hidden">
|
||||
<SidebarTrigger />
|
||||
</header>
|
||||
<main className="flex-1 p-4 md:p-6">{children}</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
export default function CampaignHomePage() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold">Painel da campanha</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Em breve: ferramentas de gerenciamento da campanha.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTransition } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
|
||||
import { loginCampaign } from '@/app/(campanha)/campanha/actions/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { campaignLoginSchema, type CampaignLoginInput } from '@/lib/schemas/campaign-login'
|
||||
|
||||
export const LoginForm = () => {
|
||||
const methods = useForm<CampaignLoginInput>({
|
||||
resolver: zodResolver(campaignLoginSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
const [isSubmitting, startTransition] = useTransition()
|
||||
|
||||
const handleSubmit: SubmitHandler<CampaignLoginInput> = (input) => {
|
||||
startTransition(async () => {
|
||||
const result = await loginCampaign(input)
|
||||
|
||||
if (result?.error) {
|
||||
methods.setError('root', { message: result.error })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = methods
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Acessar painel</CardTitle>
|
||||
<CardDescription>Entre com seu e-mail e senha para continuar.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={methods.handleSubmit(handleSubmit)} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.email)}>
|
||||
<FieldLabel htmlFor="email">E-mail</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="voce@exemplo.com"
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email?.message ? (
|
||||
<FieldDescription className="text-destructive">
|
||||
{errors.email.message}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.password)}>
|
||||
<FieldLabel htmlFor="password">Senha</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
aria-invalid={Boolean(errors.password)}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password?.message ? (
|
||||
<FieldDescription className="text-destructive">
|
||||
{errors.password.message}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field>
|
||||
{errors.root?.message ? <FieldError>{errors.root.message}</FieldError> : null}
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Entrando...' : 'Entrar'}
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import type { Crumb } from '@/components/SiteHeader'
|
||||
import { SiteHeader } from '@/components/SiteHeader'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import type { Media, Post } from '@/payload-types'
|
||||
import { getCachedDocumentById } from '@/utilities/documents'
|
||||
import { getCachedGlobal } from '@/utilities/globals'
|
||||
import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical'
|
||||
import { stripTrailingSlash, toAbsoluteUrl, truncate } from '@/utilities/seo'
|
||||
import { getCachedGlobal } from '@/utilities/globals'
|
||||
import {
|
||||
POST_TYPE_LABELS,
|
||||
formatPostDate,
|
||||
|
|
@ -15,15 +18,12 @@ import {
|
|||
isPostType,
|
||||
isPostVisible,
|
||||
} from '@/utilities/posts'
|
||||
import { SiteHeader } from '@/components/SiteHeader'
|
||||
import type { Crumb } from '@/components/SiteHeader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { stripTrailingSlash, toAbsoluteUrl, truncate } from '@/utilities/seo'
|
||||
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
|
||||
import type { Media, Post } from '@/payload-types'
|
||||
import type { Metadata } from 'next'
|
||||
import type { Article, WithContext } from 'schema-dts'
|
||||
import Image from 'next/image'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import type { Article, WithContext } from 'schema-dts'
|
||||
|
||||
export const dynamicParams = true
|
||||
|
||||
|
|
@ -235,7 +235,7 @@ export default async function Page({ params }: { params: Promise<RouteParams> })
|
|||
|
||||
{coverImage?.url ? (
|
||||
<figure className="mx-auto w-full max-w-4xl px-4 pt-10 sm:px-6 lg:px-8 lg:pt-12">
|
||||
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-xl bg-muted ring-1 ring-border">
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-xl bg-muted ring-1 ring-border">
|
||||
<Image
|
||||
src={coverImage.url}
|
||||
alt={coverImage.alt ?? post.title}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { PostCard } from '@/components/PostCard'
|
||||
import { SiteHeader } from '@/components/SiteHeader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { getCachedGlobal } from '@/utilities/globals'
|
||||
import { stripTrailingSlash } from '@/utilities/seo'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { PostCard } from '@/components/PostCard'
|
||||
import { SiteHeader } from '@/components/SiteHeader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { getCachedGlobal } from '@/utilities/globals'
|
||||
import { stripTrailingSlash } from '@/utilities/seo'
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@ import { Analytics } from '@vercel/analytics/next'
|
|||
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' })
|
||||
|
||||
const fallbackMetadata = {
|
||||
URL: 'https://jorgesolla.com.br',
|
||||
title: 'Jorge Solla',
|
||||
description: 'Notícias, campanhas e a atuação do deputado Jorge Solla pela Bahia.',
|
||||
openGraphSiteName: 'Jorge Solla',
|
||||
twitterCreator: '@jorgesolla',
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const payload = await getCachedGlobal('metadata')()
|
||||
|
||||
|
|
@ -21,9 +29,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
}
|
||||
|
||||
return {
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
keywords: payload.keywords.filter((keyword) => typeof keyword === 'string'),
|
||||
title: payload.title || fallbackMetadata.title,
|
||||
description: payload.description || fallbackMetadata.description,
|
||||
keywords: (payload.keywords ?? []).map(({ keyword }) => keyword).filter(Boolean),
|
||||
authors: [
|
||||
{ name: 'Francisco Solla', url: 'https://solla.dev' },
|
||||
{ name: 'Teqo', url: 'https://teqo.app' },
|
||||
|
|
@ -37,10 +45,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'pt-BR',
|
||||
url: payload.URL,
|
||||
siteName: payload.openGraph.siteName,
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
url: payload.URL || fallbackMetadata.URL,
|
||||
siteName: payload.openGraph?.siteName || fallbackMetadata.openGraphSiteName,
|
||||
title: payload.title || fallbackMetadata.title,
|
||||
description: payload.description || fallbackMetadata.description,
|
||||
images: image?.url
|
||||
? [
|
||||
{
|
||||
|
|
@ -54,9 +62,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: payload.title,
|
||||
description: payload.twitter.description,
|
||||
creator: payload.twitter.creator,
|
||||
title: payload.title || fallbackMetadata.title,
|
||||
description:
|
||||
payload.twitter?.description || payload.description || fallbackMetadata.description,
|
||||
creator: payload.twitter?.creator || fallbackMetadata.twitterCreator,
|
||||
images: image?.url ? [image.url] : [],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
@plugin 'tailwindcss-animate';
|
||||
@import 'tw-animate-css';
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn/tailwind.css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
|
@ -62,6 +61,24 @@ body,
|
|||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-scope-border: var(--scope-border);
|
||||
--color-scope-foreground: var(--scope-foreground);
|
||||
--color-scope: var(--scope);
|
||||
--color-tse-foreground: var(--tse-foreground);
|
||||
--color-tse: var(--tse);
|
||||
--color-support-engaged-foreground: var(--support-engaged-foreground);
|
||||
--color-support-engaged: var(--support-engaged);
|
||||
--color-support-to-approach-foreground: var(--support-to-approach-foreground);
|
||||
--color-support-to-approach: var(--support-to-approach);
|
||||
--color-support-disputed-foreground: var(--support-disputed-foreground);
|
||||
--color-support-disputed: var(--support-disputed);
|
||||
--color-support-negative-foreground: var(--support-negative-foreground);
|
||||
--color-support-negative: var(--support-negative);
|
||||
--color-estimate-confirmed-foreground: var(--estimate-confirmed-foreground);
|
||||
--color-estimate-confirmed: var(--estimate-confirmed);
|
||||
--color-estimate-pending-foreground: var(--estimate-pending-foreground);
|
||||
--color-estimate-pending: var(--estimate-pending);
|
||||
--color-cadence-overdue: var(--cadence-overdue);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
|
|
@ -184,8 +201,9 @@ body,
|
|||
management panel). Mirrors the shadcn token contract with a brand-red
|
||||
primary and defines the --field-* tokens so the custom Input renders with a
|
||||
visible border on the white card. Applied via data-theme="campaign" on the
|
||||
(campanha) layout, so it never affects the public (frontend) pages. */
|
||||
(campaign) layout, so it never affects the public (frontend) pages. */
|
||||
[data-theme='campaign'] {
|
||||
--radius: 0.5rem;
|
||||
--background: #ffffff;
|
||||
--foreground: #1c1917;
|
||||
--card: #ffffff;
|
||||
|
|
@ -220,6 +238,51 @@ body,
|
|||
--sidebar-accent-foreground: #1c1917;
|
||||
--sidebar-border: rgb(28 25 23 / 12%);
|
||||
--sidebar-ring: #c51414;
|
||||
--scope: #fff5ed;
|
||||
--scope-foreground: #6b2d15;
|
||||
--scope-border: #e8c5ae;
|
||||
--tse: #f1f3f5;
|
||||
--tse-foreground: #3f4854;
|
||||
--support-engaged: #dcfce7;
|
||||
--support-engaged-foreground: #166534;
|
||||
--support-to-approach: #eef0f3;
|
||||
--support-to-approach-foreground: #46505c;
|
||||
--support-disputed: #fef3c7;
|
||||
--support-disputed-foreground: #92400e;
|
||||
--support-negative: #fee2e2;
|
||||
--support-negative-foreground: #991b1b;
|
||||
--estimate-confirmed: #dcfce7;
|
||||
--estimate-confirmed-foreground: #166534;
|
||||
--estimate-pending: #fef3c7;
|
||||
--estimate-pending-foreground: #92400e;
|
||||
--cadence-overdue: #b42318;
|
||||
}
|
||||
|
||||
[data-theme='campaign']
|
||||
:is(
|
||||
[data-slot='input'],
|
||||
[data-slot='input-group'],
|
||||
[data-slot='combobox-trigger'],
|
||||
[data-slot='native-select'],
|
||||
[data-slot='select-trigger']
|
||||
) {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* The public-site base `h1` is centered + display-sized for article heroes.
|
||||
Campaign dashboard headings are left-aligned section titles, so undo the
|
||||
base centering and text balancing here; each heading keeps its own
|
||||
size/weight utility classes. The base `p { not-first:mt-6 }` is also for
|
||||
article body flow — campaign layouts space subtitles via flex gap, so the
|
||||
extra top margin would stack on top of the gap and create a large gap
|
||||
between a title and its subtitle. */
|
||||
[data-theme='campaign'] h1 {
|
||||
text-align: left;
|
||||
text-wrap: initial;
|
||||
}
|
||||
|
||||
[data-theme='campaign'] p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -302,7 +365,7 @@ body,
|
|||
}
|
||||
|
||||
p {
|
||||
@apply leading-7 [&:not(:first-child)]:mt-6;
|
||||
@apply leading-7 not-first:mt-6;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
|
|
|
|||
122
src/collections/CampaignInvite.ts
Normal file
122
src/collections/CampaignInvite.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import {
|
||||
canCreateCampaignInvite,
|
||||
canMutateCampaignInvite,
|
||||
canReadCampaignInvite,
|
||||
canSetCampaignInviteSystemField,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { campaignInviteExpiry } from '@/utilities/campaignInvite'
|
||||
|
||||
export const CampaignInvite: CollectionConfig = {
|
||||
slug: 'campaignInvite',
|
||||
labels: {
|
||||
singular: 'Convite',
|
||||
plural: 'Convites',
|
||||
},
|
||||
admin: {
|
||||
group: 'Campanha',
|
||||
useAsTitle: 'leadership',
|
||||
defaultColumns: ['leadership', 'kind', 'expiresAt', 'usedAt', 'revokedAt', 'createdAt'],
|
||||
},
|
||||
access: {
|
||||
create: canCreateCampaignInvite,
|
||||
read: canReadCampaignInvite,
|
||||
update: canMutateCampaignInvite,
|
||||
delete: canMutateCampaignInvite,
|
||||
},
|
||||
indexes: [
|
||||
{
|
||||
fields: ['leadership', 'kind'],
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: 'tokenHash',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetCampaignInviteSystemField,
|
||||
read: () => false,
|
||||
update: canSetCampaignInviteSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'leadership',
|
||||
type: 'relationship',
|
||||
relationTo: 'leadership',
|
||||
label: 'Liderança',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'kind',
|
||||
type: 'select',
|
||||
label: 'Tipo',
|
||||
required: true,
|
||||
index: true,
|
||||
options: [
|
||||
{ label: 'Acesso ao app', value: 'login' },
|
||||
{ label: 'Completar cadastro', value: 'autopreenchimento' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'expiresAt',
|
||||
type: 'date',
|
||||
label: 'Expira em',
|
||||
required: true,
|
||||
index: true,
|
||||
defaultValue: () => campaignInviteExpiry().toISOString(),
|
||||
access: {
|
||||
create: canSetCampaignInviteSystemField,
|
||||
update: canSetCampaignInviteSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'usedAt',
|
||||
type: 'date',
|
||||
label: 'Usado em',
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetCampaignInviteSystemField,
|
||||
update: canSetCampaignInviteSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'revokedAt',
|
||||
type: 'date',
|
||||
label: 'Revogado em',
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetCampaignInviteSystemField,
|
||||
update: canSetCampaignInviteSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'createdBy',
|
||||
type: 'relationship',
|
||||
relationTo: 'campaignUser',
|
||||
label: 'Criado por',
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetCampaignInviteSystemField,
|
||||
update: canSetCampaignInviteSystemField,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -1,4 +1,79 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import type { CampaignUser as CampaignUserDocument } from '@/payload-types'
|
||||
import {
|
||||
canCreateCampaignUserPhone,
|
||||
canManageCampaignUsers,
|
||||
canManageCampaignUserRole,
|
||||
canReadCampaignUserIdentity,
|
||||
canReadCampaignUserPhone,
|
||||
canReadCampaignUsers,
|
||||
canUpdateCampaignUserPhone,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { normalizeBrazilianPhone } from '@/utilities/phone'
|
||||
import {
|
||||
APIError,
|
||||
type CollectionAfterReadHook,
|
||||
type CollectionBeforeChangeHook,
|
||||
type CollectionConfig,
|
||||
} from 'payload'
|
||||
|
||||
const removePrivateAuthFields: CollectionAfterReadHook<CampaignUserDocument> = async ({
|
||||
doc,
|
||||
req,
|
||||
}) => {
|
||||
const user = req.user
|
||||
|
||||
if (!user || user.collection === 'users' || String(user.id) === String(doc.id)) {
|
||||
return doc
|
||||
}
|
||||
|
||||
const privateDoc = doc as unknown as Record<string, unknown>
|
||||
delete privateDoc.email
|
||||
delete privateDoc.username
|
||||
delete privateDoc.sessions
|
||||
delete privateDoc.salt
|
||||
delete privateDoc.hash
|
||||
delete privateDoc.resetPasswordToken
|
||||
delete privateDoc.resetPasswordExpiration
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
const preventAssignedCoordinatorDowngrade: CollectionBeforeChangeHook<CampaignUserDocument> = async ({
|
||||
data,
|
||||
operation,
|
||||
originalDoc,
|
||||
req,
|
||||
}) => {
|
||||
if (
|
||||
operation !== 'update' ||
|
||||
!originalDoc ||
|
||||
data.role !== 'lideranca' ||
|
||||
originalDoc.role === 'lideranca'
|
||||
) {
|
||||
return data
|
||||
}
|
||||
|
||||
const assignedNucleus = await req.payload.find({
|
||||
collection: 'electoralNucleus',
|
||||
where: {
|
||||
coordinators: { contains: originalDoc.id },
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
select: { name: true, status: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
if (assignedNucleus.totalDocs > 0) {
|
||||
throw new APIError(
|
||||
'Remova ou substitua este usuário da coordenação de todos os núcleos antes de alterar o papel para liderança.',
|
||||
409,
|
||||
)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export const CampaignUser: CollectionConfig = {
|
||||
slug: 'campaignUser',
|
||||
|
|
@ -10,7 +85,23 @@ export const CampaignUser: CollectionConfig = {
|
|||
group: 'Campanha',
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
auth: true,
|
||||
auth: {
|
||||
loginWithUsername: {
|
||||
allowEmailLogin: true,
|
||||
requireEmail: false,
|
||||
requireUsername: false,
|
||||
},
|
||||
},
|
||||
access: {
|
||||
create: canManageCampaignUsers,
|
||||
read: canReadCampaignUsers,
|
||||
update: canManageCampaignUsers,
|
||||
delete: canManageCampaignUsers,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [preventAssignedCoordinatorDowngrade],
|
||||
afterRead: [removePrivateAuthFields],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
|
|
@ -18,5 +109,80 @@ export const CampaignUser: CollectionConfig = {
|
|||
label: 'Nome',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
type: 'select',
|
||||
label: 'Papel',
|
||||
required: true,
|
||||
defaultValue: 'lideranca',
|
||||
saveToJWT: true,
|
||||
options: [
|
||||
{ label: 'Coordenação geral', value: 'geral' },
|
||||
{ label: 'Coordenador', value: 'coordenador' },
|
||||
{ label: 'Liderança', value: 'lideranca' },
|
||||
],
|
||||
access: {
|
||||
update: canManageCampaignUserRole,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
label: 'E-mail',
|
||||
unique: true,
|
||||
index: true,
|
||||
access: {
|
||||
read: canReadCampaignUserIdentity,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
type: 'text',
|
||||
label: 'Celular de contato',
|
||||
index: true,
|
||||
access: {
|
||||
create: canCreateCampaignUserPhone,
|
||||
read: canReadCampaignUserPhone,
|
||||
update: canUpdateCampaignUserPhone,
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ value }) => {
|
||||
if (typeof value !== 'string' || value.length === 0) return value
|
||||
|
||||
return normalizeBrazilianPhone(value) ?? value
|
||||
},
|
||||
],
|
||||
},
|
||||
validate: (value: unknown) => {
|
||||
if (value === undefined || value === null || value === '') return true
|
||||
|
||||
return normalizeBrazilianPhone(String(value)) ? true : 'Informe um celular válido com DDD.'
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
type: 'text',
|
||||
label: 'Celular de acesso',
|
||||
unique: true,
|
||||
index: true,
|
||||
access: {
|
||||
read: canReadCampaignUserIdentity,
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ value }) => {
|
||||
if (typeof value !== 'string' || value.length === 0) return value
|
||||
|
||||
return normalizeBrazilianPhone(value) ?? value
|
||||
},
|
||||
],
|
||||
},
|
||||
validate: (value: unknown) => {
|
||||
if (value === undefined || value === null || value === '') return true
|
||||
|
||||
return normalizeBrazilianPhone(String(value)) ? true : 'Informe um celular válido com DDD.'
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,17 @@ export const Consent: CollectionConfig = {
|
|||
useAsTitle: 'text',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'key',
|
||||
type: 'text',
|
||||
label: 'Chave estável',
|
||||
required: false,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: 'Identificador estável para referências no código.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
type: 'richText',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,41 @@
|
|||
import { CitiesByState } from '@/lib/cities'
|
||||
import { CollectionConfig } from 'payload'
|
||||
import {
|
||||
acquireContactPhoneLocks,
|
||||
assertContactPhoneAvailable,
|
||||
} from '@/utilities/contactPhoneInvariant'
|
||||
import {
|
||||
canManageContacts,
|
||||
canReadContacts,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { normalizeBrazilianPhone } from '@/utilities/phone'
|
||||
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({
|
||||
data,
|
||||
operation,
|
||||
originalDoc,
|
||||
req,
|
||||
}) => {
|
||||
const phone = String(data.phone ?? originalDoc?.phone ?? '')
|
||||
const oldPhone =
|
||||
operation === 'update' && typeof originalDoc?.phone === 'string'
|
||||
? originalDoc.phone
|
||||
: undefined
|
||||
|
||||
await acquireContactPhoneLocks(
|
||||
req.payload,
|
||||
req,
|
||||
oldPhone === undefined ? [phone] : [oldPhone, phone],
|
||||
)
|
||||
await assertContactPhoneAvailable(
|
||||
req.payload,
|
||||
req,
|
||||
phone,
|
||||
operation === 'update' ? Number(originalDoc?.id) : undefined,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const Contact: CollectionConfig = {
|
||||
slug: 'contact',
|
||||
|
|
@ -11,6 +47,36 @@ export const Contact: CollectionConfig = {
|
|||
group: 'Contatos',
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
access: {
|
||||
create: canManageContacts,
|
||||
read: canReadContacts,
|
||||
update: canManageContacts,
|
||||
delete: canManageContacts,
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ data }) => {
|
||||
if (!data || data.phone === undefined) return data
|
||||
|
||||
const input = String(data.phone)
|
||||
if (/^\d{11}$/.test(input)) {
|
||||
if (!/^[1-9]{2}9\d{8}$/.test(input)) {
|
||||
throw new APIError('Celular brasileiro inválido.', 400)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const phone = normalizeBrazilianPhone(input)
|
||||
if (!phone) {
|
||||
throw new APIError('Celular brasileiro inválido.', 400)
|
||||
}
|
||||
|
||||
data.phone = phone
|
||||
return data
|
||||
},
|
||||
],
|
||||
beforeChange: [enforceUniqueContactPhone],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
|
|
@ -24,7 +90,7 @@ export const Contact: CollectionConfig = {
|
|||
name: 'email',
|
||||
type: 'email',
|
||||
label: 'E-mail',
|
||||
required: true,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
|
|
@ -33,6 +99,18 @@ export const Contact: CollectionConfig = {
|
|||
minLength: 11,
|
||||
maxLength: 11,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'gender',
|
||||
type: 'select',
|
||||
label: 'Gênero',
|
||||
options: [
|
||||
{ label: 'Feminino', value: 'feminino' },
|
||||
{ label: 'Masculino', value: 'masculino' },
|
||||
{ label: 'Outro', value: 'outro' },
|
||||
{ label: 'Não informado', value: 'nao_informado' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
|
|
@ -47,7 +125,7 @@ export const Contact: CollectionConfig = {
|
|||
label: 'Cidade',
|
||||
minLength: 2,
|
||||
maxLength: 100,
|
||||
required: true,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'postalCode',
|
||||
|
|
|
|||
582
src/collections/ElectoralNucleus.ts
Normal file
582
src/collections/ElectoralNucleus.ts
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import {
|
||||
isBahiaIdentityTerritory,
|
||||
isBahiaMunicipality,
|
||||
validateBahiaTerritoryPair,
|
||||
} from '@/lib/bahiaTerritories'
|
||||
import {
|
||||
canCreateElectoralNucleus,
|
||||
canCreateNucleusCoordinators,
|
||||
canDeleteElectoralNucleus,
|
||||
canManageNucleusCoordinators,
|
||||
canManageNucleusLifecycle,
|
||||
canReadLeadershipInternal,
|
||||
canReadElectoralNucleus,
|
||||
canSetDerivedNucleusField,
|
||||
canUpdateElectoralNucleus,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorOptions'
|
||||
import { acquirePrimaryContactInvariantLocks } from '@/utilities/primaryContactInvariantLock'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
import { slugify } from '@/utilities/slug'
|
||||
|
||||
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
const validateNucleusTerritoryAndZones: CollectionBeforeValidateHook = ({
|
||||
data,
|
||||
operation,
|
||||
originalDoc,
|
||||
}) => {
|
||||
if (!data) return data
|
||||
|
||||
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
|
||||
const region = trimmedText(nextData?.region)
|
||||
const city = trimmedText(nextData?.city)
|
||||
const neighborhood = trimmedText(nextData?.neighborhood)
|
||||
const locality = trimmedText(nextData?.locality)
|
||||
const territoryNotes = trimmedText(nextData?.territoryNotes)
|
||||
|
||||
if (region && !isBahiaIdentityTerritory(region)) {
|
||||
throw new APIError('Selecione um território de identidade válido da Bahia.', 400)
|
||||
}
|
||||
if (city && !isBahiaMunicipality(city)) {
|
||||
throw new APIError('Selecione um município válido da Bahia.', 400)
|
||||
}
|
||||
if (!validateBahiaTerritoryPair(region, city)) {
|
||||
throw new APIError('O município não pertence ao território de identidade selecionado.', 400)
|
||||
}
|
||||
if (neighborhood && !city) {
|
||||
throw new APIError('Informe o município antes do bairro.', 400)
|
||||
}
|
||||
if (!region && !city && !locality) {
|
||||
throw new APIError(
|
||||
'Informe o território de identidade, município ou localidade do núcleo.',
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
data.region = region || null
|
||||
data.city = city || null
|
||||
data.neighborhood = neighborhood || null
|
||||
data.locality = locality || null
|
||||
data.territoryNotes = territoryNotes || null
|
||||
|
||||
const zones = Array.isArray(nextData.tseZones) ? nextData.tseZones : []
|
||||
const zoneNumbers = zones.map((zone: { zoneNumber?: unknown }) => zone?.zoneNumber)
|
||||
|
||||
if (new Set(zoneNumbers).size !== zoneNumbers.length) {
|
||||
throw new APIError('Cada Zona TSE deve aparecer apenas uma vez.', 400)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
const setCanonicalNucleusSlug: CollectionBeforeValidateHook = ({
|
||||
data,
|
||||
operation,
|
||||
originalDoc,
|
||||
}) => {
|
||||
if (!data) return data
|
||||
const name = trimmedText(data.name ?? originalDoc?.name)
|
||||
const slug = slugify(name)
|
||||
if (!slug) {
|
||||
throw new APIError('Informe um nome com letras ou números.', 400)
|
||||
}
|
||||
if (operation === 'update' && data.name !== undefined && name !== originalDoc?.name) {
|
||||
throw new APIError('O nome do núcleo não pode ser alterado após a criação.', 409)
|
||||
}
|
||||
data.name = name
|
||||
data.slug = operation === 'create' ? slug : originalDoc?.slug
|
||||
return data
|
||||
}
|
||||
|
||||
const validateNucleusCoordinators: CollectionBeforeValidateHook = async ({ data, req }) => {
|
||||
if (!data) return data
|
||||
|
||||
if (data.coordinators === undefined) return data
|
||||
const coordinatorValues = data.coordinators
|
||||
const coordinatorIDs = Array.isArray(coordinatorValues)
|
||||
? [...new Set(coordinatorValues.map(relationshipId).filter((id): id is number => id !== null))]
|
||||
: []
|
||||
|
||||
if (coordinatorIDs.length === 0) return data
|
||||
|
||||
const eligibleCoordinators = await req.payload.find({
|
||||
collection: 'campaignUser',
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
where: {
|
||||
and: [{ id: { in: coordinatorIDs } }, eligibleNucleusCoordinatorWhere],
|
||||
},
|
||||
select: { name: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
if (eligibleCoordinators.docs.length !== coordinatorIDs.length) {
|
||||
throw new APIError('Cada responsável deve ter papel de coordenação geral ou coordenador.', 400)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export const ElectoralNucleus: CollectionConfig = {
|
||||
slug: 'electoralNucleus',
|
||||
labels: {
|
||||
singular: 'Núcleo eleitoral',
|
||||
plural: 'Núcleos eleitorais',
|
||||
},
|
||||
admin: {
|
||||
group: 'Campanha',
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'status', 'region', 'city', 'updatedAt'],
|
||||
},
|
||||
access: {
|
||||
create: canCreateElectoralNucleus,
|
||||
read: canReadElectoralNucleus,
|
||||
update: canUpdateElectoralNucleus,
|
||||
delete: canDeleteElectoralNucleus,
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
setCanonicalNucleusSlug,
|
||||
validateNucleusTerritoryAndZones,
|
||||
validateNucleusCoordinators,
|
||||
],
|
||||
beforeChange: [
|
||||
({ data, operation, req }) => {
|
||||
if (operation === 'create' && req.user?.collection === 'campaignUser') {
|
||||
data.createdBy = req.user.id
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
async ({ data, operation, originalDoc, req }) => {
|
||||
if (data.primaryContact === undefined || data.primaryContact === null) return data
|
||||
|
||||
const contact = relationshipId(data.primaryContact)
|
||||
const nucleus = relationshipId(originalDoc?.id)
|
||||
if (!contact || operation !== 'update' || !nucleus) {
|
||||
throw new APIError(
|
||||
'Escolha o contato principal entre as lideranças engajadas após criar o núcleo.',
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
await acquirePrimaryContactInvariantLocks(req, [nucleus])
|
||||
const engagedLeadership = await req.payload.find({
|
||||
collection: 'leadership',
|
||||
where: {
|
||||
and: [
|
||||
{ nucleus: { equals: nucleus } },
|
||||
{ contact: { equals: contact } },
|
||||
{ supportStatus: { equals: 'engajado' } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
if (engagedLeadership.totalDocs === 0) {
|
||||
throw new APIError(
|
||||
'O contato principal deve ser uma liderança engajada neste núcleo.',
|
||||
409,
|
||||
)
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
label: 'Nome',
|
||||
required: true,
|
||||
minLength: 2,
|
||||
maxLength: 160,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
label: 'Slug',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetDerivedNucleusField,
|
||||
update: canSetDerivedNucleusField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
label: 'Status',
|
||||
required: true,
|
||||
defaultValue: 'ativo',
|
||||
index: true,
|
||||
access: {
|
||||
create: canSetDerivedNucleusField,
|
||||
update: canManageNucleusLifecycle,
|
||||
},
|
||||
options: [
|
||||
{ label: 'Ativo', value: 'ativo' },
|
||||
{ label: 'Arquivado', value: 'arquivado' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'coordinators',
|
||||
type: 'relationship',
|
||||
relationTo: 'campaignUser',
|
||||
label: 'Coordenadores',
|
||||
hasMany: true,
|
||||
index: true,
|
||||
access: {
|
||||
create: canCreateNucleusCoordinators,
|
||||
update: canManageNucleusCoordinators,
|
||||
},
|
||||
filterOptions: eligibleNucleusCoordinatorWhere,
|
||||
},
|
||||
{
|
||||
name: 'region',
|
||||
type: 'text',
|
||||
label: 'Território de identidade',
|
||||
index: true,
|
||||
validate: (value: unknown) =>
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
(typeof value === 'string' && isBahiaIdentityTerritory(value))
|
||||
? true
|
||||
: 'Selecione um território de identidade válido da Bahia.',
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text',
|
||||
label: 'Município',
|
||||
maxLength: 120,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'neighborhood',
|
||||
type: 'text',
|
||||
label: 'Bairro',
|
||||
maxLength: 160,
|
||||
},
|
||||
{
|
||||
name: 'locality',
|
||||
type: 'text',
|
||||
label: 'Localidade',
|
||||
maxLength: 160,
|
||||
},
|
||||
{
|
||||
name: 'territoryNotes',
|
||||
type: 'textarea',
|
||||
label: 'Observações do território',
|
||||
maxLength: 2000,
|
||||
},
|
||||
{
|
||||
name: 'organizationKind',
|
||||
type: 'select',
|
||||
label: 'Natureza organizativa',
|
||||
required: true,
|
||||
defaultValue: 'territorial',
|
||||
index: true,
|
||||
options: [
|
||||
{ label: 'Territorial', value: 'territorial' },
|
||||
{ label: 'Associação', value: 'associacao' },
|
||||
{ label: 'Sindicato', value: 'sindicato' },
|
||||
{ label: 'Religioso', value: 'religioso' },
|
||||
{ label: 'Movimento', value: 'movimento' },
|
||||
{ label: 'Categoria profissional', value: 'categoria_profissional' },
|
||||
{ label: 'Outro', value: 'outro' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'organizationLabel',
|
||||
type: 'text',
|
||||
label: 'Nome da organização',
|
||||
maxLength: 160,
|
||||
},
|
||||
{
|
||||
name: 'sectorKind',
|
||||
type: 'select',
|
||||
label: 'Setor',
|
||||
index: true,
|
||||
options: [
|
||||
{ label: 'Rural', value: 'rural' },
|
||||
{ label: 'Religioso', value: 'religioso' },
|
||||
{ label: 'Sindical', value: 'sindical' },
|
||||
{ label: 'Empresarial', value: 'empresarial' },
|
||||
{ label: 'Juventude', value: 'juventude' },
|
||||
{ label: 'Saúde', value: 'saude' },
|
||||
{ label: 'Educação', value: 'educacao' },
|
||||
{ label: 'Cultura', value: 'cultura' },
|
||||
{ label: 'Outro', value: 'outro' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tseZones',
|
||||
type: 'array',
|
||||
label: 'Zonas TSE',
|
||||
labels: {
|
||||
singular: 'Zona TSE',
|
||||
plural: 'Zonas TSE',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'zoneNumber',
|
||||
type: 'number',
|
||||
label: 'Número',
|
||||
required: true,
|
||||
min: 1,
|
||||
max: 999,
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'number' && Number.isInteger(value)
|
||||
? true
|
||||
: 'O número da Zona TSE deve ser inteiro.',
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
label: 'Descrição',
|
||||
maxLength: 160,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'primaryContact',
|
||||
type: 'relationship',
|
||||
relationTo: 'contact',
|
||||
label: 'Contato principal',
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'voterProfiles',
|
||||
type: 'array',
|
||||
label: 'Perfis do eleitorado',
|
||||
labels: {
|
||||
singular: 'Perfil do eleitorado',
|
||||
plural: 'Perfis do eleitorado',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
label: 'Nome do perfil',
|
||||
required: true,
|
||||
maxLength: 120,
|
||||
},
|
||||
{
|
||||
name: 'ageRange',
|
||||
type: 'text',
|
||||
label: 'Faixa etária',
|
||||
maxLength: 80,
|
||||
},
|
||||
{
|
||||
name: 'incomeBand',
|
||||
type: 'text',
|
||||
label: 'Faixa de renda',
|
||||
maxLength: 80,
|
||||
},
|
||||
{
|
||||
name: 'occupation',
|
||||
type: 'text',
|
||||
label: 'Ocupação',
|
||||
maxLength: 120,
|
||||
},
|
||||
{
|
||||
name: 'localTraits',
|
||||
type: 'textarea',
|
||||
label: 'Características locais',
|
||||
maxLength: 500,
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'textarea',
|
||||
label: 'Observações',
|
||||
maxLength: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'strengths',
|
||||
type: 'array',
|
||||
label: 'Forças',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'textarea',
|
||||
label: 'Força',
|
||||
required: true,
|
||||
maxLength: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'risks',
|
||||
type: 'array',
|
||||
label: 'Riscos',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'textarea',
|
||||
label: 'Risco',
|
||||
required: true,
|
||||
maxLength: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'confirmedVoteEstimate',
|
||||
type: 'number',
|
||||
label: 'Estimativa de votos confirmada',
|
||||
min: 0,
|
||||
index: true,
|
||||
access: {
|
||||
create: canSetDerivedNucleusField,
|
||||
update: canSetDerivedNucleusField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'confirmedVoteEstimateAt',
|
||||
type: 'date',
|
||||
label: 'Estimativa confirmada em',
|
||||
access: {
|
||||
create: canSetDerivedNucleusField,
|
||||
update: canSetDerivedNucleusField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'confirmedVoteEstimateBy',
|
||||
type: 'relationship',
|
||||
relationTo: 'campaignUser',
|
||||
label: 'Estimativa confirmada por',
|
||||
access: {
|
||||
create: canSetDerivedNucleusField,
|
||||
update: canSetDerivedNucleusField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'confirmationNote',
|
||||
type: 'textarea',
|
||||
label: 'Justificativa da confirmação',
|
||||
maxLength: 1000,
|
||||
access: {
|
||||
create: canSetDerivedNucleusField,
|
||||
update: canSetDerivedNucleusField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'proposedVoteEstimate',
|
||||
type: 'number',
|
||||
label: 'Sugestão de estimativa de votos',
|
||||
min: 0,
|
||||
index: true,
|
||||
access: {
|
||||
create: canSetDerivedNucleusField,
|
||||
update: canSetDerivedNucleusField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'proposedVoteEstimateAt',
|
||||
type: 'date',
|
||||
label: 'Sugestão enviada em',
|
||||
access: {
|
||||
create: canSetDerivedNucleusField,
|
||||
update: canSetDerivedNucleusField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'proposedVoteEstimateBy',
|
||||
type: 'relationship',
|
||||
relationTo: 'campaignUser',
|
||||
label: 'Sugestão enviada por',
|
||||
access: {
|
||||
create: canSetDerivedNucleusField,
|
||||
update: canSetDerivedNucleusField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'proposedVoteEstimateVersion',
|
||||
type: 'text',
|
||||
label: 'Versão da sugestão de estimativa',
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
read: canReadLeadershipInternal,
|
||||
create: () => false,
|
||||
update: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ticketAlliance',
|
||||
type: 'group',
|
||||
label: 'Dobrada',
|
||||
fields: [
|
||||
{
|
||||
name: 'partnerName',
|
||||
type: 'text',
|
||||
label: 'Nome da parceria',
|
||||
maxLength: 120,
|
||||
},
|
||||
{
|
||||
name: 'office',
|
||||
type: 'text',
|
||||
label: 'Cargo',
|
||||
maxLength: 120,
|
||||
},
|
||||
{
|
||||
name: 'isCampaignPartner',
|
||||
type: 'checkbox',
|
||||
label: 'Parceiro da campanha',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'textarea',
|
||||
label: 'Observações',
|
||||
maxLength: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'lastUpdateAt',
|
||||
type: 'date',
|
||||
label: 'Última atualização',
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Preenchido automaticamente quando o domínio de atualizações for ativado.',
|
||||
},
|
||||
access: {
|
||||
create: canSetDerivedNucleusField,
|
||||
update: canSetDerivedNucleusField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'createdBy',
|
||||
type: 'relationship',
|
||||
relationTo: 'campaignUser',
|
||||
label: 'Criado por',
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetDerivedNucleusField,
|
||||
update: canSetDerivedNucleusField,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
235
src/collections/Leadership.ts
Normal file
235
src/collections/Leadership.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import {
|
||||
canCreateLeadership,
|
||||
canDeleteLeadership,
|
||||
canManageLeadership,
|
||||
canManageLeadershipInternal,
|
||||
canReadLeadership,
|
||||
canReadLeadershipInternal,
|
||||
canSetAdministrativeLeadershipField,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { acquirePrimaryContactInvariantLocks } from '@/utilities/primaryContactInvariantLock'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
|
||||
export const Leadership: CollectionConfig = {
|
||||
slug: 'leadership',
|
||||
labels: {
|
||||
singular: 'Liderança',
|
||||
plural: 'Lideranças',
|
||||
},
|
||||
admin: {
|
||||
group: 'Campanha',
|
||||
useAsTitle: 'contact',
|
||||
defaultColumns: ['contact', 'nucleus', 'sector', 'supportStatus', 'updatedAt'],
|
||||
},
|
||||
access: {
|
||||
create: canCreateLeadership,
|
||||
read: canReadLeadership,
|
||||
update: canManageLeadership,
|
||||
delete: canDeleteLeadership,
|
||||
},
|
||||
indexes: [
|
||||
{
|
||||
fields: ['contact', 'nucleus'],
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
async ({ data, operation, originalDoc, req }) => {
|
||||
if (operation === 'create' && req.user?.collection === 'campaignUser') {
|
||||
data.createdBy = req.user.id
|
||||
}
|
||||
|
||||
if (operation !== 'update') return data
|
||||
const previousContact = relationshipId(originalDoc?.contact)
|
||||
const previousNucleus = relationshipId(originalDoc?.nucleus)
|
||||
const nextContact = relationshipId(data.contact ?? originalDoc?.contact)
|
||||
const nextNucleus = relationshipId(data.nucleus ?? originalDoc?.nucleus)
|
||||
const nextStatus = data.supportStatus ?? originalDoc?.supportStatus
|
||||
if (!previousContact || !previousNucleus || !nextContact || !nextNucleus) return data
|
||||
|
||||
await acquirePrimaryContactInvariantLocks(req, [previousNucleus, nextNucleus])
|
||||
const remainsEligible =
|
||||
nextStatus === 'engajado' &&
|
||||
nextContact === previousContact &&
|
||||
nextNucleus === previousNucleus
|
||||
if (remainsEligible) return data
|
||||
|
||||
const primaryContactUse = await req.payload.find({
|
||||
collection: 'electoralNucleus',
|
||||
where: {
|
||||
and: [
|
||||
{ id: { equals: previousNucleus } },
|
||||
{ primaryContact: { equals: previousContact } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
if (primaryContactUse.totalDocs > 0) {
|
||||
throw new APIError(
|
||||
'Escolha outro contato principal antes de alterar o status desta liderança.',
|
||||
409,
|
||||
)
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'contact',
|
||||
type: 'relationship',
|
||||
relationTo: 'contact',
|
||||
label: 'Contato',
|
||||
required: true,
|
||||
index: true,
|
||||
access: {
|
||||
update: canSetAdministrativeLeadershipField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'nucleus',
|
||||
type: 'relationship',
|
||||
relationTo: 'electoralNucleus',
|
||||
label: 'Núcleo eleitoral',
|
||||
required: true,
|
||||
index: true,
|
||||
access: {
|
||||
update: canSetAdministrativeLeadershipField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sector',
|
||||
type: 'select',
|
||||
label: 'Setor',
|
||||
index: true,
|
||||
options: [
|
||||
{ label: 'Religioso', value: 'religioso' },
|
||||
{ label: 'Sindical', value: 'sindical' },
|
||||
{ label: 'Comunitário', value: 'comunitario' },
|
||||
{ label: 'Rural', value: 'rural' },
|
||||
{ label: 'Empresarial', value: 'empresarial' },
|
||||
{ label: 'Juventude', value: 'juventude' },
|
||||
{ label: 'Saúde', value: 'saude' },
|
||||
{ label: 'Educação', value: 'educacao' },
|
||||
{ label: 'Cultura', value: 'cultura' },
|
||||
{ label: 'Outro', value: 'outro' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sectorNotes',
|
||||
type: 'textarea',
|
||||
label: 'Observações do setor',
|
||||
maxLength: 1000,
|
||||
},
|
||||
{
|
||||
name: 'supportStatus',
|
||||
type: 'select',
|
||||
label: 'Status de apoio',
|
||||
required: true,
|
||||
defaultValue: 'a_abordar',
|
||||
index: true,
|
||||
access: {
|
||||
create: canManageLeadershipInternal,
|
||||
read: canReadLeadershipInternal,
|
||||
update: canManageLeadershipInternal,
|
||||
},
|
||||
options: [
|
||||
{ label: 'Engajado', value: 'engajado' },
|
||||
{ label: 'A abordar', value: 'a_abordar' },
|
||||
{ label: 'Em disputa', value: 'em_disputa' },
|
||||
{ label: 'Negativo', value: 'negativo' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'campaignUser',
|
||||
label: 'Acesso ao app',
|
||||
index: true,
|
||||
access: {
|
||||
create: canSetAdministrativeLeadershipField,
|
||||
update: canSetAdministrativeLeadershipField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'consent',
|
||||
type: 'relationship',
|
||||
relationTo: 'consent',
|
||||
label: 'Consentimento confirmado',
|
||||
access: {
|
||||
create: canSetAdministrativeLeadershipField,
|
||||
update: canSetAdministrativeLeadershipField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'consentContentHash',
|
||||
type: 'text',
|
||||
label: 'Hash do conteúdo consentido',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetAdministrativeLeadershipField,
|
||||
update: canSetAdministrativeLeadershipField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'consentedAt',
|
||||
type: 'date',
|
||||
label: 'Consentimento confirmado em',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetAdministrativeLeadershipField,
|
||||
update: canSetAdministrativeLeadershipField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'textarea',
|
||||
label: 'Observações internas',
|
||||
maxLength: 3000,
|
||||
access: {
|
||||
create: canManageLeadershipInternal,
|
||||
read: canReadLeadershipInternal,
|
||||
update: canManageLeadershipInternal,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'consentNote',
|
||||
type: 'textarea',
|
||||
label: 'Registro de consentimento externo',
|
||||
maxLength: 2000,
|
||||
access: {
|
||||
create: canManageLeadershipInternal,
|
||||
read: canReadLeadershipInternal,
|
||||
update: canManageLeadershipInternal,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'createdBy',
|
||||
type: 'relationship',
|
||||
relationTo: 'campaignUser',
|
||||
label: 'Criado por',
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetAdministrativeLeadershipField,
|
||||
read: canReadLeadershipInternal,
|
||||
update: canSetAdministrativeLeadershipField,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
264
src/collections/NucleusUpdate.ts
Normal file
264
src/collections/NucleusUpdate.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import type {
|
||||
CollectionAfterChangeHook,
|
||||
CollectionAfterDeleteHook,
|
||||
CollectionBeforeChangeHook,
|
||||
CollectionBeforeDeleteHook,
|
||||
CollectionBeforeValidateHook,
|
||||
CollectionConfig,
|
||||
PayloadRequest,
|
||||
} from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import {
|
||||
canCreateNucleusUpdate,
|
||||
canMutateNucleusUpdate,
|
||||
canReadNucleusUpdate,
|
||||
canSetNucleusUpdateAuthor,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
|
||||
const DERIVED_NUCLEUS_UPDATE_CONTEXT = 'nucleusUpdateDerivedField'
|
||||
|
||||
const nonEmptyText = (value: unknown): boolean =>
|
||||
typeof value === 'string' && value.trim().length > 0
|
||||
|
||||
const validateNucleusUpdateKind: CollectionBeforeValidateHook = ({
|
||||
data,
|
||||
operation,
|
||||
originalDoc,
|
||||
}) => {
|
||||
if (!data) return data
|
||||
|
||||
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
|
||||
const kind = nextData.kind ?? 'semanal'
|
||||
|
||||
if (kind === 'semanal') {
|
||||
if (!nonEmptyText(nextData.worked)) throw new APIError('Informe o que funcionou.', 400)
|
||||
if (!nonEmptyText(nextData.failed)) throw new APIError('Informe o que não funcionou.', 400)
|
||||
if (!nonEmptyText(nextData.needs)) throw new APIError('Informe o que você precisa.', 400)
|
||||
} else if (!nonEmptyText(nextData.body)) {
|
||||
throw new APIError('Informe o texto da atualização.', 400)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
const uniqueSortedNucleusIDs = (values: Array<number | null>): number[] =>
|
||||
[...new Set(values.filter((value): value is number => value !== null))].sort(
|
||||
(left, right) => left - right,
|
||||
)
|
||||
|
||||
const acquireNucleusUpdateLocks = (req: PayloadRequest, nucleusIDs: number[]) =>
|
||||
acquireTextAdvisoryLocks(
|
||||
req.payload,
|
||||
req,
|
||||
nucleusIDs.map((nucleusID) => `nucleus-updates:${nucleusID}`),
|
||||
)
|
||||
|
||||
const recomputeNucleusLastUpdateAt = async (req: PayloadRequest, nucleusIDs: number[]) => {
|
||||
for (const nucleusID of nucleusIDs) {
|
||||
const latest = await req.payload.find({
|
||||
collection: 'nucleusUpdate',
|
||||
where: { nucleus: { equals: nucleusID } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
sort: '-createdAt',
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
await req.payload.update({
|
||||
collection: 'electoralNucleus',
|
||||
id: nucleusID,
|
||||
data: { lastUpdateAt: latest.docs[0]?.createdAt ?? null },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
context: {
|
||||
[DERIVED_NUCLEUS_UPDATE_CONTEXT]: true,
|
||||
},
|
||||
req,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const lockNucleiBeforeChange: CollectionBeforeChangeHook = async ({
|
||||
context,
|
||||
data,
|
||||
operation,
|
||||
originalDoc,
|
||||
req,
|
||||
}) => {
|
||||
if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return data
|
||||
|
||||
if (operation === 'create' && req.user?.collection === 'campaignUser') {
|
||||
data.author = req.user.id
|
||||
}
|
||||
|
||||
const nucleusIDs = uniqueSortedNucleusIDs([
|
||||
relationshipId(originalDoc?.nucleus),
|
||||
relationshipId(data.nucleus ?? originalDoc?.nucleus),
|
||||
])
|
||||
if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500)
|
||||
|
||||
await acquireNucleusUpdateLocks(req, nucleusIDs)
|
||||
return data
|
||||
}
|
||||
|
||||
const lockNucleusBeforeDelete: CollectionBeforeDeleteHook = async ({ context, id, req }) => {
|
||||
if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return
|
||||
|
||||
const doc = await req.payload.findByID({
|
||||
collection: 'nucleusUpdate',
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const nucleusIDs = uniqueSortedNucleusIDs([relationshipId(doc.nucleus)])
|
||||
if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500)
|
||||
|
||||
await acquireNucleusUpdateLocks(req, nucleusIDs)
|
||||
}
|
||||
|
||||
const recomputeChangedNuclei: CollectionAfterChangeHook = async ({
|
||||
context,
|
||||
doc,
|
||||
previousDoc,
|
||||
req,
|
||||
}) => {
|
||||
if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return doc
|
||||
|
||||
const nucleusIDs = uniqueSortedNucleusIDs([
|
||||
relationshipId(previousDoc?.nucleus),
|
||||
relationshipId(doc.nucleus),
|
||||
])
|
||||
if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500)
|
||||
|
||||
await acquireNucleusUpdateLocks(req, nucleusIDs)
|
||||
await recomputeNucleusLastUpdateAt(req, nucleusIDs)
|
||||
return doc
|
||||
}
|
||||
|
||||
const recomputeDeletedNucleus: CollectionAfterDeleteHook = async ({ context, doc, req }) => {
|
||||
if (context[DERIVED_NUCLEUS_UPDATE_CONTEXT]) return doc
|
||||
|
||||
const nucleusIDs = uniqueSortedNucleusIDs([relationshipId(doc.nucleus)])
|
||||
if (nucleusIDs.length === 0) throw new APIError('Núcleo da atualização inválido.', 500)
|
||||
|
||||
await acquireNucleusUpdateLocks(req, nucleusIDs)
|
||||
await recomputeNucleusLastUpdateAt(req, nucleusIDs)
|
||||
return doc
|
||||
}
|
||||
|
||||
export const NucleusUpdate: CollectionConfig = {
|
||||
slug: 'nucleusUpdate',
|
||||
labels: {
|
||||
singular: 'Atualização do núcleo',
|
||||
plural: 'Atualizações dos núcleos',
|
||||
},
|
||||
admin: {
|
||||
group: 'Campanha',
|
||||
useAsTitle: 'kind',
|
||||
defaultColumns: ['nucleus', 'author', 'kind', 'createdAt'],
|
||||
},
|
||||
access: {
|
||||
create: canCreateNucleusUpdate,
|
||||
read: canReadNucleusUpdate,
|
||||
update: canMutateNucleusUpdate,
|
||||
delete: canMutateNucleusUpdate,
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [validateNucleusUpdateKind],
|
||||
beforeChange: [lockNucleiBeforeChange],
|
||||
beforeDelete: [lockNucleusBeforeDelete],
|
||||
afterChange: [recomputeChangedNuclei],
|
||||
afterDelete: [recomputeDeletedNucleus],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'nucleus',
|
||||
type: 'relationship',
|
||||
relationTo: 'electoralNucleus',
|
||||
label: 'Núcleo eleitoral',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'author',
|
||||
type: 'relationship',
|
||||
relationTo: 'campaignUser',
|
||||
label: 'Autor',
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetNucleusUpdateAuthor,
|
||||
update: canSetNucleusUpdateAuthor,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'kind',
|
||||
type: 'select',
|
||||
label: 'Tipo',
|
||||
required: true,
|
||||
defaultValue: 'semanal',
|
||||
index: true,
|
||||
options: [
|
||||
{ label: 'Semanal', value: 'semanal' },
|
||||
{ label: 'Urgente', value: 'urgente' },
|
||||
{ label: 'Nota', value: 'nota' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'worked',
|
||||
type: 'textarea',
|
||||
label: 'O que funcionou',
|
||||
maxLength: 3000,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.kind === 'semanal',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'failed',
|
||||
type: 'textarea',
|
||||
label: 'O que não funcionou',
|
||||
maxLength: 3000,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.kind === 'semanal',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'needs',
|
||||
type: 'textarea',
|
||||
label: 'O que preciso',
|
||||
maxLength: 3000,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.kind === 'semanal',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'activeVolunteers',
|
||||
type: 'number',
|
||||
label: 'Voluntários ativos',
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'newSupports',
|
||||
type: 'number',
|
||||
label: 'Novos apoios',
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'textarea',
|
||||
label: 'Texto',
|
||||
maxLength: 5000,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.kind !== 'semanal',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -1,15 +1,11 @@
|
|||
'use client'
|
||||
|
||||
import type {
|
||||
CompositionEventHandler,
|
||||
FocusEventHandler,
|
||||
InputEventHandler,
|
||||
ComponentProps,
|
||||
} from 'react'
|
||||
import { Input } from './ui/input'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { type FieldPath, type FieldValues, useFormContext } from 'react-hook-form'
|
||||
|
||||
interface FormInpuProps<
|
||||
import { FormattedInput } from '@/components/FormattedInput'
|
||||
|
||||
interface FormInputProps<
|
||||
TFieldValues extends FieldValues,
|
||||
TFieldName extends FieldPath<TFieldValues>,
|
||||
> extends ComponentProps<'input'> {
|
||||
|
|
@ -26,50 +22,20 @@ export const FormInput = <
|
|||
format,
|
||||
sanitize,
|
||||
...props
|
||||
}: FormInpuProps<TFieldValues, TFieldName>) => {
|
||||
}: FormInputProps<TFieldValues, TFieldName>) => {
|
||||
const { onChange, onBlur, ...registerResult } = useFormContext<TFieldValues>().register(
|
||||
name,
|
||||
sanitize ? { setValueAs: sanitize } : undefined,
|
||||
)
|
||||
|
||||
const handleUpdate = (e: EventTarget & HTMLInputElement, value: string) => {
|
||||
const selectiondistance = e.value.length - (e.selectionEnd ?? 0)
|
||||
e.value = value
|
||||
const caretPos = value.length - selectiondistance
|
||||
e.setSelectionRange(caretPos, caretPos)
|
||||
}
|
||||
|
||||
const handleInput: InputEventHandler<HTMLInputElement> = (e) => {
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (format) {
|
||||
handleUpdate(e.currentTarget, format(e.currentTarget.value))
|
||||
}
|
||||
|
||||
onChange(e)
|
||||
}
|
||||
|
||||
const handleCompositionEnd: CompositionEventHandler<HTMLInputElement> | undefined = format
|
||||
? (e) => handleUpdate(e.currentTarget, format(e.currentTarget.value))
|
||||
: undefined
|
||||
|
||||
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
|
||||
handleUpdate(
|
||||
e.currentTarget,
|
||||
format?.(sanitize?.(e.currentTarget.value) ?? e.currentTarget.value) ?? e.currentTarget.value,
|
||||
)
|
||||
onBlur(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
<FormattedInput
|
||||
{...props}
|
||||
{...registerResult}
|
||||
onInput={handleInput}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onBlur={handleBlur}
|
||||
format={format}
|
||||
sanitize={sanitize}
|
||||
onInput={onChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
60
src/components/FormattedInput.tsx
Normal file
60
src/components/FormattedInput.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use client'
|
||||
|
||||
import type {
|
||||
ComponentProps,
|
||||
CompositionEventHandler,
|
||||
FocusEventHandler,
|
||||
InputEventHandler,
|
||||
} from 'react'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
type FormattedInputProps = ComponentProps<'input'> & {
|
||||
format?: (value: string) => string
|
||||
sanitize?: (value: string) => string
|
||||
}
|
||||
|
||||
const updateInputValue = (input: HTMLInputElement, value: string): void => {
|
||||
const selectionDistance = input.value.length - (input.selectionEnd ?? input.value.length)
|
||||
input.value = value
|
||||
const caretPosition = Math.max(0, value.length - selectionDistance)
|
||||
input.setSelectionRange(caretPosition, caretPosition)
|
||||
}
|
||||
|
||||
export const FormattedInput = ({
|
||||
format,
|
||||
sanitize,
|
||||
onInput,
|
||||
onCompositionEnd,
|
||||
onBlur,
|
||||
...props
|
||||
}: FormattedInputProps) => {
|
||||
const handleInput: InputEventHandler<HTMLInputElement> = (event) => {
|
||||
if (!event.nativeEvent.isComposing && format) {
|
||||
updateInputValue(event.currentTarget, format(event.currentTarget.value))
|
||||
}
|
||||
onInput?.(event)
|
||||
}
|
||||
|
||||
const handleCompositionEnd: CompositionEventHandler<HTMLInputElement> = (event) => {
|
||||
if (format) {
|
||||
updateInputValue(event.currentTarget, format(event.currentTarget.value))
|
||||
}
|
||||
onCompositionEnd?.(event)
|
||||
}
|
||||
|
||||
const handleBlur: FocusEventHandler<HTMLInputElement> = (event) => {
|
||||
const value = sanitize?.(event.currentTarget.value) ?? event.currentTarget.value
|
||||
updateInputValue(event.currentTarget, format?.(value) ?? value)
|
||||
onBlur?.(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
onInput={handleInput}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
|
||||
|
||||
import { FormInput } from './FormInput'
|
||||
|
||||
export const PhoneInput = (props: ComponentProps<'input'>) => (
|
||||
|
|
@ -9,22 +12,10 @@ export const PhoneInput = (props: ComponentProps<'input'>) => (
|
|||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
type="tel"
|
||||
maxLength={15}
|
||||
maxLength={19}
|
||||
minLength={15}
|
||||
format={format}
|
||||
sanitize={sanitize}
|
||||
format={formatBrazilianPhoneInput}
|
||||
sanitize={sanitizeBrazilianPhoneInput}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const sanitize = (value: string) => value.replace(/\D/g, '').slice(0, 11)
|
||||
|
||||
const format = (value: string) => {
|
||||
const digits = sanitize(value)
|
||||
|
||||
if (!digits) return ''
|
||||
if (digits.length <= 2) return `(${digits}`
|
||||
if (digits.length <= 7) return `(${digits.slice(0, 2)}) ${digits.slice(2)}`
|
||||
|
||||
return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Post } from '@/payload-types'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { formatPostDate, getCategoryName, getPostCanonicalPath } from '@/utilities/posts'
|
||||
import Image from 'next/image'
|
||||
|
|
|
|||
60
src/components/campaign/ArchiveNucleusDialog.tsx
Normal file
60
src/components/campaign/ArchiveNucleusDialog.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { ArchiveIcon } from 'lucide-react'
|
||||
|
||||
import { archiveNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/AlertDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
|
||||
export const ArchiveNucleusDialog = ({ nucleusId }: { nucleusId: number }) => {
|
||||
const [state, action, pending] = useActionState(archiveNucleusFormAction, {})
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button type="button" variant="destructive" className="min-h-11">
|
||||
<ArchiveIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Arquivar
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Arquivar este núcleo?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
O núcleo sairá das listas ativas. Os dados permanecem preservados no sistema.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{state.message ? (
|
||||
<Alert variant="destructive" aria-live="polite">
|
||||
<AlertTitle>Não foi possível arquivar</AlertTitle>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<form action={action}>
|
||||
<input type="hidden" name="id" value={nucleusId} />
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel type="button" className="min-h-11" disabled={pending}>
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<Button type="submit" variant="destructive" className="min-h-11" disabled={pending}>
|
||||
{pending ? <Spinner data-icon="inline-start" /> : null}
|
||||
{pending ? 'Arquivando…' : 'Confirmar arquivamento'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
41
src/components/campaign/CampaignBottomNav.tsx
Normal file
41
src/components/campaign/CampaignBottomNav.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import { getCampaignNav, 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()
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
className="fixed inset-x-0 bottom-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden"
|
||||
>
|
||||
<ul className="m-0 grid grid-cols-2 list-none p-0">
|
||||
{getCampaignNav(role).map((item) => {
|
||||
const active = isCampaignNavActive(pathname, item.href)
|
||||
|
||||
return (
|
||||
<li key={item.href} className="m-0 p-0">
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex min-h-12 flex-col items-center justify-center gap-0.5 px-3 py-1.5 text-xs font-medium text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active && 'text-primary',
|
||||
)}
|
||||
>
|
||||
<item.icon aria-hidden="true" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
369
src/components/campaign/CampaignDashboard.tsx
Normal file
369
src/components/campaign/CampaignDashboard.tsx
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
import {
|
||||
AlertTriangleIcon,
|
||||
ArrowRightIcon,
|
||||
ClipboardListIcon,
|
||||
MessageCircleIcon,
|
||||
PlusIcon,
|
||||
UserRoundPlusIcon,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { NucleusCard } from '@/components/campaign/NucleusCard'
|
||||
import { SupportStatusBadge } from '@/components/campaign/SupportStatusBadge'
|
||||
import { TseZoneBadge } from '@/components/campaign/TseZoneBadge'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { Progress } from '@/components/ui/Progress'
|
||||
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
|
||||
import type {
|
||||
DashboardQueueItem,
|
||||
GeneralDashboardViewModel,
|
||||
ScopedDashboardViewModel,
|
||||
} from '@/utilities/campaignDashboardViewModels'
|
||||
import { buildLeadershipPanelHref } from '@/utilities/leadershipUi'
|
||||
import { getCampaignScopeLabel } from '@/utilities/nucleusUi'
|
||||
import { buildWhatsAppUrl } from '@/utilities/phone'
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('pt-BR')
|
||||
const relativeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' })
|
||||
|
||||
const lastUpdateLabel = (value: string | null, now: Date): string => {
|
||||
if (!value) return 'Nenhuma atualização'
|
||||
const days = Math.floor((new Date(value).getTime() - now.getTime()) / (24 * 60 * 60 * 1000))
|
||||
return `Última atualização ${relativeFormatter.format(days, 'day')}`
|
||||
}
|
||||
|
||||
export const buildDashboardQueueItemHref = (
|
||||
slug: string,
|
||||
openCoordinatorAssignment: boolean,
|
||||
): string => `/campanha/nucleos/${slug}${openCoordinatorAssignment ? '?assignCoordinators=1' : ''}`
|
||||
|
||||
const KpiCard = ({
|
||||
label,
|
||||
value,
|
||||
progress,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
progress?: number
|
||||
}) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
|
||||
</CardHeader>
|
||||
{progress !== undefined ? (
|
||||
<CardContent>
|
||||
<Progress value={progress} aria-label={`${label}: ${progress}%`} />
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
|
||||
const QueueList = ({
|
||||
title,
|
||||
items,
|
||||
empty,
|
||||
openCoordinatorAssignment = false,
|
||||
}: {
|
||||
title: string
|
||||
items: DashboardQueueItem[]
|
||||
empty: string
|
||||
openCoordinatorAssignment?: boolean
|
||||
}) => (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<Badge variant={items.length ? 'estimate-pending' : 'secondary'}>{items.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{items.length ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Button asChild variant="ghost" className="h-auto min-h-11 w-full justify-between">
|
||||
<Link href={buildDashboardQueueItemHref(item.slug, openCoordinatorAssignment)}>
|
||||
<span className="min-w-0 text-left">
|
||||
<strong className="block truncate">{item.name}</strong>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{item.territory}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{empty}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
const GeneralDashboard = ({ view }: { view: GeneralDashboardViewModel }) => (
|
||||
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Visão geral da campanha</h1>
|
||||
<p className="text-muted-foreground">Cobertura, mobilização e pendências dos núcleos.</p>
|
||||
<CampaignScopeBadge>
|
||||
{getCampaignScopeLabel('geral', view.kpis.activeNuclei)}
|
||||
</CampaignScopeBadge>
|
||||
</div>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/nucleos/novo">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Novo núcleo
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="campaign-kpis">
|
||||
<h2 id="campaign-kpis" className="sr-only">
|
||||
Indicadores da campanha
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<KpiCard label="Núcleos ativos" value={numberFormatter.format(view.kpis.activeNuclei)} />
|
||||
<KpiCard
|
||||
label="% com coordenador"
|
||||
value={`${view.kpis.coordinatorCoveragePercent}%`}
|
||||
progress={view.kpis.coordinatorCoveragePercent}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Estimativas confirmadas"
|
||||
value={`${numberFormatter.format(view.kpis.confirmedVoteEstimateTotal)} votos`}
|
||||
/>
|
||||
<KpiCard
|
||||
label="% com estimativa confirmada"
|
||||
value={`${view.kpis.confirmedEstimatePercent}%`}
|
||||
progress={view.kpis.confirmedEstimatePercent}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Atualizações esta semana"
|
||||
value={numberFormatter.format(view.kpis.updatesThisWeek)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lideranças por status de apoio</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
{leadershipSupportStatuses.map((status) => (
|
||||
<div key={status} className="flex items-center gap-2">
|
||||
<SupportStatusBadge status={status} />
|
||||
<strong className="tabular-nums">
|
||||
{numberFormatter.format(view.supportCounts[status])}
|
||||
</strong>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section aria-labelledby="action-queues" className="flex flex-col gap-3">
|
||||
<h2 id="action-queues" className="text-lg font-semibold">
|
||||
Filas de ação
|
||||
</h2>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<QueueList
|
||||
title="Sem coordenador"
|
||||
items={view.queues.withoutCoordinator}
|
||||
empty="Todos os núcleos ativos têm coordenação."
|
||||
openCoordinatorAssignment
|
||||
/>
|
||||
<QueueList
|
||||
title="Sem atualização há mais de 7 dias"
|
||||
items={view.queues.withoutRecentUpdate}
|
||||
empty="Todos os núcleos estão com a cadência em dia."
|
||||
/>
|
||||
<QueueList
|
||||
title="Estimativas pendentes"
|
||||
items={view.queues.pendingEstimate}
|
||||
empty="Nenhuma estimativa aguarda confirmação."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
const CoordinatorDashboard = ({
|
||||
view,
|
||||
now,
|
||||
}: {
|
||||
view: Extract<ScopedDashboardViewModel, { role: 'coordenador' }>
|
||||
now: Date
|
||||
}) => (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<CampaignScopeBadge>{getCampaignScopeLabel(view.role, view.cards.length)}</CampaignScopeBadge>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Sua região</h1>
|
||||
<p className="text-muted-foreground">O que precisa de atenção nos seus núcleos?</p>
|
||||
</header>
|
||||
{view.cards.length ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{view.cards.map((card) => (
|
||||
<NucleusCard
|
||||
key={card.id}
|
||||
name={card.name}
|
||||
territory={card.territory}
|
||||
organization={card.organization}
|
||||
tseZones={card.tseZones}
|
||||
confirmedVoteEstimate={card.confirmedVoteEstimate}
|
||||
hasPendingEstimate={card.hasPendingEstimate}
|
||||
lastUpdateLabel={lastUpdateLabel(card.lastUpdateAt, now)}
|
||||
isUpdateOverdue={card.isUpdateOverdue}
|
||||
leadershipCounts={card.leadershipCounts}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={buildLeadershipPanelHref(card.slug, {}, { mode: 'create' })}>
|
||||
<UserRoundPlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova liderança
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href={`/campanha/nucleos/${card.slug}?tab=updates&newUpdate=1`}>
|
||||
<ClipboardListIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova atualização
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty className="border">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Nenhum núcleo sob sua coordenação</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
A coordenação geral ainda não atribuiu um núcleo a você.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const LeadershipDashboard = ({
|
||||
view,
|
||||
}: {
|
||||
view: Extract<ScopedDashboardViewModel, { role: 'lideranca' }>
|
||||
}) => (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<CampaignScopeBadge>{getCampaignScopeLabel(view.role, view.cards.length)}</CampaignScopeBadge>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Meus núcleos</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Envie seu reporte e acompanhe a estimativa confirmada.
|
||||
</p>
|
||||
</header>
|
||||
{view.cards.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{view.cards.map((card) => (
|
||||
<Card key={card.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{card.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{[card.organization, card.territory].filter(Boolean).join(' · ')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{card.tseZones.map((zoneNumber) => (
|
||||
<TseZoneBadge key={zoneNumber} zoneNumber={zoneNumber} />
|
||||
))}
|
||||
</div>
|
||||
<p>
|
||||
<strong>
|
||||
{card.confirmedVoteEstimate === null
|
||||
? 'Sem estimativa confirmada'
|
||||
: `${numberFormatter.format(card.confirmedVoteEstimate)} votos`}
|
||||
</strong>
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">Coordenação</p>
|
||||
{card.coordinators.length ? (
|
||||
card.coordinators.map((coordinator) => (
|
||||
<div key={coordinator.name} className="flex flex-wrap items-center gap-2">
|
||||
<span>{coordinator.name}</span>
|
||||
{coordinator.phone ? (
|
||||
<Button asChild size="sm" variant="outline" className="min-h-11">
|
||||
<a
|
||||
href={buildWhatsAppUrl(coordinator.phone)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<MessageCircleIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Falar no WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Contato não disponível
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Coordenação não informada</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button asChild className="min-h-11 w-full">
|
||||
<Link href={`/campanha/nucleos/${card.slug}?tab=updates&newUpdate=1`}>
|
||||
<ClipboardListIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Enviar atualização
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty className="border">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Nenhum núcleo disponível</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Seu acesso depende de um vínculo engajado com o núcleo.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<AlertTriangleIcon aria-hidden="true" />
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const CampaignDashboard = ({
|
||||
view,
|
||||
now,
|
||||
}: {
|
||||
view: GeneralDashboardViewModel | ScopedDashboardViewModel
|
||||
now: Date
|
||||
}) => {
|
||||
if (view.role === 'geral') return <GeneralDashboard view={view} />
|
||||
if (view.role === 'coordenador') return <CoordinatorDashboard view={view} now={now} />
|
||||
return <LeadershipDashboard view={view} />
|
||||
}
|
||||
370
src/components/campaign/CampaignInviteForm.tsx
Normal file
370
src/components/campaign/CampaignInviteForm.tsx
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
'use client'
|
||||
|
||||
import { type ReactNode, useActionState } from 'react'
|
||||
import { CheckCircle2Icon, ShieldCheckIcon } from 'lucide-react'
|
||||
|
||||
import type {
|
||||
BoundCampaignInviteFormAction,
|
||||
CampaignInviteFormState,
|
||||
} from '@/app/(campaign)/campanha/convite/[token]/formActions'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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 { Textarea } from '@/components/ui/textarea'
|
||||
import { leadershipGenderLabels, leadershipSectorLabels } from '@/utilities/leadershipUi'
|
||||
|
||||
type CampaignInviteProfile = {
|
||||
name: string
|
||||
phone: string
|
||||
email: string | null
|
||||
gender: keyof typeof leadershipGenderLabels | null
|
||||
sector: keyof typeof leadershipSectorLabels | null | undefined
|
||||
sectorNotes: string | null
|
||||
}
|
||||
|
||||
const genderOptions = Object.entries(leadershipGenderLabels) as Array<
|
||||
[NonNullable<CampaignInviteProfile['gender']>, string]
|
||||
>
|
||||
const sectorOptions = Object.entries(leadershipSectorLabels) as Array<
|
||||
[NonNullable<CampaignInviteProfile['sector']>, string]
|
||||
>
|
||||
|
||||
const firstError = (state: CampaignInviteFormState, field: string) =>
|
||||
state.fieldErrors?.[field]?.[0]
|
||||
|
||||
const CampaignInviteFeedback = ({ state }: { state: CampaignInviteFormState }) => {
|
||||
if (!state.message || state.status === 'success') return null
|
||||
return (
|
||||
<Alert variant="destructive" aria-live="polite">
|
||||
<AlertTitle>Não foi possível confirmar</AlertTitle>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const ConsentField = ({
|
||||
children,
|
||||
error,
|
||||
}: {
|
||||
children: ReactNode
|
||||
error?: string
|
||||
}) => (
|
||||
<section id="texto-consentimento" className="flex flex-col gap-3" aria-labelledby="consent-title">
|
||||
<div>
|
||||
<h2 id="consent-title" className="font-medium">
|
||||
Consentimento para uso dos dados
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leia o texto e confirme somente se estiver de acordo.{' '}
|
||||
<a className="underline underline-offset-4" href="#campaign-invite-consent-full">
|
||||
Ler o texto completo
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="campaign-invite-consent-full"
|
||||
className="max-h-56 overflow-y-auto rounded-lg border p-3 text-sm"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<Field orientation="horizontal" data-invalid={Boolean(error)}>
|
||||
<Checkbox
|
||||
id="campaign-invite-consent"
|
||||
name="consentAccepted"
|
||||
required
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? 'campaign-invite-consent-error' : 'campaign-invite-consent-help'}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="campaign-invite-consent">
|
||||
Li e concordo com o texto de consentimento *
|
||||
</FieldLabel>
|
||||
<FieldDescription id="campaign-invite-consent-help">
|
||||
Você confirma os próprios dados enviados neste formulário.
|
||||
</FieldDescription>
|
||||
{error ? <FieldError id="campaign-invite-consent-error">{error}</FieldError> : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</section>
|
||||
)
|
||||
|
||||
const ProfileFields = ({
|
||||
preview,
|
||||
state,
|
||||
}: {
|
||||
preview: { profile: CampaignInviteProfile }
|
||||
state: CampaignInviteFormState
|
||||
}) => {
|
||||
const { profile } = preview
|
||||
return (
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(firstError(state, 'name'))}>
|
||||
<FieldLabel htmlFor="campaign-invite-name">Nome *</FieldLabel>
|
||||
<Input
|
||||
id="campaign-invite-name"
|
||||
name="name"
|
||||
defaultValue={profile.name}
|
||||
autoComplete="name"
|
||||
required
|
||||
maxLength={120}
|
||||
aria-invalid={Boolean(firstError(state, 'name'))}
|
||||
aria-describedby={firstError(state, 'name') ? 'campaign-invite-name-error' : undefined}
|
||||
/>
|
||||
{firstError(state, 'name') ? (
|
||||
<FieldError id="campaign-invite-name-error">{firstError(state, 'name')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(firstError(state, 'phone'))}>
|
||||
<FieldLabel htmlFor="campaign-invite-phone">Celular (WhatsApp) *</FieldLabel>
|
||||
<Input
|
||||
id="campaign-invite-phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
defaultValue={profile.phone}
|
||||
autoComplete="tel"
|
||||
required
|
||||
aria-invalid={Boolean(firstError(state, 'phone'))}
|
||||
aria-describedby={firstError(state, 'phone') ? 'campaign-invite-phone-error' : undefined}
|
||||
/>
|
||||
{firstError(state, 'phone') ? (
|
||||
<FieldError id="campaign-invite-phone-error">{firstError(state, 'phone')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(firstError(state, 'email'))}>
|
||||
<FieldLabel htmlFor="campaign-invite-email">E-mail (opcional)</FieldLabel>
|
||||
<Input
|
||||
id="campaign-invite-email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={profile.email ?? ''}
|
||||
autoComplete="email"
|
||||
aria-invalid={Boolean(firstError(state, 'email'))}
|
||||
aria-describedby={firstError(state, 'email') ? 'campaign-invite-email-error' : undefined}
|
||||
/>
|
||||
{firstError(state, 'email') ? (
|
||||
<FieldError id="campaign-invite-email-error">{firstError(state, 'email')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={Boolean(firstError(state, 'gender'))}>
|
||||
<FieldLabel htmlFor="campaign-invite-gender">Gênero</FieldLabel>
|
||||
<NativeSelect
|
||||
id="campaign-invite-gender"
|
||||
name="gender"
|
||||
className="w-full"
|
||||
defaultValue={profile.gender ?? ''}
|
||||
aria-invalid={Boolean(firstError(state, 'gender'))}
|
||||
aria-describedby={
|
||||
firstError(state, 'gender') ? 'campaign-invite-gender-error' : undefined
|
||||
}
|
||||
>
|
||||
<NativeSelectOption value="">Não informar</NativeSelectOption>
|
||||
{genderOptions.map(([value, label]) => (
|
||||
<NativeSelectOption key={value} value={value}>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{firstError(state, 'gender') ? (
|
||||
<FieldError id="campaign-invite-gender-error">{firstError(state, 'gender')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(firstError(state, 'sector'))}>
|
||||
<FieldLabel htmlFor="campaign-invite-sector">Setor</FieldLabel>
|
||||
<NativeSelect
|
||||
id="campaign-invite-sector"
|
||||
name="sector"
|
||||
className="w-full"
|
||||
defaultValue={profile.sector ?? ''}
|
||||
aria-invalid={Boolean(firstError(state, 'sector'))}
|
||||
aria-describedby={
|
||||
firstError(state, 'sector') ? 'campaign-invite-sector-error' : undefined
|
||||
}
|
||||
>
|
||||
<NativeSelectOption value="">Não informar</NativeSelectOption>
|
||||
{sectorOptions.map(([value, label]) => (
|
||||
<NativeSelectOption key={value} value={value}>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{firstError(state, 'sector') ? (
|
||||
<FieldError id="campaign-invite-sector-error">{firstError(state, 'sector')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={Boolean(firstError(state, 'sectorNotes'))}>
|
||||
<FieldLabel htmlFor="campaign-invite-sector-notes">
|
||||
Como você atua nesse setor? (opcional)
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id="campaign-invite-sector-notes"
|
||||
name="sectorNotes"
|
||||
defaultValue={profile.sectorNotes ?? ''}
|
||||
maxLength={1000}
|
||||
aria-invalid={Boolean(firstError(state, 'sectorNotes'))}
|
||||
aria-describedby={
|
||||
firstError(state, 'sectorNotes') ? 'campaign-invite-sector-notes-error' : undefined
|
||||
}
|
||||
/>
|
||||
{firstError(state, 'sectorNotes') ? (
|
||||
<FieldError id="campaign-invite-sector-notes-error">
|
||||
{firstError(state, 'sectorNotes')}
|
||||
</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export const CampaignInviteForm = ({
|
||||
action,
|
||||
children,
|
||||
kind,
|
||||
profile,
|
||||
requiresConsent,
|
||||
}: {
|
||||
action: BoundCampaignInviteFormAction
|
||||
children?: ReactNode
|
||||
kind: 'autopreenchimento' | 'login'
|
||||
profile: CampaignInviteProfile
|
||||
requiresConsent: boolean
|
||||
}) => {
|
||||
const [state, formAction, pending] = useActionState(action, {})
|
||||
const consentError = firstError(state, 'consentAccepted')
|
||||
const passwordError = firstError(state, 'password')
|
||||
const confirmationError = firstError(state, 'passwordConfirmation')
|
||||
|
||||
if (state.status === 'success') {
|
||||
return (
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader>
|
||||
<CheckCircle2Icon aria-hidden="true" />
|
||||
<CardTitle>Dados confirmados</CardTitle>
|
||||
<CardDescription>{state.message}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Você já pode fechar esta página.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<ShieldCheckIcon aria-hidden="true" />
|
||||
<span className="text-sm font-medium">Campanha do Solla</span>
|
||||
</div>
|
||||
<CardTitle>Oi, {profile.name}!</CardTitle>
|
||||
<CardDescription>
|
||||
{kind === 'login'
|
||||
? 'Revise seus dados e defina uma senha para acessar a plataforma da campanha.'
|
||||
: 'Revise e confirme seus dados para a campanha do Jorge Solla.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={formAction} className="flex flex-col gap-5" aria-busy={pending}>
|
||||
<CampaignInviteFeedback state={state} />
|
||||
<ProfileFields preview={{ profile }} state={state} />
|
||||
|
||||
{requiresConsent ? (
|
||||
<ConsentField error={consentError}>{children}</ConsentField>
|
||||
) : (
|
||||
<Alert>
|
||||
<CheckCircle2Icon aria-hidden="true" />
|
||||
<AlertTitle>Consentimento já confirmado</AlertTitle>
|
||||
<AlertDescription>
|
||||
Você pode revisar seus dados e continuar sem aceitar novamente.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{kind === 'login' ? (
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(passwordError)}>
|
||||
<FieldLabel htmlFor="campaign-invite-password">Nova senha *</FieldLabel>
|
||||
<Input
|
||||
id="campaign-invite-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
required
|
||||
aria-invalid={Boolean(passwordError)}
|
||||
aria-describedby={
|
||||
passwordError
|
||||
? 'campaign-invite-password-error'
|
||||
: 'campaign-invite-password-help'
|
||||
}
|
||||
/>
|
||||
<FieldDescription id="campaign-invite-password-help">
|
||||
Use pelo menos 8 caracteres.
|
||||
</FieldDescription>
|
||||
{passwordError ? (
|
||||
<FieldError id="campaign-invite-password-error">{passwordError}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(confirmationError)}>
|
||||
<FieldLabel htmlFor="campaign-invite-password-confirmation">
|
||||
Confirme a nova senha *
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="campaign-invite-password-confirmation"
|
||||
name="passwordConfirmation"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
required
|
||||
aria-invalid={Boolean(confirmationError)}
|
||||
aria-describedby={
|
||||
confirmationError ? 'campaign-invite-password-confirmation-error' : undefined
|
||||
}
|
||||
/>
|
||||
{confirmationError ? (
|
||||
<FieldError id="campaign-invite-password-confirmation-error">
|
||||
{confirmationError}
|
||||
</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" className="min-h-11 w-full" disabled={pending}>
|
||||
{pending ? <Spinner data-icon="inline-start" /> : null}
|
||||
{pending
|
||||
? 'Confirmando…'
|
||||
: kind === 'login'
|
||||
? 'Criar ou recuperar meu acesso'
|
||||
: 'Confirmar meus dados'}
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Esta página não usa rastreadores externos.
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
18
src/components/campaign/CampaignScopeBadge.tsx
Normal file
18
src/components/campaign/CampaignScopeBadge.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { EyeIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CampaignScopeBadgeProps = React.ComponentProps<'span'>
|
||||
|
||||
export const CampaignScopeBadge = ({ children, className, ...props }: CampaignScopeBadgeProps) => (
|
||||
<Badge
|
||||
variant="scope"
|
||||
data-scope="campaign"
|
||||
className={cn('h-auto min-h-7 whitespace-normal px-3 py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<EyeIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
42
src/components/campaign/CampaignSearchInput.tsx
Normal file
42
src/components/campaign/CampaignSearchInput.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { ComponentProps } from 'react'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@/components/ui/input-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CampaignSearchInputProps = Omit<ComponentProps<typeof InputGroupInput>, 'id'> & {
|
||||
id: string
|
||||
label: string
|
||||
fieldClassName?: string
|
||||
}
|
||||
|
||||
export const CampaignSearchInput = ({
|
||||
id,
|
||||
label,
|
||||
fieldClassName,
|
||||
className,
|
||||
type = 'search',
|
||||
...props
|
||||
}: CampaignSearchInputProps) => (
|
||||
<Field className={cn('relative flex-1', fieldClassName)}>
|
||||
<FieldLabel htmlFor={id} className="sr-only">
|
||||
{label}
|
||||
</FieldLabel>
|
||||
<InputGroup className="min-h-11 rounded-[6px]">
|
||||
<InputGroupInput
|
||||
id={id}
|
||||
type={type}
|
||||
className={cn('min-h-11', className)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)
|
||||
|
|
@ -3,9 +3,10 @@
|
|||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import { logoutCampaign } from '@/app/(campanha)/campanha/actions/auth'
|
||||
import { CampaignLogo } from '@/components/campanha/campaign-logo'
|
||||
import { campaignNav, isCampaignNavActive } from '@/components/campanha/nav'
|
||||
import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth'
|
||||
import { CampaignLogo } from '@/components/campaign/campaign-logo'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sidebar,
|
||||
|
|
@ -17,18 +18,28 @@ import {
|
|||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar'
|
||||
useSidebar,
|
||||
} from '@/components/ui/Sidebar'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
|
||||
export type CampaignSidebarUser = {
|
||||
name: string
|
||||
email: string
|
||||
email?: string | null
|
||||
role: CampaignUser['role']
|
||||
}
|
||||
|
||||
export function CampaignSidebar({ user }: { user: CampaignSidebarUser }) {
|
||||
const roleLabels: Record<CampaignUser['role'], string> = {
|
||||
geral: 'Coordenação geral',
|
||||
coordenador: 'Coordenador',
|
||||
lideranca: 'Liderança',
|
||||
}
|
||||
|
||||
export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
|
||||
const pathname = usePathname()
|
||||
const { isMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="none" className="h-svh border-r border-sidebar-border">
|
||||
<Sidebar collapsible="none" className="h-svh shrink-0 border-r border-sidebar-border">
|
||||
<SidebarHeader className="border-b border-sidebar-border">
|
||||
<Link
|
||||
href="/campanha"
|
||||
|
|
@ -40,12 +51,18 @@ export function CampaignSidebar({ user }: { user: CampaignSidebarUser }) {
|
|||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<CampaignScopeBadge className="mb-3 w-fit">{roleLabels[user.role]}</CampaignScopeBadge>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{campaignNav.map((item) => (
|
||||
{getCampaignNav(user.role).map((item) => (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton asChild isActive={isCampaignNavActive(pathname, item.href)}>
|
||||
<Link href={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => {
|
||||
if (isMobile) setOpenMobile(false)
|
||||
}}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
|
|
@ -60,14 +77,17 @@ export function CampaignSidebar({ user }: { user: CampaignSidebarUser }) {
|
|||
<SidebarFooter className="border-t border-sidebar-border">
|
||||
<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-xs text-muted-foreground">{user.email}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{roleLabels[user.role]}</span>
|
||||
{user.email ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<form action={logoutCampaign}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-sidebar-border bg-sidebar font-medium text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
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
|
||||
</Button>
|
||||
127
src/components/campaign/ConsentText.tsx
Normal file
127
src/components/campaign/ConsentText.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import 'server-only'
|
||||
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
|
||||
import type { Consent } from '@/payload-types'
|
||||
|
||||
type ConsentNode = {
|
||||
children?: unknown
|
||||
fields?: unknown
|
||||
format?: unknown
|
||||
listType?: unknown
|
||||
newTab?: unknown
|
||||
tag?: unknown
|
||||
text?: unknown
|
||||
type?: unknown
|
||||
url?: unknown
|
||||
}
|
||||
|
||||
const asNode = (value: unknown): ConsentNode | null =>
|
||||
typeof value === 'object' && value !== null ? (value as ConsentNode) : null
|
||||
|
||||
const childrenOf = (node: ConsentNode): unknown[] => (Array.isArray(node.children) ? node.children : [])
|
||||
|
||||
const safeLink = (value: unknown): string | null => {
|
||||
if (typeof value !== 'string') return null
|
||||
const href = value.trim()
|
||||
if (href.startsWith('/') || href.startsWith('#')) return href
|
||||
|
||||
try {
|
||||
const url = new URL(href)
|
||||
return ['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol) ? href : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const renderChildren = (node: ConsentNode, key: string, depth: number): ReactNode =>
|
||||
childrenOf(node).map((child, index) => renderNode(child, `${key}-${index}`, depth + 1))
|
||||
|
||||
const renderText = (node: ConsentNode, key: string): ReactNode => {
|
||||
if (typeof node.text !== 'string') return null
|
||||
|
||||
let content: ReactNode = node.text
|
||||
const format = typeof node.format === 'number' ? node.format : 0
|
||||
if (format & 1) content = <strong>{content}</strong>
|
||||
if (format & 2) content = <em>{content}</em>
|
||||
if (format & 4) content = <s>{content}</s>
|
||||
if (format & 8) content = <u>{content}</u>
|
||||
if (format & 16) content = <code>{content}</code>
|
||||
if (format & 32) content = <sub>{content}</sub>
|
||||
if (format & 64) content = <sup>{content}</sup>
|
||||
if (format & 128) content = <mark>{content}</mark>
|
||||
|
||||
return <Fragment key={key}>{content}</Fragment>
|
||||
}
|
||||
|
||||
const renderNode = (value: unknown, key: string, depth: number): ReactNode => {
|
||||
if (depth > 40) return null
|
||||
const node = asNode(value)
|
||||
if (!node || typeof node.type !== 'string') return null
|
||||
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
return renderText(node, key)
|
||||
case 'linebreak':
|
||||
return <br key={key} />
|
||||
case 'paragraph':
|
||||
return <p key={key}>{renderChildren(node, key, depth)}</p>
|
||||
case 'heading': {
|
||||
const children = renderChildren(node, key, depth)
|
||||
switch (node.tag) {
|
||||
case 'h2':
|
||||
return <h2 key={key}>{children}</h2>
|
||||
case 'h3':
|
||||
return <h3 key={key}>{children}</h3>
|
||||
case 'h4':
|
||||
return <h4 key={key}>{children}</h4>
|
||||
case 'h5':
|
||||
return <h5 key={key}>{children}</h5>
|
||||
case 'h6':
|
||||
return <h6 key={key}>{children}</h6>
|
||||
default:
|
||||
return <h2 key={key}>{children}</h2>
|
||||
}
|
||||
}
|
||||
case 'quote':
|
||||
return <blockquote key={key}>{renderChildren(node, key, depth)}</blockquote>
|
||||
case 'list': {
|
||||
const children = renderChildren(node, key, depth)
|
||||
return node.tag === 'ol' || node.listType === 'number' ? (
|
||||
<ol key={key}>{children}</ol>
|
||||
) : (
|
||||
<ul key={key}>{children}</ul>
|
||||
)
|
||||
}
|
||||
case 'listitem':
|
||||
return <li key={key}>{renderChildren(node, key, depth)}</li>
|
||||
case 'link':
|
||||
case 'autolink': {
|
||||
const fields = asNode(node.fields)
|
||||
const href = safeLink(fields?.url ?? node.url)
|
||||
const children = renderChildren(node, key, depth)
|
||||
if (!href) return <Fragment key={key}>{children}</Fragment>
|
||||
const opensNewTab = fields?.newTab === true
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
rel={opensNewTab ? 'noopener noreferrer' : undefined}
|
||||
target={opensNewTab ? '_blank' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
case 'horizontalrule':
|
||||
return <hr key={key} />
|
||||
default:
|
||||
return <Fragment key={key}>{renderChildren(node, key, depth)}</Fragment>
|
||||
}
|
||||
}
|
||||
|
||||
export const ConsentText = ({ data }: { data: Consent['text'] }) => (
|
||||
<div className="flex flex-col gap-3 text-foreground">
|
||||
{data.root.children.map((node, index) => renderNode(node, `consent-${index}`, 0))}
|
||||
</div>
|
||||
)
|
||||
85
src/components/campaign/CoordinatorAssignmentCard.tsx
Normal file
85
src/components/campaign/CoordinatorAssignmentCard.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangleIcon, MessageCircleIcon } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import type { AssignedCoordinatorViewModel } from '@/utilities/nucleusCoordinatorAssignmentPageData'
|
||||
import { buildWhatsAppUrl } from '@/utilities/phone'
|
||||
|
||||
type CoordinatorAssignmentCardProps = {
|
||||
coordinators: AssignedCoordinatorViewModel[]
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const initials = (name: string): string =>
|
||||
name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join('')
|
||||
|
||||
export const CoordinatorAssignmentCard = ({
|
||||
coordinators,
|
||||
children,
|
||||
}: CoordinatorAssignmentCardProps) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coordenação responsável</CardTitle>
|
||||
<CardDescription>
|
||||
Pessoas atualmente responsáveis pela articulação deste núcleo.
|
||||
</CardDescription>
|
||||
{children ? <CardAction>{children}</CardAction> : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{coordinators.length ? (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{coordinators.map((coordinator) => (
|
||||
<li
|
||||
key={coordinator.id}
|
||||
className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback>{initials(coordinator.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate font-medium">{coordinator.name}</span>
|
||||
</div>
|
||||
{coordinator.phone ? (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<a
|
||||
href={buildWhatsAppUrl(coordinator.phone)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<MessageCircleIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Falar no WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Contato não disponível</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<Alert variant="pending">
|
||||
<AlertTriangleIcon aria-hidden="true" />
|
||||
<AlertTitle>Sem coordenação responsável</AlertTitle>
|
||||
<AlertDescription>
|
||||
Este núcleo ainda não tem uma pessoa elegível designada para coordenação.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
167
src/components/campaign/CoordinatorAssignmentDialog.tsx
Normal file
167
src/components/campaign/CoordinatorAssignmentDialog.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
'use client'
|
||||
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertTriangleIcon, UserRoundCogIcon } from 'lucide-react'
|
||||
|
||||
import type { CoordinatorAssignmentFormState } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import type { AssignedCoordinatorViewModel } from '@/utilities/nucleusCoordinatorAssignmentPageData'
|
||||
import type { NucleusCoordinatorOption } from '@/utilities/nucleusCoordinatorOptions'
|
||||
|
||||
type CoordinatorAssignmentDialogBodyModule = {
|
||||
default: typeof import('./CoordinatorAssignmentDialogBody').CoordinatorAssignmentDialogBody
|
||||
}
|
||||
|
||||
const loadCoordinatorAssignmentDialogBody = (): Promise<CoordinatorAssignmentDialogBodyModule> =>
|
||||
import('./CoordinatorAssignmentDialogBody').then((module) => ({
|
||||
default: module.CoordinatorAssignmentDialogBody,
|
||||
}))
|
||||
|
||||
export type CoordinatorAssignmentFormAction = (
|
||||
state: CoordinatorAssignmentFormState,
|
||||
formData: FormData,
|
||||
) => Promise<CoordinatorAssignmentFormState>
|
||||
|
||||
export type CoordinatorAssignmentOptionsResult = {
|
||||
expectedUpdatedAt: string
|
||||
options: NucleusCoordinatorOption[]
|
||||
}
|
||||
|
||||
type CoordinatorAssignmentOptionsAction = () => Promise<CoordinatorAssignmentOptionsResult>
|
||||
|
||||
export const CoordinatorAssignmentDialog = ({
|
||||
action,
|
||||
coordinators,
|
||||
initialOpen = false,
|
||||
loadBodyModule = loadCoordinatorAssignmentDialogBody,
|
||||
loadOptions,
|
||||
}: {
|
||||
action: CoordinatorAssignmentFormAction
|
||||
coordinators: AssignedCoordinatorViewModel[]
|
||||
initialOpen?: boolean
|
||||
loadBodyModule?: () => Promise<CoordinatorAssignmentDialogBodyModule>
|
||||
loadOptions: CoordinatorAssignmentOptionsAction
|
||||
}) => {
|
||||
const [open, setOpen] = useState(initialOpen)
|
||||
const [result, setResult] = useState<CoordinatorAssignmentOptionsResult | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [mutationPending, setMutationPending] = useState(false)
|
||||
const requestId = useRef(0)
|
||||
const LazyBody = useMemo(() => lazy(loadBodyModule), [loadBodyModule])
|
||||
const triggerLabel = coordinators.length ? 'Alterar coordenadores' : 'Designar coordenadores'
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const currentRequest = ++requestId.current
|
||||
setResult(null)
|
||||
setError(false)
|
||||
try {
|
||||
const [, nextResult] = await Promise.all([loadBodyModule(), loadOptions()])
|
||||
if (requestId.current === currentRequest) setResult(nextResult)
|
||||
} catch {
|
||||
if (requestId.current === currentRequest) setError(true)
|
||||
}
|
||||
}, [loadBodyModule, loadOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load()
|
||||
return () => {
|
||||
requestId.current += 1
|
||||
}
|
||||
}, [load, open])
|
||||
|
||||
const closeDialog = () => {
|
||||
requestId.current += 1
|
||||
setResult(null)
|
||||
setError(false)
|
||||
setMutationPending(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const setDialogOpen = (nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
if (mutationPending) return
|
||||
closeDialog()
|
||||
return
|
||||
}
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" className="min-h-11">
|
||||
<UserRoundCogIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
showCloseButton={!mutationPending}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (mutationPending) event.preventDefault()
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (mutationPending) event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{triggerLabel}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Selecione zero ou mais pessoas. Salvar sem seleção deixa o núcleo sem cobertura.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangleIcon aria-hidden="true" />
|
||||
<AlertTitle>Não foi possível carregar</AlertTitle>
|
||||
<AlertDescription>Atualize as opções antes de alterar a coordenação.</AlertDescription>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-3 min-h-11"
|
||||
onClick={() => void load()}
|
||||
>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</Alert>
|
||||
) : result ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className="flex min-h-24 items-center justify-center"
|
||||
aria-label="Carregando seletor"
|
||||
>
|
||||
<Spinner aria-hidden="true" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyBody
|
||||
key={result.expectedUpdatedAt}
|
||||
action={action}
|
||||
initialSelectedIds={coordinators.map(({ id }) => id)}
|
||||
onClose={closeDialog}
|
||||
onPendingChange={setMutationPending}
|
||||
result={result}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<div
|
||||
className="flex min-h-24 items-center justify-center"
|
||||
aria-label="Carregando coordenadores"
|
||||
>
|
||||
<Spinner aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
169
src/components/campaign/CoordinatorAssignmentDialogBody.tsx
Normal file
169
src/components/campaign/CoordinatorAssignmentDialogBody.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState, useEffect, useId, useState } from 'react'
|
||||
import { AlertTriangleIcon, CheckIcon, SearchIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type {
|
||||
CoordinatorAssignmentFormAction,
|
||||
CoordinatorAssignmentOptionsResult,
|
||||
} from '@/components/campaign/CoordinatorAssignmentDialog'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogFooter } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
|
||||
export const CoordinatorAssignmentDialogBody = ({
|
||||
action,
|
||||
initialSelectedIds,
|
||||
onClose,
|
||||
onPendingChange,
|
||||
result,
|
||||
}: {
|
||||
action: CoordinatorAssignmentFormAction
|
||||
initialSelectedIds: number[]
|
||||
onClose: () => void
|
||||
onPendingChange?: (pending: boolean) => void
|
||||
result: CoordinatorAssignmentOptionsResult
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const optionsId = useId()
|
||||
const optionsHeadingId = useId()
|
||||
const [state, formAction, pending] = useActionState(action, {})
|
||||
const [selectedIds, setSelectedIds] = useState(initialSelectedIds)
|
||||
const [query, setQuery] = useState('')
|
||||
const selected = new Set(selectedIds)
|
||||
const visibleOptions = result.options.filter((option) =>
|
||||
option.name.toLocaleLowerCase('pt-BR').includes(query.trim().toLocaleLowerCase('pt-BR')),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending) return
|
||||
toast.loading('Salvando coordenação…', { id: 'coordinator-assignment' })
|
||||
}, [pending])
|
||||
|
||||
useEffect(() => {
|
||||
onPendingChange?.(pending)
|
||||
}, [onPendingChange, pending])
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.message || pending) return
|
||||
if (state.status === 'success') {
|
||||
toast.success(state.message, { id: 'coordinator-assignment' })
|
||||
onClose()
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
toast.error(state.message, { id: 'coordinator-assignment' })
|
||||
}, [onClose, pending, router, state])
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex min-h-0 flex-col gap-4">
|
||||
<input type="hidden" name="expectedUpdatedAt" value={result.expectedUpdatedAt} />
|
||||
{selectedIds.map((id) => (
|
||||
<input key={id} type="hidden" name="coordinators" value={String(id)} />
|
||||
))}
|
||||
{result.options.length ? (
|
||||
<div className="min-h-0 rounded-lg border p-1">
|
||||
<div className="relative">
|
||||
<SearchIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
aria-controls={optionsId}
|
||||
aria-describedby={optionsHeadingId}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="min-h-11 rounded-lg pr-3 pl-9"
|
||||
aria-label="Buscar coordenadores"
|
||||
placeholder="Buscar por nome…"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id={optionsHeadingId}
|
||||
className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Usuários elegíveis
|
||||
</div>
|
||||
<div id={optionsId}>
|
||||
{visibleOptions.length ? (
|
||||
<ul className="no-scrollbar max-h-72 overflow-x-hidden overflow-y-auto p-1">
|
||||
{visibleOptions.map((option) => {
|
||||
const checked = selected.has(option.id)
|
||||
return (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
data-checked={checked}
|
||||
className="flex min-h-11 w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm outline-none hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 data-[checked=true]:bg-muted"
|
||||
onClick={() =>
|
||||
setSelectedIds((current) =>
|
||||
current.includes(option.id)
|
||||
? current.filter((id) => id !== option.id)
|
||||
: [...current, option.id],
|
||||
)
|
||||
}
|
||||
>
|
||||
<span
|
||||
data-checked={checked}
|
||||
aria-hidden="true"
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[checked=true]:border-primary data-[checked=true]:bg-primary data-[checked=true]:text-primary-foreground"
|
||||
>
|
||||
{checked ? <CheckIcon className="size-3.5" /> : null}
|
||||
</span>
|
||||
<span>
|
||||
{option.name}
|
||||
{option.isCurrent ? ' (você)' : ''}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm">Nenhuma pessoa corresponde à busca.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertTriangleIcon aria-hidden="true" />
|
||||
<AlertTitle>Nenhum usuário elegível disponível</AlertTitle>
|
||||
<AlertDescription>
|
||||
Não há usuários com papel de coordenação geral ou coordenador para designar.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{state.message && state.status !== 'success' ? (
|
||||
<Alert variant="destructive" aria-live="polite">
|
||||
<AlertTitle>Não foi possível salvar</AlertTitle>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
onClick={onClose}
|
||||
disabled={pending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" className="min-h-11" disabled={pending}>
|
||||
{pending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
Salvar coordenação
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
26
src/components/campaign/InvalidCampaignInvite.tsx
Normal file
26
src/components/campaign/InvalidCampaignInvite.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import 'server-only'
|
||||
|
||||
import { LockKeyholeIcon } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export const InvalidCampaignInvite = () => (
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Este convite não está disponível</CardTitle>
|
||||
<CardDescription>
|
||||
Peça um novo convite à pessoa da campanha que falou com você.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert>
|
||||
<LockKeyholeIcon aria-hidden="true" />
|
||||
<AlertTitle>Proteção dos seus dados</AlertTitle>
|
||||
<AlertDescription>
|
||||
Por segurança, não informamos detalhes sobre o estado deste link.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
59
src/components/campaign/LazyDialogModuleFallback.tsx
Normal file
59
src/components/campaign/LazyDialogModuleFallback.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
'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>
|
||||
)
|
||||
118
src/components/campaign/LeadershipDetail.tsx
Normal file
118
src/components/campaign/LeadershipDetail.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import Link from 'next/link'
|
||||
import { CheckCircle2Icon, PencilIcon, StarIcon } from 'lucide-react'
|
||||
|
||||
import { LeadershipInviteDialogShell } from '@/components/campaign/LeadershipInviteDialogShell'
|
||||
import { LeadershipPrimaryContactAction } from '@/components/campaign/LeadershipPrimaryContactAction'
|
||||
import { SupportStatusBadge } from '@/components/campaign/SupportStatusBadge'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
formatLeadershipPhone,
|
||||
leadershipGenderLabels,
|
||||
leadershipSectorLabels,
|
||||
} from '@/utilities/leadershipUi'
|
||||
import type { LeadershipStaffViewModel } from '@/utilities/leadershipViewModels'
|
||||
|
||||
export const LeadershipDetail = ({
|
||||
editHref,
|
||||
inviteConsentConfigured,
|
||||
isPrimaryContact,
|
||||
leadership,
|
||||
nucleusId,
|
||||
}: {
|
||||
editHref: string
|
||||
inviteConsentConfigured: boolean
|
||||
isPrimaryContact: boolean
|
||||
leadership: LeadershipStaffViewModel
|
||||
nucleusId: number
|
||||
}) => (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SupportStatusBadge status={leadership.supportStatus} />
|
||||
{leadership.confirmedByPerson ? (
|
||||
<Badge variant="estimate-confirmed">
|
||||
<CheckCircle2Icon data-icon="inline-start" aria-hidden="true" />
|
||||
Cadastro confirmado pela pessoa
|
||||
</Badge>
|
||||
) : null}
|
||||
{isPrimaryContact ? (
|
||||
<Badge variant="secondary">
|
||||
<StarIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Contato principal
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 grid gap-4 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="font-medium">Celular</dt>
|
||||
<dd className="text-muted-foreground">{formatLeadershipPhone(leadership.phone)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium">E-mail</dt>
|
||||
<dd className="break-all text-muted-foreground">
|
||||
{leadership.email ?? 'Não informado'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium">Gênero</dt>
|
||||
<dd className="text-muted-foreground">
|
||||
{leadership.gender ? leadershipGenderLabels[leadership.gender] : 'Não informado'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium">Setor</dt>
|
||||
<dd className="text-muted-foreground">
|
||||
{leadership.sector ? leadershipSectorLabels[leadership.sector] : 'Não informado'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{leadership.sectorNotes ? (
|
||||
<section className="mt-5">
|
||||
<h3 className="font-medium">Observações do setor</h3>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{leadership.sectorNotes}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mt-5">
|
||||
<h3 className="font-medium">Observações internas</h3>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{leadership.notes || 'Nenhuma observação interna registrada.'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{leadership.consentNote ? (
|
||||
<section className="mt-5">
|
||||
<h3 className="font-medium">Registro de consentimento externo</h3>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{leadership.consentNote}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t p-4">
|
||||
{!isPrimaryContact && leadership.supportStatus === 'engajado' ? (
|
||||
<LeadershipPrimaryContactAction
|
||||
nucleusId={nucleusId}
|
||||
contactId={leadership.contactId}
|
||||
/>
|
||||
) : null}
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={editHref}>
|
||||
<PencilIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Editar avaliação
|
||||
</Link>
|
||||
</Button>
|
||||
<LeadershipInviteDialogShell
|
||||
leadershipId={leadership.id}
|
||||
supportStatus={leadership.supportStatus}
|
||||
consentConfigured={inviteConsentConfigured}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
427
src/components/campaign/LeadershipForm.tsx
Normal file
427
src/components/campaign/LeadershipForm.tsx
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type {
|
||||
LeadershipFormState,
|
||||
LeadershipFormValues,
|
||||
} from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/leadershipFormActions'
|
||||
import { FormattedInput } from '@/components/FormattedInput'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import { leadershipGenderLabels, leadershipSectorLabels } from '@/utilities/leadershipUi'
|
||||
import type { LeadershipEditViewModel } from '@/utilities/leadershipViewModels'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
|
||||
|
||||
export type LeadershipFormAction = (
|
||||
state: LeadershipFormState,
|
||||
formData: FormData,
|
||||
) => Promise<LeadershipFormState>
|
||||
|
||||
export type LeadershipFormFieldsProps = {
|
||||
mode: 'create' | 'edit'
|
||||
leadership?: LeadershipEditViewModel
|
||||
fieldErrors?: Record<string, string[]>
|
||||
values?: LeadershipFormValues
|
||||
isPrimaryContact?: boolean
|
||||
}
|
||||
|
||||
const supportStatusOptions = [
|
||||
{ value: 'engajado', label: 'Engajado' },
|
||||
{ value: 'a_abordar', label: 'A abordar' },
|
||||
{ value: 'em_disputa', label: 'Em disputa' },
|
||||
{ value: 'negativo', label: 'Negativo' },
|
||||
] as const
|
||||
|
||||
const fieldError = (
|
||||
fieldErrors: Record<string, string[]> | undefined,
|
||||
field: string,
|
||||
): string | undefined => fieldErrors?.[field]?.[0]
|
||||
|
||||
const errorProps = (fieldErrors: Record<string, string[]> | undefined, field: string) => {
|
||||
const error = fieldError(fieldErrors, field)
|
||||
return {
|
||||
error,
|
||||
invalid: Boolean(error),
|
||||
describedBy: error ? `leadership-${field}-error` : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const LeadershipFormFeedback = ({
|
||||
message,
|
||||
formErrors,
|
||||
}: {
|
||||
message?: string
|
||||
formErrors?: string[]
|
||||
}) => {
|
||||
if (!message && !formErrors?.length) return null
|
||||
|
||||
return (
|
||||
<Alert id="leadership-form-error" variant="destructive" aria-live="polite" className="mb-4">
|
||||
<AlertTitle>Não foi possível salvar</AlertTitle>
|
||||
<AlertDescription>
|
||||
{message ? <p>{message}</p> : null}
|
||||
{formErrors?.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export const LeadershipHiddenFields = ({
|
||||
nucleusId,
|
||||
leadershipId,
|
||||
fieldErrors,
|
||||
}: {
|
||||
nucleusId: number
|
||||
leadershipId?: number
|
||||
fieldErrors?: Record<string, string[]>
|
||||
}) => {
|
||||
const nucleusError = errorProps(fieldErrors, 'nucleus')
|
||||
const idError = errorProps(fieldErrors, 'id')
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="hidden"
|
||||
name="nucleus"
|
||||
value={nucleusId}
|
||||
aria-invalid={nucleusError.invalid}
|
||||
aria-describedby={nucleusError.describedBy}
|
||||
/>
|
||||
{nucleusError.error ? (
|
||||
<FieldError id="leadership-nucleus-error">{nucleusError.error}</FieldError>
|
||||
) : null}
|
||||
{leadershipId ? (
|
||||
<>
|
||||
<input
|
||||
type="hidden"
|
||||
name="id"
|
||||
value={leadershipId}
|
||||
aria-invalid={idError.invalid}
|
||||
aria-describedby={idError.describedBy}
|
||||
/>
|
||||
{idError.error ? <FieldError id="leadership-id-error">{idError.error}</FieldError> : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const LeadershipFormFields = ({
|
||||
mode,
|
||||
leadership,
|
||||
fieldErrors,
|
||||
values,
|
||||
isPrimaryContact = false,
|
||||
}: LeadershipFormFieldsProps) => {
|
||||
const [supportStatus, setSupportStatus] = useState(
|
||||
values?.supportStatus ?? leadership?.supportStatus ?? 'a_abordar',
|
||||
)
|
||||
const statusError = errorProps(fieldErrors, 'supportStatus')
|
||||
|
||||
return (
|
||||
<FieldGroup>
|
||||
{mode === 'create' ? (
|
||||
<>
|
||||
<Field data-invalid={errorProps(fieldErrors, 'name').invalid}>
|
||||
<FieldLabel htmlFor="leadership-name">Nome *</FieldLabel>
|
||||
<Input
|
||||
id="leadership-name"
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={120}
|
||||
defaultValue={values?.name}
|
||||
autoComplete="name"
|
||||
className="min-h-11"
|
||||
aria-invalid={errorProps(fieldErrors, 'name').invalid}
|
||||
aria-describedby={errorProps(fieldErrors, 'name').describedBy}
|
||||
/>
|
||||
{fieldError(fieldErrors, 'name') ? (
|
||||
<FieldError id="leadership-name-error">{fieldError(fieldErrors, 'name')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={errorProps(fieldErrors, 'phone').invalid}>
|
||||
<FieldLabel htmlFor="leadership-phone">Celular (WhatsApp) *</FieldLabel>
|
||||
<FormattedInput
|
||||
id="leadership-phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
required
|
||||
maxLength={19}
|
||||
defaultValue={formatBrazilianPhoneInput(values?.phone ?? '')}
|
||||
format={formatBrazilianPhoneInput}
|
||||
sanitize={sanitizeBrazilianPhoneInput}
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
placeholder="(71) 99999-9999"
|
||||
className="min-h-11"
|
||||
aria-invalid={errorProps(fieldErrors, 'phone').invalid}
|
||||
aria-describedby={
|
||||
errorProps(fieldErrors, 'phone').describedBy
|
||||
? `${errorProps(fieldErrors, 'phone').describedBy} leadership-phone-description`
|
||||
: 'leadership-phone-description'
|
||||
}
|
||||
/>
|
||||
<FieldDescription id="leadership-phone-description">
|
||||
Se este celular já existir, o contato será reutilizado sem sobrescrever os dados.
|
||||
</FieldDescription>
|
||||
{fieldError(fieldErrors, 'phone') ? (
|
||||
<FieldError id="leadership-phone-error">
|
||||
{fieldError(fieldErrors, 'phone')}
|
||||
</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={errorProps(fieldErrors, 'email').invalid}>
|
||||
<FieldLabel htmlFor="leadership-email">E-mail (opcional)</FieldLabel>
|
||||
<Input
|
||||
id="leadership-email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={values?.email}
|
||||
autoComplete="email"
|
||||
className="min-h-11"
|
||||
aria-invalid={errorProps(fieldErrors, 'email').invalid}
|
||||
aria-describedby={errorProps(fieldErrors, 'email').describedBy}
|
||||
/>
|
||||
{fieldError(fieldErrors, 'email') ? (
|
||||
<FieldError id="leadership-email-error">
|
||||
{fieldError(fieldErrors, 'email')}
|
||||
</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={errorProps(fieldErrors, 'gender').invalid}>
|
||||
<FieldLabel htmlFor="leadership-gender">Gênero</FieldLabel>
|
||||
<NativeSelect
|
||||
id="leadership-gender"
|
||||
name="gender"
|
||||
defaultValue={values?.gender ?? ''}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11"
|
||||
aria-invalid={errorProps(fieldErrors, 'gender').invalid}
|
||||
aria-describedby={errorProps(fieldErrors, 'gender').describedBy}
|
||||
>
|
||||
<NativeSelectOption value="">Não informado</NativeSelectOption>
|
||||
{Object.entries(leadershipGenderLabels)
|
||||
.filter(([value]) => value !== 'nao_informado')
|
||||
.map(([value, label]) => (
|
||||
<NativeSelectOption key={value} value={value}>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{fieldError(fieldErrors, 'gender') ? (
|
||||
<FieldError id="leadership-gender-error">
|
||||
{fieldError(fieldErrors, 'gender')}
|
||||
</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Field data-invalid={errorProps(fieldErrors, 'sector').invalid}>
|
||||
<FieldLabel htmlFor="leadership-sector">Setor</FieldLabel>
|
||||
<NativeSelect
|
||||
id="leadership-sector"
|
||||
name="sector"
|
||||
defaultValue={values?.sector ?? leadership?.sector ?? ''}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11"
|
||||
aria-invalid={errorProps(fieldErrors, 'sector').invalid}
|
||||
aria-describedby={errorProps(fieldErrors, 'sector').describedBy}
|
||||
>
|
||||
<NativeSelectOption value="">Não informado</NativeSelectOption>
|
||||
{Object.entries(leadershipSectorLabels).map(([value, label]) => (
|
||||
<NativeSelectOption key={value} value={value}>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{fieldError(fieldErrors, 'sector') ? (
|
||||
<FieldError id="leadership-sector-error">{fieldError(fieldErrors, 'sector')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={errorProps(fieldErrors, 'sectorNotes').invalid}>
|
||||
<FieldLabel htmlFor="leadership-sectorNotes">Observações do setor</FieldLabel>
|
||||
<Textarea
|
||||
id="leadership-sectorNotes"
|
||||
name="sectorNotes"
|
||||
defaultValue={values?.sectorNotes ?? leadership?.sectorNotes ?? ''}
|
||||
maxLength={1000}
|
||||
className="min-h-20"
|
||||
aria-invalid={errorProps(fieldErrors, 'sectorNotes').invalid}
|
||||
aria-describedby={errorProps(fieldErrors, 'sectorNotes').describedBy}
|
||||
/>
|
||||
{fieldError(fieldErrors, 'sectorNotes') ? (
|
||||
<FieldError id="leadership-sectorNotes-error">
|
||||
{fieldError(fieldErrors, 'sectorNotes')}
|
||||
</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<FieldSet data-invalid={statusError.invalid}>
|
||||
<FieldLegend>Status de apoio *</FieldLegend>
|
||||
<input type="hidden" name="supportStatus" value={supportStatus} />
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={supportStatus}
|
||||
onValueChange={(value) => value && setSupportStatus(value as typeof supportStatus)}
|
||||
variant="outline"
|
||||
className="flex w-full flex-wrap justify-start"
|
||||
aria-invalid={statusError.invalid}
|
||||
aria-describedby={statusError.describedBy}
|
||||
>
|
||||
{supportStatusOptions.map(({ value, label }) => (
|
||||
<ToggleGroupItem
|
||||
key={value}
|
||||
value={value}
|
||||
disabled={isPrimaryContact && value !== 'engajado'}
|
||||
>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
{isPrimaryContact ? (
|
||||
<FieldDescription>
|
||||
Escolha outro contato principal antes de retirar o status Engajado.
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
{statusError.error ? (
|
||||
<FieldError id="leadership-supportStatus-error">{statusError.error}</FieldError>
|
||||
) : null}
|
||||
</FieldSet>
|
||||
|
||||
<Field data-invalid={errorProps(fieldErrors, 'notes').invalid}>
|
||||
<FieldLabel htmlFor="leadership-notes">Observações internas</FieldLabel>
|
||||
<Textarea
|
||||
id="leadership-notes"
|
||||
name="notes"
|
||||
defaultValue={values?.notes ?? leadership?.notes ?? ''}
|
||||
maxLength={3000}
|
||||
className="min-h-24"
|
||||
aria-invalid={errorProps(fieldErrors, 'notes').invalid}
|
||||
aria-describedby={
|
||||
errorProps(fieldErrors, 'notes').error
|
||||
? 'leadership-notes-error leadership-notes-description'
|
||||
: 'leadership-notes-description'
|
||||
}
|
||||
/>
|
||||
<FieldDescription id="leadership-notes-description">
|
||||
Avaliação restrita à coordenação. A liderança nunca recebe este campo.
|
||||
</FieldDescription>
|
||||
{fieldError(fieldErrors, 'notes') ? (
|
||||
<FieldError id="leadership-notes-error">{fieldError(fieldErrors, 'notes')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={errorProps(fieldErrors, 'consentNote').invalid}>
|
||||
<FieldLabel htmlFor="leadership-consentNote">Registro de consentimento externo</FieldLabel>
|
||||
<Textarea
|
||||
id="leadership-consentNote"
|
||||
name="consentNote"
|
||||
defaultValue={values?.consentNote ?? leadership?.consentNote ?? ''}
|
||||
maxLength={2000}
|
||||
className="min-h-20"
|
||||
aria-invalid={errorProps(fieldErrors, 'consentNote').invalid}
|
||||
aria-describedby={errorProps(fieldErrors, 'consentNote').describedBy}
|
||||
/>
|
||||
{fieldError(fieldErrors, 'consentNote') ? (
|
||||
<FieldError id="leadership-consentNote-error">
|
||||
{fieldError(fieldErrors, 'consentNote')}
|
||||
</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export type LeadershipFormProps = LeadershipFormFieldsProps & {
|
||||
action: LeadershipFormAction
|
||||
nucleusId: number
|
||||
successHref: string
|
||||
cancelHref: string
|
||||
}
|
||||
|
||||
export const LeadershipForm = ({
|
||||
action,
|
||||
mode,
|
||||
nucleusId,
|
||||
leadership,
|
||||
isPrimaryContact,
|
||||
successHref,
|
||||
cancelHref,
|
||||
}: LeadershipFormProps) => {
|
||||
const router = useRouter()
|
||||
const [state, formAction, pending] = useActionState(action, {})
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== 'success') return
|
||||
toast.success(state.message)
|
||||
router.push(successHref, { scroll: false })
|
||||
}, [router, state.message, state.status, successHref])
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
aria-describedby={
|
||||
state.message || state.fieldErrors?.form ? 'leadership-form-error' : undefined
|
||||
}
|
||||
>
|
||||
<LeadershipHiddenFields
|
||||
nucleusId={nucleusId}
|
||||
leadershipId={leadership?.id}
|
||||
fieldErrors={state.fieldErrors}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<LeadershipFormFeedback
|
||||
message={state.status !== 'success' ? state.message : undefined}
|
||||
formErrors={state.fieldErrors?.form}
|
||||
/>
|
||||
<LeadershipFormFields
|
||||
key={state.revision ?? 0}
|
||||
mode={mode}
|
||||
leadership={leadership}
|
||||
fieldErrors={state.fieldErrors}
|
||||
values={state.values}
|
||||
isPrimaryContact={isPrimaryContact}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 border-t p-4 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
onClick={() => router.push(cancelHref, { scroll: false })}
|
||||
disabled={pending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" className="min-h-11" disabled={pending}>
|
||||
{pending ? <Spinner data-icon="inline-start" /> : null}
|
||||
{pending ? 'Salvando…' : mode === 'create' ? 'Cadastrar liderança' : 'Salvar alterações'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
24
src/components/campaign/LeadershipFormLazy.tsx
Normal file
24
src/components/campaign/LeadershipFormLazy.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use client'
|
||||
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
import type { LeadershipFormProps } from '@/components/campaign/LeadershipForm'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
const LazyLeadershipForm = lazy(() =>
|
||||
import('./LeadershipForm').then((module) => ({ default: module.LeadershipForm })),
|
||||
)
|
||||
|
||||
export const LeadershipFormLazy = (props: LeadershipFormProps) => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 pb-4" aria-label="Carregando formulário">
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyLeadershipForm {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue