diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 66e6b8c6..df8dde80 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **C12 Registro-fundação entregue em código (2026-07-24):** trajetória de `votePledge` por versions nativas com baseline; atualização municipal `sinal` tipada (explicações, fonte e triangulação); `actionPlan.origin`; demandas vinculadas ao plano com custo derivado e criação múltipla atômica no formulário; `allocationDecision` ex-ante imutável com snapshot pequeno. Migration consolidada `20260724_180000_add_campaign_foundation_records`; testes de integração e jornada E2E dedicados. - **B18 registrado (2026-07-24):** filtros **salvos com nome** em `/campanha/municipios` (`localStorage`) + acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo). Soft B16/B17 (barra). Fatia concreta do “saved views” que FD2 adiava como genérico. Sem migration. Plano: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md). - **Avaliação: reorder de colunas por DnD (2026-07-24):** pedido para arrastar headers em `/campanha/municipios` → **fora de escopo** (não confundir com **B17** hide/show). Tecnicamente possível (~1–1,5d), mas conflita com sort B15 + filtros B16 no mesmo header, anti spreadsheet do PRODUCT, e não desbloqueia decisão de alocação. Gatilho em [`reordenar-colunas-lista-municipios.md`](../../../docs/plans/reordenar-colunas-lista-municipios.md). - **B17 registrado (2026-07-24):** seletor **mostrar/ocultar** colunas em `/campanha/municipios` (desktop) — Popover + `localStorage`; `name` obrigatória; soft B15/B16; prepara viewport para E9. Distinto do reorder DnD (fora de escopo). Sem migration. Plano: [`seletor-colunas-lista-municipios.md`](../../../docs/plans/seletor-colunas-lista-municipios.md). @@ -84,6 +85,7 @@ alwaysApply: false ## Decisões - Em 2026-07-24, **header da coluna A11 = `2022` (não "Concentração 2022"):** o título longo competia com células curtas (`%` + `12º`); no contexto da mesa o ano basta. Definição da métrica fica no hover do header (`formatMunicipalityConcentrationHint`). **Rejeitado:** manter "Concentração" no rótulo visível. +- Em 2026-07-24, **C12 preserva fatos e decisões antes de sugerir:** pledges mantêm histórico nativo; sinais exigem tipo+fonte e distinguem triangulação; origem/custo são observados via plano→demandas; decisão de alocação é append-only para campanha (correção só por admin), sempre com racional e snapshot ex-ante pequeno. Demandas criadas junto do plano compartilham a mesma transação e fazem rollback integral em conflito. **Rejeitado:** sobrescrever trajetória, custo duplicado no plano, sugestão que decide automaticamente. - Em 2026-07-24, **uma única série por cenário — "Metas" eliminadas:** o grupo `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") duplicava o conceito de votos estimados e foi removido do app (collection, zod, view models, form/card, `utilities/voteGoals.ts`); a série do município é `expectedVotes` (pessimista/média/otimista). Planilha EXPECTATIVA mapeia **Bom→otimista, Regular→média, Mínimo→pessimista**. Migration `20260724_133600_drop_municipality_vote_goals` com backfill metas→estimativas (estimativa existente vence). **Supersede** o "não fundir com `voteGoals`" da decisão A10 abaixo. **Rejeitado:** manter os dois grupos com semânticas distintas (meta de planejamento × estimativa) — a mesa trabalha com um número por cenário. - Em 2026-07-24, **E4R overwrite-always:** `pnpm db:seed:projecao` sempre sobrescreve `expectedVotes` + `priority` nas linhas casadas (planilha nova sobre DB já populado). **Rejeitado:** empty-only / `--overwrite` opt-in (quebraria o re-seed). Dry-run continua obrigatório antes de produção. - Em 2026-07-23, **access explícito em toda collection (hardening Fase 0):** o default do Payload ("qualquer autenticado pode CRUD") é tratado como bug; toda collection declara `access` explícito — `users`/`signature`/`subscription`/`consent` só Payload admin (`payloadAdminOnly`), escrita de CMS admin-only com leitura pública preservada. JWTs de `campaignUser` não operam mais essas collections via `/api`. **Rejeitado:** confiar no default do Payload; RBAC só na camada de UI. diff --git a/docs/plans/escala-dry-pos-c7.md b/docs/plans/escala-dry-pos-c7.md index f783b325..3b00f62b 100644 --- a/docs/plans/escala-dry-pos-c7.md +++ b/docs/plans/escala-dry-pos-c7.md @@ -1,8 +1,10 @@ # Escala e DRY pós-C7 (planos de ação / agenda) Status: registrado no roadmap (fases pendentes) -Atualizado em: 2026-07-19 +Atualizado em: 2026-07-24 (extensão pós-C12 absorvida) Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C11) +Appetite da extensão pós-C12: ~0,5–1 dia eng, sem migration +Impeccable: B — ajustes encaixados nos forms/detalhe existentes Responsável: — ## Contexto @@ -22,6 +24,8 @@ Duas passagens `/simplify` (pré- e pós-rebase com `main`/C8–C9) marcaram com 9. **Lista de coordenadores sem paginação no form.** `getEligibleNucleusCoordinatorOptions` com `pagination: false` em novo/editar — O(staff) por page view (aceitável no MVP, débito se o time crescer). 10. **`mutationKind` sem tipo compartilhado.** Magic strings `'taskToggle' | 'appendUpdate'` em actions e `ActionPlan.ts` — risco de drift. 11. **DRY menor:** `searchContactComboboxOptions` ainda inline em `contactSearchActions.ts` (liderança já em `utilities/`); label de liderança duplicada entre `actionPlanLeadershipOptions` e `toActionPlanFormViewModel`; `firstValue` local em `actionPlanDetailTabUi.ts` (já existe em `campaignListUrl.ts`); loader combinado da lista `/campanha/planos` (precedente: `loadSupportersPageData` no C9). +12. **Opções de plano sem limite na nova demanda (C12).** `loadActionPlanOptions` usa `pagination: false` e serializa todos os planos acessíveis no `DemandForm`; custo de RSC/hidratação/DOM cresce com a agenda. +13. **Demandas vinculadas sem paginação no detalhe do plano (C12).** A visão geral carrega e renderiza todas as demandas para somar custo e listar vínculos; aceitável no volume atual, mas sem teto. **Explicitamente fora (revisores pediram skip no simplify / já coberto em outro item):** @@ -31,12 +35,16 @@ Duas passagens `/simplify` (pré- e pós-rebase com `main`/C8–C9) marcaram com - **Renomear `NucleusTerritoryFields.tsx` → `CampaignTerritoryFields.tsx`** — fricção de descoberta; fill-in, não bloqueia escala. - **View-model dedup form ↔ detalhe** — refactor grande; só se surgir terceiro consumidor. - **Testes E2E da vertical** — fora do escopo deste item de escala/DRY. +- **Serialização de `demandsJson` a cada tecla** — máximo de 20 drafts torna o custo limitado; reavaliar somente se houver input lag medido. +- **Bulk de demandas criadas com o plano** — manter Local API sequencial para preservar hooks/access/slug; reavaliar se p95 da transação passar de 500 ms. +- **Helper compartilhado create/update para drafts** — só extrair quando surgir o 3º call site; dois loops curtos não justificam uma abstração. ## Objetivos - Agenda (`/campanha/planos`) permanece responsiva com dezenas de atualizações e tarefas por plano — medir antes de migration; feed append-only só se o volume justificar. - Detalhe e edição seguem o mesmo padrão de loaders que núcleos (orquestrador + tab data). - Hot paths de mutação (toggle tarefa, append update) minimizam bytes lidos/escritos sem quebrar locks transacionais nem RBAC. +- Seletores/listas do vínculo plano↔demanda permanecem limitados em payload e DOM quando a agenda crescer. - Guardrails: `overrideAccess: false` com `user`; sem novo `Consent`; migrations só na Fase 1 condicional (`actionPlanUpdate`). ## Decisões travadas @@ -47,6 +55,7 @@ Duas passagens `/simplify` (pré- e pós-rebase com `main`/C8–C9) marcaram com - **Fase 1 condicional respeita congelamento ~20/09** — se entrar perto da reta final, adiar para pós-eleição; Fases 2–4 não exigem migration. - **Cortável** se a agenda permanecer pequena (poucos planos, feeds curtos). Fase 1 é a mais cortável (e a única com migration). Fases 2–3 são as mais valiosas se houver uso real da agenda antes de 16/08. - **i18n e naming** (AGENTS.md): identificadores em inglês (`loadActionPlanDetailPageData`, `loadCampaignUserNamesById`, `ACTION_PLAN_MUTATION_KIND`), strings visíveis em pt-BR inalteradas. +- **Fase 6 pós-C12 é condicional e sem schema.** Opções: manter listas integrais | picker remoto + paginação | data-grid. **Recomendação:** picker remoto filtrado por município e lista paginada/limitada no card, só quando houver volume real; **rejeitado:** data-grid ou bulk SQL que contorne hooks. ## Questões em aberto @@ -67,6 +76,7 @@ flowchart TD C7 --> F1 F1 -.condicional volume.-> F5["Fase 5 — Loader lista planos"] F3 -.opcional.-> F5 + C12["C12 Registro-fundação ✓"] --> F6["Fase 6 — Plano ↔ demandas
picker + paginação condicional"] ``` ### Fase 1 — Escala do feed de atualizações _(condicional)_ @@ -100,11 +110,30 @@ flowchart TD - `loadActionPlansPageData` combinando canonical URL + list (precedente `loadSupportersPageData`). - Entra só se a lista ganhar filtros/overview pesados; cortável. +### Fase 6 (condicional) — Escala do vínculo plano ↔ demandas + +- Trocar `loadActionPlanOptions(... pagination: false)` por picker server-backed após o município ser escolhido; consulta seleciona somente `id`, `title` e `municipality`. +- Paginar ou limitar o card “Demandas vinculadas” no detalhe do plano, preservando a soma de custo em aggregate separado e link para a lista filtrada. +- Gatilhos: ≥100 planos acessíveis a um ator, ≥50 demandas num plano, payload RSC >100 KB ou latência p95 >500 ms. +- Sem migration; reusar `AsyncSearchCombobox`, access existente e o shell de paginação. Não criar segundo sistema de picker/lista. + +## Já resolvido no simplify/critique (não reabrir) + +- Snapshot JSON da migration C12 gerado e verificado sem schema drift. +- Schema dos drafts derivado de `campaignDemandCreateSchema`; opções de sinal derivadas dos enums canônicos. +- Casts/estado/nullabilidade redundantes removidos; query params e access reutilizam helpers/política existentes. +- Município do plano cacheado em `req.context` durante creates aninhados, removendo leituras repetidas na mesma transação. + +## Dados → decisão → apresentação + +Dados: N/A — esta extensão reduz payload, I/O e DOM; não cria métrica nem nova apresentação analítica. + **Migration:** somente Fase 1b (condicional). Demais fases sem schema. ## Dependências -- **Dura:** C7 Escala e DRY pós-C3 — entregue (merge em `main` pendente do PR atual). +- **Dura:** C7 Escala e DRY pós-C3 — entregue. +- **Dura da Fase 6:** C12 Registro-fundação — entregue em código. - **Suave:** C8 `pg_trgm` em `contact` — já em `main`; acelera typeahead se Fase 4 adotar o padrão. - **Suave:** C10 `errorProps` — paralelo; não bloqueia C11 (escopos distintos). - Reuso: `src/utilities/actionPlanPageData.ts`, `actionPlanDetailPageData.ts`, `actionPlanViewModels.ts`, `src/app/(campaign)/campanha/actions/actionPlan.ts`, `src/collections/ActionPlan.ts`, padrão `loadNucleusDetailPageData`, `campaignListUrl.ts`, `contactSearchQuery.ts`. diff --git a/docs/plans/inteligencia-campanha.md b/docs/plans/inteligencia-campanha.md index 73cbff8e..2bfcdc44 100644 --- a/docs/plans/inteligencia-campanha.md +++ b/docs/plans/inteligencia-campanha.md @@ -23,7 +23,7 @@ Auditoria relatório × schema atual (2026-07-21). O que **já existe e cobre**: | --- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | | G1 | **Trajetória de pledge** ("o que sabíamos em 15/08") — valor atual sobrescreve o anterior | `votePledge` guarda só o corrente | `versions: true` (sem drafts) em `votePledge` + leitura da série; **nunca sobrescrever** vira garantia de plataforma | C12 | | G2 | **Sinal qualitativo de rede tipado** (invasão, esfriamento, visita de adversário, proposta a broker; fonte; triangulação) | `municipalityUpdate.kind` = semanal/urgente/nota, texto livre | Novo kind `sinal` + campos `signalType`, `signalSource`, `triangulated` (staff-only) | C12 | -| G3 | **Esforço/agenda por município** (o lado privado que evapora: dias de candidato, origem da visita, recurso) | `actionPlan` tem evento+status; sem origem nem custo | `actionPlan.origin` (`dado \| pedido_broker \| obrigacao_politica` — J-B) + `resourceCost` staff-only opcional | C12 | +| G3 | **Esforço/agenda por município** (o lado privado que evapora: dias de candidato, origem da visita, recurso) | `actionPlan` tem evento+status; `campaignDemand` já relaciona plano e custo | `actionPlan.origin` (`dado \| pedido_broker \| obrigacao_politica` — J-B) + custo derivado das `campaignDemand` vinculadas | C12 | | G4 | **Decisão registrada ex-ante** (sugestão dada, aceita/descartada, leitura alternativa, foto da classificação no momento) | Nada | Nova collection `allocationDecision` (municipality, patternId, suggestion, decision `aceita\|descartada\|adiada`, alternativeReading, snapshot, decidedBy/At) — também registra mudanças de nível N0–N4 | C12 | | G5 | **Meta estadual e decomposição derivada** (QE projetado, faixa da cadeira, potencial por município) | `municipality.expectedVotes` manual, sem conta de cima para baixo nem cobertura | Global `campaignGoals` (meta estadual, margem, ano-base) + utilities de potencial (válidos projetados, captura, headroom) e cobertura Σpledges÷meta | E8 | | G6 | **Definição de "campo" por ano** (share intracampo, teto do campo) | `electionCandidate.party` existe; não há lista de partidos do campo por eleição | Lib estática curada `src/lib/campoParties.ts` (ano → siglas/números) — dado de configuração, não collection | E8 | @@ -53,7 +53,7 @@ Regras transversais (de §6.2/§6.4, viram comportamento do produto): **barato-a | --- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ------ | -------- | --------- | | E8 | **[Conta da cadeira](conta-da-cadeira.md)** | Global `campaignGoals`; potencial por município (válidos projetados, captura do campo, share intracampo via `campoParties.ts`, roll-off, headroom); decomposição meta estadual→município com sanity por TI; cobertura Σ(estimativa do cenário — default média via **A10**)÷meta no dashboard/lista; denominadores sempre válidos projetados (I-B) | O-A | B | ~2d | 2 | | E9 | **[Fila de alocação](fila-de-alocacao.md)** | Lista de decisão com as 7 colunas (§5.2 do relatório: votos em jogo, meta/cobertura/delta, classe, LQ/captura, rede+frescor, competição, tendência) + **% da própria votação** (âncora da mesa, via A11); ordenação default por déficit descoberto (default do staff quando houver metas); ordenação de risco (defesa por frescor); "coluna da vergonha" | O-B, O-F | B | ~1,5d | 2 | -| C12 | **[Registro-fundação](registro-fundacao.md)** | Versions em `votePledge`; sinais tipados no `municipalityUpdate`; `actionPlan.origin`+`resourceCost`; collection `allocationDecision`; garantia "nunca sobrescrever" | O-E, O-G(base) | B | ~2d | 2 | +| C12 | **[Registro-fundação](registro-fundacao.md)** | Versions em `votePledge`; sinais tipados no `municipalityUpdate`; `actionPlan.origin` + demandas/custo vinculado; collection `allocationDecision`; garantia "nunca sobrescrever" | O-E, O-G(base) | B | ~2d | 2 | | E16 | **[Dossiê do município](dossie-municipio.md)** | Dossiê pré-visita/print compondo série+captura, pledges/cobertura, lideranças, conjuntura (`politicalTrend`), forças/riscos, dobradinhas, sinais recentes e perfil A8; emendas quando houver dado (G11) — pedido explícito de campo (O6 do CUSTOMER.md) | O6 (campo) | B | ~1d | 2 | | E10 | **[Classificação territorial relativa](classificacao-territorial-relativa.md)** | Recalibra defesa/ataque/indecisa/perdida com âncoras relativas (LQ vs. share estadual próprio; múltiplos do share da cadeira marginal), multi-eixo (dominância+importância+competição+teto do campo); substitui limiares 35/20/10 de `electionInsights.ts` para DF | O-D | B | ~1d | 3 | | B13 | **[Escala relativa no mapa](escala-relativa-mapa.md)** | Modo de escala novo: quantis do próprio candidato (5 classes) default, LQ e rank no município; símbolo proporcional por votos em jogo sobre o polígono; % dos válidos mantido como opção; roll-off como métrica adicional | O-C | B | ~2d | 3 | diff --git a/docs/plans/registro-fundacao.md b/docs/plans/registro-fundacao.md index a0c996da..22194bb0 100644 --- a/docs/plans/registro-fundacao.md +++ b/docs/plans/registro-fundacao.md @@ -1,7 +1,7 @@ # C12 — Registro-fundação (trajetória, sinais tipados, origem de visita, decisões ex-ante) -Status: rascunho -Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening) +Status: entregue em código +Atualizado em: 2026-07-24 (implementação, migration, testes e simplify concluídos) Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", C12; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md)) Impeccable: B — sem rota nova; campos novos em forms existentes (`municipalityUpdate`, `actionPlan`) + collection admin-hidden Appetite: ~2 dias eng; 1 migration consolidada @@ -27,7 +27,7 @@ FU4 do relatório: backtest compara o que se sabia antes com o que aconteceu dep - **G1 — trajetória de pledge:** `versions: true` (sem drafts) em `votePledge`; leitura da série por município/liderança para o delta semanal (E8) e o backtest (E15). - **G2 — sinais tipados:** `municipalityUpdate.kind` ganha `sinal`; campos condicionais `signalType` (`invasao | esfriamento | visita_adversario | proposta_broker | outro`), `signalSource` (texto curto), `triangulated` (checkbox staff-only). Continua imutável e mantendo `municipality.lastUpdateAt`. -- **G3 — origem/custo de plano:** `actionPlan.origin` (`dado | pedido_broker | obrigacao_politica`, default `dado`, staff-only) + `resourceCost` (number, staff-only, opcional). +- **G3 — origem/custo de plano:** `actionPlan.origin` (`dado | pedido_broker | obrigacao_politica`, default `dado`, staff-only) + custo derivado de `campaignDemand.cost` nas demandas vinculadas ao plano. A relação opcional `campaignDemand.actionPlan` já existe e só aceita o mesmo município. - **G4 — decisões ex-ante:** collection `allocationDecision` (admin group `Campanha`, `admin.hidden` como `supporterImportBatch`): `municipality`, `patternId` (texto — P1…K-C ou `manual`), `suggestion` (texto), `decision` (`aceita | descartada | adiada`), `alternativeReading` (obrigatório ao descartar), `snapshot` (json — classificação/números no momento), `decidedBy` (derivado), `note`. Imutável após criação. - Access: tudo staff; `leader` não lê sinais staff-only (`triangulated`), não lê `allocationDecision`, não lê `origin`/`resourceCost` — mesmo padrão `canReadCampaignStaffField`. - Garantia transversal: nenhuma action nova sobrescreve histórico; escritas multi-collection com `withPayloadTransaction` + `req`. @@ -38,7 +38,7 @@ FU4 do relatório: backtest compara o que se sabia antes com o que aconteceu dep - **Histórico via Payload `versions`, não collection de snapshots.** Menos código, admin de graça, migration gerada. **Rejeitado:** `pledgeSnapshot` própria (duplica access/hooks); campo `history` json (não consultável, cresce sem índice). - **Decisão registrada é collection própria e imutável** — é o dado que o backtest paga caro para ter; mudanças de nível N0–N4 (E14) também gravam aqui, um mecanismo só. **Rejeitado:** log em `municipalityUpdate` (semântica de feed de campo, autoria de liderança possível — decisão é de coordenação); campo no `municipality` (sobrescreve). - **Sinal nasce no form existente de atualização** (custo de digitação mínimo; "o campo dita, a sede registra" continua possível por quem tiver acesso). **Rejeitado:** collection nova de sinais (segundo feed; FU1 pede agregação, não outra caixa). -- **i18n e naming:** `signalType`, `signalSource`, `triangulated`, `origin`, `resourceCost`, `allocationDecision`, `patternId`, `alternativeReading`; enums como valores de dado em pt (`invasao`, `pedido_broker`, `aceita`) por paridade com enums existentes (`semanal`, `alta`); labels pt-BR. +- **i18n e naming:** `signalType`, `signalSource`, `triangulated`, `origin`, `allocationDecision`, `patternId`, `alternativeReading`; enums como valores de dado em pt (`invasao`, `pedido_broker`, `aceita`) por paridade com enums existentes (`semanal`, `alta`); labels pt-BR. ## Questões em aberto @@ -51,7 +51,7 @@ FU4 do relatório: backtest compara o que se sabia antes com o que aconteceu dep flowchart LR Pledge["votePledge + versions"] Update["municipalityUpdate kind=sinal
(signalType/source/triangulated)"] - Plan["actionPlan origin/resourceCost"] + Plan["actionPlan origin + demandas/custo"] Dec["allocationDecision
(imutável, staff)"] Series["leitores de série
(E8 delta · E11 gatilhos · E15 backtest)"] Pledge --> Series @@ -63,11 +63,10 @@ flowchart LR Componentes: - **`src/collections/VotePledge.ts`**: `versions: true`; sem mudança de campos. -- **`src/collections/MunicipalityUpdate.ts`**: opção `sinal` no kind + 3 campos condicionais (`admin.condition` como os campos de `semanal`); access staff no `triangulated`. -- **`src/collections/ActionPlan.ts`**: `origin` + `resourceCost` com `canReadCampaignStaffField`. +- **`src/collections/MunicipalityUpdate.ts`**: opção `sinal` no kind + `body` curto, `signalType`, `signalSource` e `triangulated` condicional; access staff no `triangulated`. +- **`src/collections/ActionPlan.ts`**: `origin` com `canReadCampaignStaffField`; custo é lido das `campaignDemand` vinculadas, sem duplicar valor no plano. - **`src/collections/AllocationDecision.ts`** (nova): imutável (update/delete admin-only), `createdBy` derivado no hook (padrão `Supporter.createdBy`). -- **`src/utilities/pledgeHistory.ts`**: leitura da série de versions por município (consumida por E8/E15). -- **Actions:** `recordAllocationDecision` em `src/app/(campaign)/campanha/actions/` (usada por E11; utilizável manualmente antes disso), com `withPayloadTransaction`. +- **Leituras e actions downstream:** E8/E15 definem o leitor de versions conforme seu contrato; E11 define a action de criar `allocationDecision` junto ao fluxo de sugestão. C12 entrega as estruturas e seus invariantes, sem abstrações sem consumidor. - **Migration**: `pnpm migrate:create add_intel_foundation` — tabelas de versions do `votePledge`, campos novos, collection `allocationDecision`. ## Dependências @@ -75,6 +74,13 @@ Componentes: - Dura: nenhuma pendente — deploy da remodelagem aplicado em produção (2026-07-23). Nenhuma de outro plano do programa (é a fundação; E8 usa se existir). - Reusa: `withPayloadTransaction` (`payloadTransaction.ts`), `campaignAccess.ts`, padrão `createdBy` de `Supporter.ts`, `municipalityUpdatePageData.ts` (feed). +## Dados → decisão → apresentação + +- **Dados:** sinais de rede, causalidade da visita e custo de demandas ligadas ao plano. +- **Decisão:** registrar um fato de campo datado, distinguir visita justificada de pedido político e julgar o recurso já comprometido com cada plano. +- **Forma:** formulário curto e feed cronológico com badges de tipo/fonte/triangulação; lista de demandas e soma em R$ no detalhe do plano. Sem KPI, gráfico ou mapa nesta fatia. +- **Anti-goals:** segundo feed, formulário de inteligência longo, dashboard de custos ou métrica estadual absoluta. + ## Não escopo - Computar gatilhos sobre os sinais (E11); UI de leitura da trajetória (E8 delta/E15); nível N0–N4 em si (E14 — só o registro da mudança mora aqui); notificações de sinal (D2). @@ -90,6 +96,11 @@ Componentes: - **Versions em `municipality` (histórico de estratégia).** Gatilho: E15 precisar de foto além do `allocationDecision.snapshot` em caso real. - **Import de sinais via WhatsApp/copy-paste estruturado.** Gatilho: volume real de sinais >20/semana com reclamação de digitação. +## Revisões + +- **2026-07-24:** G3 passa a reutilizar `campaignDemand.actionPlan` + `campaignDemand.cost`, já existentes; removido `resourceCost` duplicado no plano. Leitores de versions e action de decisão seguem para E8/E15 e E11, respectivamente. +- **2026-07-24:** implementação concluída com migration consolidada `20260724_180000_add_campaign_foundation_records`, testes de integração e jornada E2E; débitos de escala plano↔demandas absorvidos em C11. + ## Referências - `docs/roadmap.md` (Inteligência de campanha, C12) · [plano-mestre](inteligencia-campanha.md) (gaps G1–G4) diff --git a/docs/roadmap.md b/docs/roadmap.md index f3c277ca..50102659 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-24 (B18 filtros salvos + atalho sidebar — [plano](plans/filtros-salvos-municipios.md); B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; fill-ins; E4R ✓; E17 ✓; B14; janela 1: smoke, R6, E8) +Atualizado em: 2026-07-24 (C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6, E8) Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -46,6 +46,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md]( - **Hardening de engenharia (2026-07-23, Fases 0–6)** — access lockdown (PII/CMS admin-only + gates de rota do leader), tooling gate (knip no CI, `--max-warnings=0`, ban `as never`), artefato TSE commitado + cache (`bahiaElectionAggregates`, `election-tse`, `loadMunicipalityScope`), pending honesto + Suspense streaming, state scoping, splits (`src/utilities/access/*`, `supporterImport.ts`, shells DRY). Tracker: [IMPROVE-CODE-QUALITY-PLAN.md](IMPROVE-CODE-QUALITY-PLAN.md) · ledger: [TECH-DEBT.md](TECH-DEBT.md) · mapa de testes: [TESTING.md](TESTING.md). - **E4R (2026-07-24)** — import único da planilha de projeção → `municipality.expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` (`pnpm db:seed:projecao`, always-overwrite, dry-run + runbook; Salvador pulado; zero PII). No mesmo dia o grupo duplicado `voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido do app** (migration `20260724_133600` com backfill metas→estimativas) — a única série por cenário é `expectedVotes`. Plano: [import-planilha-projecao.md](plans/import-planilha-projecao.md). Seed local verificado (189 estimativas / 50 alta); produção após smoke. - **E17 (2026-07-24)** — tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`): `territoryOverview.ts` (rollup puro client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true`) + `TerritoryOverviewTable.tsx` (tabela densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Primeira fatia de E12. Plano: [tabela-ti-inicio.md](plans/tabela-ti-inicio.md). +- **C12 (2026-07-24)** — registro-fundação: versions nativas em `votePledge` com baseline; sinais tipados em `municipalityUpdate`; origem do `actionPlan`; demandas vinculadas com custo derivado e criação múltipla atômica; `allocationDecision` ex-ante imutável. Migration `20260724_180000_add_campaign_foundation_records`; testes int + E2E. Plano: [registro-fundacao.md](plans/registro-fundacao.md). ## Próximos — Campanha (`/campanha`) @@ -57,7 +58,6 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re | --- | ---------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | ------------------------------ | | E8 | Conta da cadeira | [detalhes](plans/conta-da-cadeira.md) | Global `campaignGoals`; potencial por município (válidos projetados, captura do campo, roll-off, headroom); decomposição meta→município; cobertura Σpledges÷meta (cenário default = média); meta inicial ancorada em campo: piso 150 mil (2022: 129k) | B | ~2d | 1–2 | — (A10 ✓, remodelagem em prod) | | E9 | Fila de alocação | [detalhes](plans/fila-de-alocacao.md) | Lista de decisão (7 colunas + % da própria votação — absorve A11), ordenação por déficit descoberto (default do staff quando houver metas — registro no fluxo de poder) + risco por frescor, "coluna da vergonha" | B | ~1,5d | 2 | E8 | -| C12 | Registro-fundação | [detalhes](plans/registro-fundacao.md) | Versions em `votePledge`; sinais tipados no `municipalityUpdate`; `actionPlan.origin`+custo; collection `allocationDecision` | B | ~2d | 2 | — (paralelo a E8) | | E16 | Dossiê do município (pré-agenda) | [detalhes](plans/dossie-municipio.md) | Dossiê pré-visita compondo dados existentes (série/captura, pledges/cobertura, lideranças, conjuntura, forças/riscos, dobradinhas, sinais, perfil A8) + emendas quando houver dado (G11); visão print — pedido explícito de campo (O6) | B | ~1d | 2 | — (melhora com E8/C12) | | E10 | Classificação territorial relativa | [detalhes](plans/classificacao-territorial-relativa.md) | Âncoras relativas (LQ / share da cadeira marginal), multi-eixo; substitui 35/20/10 para DF | B | ~1d | 3 | E8 | | B13 | Escala relativa no mapa | [detalhes](plans/escala-relativa-mapa.md) | Quantis do candidato (default) + LQ + rank; símbolo proporcional por votos em jogo; % válidos mantido | B | ~2d | 3 | E8, E10 | @@ -72,6 +72,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**E4R** import único da planilha de projeção~~ — **entregue 2026-07-24** (`pnpm db:seed:projecao`; overwrite-always; [plano](plans/import-planilha-projecao.md)) - ~~**A11** posição em votos do município (rank absoluto + % da própria votação) + ordenação da lista por votação~~ — **entregue 2026-07-24** (helper `municipalityVoteRank`; baseline + coluna lista; default `?sort=votos` desc; header=`2022`; consome contrato B15) · [plano](plans/ranking-votos-municipio.md) - ~~**E17** tabela comparativa dos Territórios de Identidade no Início (pedido do candidato) · somas/razões apenas, Metropolitano decomposto (salvaguardas MAUP) · ~1d · Janela 1–2 · primeira fatia de E12 · [plano](plans/tabela-ti-inicio.md)~~ — **entregue 2026-07-24** (`territoryOverview.ts` + `loadTerritoryOverview.ts` + `TerritoryOverviewTable.tsx`; rollup puro client-safe, loader `overrideAccess: true`, Metropolitano decomposto, ordenação client-side; sem migration) +- ~~**C12** registro-fundação~~ — **entregue em código 2026-07-24** (versions, sinais, origem/custo derivado, decisões ex-ante; migration + testes) · [plano](plans/registro-fundacao.md) - **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · camada de insight sobre o registro operacional `stateDeputy` (M4) · alimenta **E13** · [plano](plans/insight-dobradinha-2026.md) - **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md) - **B8 F2** polígonos dos Municípios-zona de Salvador (ZE 1–19; Camaçari saiu do escopo na M1 — município inteiro): F1 catálogo zona→bairros entregue (hoje Salvador-only); falta prep do catálogo (~½ dia) + dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável · [plano](plans/poligonos-pracas-zona.md) @@ -94,7 +95,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **Cenário junto aos filtros** em `/campanha/municipios` — mover `VoteEstimateScenarioField` para a fileira/barra slim de `MunicipalityFilters` (com **B16**, a barra deixa de carregar os selects de coluna); overview só consome o contexto · ~0,25–0,5d · Impeccable B · soft: B16 · [plano](plans/cenario-junto-filtros-municipios.md) - **O0+** escala/DRY pós-Onda 0 · [plano](plans/escala-dry-pos-onda0.md) - **RS+** auth read leve + shells de senha · [plano](plans/escala-dry-pos-reset-senha-perfil.md) -- **C10 / C11** escala apoiadores/planos · reavaliar nas superfícies remodeladas · gatilhos originais intactos: base nominal crescendo ([plano](plans/escala-dry-pos-c9.md)) / volume de planos medido ([plano](plans/escala-dry-pos-c7.md)) +- **C10 / C11** escala apoiadores/planos · C11 absorveu a extensão pós-C12 para picker de planos e paginação das demandas vinculadas (Fase 6 condicional; ≥100 planos ou ≥50 demandas/plano) · gatilhos: base nominal crescendo ([plano](plans/escala-dry-pos-c9.md)) / volume de planos medido ([plano](plans/escala-dry-pos-c7.md)) - **C8 F1–F3 restantes** perf de import/lista + DRY de helpers (F4 DRY de forms entregue; parte de F2/F3 absorvida pelo split de access) · gatilho: import em volume real pós-Onda 0 · [plano](plans/escala-dry-pos-c6.md) - Higiene PascalCase de componentes legados @@ -173,13 +174,13 @@ flowchart TD B8F2 -.ZE Salvador.-> B14n ``` -Paralelizáveis agora: **E8** (A10 ✓ e remodelagem em produção), **C12** (paralelo a E8), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B16** (filtros no header; soft B15 ✓ já entregue), **B17** (seletor de colunas; soft B15/B16 — pousa na barra atual ou slim), **B18** (filtros salvos + submenu Municípios; soft B16/B17 — botão na barra), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9). +Paralelizáveis agora: **E8** (A10 ✓, C12 ✓ e remodelagem em produção), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B16** (filtros no header; soft B15 ✓ já entregue), **B17** (seletor de colunas; soft B15/B16 — pousa na barra atual ou slim), **B18** (filtros salvos + submenu Municípios; soft B16/B17 — botão na barra), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9). ### Sequência por janela (só pendentes) **Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + default `sort=votos`) entregues 2026-07-24; **B16** (filtros no header) / **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa). -**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **E8** conta da cadeira → **E9** fila de alocação, com **C12** registro-fundação em paralelo (migrations cedo, longe do congelamento); **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8); D2 se sobrar folga. +**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **C12 ✓** registro-fundação já em código; **E8** conta da cadeira → **E9** fila de alocação; **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8); D2 se sobrar folga. **Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08); **E10** classificação relativa → **B13** escala relativa no mapa; **E14** níveis N0–N4; **E11** motor de sugestões v1; **E12** camada TI; **E13** planejador de giros; **B8 F2** polígonos das zonas de Salvador; débitos sobreviventes. Se houver folga **e** os deltas do ZAP não estiverem sendo absorvidos pelo fluxo sede-digita (C12): começar **D3**. diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx index daf83c54..d2077e22 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx @@ -79,6 +79,18 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps > {demand.municipalityName} + {demand.actionPlan ? ( + <> + {' '} + · Plano:{' '} + + {demand.actionPlan.title} + + + ) : null} {demand.requesterName ? ` · Solicitada por ${demand.requesterName}` : ''} · Aberta em{' '} {dateTimeFormatter.format(new Date(demand.createdAt))}

diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts index 7c1da364..6d21b835 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/formActions.ts @@ -1,7 +1,12 @@ 'use server' import { createCampaignDemand } from '@/app/(campaign)/campanha/actions/demand' -import { optionalFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData' +import { + nullableRelationshipFormValue, + optionalFormText, + requiredFormText, + requiredRelationshipFormValue, +} from '@/lib/formData' import { campaignDemandKinds, type CampaignDemandKind } from '@/lib/schemas/campaignDemand' import { runCampaignRedirectFormAction, @@ -22,6 +27,7 @@ export const createDemandFormAction = async ( : 'outro', description: optionalFormText(formData, 'description'), municipality: requiredRelationshipFormValue(formData, 'municipalityId'), + actionPlan: nullableRelationshipFormValue(formData, 'actionPlanId') ?? undefined, }) }, redirectTo: (demand) => `/campanha/demandas/${demand.slug}`, diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx index 435ea8ba..6d8e6496 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx @@ -9,15 +9,34 @@ import { DemandForm } from '@/components/campaign/DemandForm' import { Button } from '@/components/ui/button' import { isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' -import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' +import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl' +import { loadActionPlanOptions, loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' import { createDemandFormAction } from './formActions' -export default async function NewDemandPage() { - const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) +type NewDemandPageProps = { + searchParams: Promise> +} + +export default async function NewDemandPage({ searchParams }: NewDemandPageProps) { + const [user, payload, query] = await Promise.all([ + getCampaignUser(), + getPayload({ config }), + searchParams, + ]) if (!user) redirect('/campanha/login') if (!isCampaignStaff(user)) redirect('/campanha') - const municipalityOptions = await loadMunicipalityOptions(payload, user) + const [municipalityOptions, actionPlanOptions] = await Promise.all([ + loadMunicipalityOptions(payload, user), + loadActionPlanOptions(payload, user), + ]) + const requestedActionPlan = actionPlanOptions.find( + (plan) => plan.id === strictDecimalInteger(firstValue(query.actionPlan)), + ) + const requestedMunicipalityId = strictDecimalInteger(firstValue(query.municipality)) + const initialMunicipalityId = + requestedActionPlan?.municipalityId ?? + municipalityOptions.find((option) => option.id === requestedMunicipalityId)?.id return ( @@ -35,7 +54,13 @@ export default async function NewDemandPage() {

- +
) } diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts index 0b2f5165..e2179cc2 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts @@ -4,12 +4,18 @@ import { revalidatePath } from 'next/cache' import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate' import { + checkboxFormValue, optionalFormText, optionalIntegerFormValue, requiredFormText, requiredRelationshipFormValue, } from '@/lib/formData' -import { municipalityUpdateKinds, type MunicipalityUpdateKind } from '@/lib/schemas/municipalityUpdate' +import { + municipalitySignalTypes, + municipalityUpdateKinds, + type MunicipalitySignalType, + type MunicipalityUpdateKind, +} from '@/lib/schemas/municipalityUpdate' import { mapCampaignFormActionError, type CampaignFormActionState, @@ -26,6 +32,11 @@ export const createMunicipalityUpdateFormAction = async ( ? (rawKind as MunicipalityUpdateKind) : 'semanal' + const rawSignalType = optionalFormText(formData, 'signalType') + const signalType = municipalitySignalTypes.includes(rawSignalType as MunicipalitySignalType) + ? (rawSignalType as MunicipalitySignalType) + : undefined + await createMunicipalityUpdate({ municipality, kind, @@ -35,6 +46,9 @@ export const createMunicipalityUpdateFormAction = async ( body: optionalFormText(formData, 'body'), activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'), newSupports: optionalIntegerFormValue(formData, 'newSupports'), + signalType, + signalSource: optionalFormText(formData, 'signalSource'), + triangulated: checkboxFormValue(formData, 'triangulated'), }) revalidatePath('/campanha/municipios/[slug]', 'page') return { status: 'success', message: 'Atualização registrada com sucesso.' } diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx index 4a2f6447..d5e927e9 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx @@ -17,7 +17,8 @@ import { ActionPlanUpdateForm } from '@/components/campaign/ActionPlanUpdateForm import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { actionPlanKindLabels } from '@/lib/schemas/actionPlan' +import { actionPlanKindLabels, actionPlanOriginLabels } from '@/lib/schemas/actionPlan' +import { campaignDemandKindLabels, campaignDemandStatusLabels } from '@/lib/schemas/campaignDemand' import { isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { @@ -36,6 +37,11 @@ type ActionPlanDetailPageProps = { searchParams: Promise> } +const currencyFormatter = new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', +}) + export default async function ActionPlanDetailPage({ params, searchParams, @@ -63,8 +69,7 @@ export default async function ActionPlanDetailPage({ if (canonicalTabRedirect) redirect(canonicalTabRedirect) const isStaff = isCampaignStaff(user) - const canManageLifecycle = - isStaff && view.status !== 'realizado' && view.status !== 'cancelado' + const canManageLifecycle = isStaff && view.status !== 'realizado' && view.status !== 'cancelado' return (
@@ -160,6 +165,10 @@ export default async function ActionPlanDetailPage({
Responsável
{view.responsibleName ?? 'Não definido'}
+
+
Origem da ação
+
{actionPlanOriginLabels[view.origin]}
+
@@ -205,6 +214,61 @@ export default async function ActionPlanDetailPage({ + + +
+ Demandas vinculadas +

+ Custo estimado: {currencyFormatter.format(view.demandCostTotal)} +

+
+ {view.municipality ? ( + + ) : null} +
+ + {view.demands.length ? ( +
    + {view.demands.map((demand) => ( +
  • +
    + + {demand.title} + +

    + {campaignDemandKindLabels[demand.kind]} ·{' '} + {campaignDemandStatusLabels[demand.status]} +

    +
    + + {demand.cost == null + ? 'Custo não registrado' + : currencyFormatter.format(demand.cost)} + +
  • + ))} +
+ ) : ( +

+ Nenhuma demanda vinculada a este plano. +

+ )} +
+
+ {view.status === 'realizado' && isStaff ? ( { + if ( + error instanceof Error && + /campaign_demand.*(?:slug|title)|campaignDemand.*(?:slug|title)/i.test(error.message) + ) { + return { + fieldErrors: { + demandsJson: ['Já existe uma demanda com um dos títulos informados.'], + }, + } + } // Checked before the shared mapper: a DB-level unique-violation message isn't // a `FormDataBoundaryError`/`ZodError`/known-safe string, so it needs its own // branch to surface as a field error instead of falling to the generic message. @@ -62,7 +72,8 @@ export const createActionPlanFormAction = async ( let planSlug: string try { - const plan = await createActionPlan(parseActionPlanCreateFormData(formData)) + const { demands, ...planInput } = parseActionPlanCreateFormData(formData) + const plan = await createActionPlan(planInput, demands) planSlug = plan.slug } catch (error) { const existing = await existingActionPlanState(formData) @@ -80,8 +91,8 @@ export const updateActionPlanFormAction = async ( let planSlug: string try { - const input = parseActionPlanUpdateFormData(formData) - const plan = await updateActionPlan(input) + const { demands, ...planInput } = parseActionPlanUpdateFormData(formData) + const plan = await updateActionPlan(planInput, demands) planSlug = plan.slug } catch (error) { return getActionError(error) diff --git a/src/app/(campaign)/campanha/actions/actionPlan.ts b/src/app/(campaign)/campanha/actions/actionPlan.ts index 1f36ebaa..011dff8a 100644 --- a/src/app/(campaign)/campanha/actions/actionPlan.ts +++ b/src/app/(campaign)/campanha/actions/actionPlan.ts @@ -4,7 +4,9 @@ import type { Payload } from 'payload' import { actionPlanCreateSchema, + actionPlanDemandDraftsSchema, actionPlanUpdateSchema, + type ActionPlanDemandDraft, type ActionPlanCreateInput, type ActionPlanUpdateInput, } from '@/lib/schemas/actionPlan' @@ -22,31 +24,60 @@ export const createActionPlanRecord = async ( payload: Payload, actor: CampaignUser, input: ActionPlanCreateInput, + demandDrafts: ActionPlanDemandDraft[] = [], ) => { const data = actionPlanCreateSchema.parse(input) + const demands = actionPlanDemandDraftsSchema.parse(demandDrafts) - return payload.create({ - collection: 'actionPlan', - data: hookFilledCreateData<'actionPlan'>(data), - depth: 0, - user: actor, - overrideAccess: false, - }) + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadCampaignActor(payload, actor, req) + const plan = await payload.create({ + collection: 'actionPlan', + data: hookFilledCreateData<'actionPlan'>(data), + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + + for (const demand of demands) { + await payload.create({ + collection: 'campaignDemand', + data: hookFilledCreateData<'campaignDemand'>({ + ...demand, + municipality: data.municipality, + actionPlan: plan.id, + }), + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + } + + return plan + }, + { beginFailureMessage: 'Não foi possível iniciar a criação do plano.' }, + ) } export const updateActionPlanRecord = async ( payload: Payload, actor: CampaignUser, input: ActionPlanUpdateInput, + demandDrafts: ActionPlanDemandDraft[] = [], ) => { const { id, ...data } = actionPlanUpdateSchema.parse(input) + const demands = actionPlanDemandDraftsSchema.parse(demandDrafts) return withPayloadTransaction( payload, async ({ req }) => { const currentActor = await reloadCampaignActor(payload, actor, req) - return payload.update({ + const plan = await payload.update({ collection: 'actionPlan', id, data, @@ -55,6 +86,25 @@ export const updateActionPlanRecord = async ( overrideAccess: false, req, }) + + const municipality = + typeof plan.municipality === 'number' ? plan.municipality : plan.municipality.id + for (const demand of demands) { + await payload.create({ + collection: 'campaignDemand', + data: hookFilledCreateData<'campaignDemand'>({ + ...demand, + municipality, + actionPlan: plan.id, + }), + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + } + + return plan }, { beginFailureMessage: 'Não foi possível iniciar a atualização do plano.' }, ) @@ -211,14 +261,20 @@ export const registerActionPlanResult = async ( ) } -export const createActionPlan = async (input: ActionPlanCreateInput) => { +export const createActionPlan = async ( + input: ActionPlanCreateInput, + demandDrafts: ActionPlanDemandDraft[] = [], +) => { const { payload, actor } = await getCampaignActionContext() - return createActionPlanRecord(payload, actor, input) + return createActionPlanRecord(payload, actor, input, demandDrafts) } -export const updateActionPlan = async (input: ActionPlanUpdateInput) => { +export const updateActionPlan = async ( + input: ActionPlanUpdateInput, + demandDrafts: ActionPlanDemandDraft[] = [], +) => { const { payload, actor } = await getCampaignActionContext() - return updateActionPlanRecord(payload, actor, input) + return updateActionPlanRecord(payload, actor, input, demandDrafts) } export const cancelActionPlan = async (id: number) => { diff --git a/src/collections/ActionPlan.ts b/src/collections/ActionPlan.ts index 3c622fc0..fc4e9345 100644 --- a/src/collections/ActionPlan.ts +++ b/src/collections/ActionPlan.ts @@ -6,6 +6,8 @@ import type { import { APIError } from 'payload' import { + actionPlanOriginLabels, + actionPlanOrigins, actionPlanKindLabels, actionPlanKinds, actionPlanStatusLabels, @@ -15,8 +17,10 @@ import { canCreateActionPlan, canCreateActionPlanAdvisors, canDeleteActionPlan, + canManageCampaignStaffField, canManageActionPlanAdvisors, canReadActionPlan, + canReadCampaignStaffField, canSetActionPlanStatus, canSetActionPlanSystemField, canUpdateActionPlan, @@ -40,6 +44,11 @@ const ACTION_PLAN_STATUS_OPTIONS = actionPlanStatuses.map((value) => ({ label: actionPlanStatusLabels[value], })) +const ACTION_PLAN_ORIGIN_OPTIONS = actionPlanOrigins.map((value) => ({ + value, + label: actionPlanOriginLabels[value], +})) + const relationshipIds = (value: unknown): number[] => (Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null) @@ -61,6 +70,7 @@ const actionPlanStaffFieldSnapshot = (doc: Record) => ({ leadership: relationshipId(doc.leadership), resultSummary: trimmedText(doc.resultSummary), resultMedia: relationshipIds(doc.resultMedia), + origin: doc.origin ?? null, }) const setCanonicalActionPlanSlug: CollectionBeforeValidateHook = ({ @@ -328,6 +338,18 @@ export const ActionPlan: CollectionConfig = { label: 'Descrição', maxLength: 4000, }, + { + name: 'origin', + type: 'select', + label: 'Origem da ação', + defaultValue: 'dado', + options: ACTION_PLAN_ORIGIN_OPTIONS, + access: { + create: canManageCampaignStaffField, + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, + }, + }, { name: 'deputyPresent', type: 'checkbox', diff --git a/src/collections/AllocationDecision.ts b/src/collections/AllocationDecision.ts new file mode 100644 index 00000000..35115157 --- /dev/null +++ b/src/collections/AllocationDecision.ts @@ -0,0 +1,147 @@ +import { Buffer } from 'node:buffer' + +import type { + CollectionBeforeChangeHook, + CollectionBeforeValidateHook, + CollectionConfig, +} from 'payload' +import { APIError } from 'payload' + +import { + canCreateAllocationDecision, + canMutateAllocationDecision, + canReadAllocationDecision, + canSetCampaignSystemField, +} from '@/utilities/campaignAccess' + +const MAX_SERIALIZED_SNAPSHOT_BYTES = 16_000 + +const validateAllocationDecision: CollectionBeforeValidateHook = ({ + data, + operation, + originalDoc, +}) => { + if (!data) return data + + const nextData = operation === 'update' ? { ...originalDoc, ...data } : data + if ( + nextData.outcome === 'descarta' && + (typeof nextData.alternativeReading !== 'string' || !nextData.alternativeReading.trim()) + ) { + throw new APIError('Informe a leitura alternativa ao descartar a sugestão.', 400) + } + + try { + const serializedSnapshot = JSON.stringify(nextData.snapshot) + if ( + typeof nextData.snapshot !== 'object' || + nextData.snapshot === null || + Array.isArray(nextData.snapshot) || + Buffer.byteLength(serializedSnapshot, 'utf8') > MAX_SERIALIZED_SNAPSHOT_BYTES + ) { + throw new APIError('O recorte da decisão está grande demais.', 400) + } + } catch (error) { + if (error instanceof APIError) throw error + throw new APIError('O recorte da decisão é inválido.', 400) + } + + return data +} + +const deriveDecisionAuthor: CollectionBeforeChangeHook = ({ data, operation, req }) => { + if (operation === 'create' && req.user?.collection === 'campaignUser') { + data.decidedBy = req.user.id + } + return data +} + +export const AllocationDecision: CollectionConfig = { + slug: 'allocationDecision', + labels: { + singular: 'Decisão de alocação', + plural: 'Decisões de alocação', + }, + admin: { + group: 'Campanha', + hidden: true, + useAsTitle: 'patternId', + }, + access: { + create: canCreateAllocationDecision, + read: canReadAllocationDecision, + update: canMutateAllocationDecision, + delete: canMutateAllocationDecision, + }, + hooks: { + beforeValidate: [validateAllocationDecision], + beforeChange: [deriveDecisionAuthor], + }, + fields: [ + { + name: 'municipality', + type: 'relationship', + relationTo: 'municipality', + label: 'Município', + required: true, + index: true, + }, + { + name: 'patternId', + type: 'text', + label: 'Padrão avaliado', + required: true, + maxLength: 120, + index: true, + }, + { + name: 'outcome', + type: 'select', + label: 'Decisão', + required: true, + options: [ + { label: 'Aceita', value: 'aceita' }, + { label: 'Descarta', value: 'descarta' }, + ], + }, + { + name: 'rationale', + type: 'textarea', + label: 'Justificativa', + required: true, + maxLength: 2000, + }, + { + name: 'alternativeReading', + type: 'textarea', + label: 'Leitura alternativa', + maxLength: 2000, + admin: { + condition: (_, siblingData) => siblingData.outcome === 'descarta', + }, + }, + { + name: 'snapshot', + type: 'json', + label: 'Recorte da decisão', + required: true, + admin: { + description: 'Somente os números e classificações usados no momento da decisão.', + }, + }, + { + name: 'decidedBy', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Decidido por', + index: true, + admin: { + readOnly: true, + }, + access: { + create: canSetCampaignSystemField, + update: canSetCampaignSystemField, + }, + }, + ], +} diff --git a/src/collections/CampaignDemand.ts b/src/collections/CampaignDemand.ts index c9fee919..275839cd 100644 --- a/src/collections/CampaignDemand.ts +++ b/src/collections/CampaignDemand.ts @@ -23,6 +23,7 @@ import { canUpdateCampaignDemand, isCampaignUnrestricted, } from '@/utilities/campaignAccess' +import { relationshipId } from '@/utilities/relationship' import { slugify } from '@/utilities/slug' const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') @@ -135,6 +136,48 @@ const enforceDemandWorkflow: CollectionBeforeChangeHook = async ({ return data } +const validateDemandActionPlanMunicipality: CollectionBeforeValidateHook = async ({ + data, + operation, + originalDoc, + req, +}) => { + if (!data) return data + + const municipalityID = relationshipId(data.municipality ?? originalDoc?.municipality) + const actionPlanID = relationshipId(data.actionPlan ?? originalDoc?.actionPlan) + if (!actionPlanID) return data + if (!municipalityID) throw new APIError('Município da demanda inválido.', 400) + if ( + operation === 'update' && + municipalityID === relationshipId(originalDoc?.municipality) && + actionPlanID === relationshipId(originalDoc?.actionPlan) + ) { + return data + } + + const requestContext = req.context as Record + const cacheKey = `campaignDemand:actionPlanMunicipality:${actionPlanID}` + let actionPlanMunicipalityID = requestContext[cacheKey] + if (typeof actionPlanMunicipalityID !== 'number') { + const actionPlan = await req.payload.findByID({ + collection: 'actionPlan', + id: actionPlanID, + depth: 0, + select: { municipality: true }, + overrideAccess: true, + req, + }) + actionPlanMunicipalityID = relationshipId(actionPlan.municipality) + requestContext[cacheKey] = actionPlanMunicipalityID + } + if (actionPlanMunicipalityID !== municipalityID) { + throw new APIError('A demanda e o plano de ação devem pertencer ao mesmo município.', 409) + } + + return data +} + export const CampaignDemand: CollectionConfig = { slug: 'campaignDemand', labels: { @@ -155,7 +198,7 @@ export const CampaignDemand: CollectionConfig = { delete: canDeleteCampaignDemand, }, hooks: { - beforeValidate: [setCanonicalDemandSlug], + beforeValidate: [setCanonicalDemandSlug, validateDemandActionPlanMunicipality], beforeChange: [enforceDemandWorkflow], }, fields: [ diff --git a/src/collections/MunicipalityUpdate.ts b/src/collections/MunicipalityUpdate.ts index dc32076c..f6ee4f6c 100644 --- a/src/collections/MunicipalityUpdate.ts +++ b/src/collections/MunicipalityUpdate.ts @@ -9,9 +9,17 @@ import type { } from 'payload' import { APIError } from 'payload' +import { + municipalitySignalTypeLabels, + municipalitySignalTypes, + municipalityUpdateKindLabels, + municipalityUpdateKinds, +} from '@/lib/schemas/municipalityUpdate' import { canCreateMunicipalityUpdate, + canManageCampaignStaffField, canMutateMunicipalityUpdate, + canReadCampaignStaffField, canReadMunicipalityUpdate, canSetMunicipalityUpdateAuthor, } from '@/utilities/campaignAccess' @@ -19,6 +27,14 @@ import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' import { relationshipId } from '@/utilities/relationship' const DERIVED_MUNICIPALITY_UPDATE_CONTEXT = 'municipalityUpdateDerivedField' +const MUNICIPALITY_UPDATE_KIND_OPTIONS = municipalityUpdateKinds.map((value) => ({ + label: municipalityUpdateKindLabels[value], + value, +})) +const MUNICIPALITY_SIGNAL_TYPE_OPTIONS = municipalitySignalTypes.map((value) => ({ + label: municipalitySignalTypeLabels[value], + value, +})) const nonEmptyText = (value: unknown): boolean => typeof value === 'string' && value.trim().length > 0 @@ -37,10 +53,22 @@ const validateMunicipalityUpdateKind: CollectionBeforeValidateHook = ({ 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 (kind === 'sinal') { + if (!nonEmptyText(nextData.body)) throw new APIError('Informe o texto da atualização.', 400) + if (typeof nextData.signalType !== 'string') { + throw new APIError('Informe o tipo do sinal.', 400) + } + if (!nonEmptyText(nextData.signalSource)) throw new APIError('Informe a fonte do sinal.', 400) } else if (!nonEmptyText(nextData.body)) { throw new APIError('Informe o texto da atualização.', 400) } + if (kind !== 'sinal') { + data.signalType = null + data.signalSource = null + data.triangulated = false + } + return data } @@ -56,7 +84,10 @@ const acquireMunicipalityUpdateLocks = (req: PayloadRequest, municipalityIDs: nu municipalityIDs.map((municipalityID) => `municipality-updates:${municipalityID}`), ) -const recomputeMunicipalityLastUpdateAt = async (req: PayloadRequest, municipalityIDs: number[]) => { +const recomputeMunicipalityLastUpdateAt = async ( + req: PayloadRequest, + municipalityIDs: number[], +) => { for (const municipalityID of municipalityIDs) { const latest = await req.payload.find({ collection: 'municipalityUpdate', @@ -206,11 +237,7 @@ export const MunicipalityUpdate: CollectionConfig = { required: true, defaultValue: 'semanal', index: true, - options: [ - { label: 'Semanal', value: 'semanal' }, - { label: 'Urgente', value: 'urgente' }, - { label: 'Nota', value: 'nota' }, - ], + options: MUNICIPALITY_UPDATE_KIND_OPTIONS, }, { name: 'worked', @@ -260,5 +287,38 @@ export const MunicipalityUpdate: CollectionConfig = { condition: (_, siblingData) => siblingData.kind !== 'semanal', }, }, + { + name: 'signalType', + type: 'select', + label: 'Tipo de sinal', + options: MUNICIPALITY_SIGNAL_TYPE_OPTIONS, + admin: { + condition: (_, siblingData) => siblingData.kind === 'sinal', + }, + }, + { + name: 'signalSource', + type: 'text', + label: 'Fonte do sinal', + maxLength: 160, + admin: { + condition: (_, siblingData) => siblingData.kind === 'sinal', + }, + }, + { + name: 'triangulated', + type: 'checkbox', + label: 'Triangulado', + defaultValue: false, + access: { + create: canManageCampaignStaffField, + read: canReadCampaignStaffField, + update: canManageCampaignStaffField, + }, + admin: { + condition: (_, siblingData) => siblingData.kind === 'sinal', + description: 'Confirmado por mais de uma fonte de campo.', + }, + }, ], } diff --git a/src/collections/VotePledge.ts b/src/collections/VotePledge.ts index d5983fd6..f3889cd8 100644 --- a/src/collections/VotePledge.ts +++ b/src/collections/VotePledge.ts @@ -9,6 +9,7 @@ import { canReadVotePledge, canSetCampaignSystemField, canUpdateVotePledge, + payloadAdminOnly, } from '@/utilities/campaignAccess' import { relationshipId } from '@/utilities/relationship' import { @@ -142,9 +143,13 @@ export const VotePledge: CollectionConfig = { access: { create: canCreateVotePledge, read: canReadVotePledge, + readVersions: payloadAdminOnly, update: canUpdateVotePledge, delete: canDeleteVotePledge, }, + versions: { + maxPerDoc: 0, + }, indexes: [ { fields: ['leadership', 'municipality'], diff --git a/src/components/campaign/ActionPlanDemandFields.tsx b/src/components/campaign/ActionPlanDemandFields.tsx new file mode 100644 index 00000000..6e875cdd --- /dev/null +++ b/src/components/campaign/ActionPlanDemandFields.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useId, useState } from 'react' +import { PlusIcon, Trash2Icon } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field' +import { Input } from '@/components/ui/input' +import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' +import { Textarea } from '@/components/ui/textarea' +import { campaignDemandKindLabels, campaignDemandKinds } from '@/lib/schemas/campaignDemand' +import { MAX_ACTION_PLAN_DEMAND_DRAFTS } from '@/lib/schemas/actionPlan' + +type DemandFieldValue = { + key: string + title: string + kind: (typeof campaignDemandKinds)[number] + description: string +} + +let demandKeySequence = 0 +const nextDemandKey = (): string => { + demandKeySequence += 1 + return `demand-${demandKeySequence}` +} + +const serializeDemands = (demands: DemandFieldValue[]) => + JSON.stringify( + demands + .filter((demand) => demand.title.trim()) + .map((demand) => ({ + title: demand.title.trim(), + kind: demand.kind, + ...(demand.description.trim() ? { description: demand.description.trim() } : {}), + })), + ) + +export const ActionPlanDemandFields = ({ error }: { error?: string }) => { + const fieldId = useId() + const [demands, setDemands] = useState([]) + + const addDemand = () => { + setDemands((current) => [ + ...current, + { key: nextDemandKey(), title: '', kind: 'material', description: '' }, + ]) + } + + const updateDemand = (key: string, patch: Partial) => { + setDemands((current) => + current.map((demand) => (demand.key === key ? { ...demand, ...patch } : demand)), + ) + } + + const removeDemand = (key: string) => { + setDemands((current) => current.filter((demand) => demand.key !== key)) + } + + return ( + + Demandas vinculadas + + Registre necessidades que surgem deste plano. Município e plano serão vinculados + automaticamente. + + +
+ {demands.map((demand, index) => ( +
+
+

Demanda {index + 1}

+ +
+
+ updateDemand(demand.key, { title: event.target.value })} + required + /> + + updateDemand(demand.key, { + kind: event.target.value as DemandFieldValue['kind'], + }) + } + className="min-h-11 w-full" + > + {campaignDemandKinds.map((kind) => ( + + {campaignDemandKindLabels[kind]} + + ))} + +
+