Merge branch 'cursor/5176346d'
This commit is contained in:
commit
86ae32ce98
44 changed files with 13576 additions and 151 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<br/>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`.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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<br/>(signalType/source/triangulated)"]
|
||||
Plan["actionPlan origin/resourceCost"]
|
||||
Plan["actionPlan origin + demandas/custo"]
|
||||
Dec["allocationDecision<br/>(imutável, staff)"]
|
||||
Series["leitores de série<br/>(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)
|
||||
|
|
|
|||
|
|
@ -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=<TI>`). 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**.
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,18 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps
|
|||
>
|
||||
{demand.municipalityName}
|
||||
</Link>
|
||||
{demand.actionPlan ? (
|
||||
<>
|
||||
{' '}
|
||||
· Plano:{' '}
|
||||
<Link
|
||||
href={`/campanha/planos/${demand.actionPlan.slug}`}
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{demand.actionPlan.title}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
{demand.requesterName ? ` · Solicitada por ${demand.requesterName}` : ''} · Aberta em{' '}
|
||||
{dateTimeFormatter.format(new Date(demand.createdAt))}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
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 (
|
||||
<CampaignPageShell>
|
||||
|
|
@ -35,7 +54,13 @@ export default async function NewDemandPage() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<DemandForm municipalityOptions={municipalityOptions} formAction={createDemandFormAction} />
|
||||
<DemandForm
|
||||
municipalityOptions={municipalityOptions}
|
||||
actionPlanOptions={actionPlanOptions}
|
||||
initialMunicipalityId={initialMunicipalityId}
|
||||
initialActionPlanId={requestedActionPlan?.id}
|
||||
formAction={createDemandFormAction}
|
||||
/>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.' }
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||
|
|
@ -160,6 +165,10 @@ export default async function ActionPlanDetailPage({
|
|||
<dt className="text-muted-foreground">Responsável</dt>
|
||||
<dd>{view.responsibleName ?? 'Não definido'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Origem da ação</dt>
|
||||
<dd>{actionPlanOriginLabels[view.origin]}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -205,6 +214,61 @@ export default async function ActionPlanDetailPage({
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle>Demandas vinculadas</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Custo estimado: {currencyFormatter.format(view.demandCostTotal)}
|
||||
</p>
|
||||
</div>
|
||||
{view.municipality ? (
|
||||
<Button asChild variant="outline" className="min-h-11 shrink-0">
|
||||
<Link
|
||||
href={`/campanha/demandas/nova?actionPlan=${view.id}&municipality=${view.municipality.id}`}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Adicionar demanda
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{view.demands.length ? (
|
||||
<ul role="list" className="divide-y">
|
||||
{view.demands.map((demand) => (
|
||||
<li
|
||||
key={demand.id}
|
||||
className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/campanha/demandas/${demand.slug}`}
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{demand.title}
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{campaignDemandKindLabels[demand.kind]} ·{' '}
|
||||
{campaignDemandStatusLabels[demand.status]}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{demand.cost == null
|
||||
? 'Custo não registrado'
|
||||
: currencyFormatter.format(demand.cost)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma demanda vinculada a este plano.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{view.status === 'realizado' && isStaff ? (
|
||||
<ActionPlanResultForm
|
||||
planId={view.id}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@ import {
|
|||
import { slugify } from '@/utilities/slug'
|
||||
|
||||
const getActionError = (error: unknown): ActionPlanFormState => {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => ({
|
|||
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',
|
||||
|
|
|
|||
147
src/collections/AllocationDecision.ts
Normal file
147
src/collections/AllocationDecision.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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<string, unknown>
|
||||
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: [
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
140
src/components/campaign/ActionPlanDemandFields.tsx
Normal file
140
src/components/campaign/ActionPlanDemandFields.tsx
Normal file
|
|
@ -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<DemandFieldValue[]>([])
|
||||
|
||||
const addDemand = () => {
|
||||
setDemands((current) => [
|
||||
...current,
|
||||
{ key: nextDemandKey(), title: '', kind: 'material', description: '' },
|
||||
])
|
||||
}
|
||||
|
||||
const updateDemand = (key: string, patch: Partial<DemandFieldValue>) => {
|
||||
setDemands((current) =>
|
||||
current.map((demand) => (demand.key === key ? { ...demand, ...patch } : demand)),
|
||||
)
|
||||
}
|
||||
|
||||
const removeDemand = (key: string) => {
|
||||
setDemands((current) => current.filter((demand) => demand.key !== key))
|
||||
}
|
||||
|
||||
return (
|
||||
<Field data-invalid={Boolean(error)}>
|
||||
<FieldLabel htmlFor={fieldId}>Demandas vinculadas</FieldLabel>
|
||||
<FieldDescription>
|
||||
Registre necessidades que surgem deste plano. Município e plano serão vinculados
|
||||
automaticamente.
|
||||
</FieldDescription>
|
||||
<input
|
||||
type="hidden"
|
||||
id={fieldId}
|
||||
name="demandsJson"
|
||||
value={serializeDemands(demands)}
|
||||
readOnly
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
{demands.map((demand, index) => (
|
||||
<div key={demand.key} className="flex flex-col gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium">Demanda {index + 1}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11"
|
||||
onClick={() => removeDemand(demand.key)}
|
||||
aria-label={`Remover demanda ${index + 1}`}
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-[2fr_1fr]">
|
||||
<Input
|
||||
aria-label={`Título da demanda ${index + 1}`}
|
||||
placeholder="O que é necessário?"
|
||||
value={demand.title}
|
||||
minLength={2}
|
||||
maxLength={160}
|
||||
className="min-h-11"
|
||||
onChange={(event) => updateDemand(demand.key, { title: event.target.value })}
|
||||
required
|
||||
/>
|
||||
<NativeSelect
|
||||
aria-label={`Tipo da demanda ${index + 1}`}
|
||||
value={demand.kind}
|
||||
onChange={(event) =>
|
||||
updateDemand(demand.key, {
|
||||
kind: event.target.value as DemandFieldValue['kind'],
|
||||
})
|
||||
}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
{campaignDemandKinds.map((kind) => (
|
||||
<NativeSelectOption key={kind} value={kind}>
|
||||
{campaignDemandKindLabels[kind]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label={`Descrição da demanda ${index + 1}`}
|
||||
placeholder="Detalhes opcionais"
|
||||
value={demand.description}
|
||||
rows={2}
|
||||
maxLength={4000}
|
||||
onChange={(event) => updateDemand(demand.key, { description: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 w-fit"
|
||||
onClick={addDemand}
|
||||
disabled={demands.length >= MAX_ACTION_PLAN_DEMAND_DRAFTS}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Adicionar demanda
|
||||
</Button>
|
||||
{error ? <FieldError id={`${fieldId}-error`}>{error}</FieldError> : null}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,12 +4,10 @@ import { useActionState, useState } from 'react'
|
|||
import Link from 'next/link'
|
||||
|
||||
import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox'
|
||||
import { ActionPlanDemandFields } from '@/components/campaign/ActionPlanDemandFields'
|
||||
import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/ContactCombobox'
|
||||
import { ActionPlanTaskFields } from '@/components/campaign/ActionPlanTaskFields'
|
||||
import {
|
||||
RelationMultiSelect,
|
||||
type RelationOption,
|
||||
} from '@/components/campaign/RelationMultiSelect'
|
||||
import { RelationMultiSelect, type RelationOption } from '@/components/campaign/RelationMultiSelect'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
|
@ -29,7 +27,11 @@ import { Textarea } from '@/components/ui/textarea'
|
|||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
|
||||
import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
|
||||
import {
|
||||
actionPlanKindLabels,
|
||||
actionPlanOriginLabels,
|
||||
actionPlanStatusLabels,
|
||||
} from '@/lib/schemas/actionPlan'
|
||||
import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions'
|
||||
import type { ActionPlanFormViewModel } from '@/utilities/actionPlanViewModels'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
|
|
@ -118,7 +120,9 @@ export const ActionPlanFormFields = ({
|
|||
aria-invalid={Boolean(errorFor('title'))}
|
||||
aria-describedby={errorFor('title') ? 'title-error' : undefined}
|
||||
/>
|
||||
{errorFor('title') ? <FieldError id="title-error">{errorFor('title')}</FieldError> : null}
|
||||
{errorFor('title') ? (
|
||||
<FieldError id="title-error">{errorFor('title')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={Boolean(errorFor('kind'))}>
|
||||
|
|
@ -139,7 +143,9 @@ export const ActionPlanFormFields = ({
|
|||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{errorFor('kind') ? <FieldError id="kind-error">{errorFor('kind')}</FieldError> : null}
|
||||
{errorFor('kind') ? (
|
||||
<FieldError id="kind-error">{errorFor('kind')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLegend>Status *</FieldLegend>
|
||||
|
|
@ -162,12 +168,33 @@ export const ActionPlanFormFields = ({
|
|||
</>
|
||||
) : (
|
||||
<FieldDescription>
|
||||
Status atual: {actionPlanStatusLabels[plan?.status ?? ''] ?? plan?.status}. Use as
|
||||
ações do detalhe para marcar como realizado ou cancelado.
|
||||
Status atual: {actionPlanStatusLabels[plan?.status ?? ''] ?? plan?.status}. Use
|
||||
as ações do detalhe para marcar como realizado ou cancelado.
|
||||
</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<Field data-invalid={Boolean(errorFor('origin'))}>
|
||||
<FieldLabel htmlFor="origin">Origem da ação</FieldLabel>
|
||||
<NativeSelect
|
||||
id="origin"
|
||||
name="origin"
|
||||
defaultValue={plan?.origin ?? 'dado'}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11 sm:max-w-sm"
|
||||
aria-invalid={Boolean(errorFor('origin'))}
|
||||
>
|
||||
{Object.entries(actionPlanOriginLabels).map(([value, label]) => (
|
||||
<NativeSelectOption key={value} value={value}>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<FieldDescription>
|
||||
Registra se o plano nasceu da análise de dados, de um pedido de articulação ou de um
|
||||
compromisso político.
|
||||
</FieldDescription>
|
||||
{errorFor('origin') ? <FieldError>{errorFor('origin')}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errorFor('description'))}>
|
||||
<FieldLabel htmlFor="description">Descrição</FieldLabel>
|
||||
<Textarea
|
||||
|
|
@ -200,9 +227,7 @@ export const ActionPlanFormFields = ({
|
|||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field data-invalid={Boolean(errorFor('startAt'))}>
|
||||
<FieldLabel htmlFor="startAt">
|
||||
Início {status !== 'rascunho' ? '*' : null}
|
||||
</FieldLabel>
|
||||
<FieldLabel htmlFor="startAt">Início {status !== 'rascunho' ? '*' : null}</FieldLabel>
|
||||
<Input
|
||||
id="startAt"
|
||||
name="startAt"
|
||||
|
|
@ -228,7 +253,9 @@ export const ActionPlanFormFields = ({
|
|||
aria-invalid={Boolean(errorFor('endAt'))}
|
||||
aria-describedby={errorFor('endAt') ? 'endAt-error' : undefined}
|
||||
/>
|
||||
{errorFor('endAt') ? <FieldError id="endAt-error">{errorFor('endAt')}</FieldError> : null}
|
||||
{errorFor('endAt') ? (
|
||||
<FieldError id="endAt-error">{errorFor('endAt')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errorFor('deadline'))}>
|
||||
<FieldLabel htmlFor="deadline">Prazo de conclusão</FieldLabel>
|
||||
|
|
@ -273,7 +300,9 @@ export const ActionPlanFormFields = ({
|
|||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{errorFor('municipality') ? <FieldError id="municipality-error">{errorFor('municipality')}</FieldError> : null}
|
||||
{errorFor('municipality') ? (
|
||||
<FieldError id="municipality-error">{errorFor('municipality')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errorFor('locality'))}>
|
||||
<FieldLabel htmlFor="locality">Local (bairro, endereço ou referência)</FieldLabel>
|
||||
|
|
@ -399,6 +428,15 @@ export const ActionPlanFormFields = ({
|
|||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Demandas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionPlanDemandFields error={errorFor('demandsJson')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FieldGroup>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { useActionState, useState } from 'react'
|
||||
|
||||
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Field, FieldDescription, FieldError, 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'
|
||||
|
|
@ -13,17 +13,36 @@ import { Textarea } from '@/components/ui/textarea'
|
|||
import { campaignDemandKindLabels, campaignDemandKinds } from '@/lib/schemas/campaignDemand'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
import type { ActionPlanRelationOption } from '@/utilities/campaignRelationOptions'
|
||||
|
||||
type DemandFormProps = {
|
||||
municipalityOptions: RelationOption[]
|
||||
actionPlanOptions: ActionPlanRelationOption[]
|
||||
initialMunicipalityId?: number
|
||||
initialActionPlanId?: number
|
||||
formAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
export const DemandForm = ({ municipalityOptions, formAction }: DemandFormProps) => {
|
||||
export const DemandForm = ({
|
||||
municipalityOptions,
|
||||
actionPlanOptions,
|
||||
initialMunicipalityId,
|
||||
initialActionPlanId,
|
||||
formAction,
|
||||
}: DemandFormProps) => {
|
||||
const [state, submitAction, isPending] = useActionState(formAction, {})
|
||||
const [municipalityId, setMunicipalityId] = useState(
|
||||
initialMunicipalityId ? String(initialMunicipalityId) : '',
|
||||
)
|
||||
const [actionPlanId, setActionPlanId] = useState(
|
||||
initialActionPlanId ? String(initialActionPlanId) : '',
|
||||
)
|
||||
const visibleActionPlans = municipalityId
|
||||
? actionPlanOptions.filter((plan) => String(plan.municipalityId) === municipalityId)
|
||||
: actionPlanOptions
|
||||
|
||||
return (
|
||||
<form action={submitAction} className="flex max-w-2xl flex-col gap-4">
|
||||
|
|
@ -63,7 +82,17 @@ export const DemandForm = ({ municipalityOptions, formAction }: DemandFormProps)
|
|||
id="demand-municipality"
|
||||
name="municipalityId"
|
||||
required
|
||||
defaultValue=""
|
||||
value={municipalityId}
|
||||
onChange={(event) => {
|
||||
const nextMunicipalityId = event.target.value
|
||||
setMunicipalityId(nextMunicipalityId)
|
||||
const selectedPlan = actionPlanOptions.find(
|
||||
(plan) => String(plan.id) === actionPlanId,
|
||||
)
|
||||
if (selectedPlan && String(selectedPlan.municipalityId) !== nextMunicipalityId) {
|
||||
setActionPlanId('')
|
||||
}
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="" disabled>
|
||||
|
|
@ -80,6 +109,34 @@ export const DemandForm = ({ municipalityOptions, formAction }: DemandFormProps)
|
|||
) : null}
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="demand-action-plan">Plano de ação relacionado</FieldLabel>
|
||||
<NativeSelect
|
||||
id="demand-action-plan"
|
||||
name="actionPlanId"
|
||||
value={actionPlanId}
|
||||
onChange={(event) => {
|
||||
const nextPlanId = event.target.value
|
||||
setActionPlanId(nextPlanId)
|
||||
const selectedPlan = actionPlanOptions.find((plan) => String(plan.id) === nextPlanId)
|
||||
if (selectedPlan) setMunicipalityId(String(selectedPlan.municipalityId))
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">Nenhum plano</NativeSelectOption>
|
||||
{visibleActionPlans.map((option) => (
|
||||
<NativeSelectOption key={option.id} value={String(option.id)}>
|
||||
{option.name}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<FieldDescription>
|
||||
Opcional. Ao escolher um plano, o município correspondente é preenchido automaticamente.
|
||||
</FieldDescription>
|
||||
{fieldError(state.fieldErrors, 'actionPlan') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'actionPlan')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="demand-description">Detalhe a necessidade</FieldLabel>
|
||||
<Textarea id="demand-description" name="description" rows={4} maxLength={4000} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { Badge } from '@/components/ui/Badge'
|
||||
import { municipalityUpdateKindLabels } from '@/lib/schemas/municipalityUpdate'
|
||||
import {
|
||||
municipalitySignalTypeLabels,
|
||||
municipalityUpdateKindLabels,
|
||||
} from '@/lib/schemas/municipalityUpdate'
|
||||
import type { MunicipalityUpdateViewModel } from '@/utilities/municipalityUpdatePageData'
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
|
||||
|
|
@ -13,6 +16,7 @@ const kindVariant = {
|
|||
semanal: 'secondary',
|
||||
urgente: 'destructive',
|
||||
nota: 'outline',
|
||||
sinal: 'default',
|
||||
} as const
|
||||
|
||||
export const MunicipalityUpdateFeed = ({ updates }: { updates: MunicipalityUpdateViewModel[] }) => {
|
||||
|
|
@ -53,6 +57,13 @@ export const MunicipalityUpdateFeed = ({ updates }: { updates: MunicipalityUpdat
|
|||
) : (
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{update.body}</p>
|
||||
)}
|
||||
{update.kind === 'sinal' ? (
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
{update.signalType ? <Badge variant="outline">{municipalitySignalTypeLabels[update.signalType]}</Badge> : null}
|
||||
{update.signalSource ? <span>Fonte: {update.signalSource}</span> : null}
|
||||
{update.triangulated ? <span>Triangulado</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{update.activeVolunteers != null || update.newSupports != null ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{update.activeVolunteers != null
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import { useActionState, useState } from 'react'
|
|||
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Field, FieldDescription, FieldError, 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 {
|
||||
municipalitySignalTypeLabels,
|
||||
municipalitySignalTypeDescriptions,
|
||||
municipalitySignalTypes,
|
||||
municipalityUpdateKindLabels,
|
||||
municipalityUpdateKinds,
|
||||
type MunicipalityUpdateKind,
|
||||
|
|
@ -25,7 +28,10 @@ type MunicipalityUpdateFormProps = {
|
|||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
export const MunicipalityUpdateForm = ({ municipalityID, formAction }: MunicipalityUpdateFormProps) => {
|
||||
export const MunicipalityUpdateForm = ({
|
||||
municipalityID,
|
||||
formAction,
|
||||
}: MunicipalityUpdateFormProps) => {
|
||||
const [state, submitAction, isPending] = useActionState(formAction, {})
|
||||
const [kind, setKind] = useState<MunicipalityUpdateKind>('semanal')
|
||||
|
||||
|
|
@ -98,13 +104,66 @@ export const MunicipalityUpdateForm = ({ municipalityID, formAction }: Municipal
|
|||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="municipality-update-body">Texto</FieldLabel>
|
||||
<Textarea id="municipality-update-body" name="body" rows={4} maxLength={5000} />
|
||||
{fieldError(state.fieldErrors, 'body') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'body')}</FieldError>
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="municipality-update-body">Texto</FieldLabel>
|
||||
<Textarea
|
||||
id="municipality-update-body"
|
||||
name="body"
|
||||
rows={kind === 'sinal' ? 2 : 4}
|
||||
maxLength={5000}
|
||||
/>
|
||||
{fieldError(state.fieldErrors, 'body') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'body')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
{kind === 'sinal' ? (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="municipality-update-signal-type">Tipo do sinal</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-update-signal-type"
|
||||
name="signalType"
|
||||
defaultValue=""
|
||||
required
|
||||
>
|
||||
<NativeSelectOption value="">Selecione</NativeSelectOption>
|
||||
{municipalitySignalTypes.map((entry) => (
|
||||
<NativeSelectOption key={entry} value={entry}>
|
||||
{municipalitySignalTypeLabels[entry]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<FieldDescription>Escolha o fato político observado:</FieldDescription>
|
||||
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{municipalitySignalTypes.map((entry) => (
|
||||
<li key={entry}>
|
||||
<span className="font-medium text-foreground">
|
||||
{municipalitySignalTypeLabels[entry]}:
|
||||
</span>{' '}
|
||||
{municipalitySignalTypeDescriptions[entry]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="municipality-update-signal-source">Fonte</FieldLabel>
|
||||
<Input
|
||||
id="municipality-update-signal-source"
|
||||
name="signalSource"
|
||||
maxLength={160}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<label className="flex min-h-11 items-center gap-2 text-sm">
|
||||
<input name="triangulated" type="checkbox" className="size-4" />
|
||||
Triangulado — confirmado por mais de uma fonte independente
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{state.message && state.status !== 'success' ? (
|
||||
<Alert variant="destructive">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { campaignDemandCreateSchema } from '@/lib/schemas/campaignDemandInput'
|
||||
import {
|
||||
positiveRelationshipId,
|
||||
trimmedNullableText,
|
||||
|
|
@ -30,6 +31,15 @@ export const actionPlanStatuses = [
|
|||
'cancelado',
|
||||
] as const
|
||||
|
||||
export const actionPlanOrigins = ['dado', 'pedido_broker', 'obrigacao_politica'] as const
|
||||
export type ActionPlanOrigin = (typeof actionPlanOrigins)[number]
|
||||
|
||||
export const actionPlanOriginLabels: Record<ActionPlanOrigin, string> = {
|
||||
dado: 'Baseado em dado',
|
||||
pedido_broker: 'Pedido de broker',
|
||||
obrigacao_politica: 'Obrigação política',
|
||||
}
|
||||
|
||||
export const actionPlanKindLabels: Record<(typeof actionPlanKinds)[number], string> = {
|
||||
caminhada: 'Caminhada',
|
||||
comicio: 'Comício',
|
||||
|
|
@ -55,6 +65,19 @@ export const actionPlanStatusLabels: Record<(typeof actionPlanStatuses)[number],
|
|||
}
|
||||
|
||||
export const MAX_ACTION_PLAN_ORGANIZATIONS = 20
|
||||
export const MAX_ACTION_PLAN_DEMAND_DRAFTS = 20
|
||||
|
||||
export const actionPlanDemandDraftSchema = campaignDemandCreateSchema.pick({
|
||||
title: true,
|
||||
kind: true,
|
||||
description: true,
|
||||
})
|
||||
|
||||
export const actionPlanDemandDraftsSchema = z
|
||||
.array(actionPlanDemandDraftSchema)
|
||||
.max(MAX_ACTION_PLAN_DEMAND_DRAFTS)
|
||||
|
||||
export type ActionPlanDemandDraft = z.input<typeof actionPlanDemandDraftSchema>
|
||||
|
||||
const taskSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
|
|
@ -79,6 +102,7 @@ const actionPlanFieldsSchema = z.object({
|
|||
responsible: positiveRelationshipId.optional(),
|
||||
leadership: positiveRelationshipId.optional(),
|
||||
tasks: z.array(taskSchema).optional(),
|
||||
origin: z.enum(actionPlanOrigins).optional(),
|
||||
})
|
||||
|
||||
const validateSchedule = (
|
||||
|
|
|
|||
|
|
@ -2,13 +2,39 @@ import { z } from 'zod'
|
|||
|
||||
import { positiveRelationshipId, trimmedOptionalText } from '@/lib/schemas/primitives'
|
||||
|
||||
export const municipalityUpdateKinds = ['semanal', 'urgente', 'nota'] as const
|
||||
export const municipalityUpdateKinds = ['semanal', 'urgente', 'nota', 'sinal'] as const
|
||||
export type MunicipalityUpdateKind = (typeof municipalityUpdateKinds)[number]
|
||||
|
||||
export const municipalityUpdateKindLabels: Record<MunicipalityUpdateKind, string> = {
|
||||
semanal: 'Semanal',
|
||||
urgente: 'Urgente',
|
||||
nota: 'Nota',
|
||||
sinal: 'Sinal',
|
||||
}
|
||||
|
||||
export const municipalitySignalTypes = [
|
||||
'invasao',
|
||||
'esfriamento',
|
||||
'visita_adversario',
|
||||
'proposta_broker',
|
||||
'outro',
|
||||
] as const
|
||||
export type MunicipalitySignalType = (typeof municipalitySignalTypes)[number]
|
||||
|
||||
export const municipalitySignalTypeLabels: Record<MunicipalitySignalType, string> = {
|
||||
invasao: 'Invasão',
|
||||
esfriamento: 'Esfriamento',
|
||||
visita_adversario: 'Visita adversária',
|
||||
proposta_broker: 'Proposta a broker',
|
||||
outro: 'Outro',
|
||||
}
|
||||
|
||||
export const municipalitySignalTypeDescriptions: Record<MunicipalitySignalType, string> = {
|
||||
invasao: 'Adversário ocupando ou ganhando espaço antes dominado pela campanha.',
|
||||
esfriamento: 'Queda de mobilização, compromisso ou resposta dos aliados locais.',
|
||||
visita_adversario: 'Agenda ou presença de adversário com possível impacto no município.',
|
||||
proposta_broker: 'Pedido, oferta ou negociação feita por um intermediário político.',
|
||||
outro: 'Fato político relevante que não se encaixa nos tipos anteriores.',
|
||||
}
|
||||
|
||||
const optionalCount = z.number().int().min(0).max(1_000_000).optional()
|
||||
|
|
@ -23,6 +49,9 @@ export const municipalityUpdateCreateSchema = z
|
|||
body: trimmedOptionalText(5000),
|
||||
activeVolunteers: optionalCount,
|
||||
newSupports: optionalCount,
|
||||
signalType: z.enum(municipalitySignalTypes).optional(),
|
||||
signalSource: trimmedOptionalText(160),
|
||||
triangulated: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((data, context) => {
|
||||
if (data.kind === 'semanal') {
|
||||
|
|
@ -50,6 +79,22 @@ export const municipalityUpdateCreateSchema = z
|
|||
path: ['body'],
|
||||
})
|
||||
}
|
||||
if (data.kind === 'sinal') {
|
||||
if (!data.signalType) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Informe o tipo do sinal.',
|
||||
path: ['signalType'],
|
||||
})
|
||||
}
|
||||
if (!data.signalSource) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Informe a fonte do sinal.',
|
||||
path: ['signalSource'],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type MunicipalityUpdateCreateInput = z.input<typeof municipalityUpdateCreateSchema>
|
||||
|
|
|
|||
11787
src/migrations/20260724_180000_add_campaign_foundation_records.json
Normal file
11787
src/migrations/20260724_180000_add_campaign_foundation_records.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,174 @@
|
|||
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TYPE "public"."enum_municipality_update_kind" ADD VALUE IF NOT EXISTS 'sinal';
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."enum_action_plan_origin" AS ENUM ('dado', 'pedido_broker', 'obrigacao_politica');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."enum_municipality_update_signal_type" AS ENUM (
|
||||
'invasao',
|
||||
'esfriamento',
|
||||
'visita_adversario',
|
||||
'proposta_broker',
|
||||
'outro'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."enum_allocation_decision_outcome" AS ENUM ('aceita', 'descarta');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
ALTER TABLE "municipality_update"
|
||||
ADD COLUMN IF NOT EXISTS "signal_type" "public"."enum_municipality_update_signal_type",
|
||||
ADD COLUMN IF NOT EXISTS "signal_source" varchar,
|
||||
ADD COLUMN IF NOT EXISTS "triangulated" boolean DEFAULT false NOT NULL;
|
||||
ALTER TABLE "action_plan" ADD COLUMN IF NOT EXISTS "origin" "public"."enum_action_plan_origin";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "allocation_decision" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"municipality_id" integer NOT NULL,
|
||||
"pattern_id" varchar NOT NULL,
|
||||
"outcome" "public"."enum_allocation_decision_outcome" NOT NULL,
|
||||
"rationale" varchar NOT NULL,
|
||||
"alternative_reading" varchar,
|
||||
"snapshot" jsonb NOT NULL,
|
||||
"decided_by_id" integer,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "allocation_decision" ADD CONSTRAINT "allocation_decision_municipality_id_municipality_id_fk"
|
||||
FOREIGN KEY ("municipality_id") REFERENCES "public"."municipality"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "allocation_decision" ADD CONSTRAINT "allocation_decision_decided_by_id_campaign_user_id_fk"
|
||||
FOREIGN KEY ("decided_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
CREATE INDEX IF NOT EXISTS "allocation_decision_municipality_idx" ON "allocation_decision" USING btree ("municipality_id");
|
||||
CREATE INDEX IF NOT EXISTS "allocation_decision_pattern_idx" ON "allocation_decision" USING btree ("pattern_id");
|
||||
CREATE INDEX IF NOT EXISTS "allocation_decision_decided_by_idx" ON "allocation_decision" USING btree ("decided_by_id");
|
||||
CREATE INDEX IF NOT EXISTS "allocation_decision_updated_at_idx" ON "allocation_decision" USING btree ("updated_at");
|
||||
CREATE INDEX IF NOT EXISTS "allocation_decision_created_at_idx" ON "allocation_decision" USING btree ("created_at");
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels"
|
||||
ADD COLUMN IF NOT EXISTS "allocation_decision_id" integer;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "payload_locked_documents_rels"
|
||||
ADD CONSTRAINT "payload_locked_documents_rels_allocation_decision_fk"
|
||||
FOREIGN KEY ("allocation_decision_id") REFERENCES "public"."allocation_decision"("id")
|
||||
ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_allocation_decision_id_idx"
|
||||
ON "payload_locked_documents_rels" USING btree ("allocation_decision_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "_vote_pledge_v" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"parent_id" integer,
|
||||
"version_leadership_id" integer,
|
||||
"version_municipality_id" integer,
|
||||
"version_declared_votes" numeric,
|
||||
"version_declared_at" timestamp(3) with time zone,
|
||||
"version_declared_by_id" integer,
|
||||
"version_estimated_votes_pessimistic" numeric,
|
||||
"version_estimated_votes_central" numeric,
|
||||
"version_estimated_votes_optimistic" numeric,
|
||||
"version_estimate_note" varchar,
|
||||
"version_estimated_by_id" integer,
|
||||
"version_estimated_at" timestamp(3) with time zone,
|
||||
"version_updated_at" timestamp(3) with time zone,
|
||||
"version_created_at" timestamp(3) with time zone,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "_vote_pledge_v" ADD CONSTRAINT "_vote_pledge_v_parent_id_vote_pledge_id_fk"
|
||||
FOREIGN KEY ("parent_id") REFERENCES "public"."vote_pledge"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "_vote_pledge_v" ADD CONSTRAINT "_vote_pledge_v_version_leadership_id_leadership_id_fk"
|
||||
FOREIGN KEY ("version_leadership_id") REFERENCES "public"."leadership"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "_vote_pledge_v" ADD CONSTRAINT "_vote_pledge_v_version_municipality_id_municipality_id_fk"
|
||||
FOREIGN KEY ("version_municipality_id") REFERENCES "public"."municipality"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "_vote_pledge_v" ADD CONSTRAINT "_vote_pledge_v_version_declared_by_id_campaign_user_id_fk"
|
||||
FOREIGN KEY ("version_declared_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "_vote_pledge_v" ADD CONSTRAINT "_vote_pledge_v_version_estimated_by_id_campaign_user_id_fk"
|
||||
FOREIGN KEY ("version_estimated_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_parent_idx" ON "_vote_pledge_v" USING btree ("parent_id");
|
||||
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_leadership_idx" ON "_vote_pledge_v" USING btree ("version_leadership_id");
|
||||
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_municipality_idx" ON "_vote_pledge_v" USING btree ("version_municipality_id");
|
||||
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_declared_votes_idx" ON "_vote_pledge_v" USING btree ("version_declared_votes");
|
||||
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_declared_by_idx" ON "_vote_pledge_v" USING btree ("version_declared_by_id");
|
||||
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_estimated_by_idx" ON "_vote_pledge_v" USING btree ("version_estimated_by_id");
|
||||
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_updated_at_idx" ON "_vote_pledge_v" USING btree ("version_updated_at");
|
||||
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_version_version_created_at_idx" ON "_vote_pledge_v" USING btree ("version_created_at");
|
||||
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_created_at_idx" ON "_vote_pledge_v" USING btree ("created_at");
|
||||
CREATE INDEX IF NOT EXISTS "_vote_pledge_v_updated_at_idx" ON "_vote_pledge_v" USING btree ("updated_at");
|
||||
|
||||
INSERT INTO "_vote_pledge_v" (
|
||||
"parent_id",
|
||||
"version_leadership_id",
|
||||
"version_municipality_id",
|
||||
"version_declared_votes",
|
||||
"version_declared_at",
|
||||
"version_declared_by_id",
|
||||
"version_estimated_votes_pessimistic",
|
||||
"version_estimated_votes_central",
|
||||
"version_estimated_votes_optimistic",
|
||||
"version_estimate_note",
|
||||
"version_estimated_by_id",
|
||||
"version_estimated_at",
|
||||
"version_updated_at",
|
||||
"version_created_at",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
"id",
|
||||
"leadership_id",
|
||||
"municipality_id",
|
||||
"declared_votes",
|
||||
"declared_at",
|
||||
"declared_by_id",
|
||||
"estimated_votes_pessimistic",
|
||||
"estimated_votes_central",
|
||||
"estimated_votes_optimistic",
|
||||
"estimate_note",
|
||||
"estimated_by_id",
|
||||
"estimated_at",
|
||||
"updated_at",
|
||||
"created_at",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
FROM "vote_pledge"
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "_vote_pledge_v" AS versions WHERE versions."parent_id" = "vote_pledge"."id"
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP TABLE IF EXISTS "_vote_pledge_v";
|
||||
ALTER TABLE "payload_locked_documents_rels"
|
||||
DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_allocation_decision_fk";
|
||||
DROP INDEX IF EXISTS "payload_locked_documents_rels_allocation_decision_id_idx";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "allocation_decision_id";
|
||||
DROP TABLE IF EXISTS "allocation_decision";
|
||||
ALTER TABLE "action_plan" DROP COLUMN IF EXISTS "origin";
|
||||
ALTER TABLE "municipality_update"
|
||||
DROP COLUMN IF EXISTS "triangulated",
|
||||
DROP COLUMN IF EXISTS "signal_source",
|
||||
DROP COLUMN IF EXISTS "signal_type";
|
||||
DROP TYPE IF EXISTS "public"."enum_action_plan_origin";
|
||||
DROP TYPE IF EXISTS "public"."enum_municipality_update_signal_type";
|
||||
DROP TYPE IF EXISTS "public"."enum_allocation_decision_outcome";
|
||||
`)
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import * as migration_20260723_201000_add_state_deputy from './20260723_201000_a
|
|||
import * as migration_20260723_202000_reconcile_municipality_remodel from './20260723_202000_reconcile_municipality_remodel'
|
||||
import * as migration_20260724_133600_drop_municipality_vote_goals from './20260724_133600_drop_municipality_vote_goals'
|
||||
import * as migration_20260724_175500_contact_phone_optional from './20260724_175500_contact_phone_optional'
|
||||
import * as migration_20260724_180000_add_campaign_foundation_records from './20260724_180000_add_campaign_foundation_records'
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -162,4 +163,9 @@ export const migrations = [
|
|||
down: migration_20260724_175500_contact_phone_optional.down,
|
||||
name: '20260724_175500_contact_phone_optional',
|
||||
},
|
||||
{
|
||||
up: migration_20260724_180000_add_campaign_foundation_records.up,
|
||||
down: migration_20260724_180000_add_campaign_foundation_records.down,
|
||||
name: '20260724_180000_add_campaign_foundation_records',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export interface Config {
|
|||
organization: Organization;
|
||||
stateDeputy: StateDeputy;
|
||||
votePledge: VotePledge;
|
||||
allocationDecision: AllocationDecision;
|
||||
campaignDemand: CampaignDemand;
|
||||
supporter: Supporter;
|
||||
supporterImportBatch: SupporterImportBatch;
|
||||
|
|
@ -110,6 +111,7 @@ export interface Config {
|
|||
organization: OrganizationSelect<false> | OrganizationSelect<true>;
|
||||
stateDeputy: StateDeputySelect<false> | StateDeputySelect<true>;
|
||||
votePledge: VotePledgeSelect<false> | VotePledgeSelect<true>;
|
||||
allocationDecision: AllocationDecisionSelect<false> | AllocationDecisionSelect<true>;
|
||||
campaignDemand: CampaignDemandSelect<false> | CampaignDemandSelect<true>;
|
||||
supporter: SupporterSelect<false> | SupporterSelect<true>;
|
||||
supporterImportBatch: SupporterImportBatchSelect<false> | SupporterImportBatchSelect<true>;
|
||||
|
|
@ -540,6 +542,33 @@ export interface VotePledge {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "allocationDecision".
|
||||
*/
|
||||
export interface AllocationDecision {
|
||||
id: number;
|
||||
municipality: number | Municipality;
|
||||
patternId: string;
|
||||
outcome: 'aceita' | 'descarta';
|
||||
rationale: string;
|
||||
alternativeReading?: string | null;
|
||||
/**
|
||||
* Somente os números e classificações usados no momento da decisão.
|
||||
*/
|
||||
snapshot:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
decidedBy?: (number | null) | CampaignUser;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Necessidades operacionais da campanha. Custo e comprovantes são controle interno — não substituem a prestação de contas oficial (SPCE/TSE).
|
||||
*
|
||||
|
|
@ -613,6 +642,7 @@ export interface ActionPlan {
|
|||
| 'outro';
|
||||
status: 'rascunho' | 'planejado' | 'confirmado' | 'realizado' | 'cancelado';
|
||||
description?: string | null;
|
||||
origin?: ('dado' | 'pedido_broker' | 'obrigacao_politica') | null;
|
||||
/**
|
||||
* Marque quando o deputado Jorge Solla estiver presente na ação.
|
||||
*/
|
||||
|
|
@ -708,13 +738,19 @@ export interface MunicipalityUpdate {
|
|||
id: number;
|
||||
municipality: number | Municipality;
|
||||
author: number | CampaignUser;
|
||||
kind: 'semanal' | 'urgente' | 'nota';
|
||||
kind: 'semanal' | 'urgente' | 'nota' | 'sinal';
|
||||
worked?: string | null;
|
||||
failed?: string | null;
|
||||
needs?: string | null;
|
||||
activeVolunteers?: number | null;
|
||||
newSupports?: number | null;
|
||||
body?: string | null;
|
||||
signalType?: ('invasao' | 'esfriamento' | 'visita_adversario' | 'proposta_broker' | 'outro') | null;
|
||||
signalSource?: string | null;
|
||||
/**
|
||||
* Confirmado por mais de uma fonte de campo.
|
||||
*/
|
||||
triangulated?: boolean | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
@ -1143,6 +1179,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'votePledge';
|
||||
value: number | VotePledge;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'allocationDecision';
|
||||
value: number | AllocationDecision;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'campaignDemand';
|
||||
value: number | CampaignDemand;
|
||||
|
|
@ -1444,6 +1484,21 @@ export interface VotePledgeSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "allocationDecision_select".
|
||||
*/
|
||||
export interface AllocationDecisionSelect<T extends boolean = true> {
|
||||
municipality?: T;
|
||||
patternId?: T;
|
||||
outcome?: T;
|
||||
rationale?: T;
|
||||
alternativeReading?: T;
|
||||
snapshot?: T;
|
||||
decidedBy?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "campaignDemand_select".
|
||||
|
|
@ -1522,6 +1577,9 @@ export interface MunicipalityUpdateSelect<T extends boolean = true> {
|
|||
activeVolunteers?: T;
|
||||
newSupports?: T;
|
||||
body?: T;
|
||||
signalType?: T;
|
||||
signalSource?: T;
|
||||
triangulated?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
|
|
@ -1535,6 +1593,7 @@ export interface ActionPlanSelect<T extends boolean = true> {
|
|||
kind?: T;
|
||||
status?: T;
|
||||
description?: T;
|
||||
origin?: T;
|
||||
deputyPresent?: T;
|
||||
startAt?: T;
|
||||
endAt?: T;
|
||||
|
|
@ -2084,6 +2143,7 @@ export interface TaskCreateCollectionExport {
|
|||
| 'organization'
|
||||
| 'stateDeputy'
|
||||
| 'votePledge'
|
||||
| 'allocationDecision'
|
||||
| 'campaignDemand'
|
||||
| 'supporter'
|
||||
| 'supporterImportBatch'
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { importExportPlugin } from '@payloadcms/plugin-import-export'
|
|||
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
|
||||
import { pt } from 'payload/i18n/pt'
|
||||
import { ActionPlan } from './collections/ActionPlan'
|
||||
import { AllocationDecision } from './collections/AllocationDecision'
|
||||
import { CampaignDemand } from './collections/CampaignDemand'
|
||||
import { CampaignInvite } from './collections/CampaignInvite'
|
||||
import { CampaignUser } from './collections/CampaignUser'
|
||||
|
|
@ -89,6 +90,7 @@ export default buildConfig({
|
|||
Organization,
|
||||
StateDeputy,
|
||||
VotePledge,
|
||||
AllocationDecision,
|
||||
CampaignDemand,
|
||||
Supporter,
|
||||
SupporterImportBatch,
|
||||
|
|
|
|||
5
src/utilities/access/allocationDecisions.ts
Normal file
5
src/utilities/access/allocationDecisions.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
canCreateMunicipalityUpdate as canCreateAllocationDecision,
|
||||
canMutateMunicipalityUpdate as canMutateAllocationDecision,
|
||||
canReadMunicipalityUpdate as canReadAllocationDecision,
|
||||
} from '@/utilities/access/municipalityUpdates'
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Payload } from 'payload'
|
||||
|
||||
import type { ActionPlan, CampaignUser } from '@/payload-types'
|
||||
import type { ActionPlan, CampaignDemand, CampaignUser } from '@/payload-types'
|
||||
import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi'
|
||||
import {
|
||||
actionPlanMunicipalitySummary,
|
||||
|
|
@ -11,6 +11,15 @@ import {
|
|||
import { relationshipId } from '@/utilities/relationship'
|
||||
import type { AccessibleActionPlanContext } from '@/utilities/actionPlanPageData'
|
||||
|
||||
type ActionPlanDemandSummary = {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
kind: CampaignDemand['kind']
|
||||
status: CampaignDemand['status']
|
||||
cost: number | null
|
||||
}
|
||||
|
||||
const loadActionPlanUpdateAuthorNames = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
|
|
@ -59,7 +68,9 @@ const loadActionPlanMunicipalitySummaryById = async (
|
|||
overrideAccess: true,
|
||||
})
|
||||
const municipality = result.docs[0]
|
||||
return municipality ? { id: municipality.id, name: municipality.name, slug: municipality.slug } : null
|
||||
return municipality
|
||||
? { id: municipality.id, name: municipality.name, slug: municipality.slug }
|
||||
: null
|
||||
}
|
||||
|
||||
export const getActionPlanDetailPageData = async (
|
||||
|
|
@ -67,16 +78,57 @@ export const getActionPlanDetailPageData = async (
|
|||
user: CampaignUser,
|
||||
context: AccessibleActionPlanContext,
|
||||
activeTab: ActionPlanDetailTab,
|
||||
): Promise<ActionPlanDetailViewModel> => {
|
||||
): Promise<
|
||||
ActionPlanDetailViewModel & {
|
||||
demands: ActionPlanDemandSummary[]
|
||||
demandCostTotal: number
|
||||
}
|
||||
> => {
|
||||
const municipalityId = relationshipId(context.document.municipality)
|
||||
const municipalitySummary =
|
||||
actionPlanMunicipalitySummary(context.document.municipality) ??
|
||||
(municipalityId ? await loadActionPlanMunicipalitySummaryById(payload, municipalityId) : null)
|
||||
|
||||
const authorNames =
|
||||
const populatedMunicipalitySummary =
|
||||
actionPlanMunicipalitySummary(context.document.municipality) ?? null
|
||||
const municipalitySummaryPromise = populatedMunicipalitySummary
|
||||
? Promise.resolve(populatedMunicipalitySummary)
|
||||
: municipalityId
|
||||
? loadActionPlanMunicipalitySummaryById(payload, municipalityId)
|
||||
: Promise.resolve(null)
|
||||
const authorNamesPromise =
|
||||
activeTab === 'updates' && context.document.updates?.length
|
||||
? await loadActionPlanUpdateAuthorNames(payload, user, context.document.updates)
|
||||
: new Map<number, string>()
|
||||
? loadActionPlanUpdateAuthorNames(payload, user, context.document.updates)
|
||||
: Promise.resolve(new Map<number, string>())
|
||||
const demandSummariesPromise: Promise<ActionPlanDemandSummary[]> =
|
||||
activeTab === 'overview'
|
||||
? payload
|
||||
.find({
|
||||
collection: 'campaignDemand',
|
||||
where: { actionPlan: { equals: context.id } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
sort: 'createdAt',
|
||||
select: { title: true, slug: true, kind: true, status: true, cost: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
.then(({ docs }) =>
|
||||
docs.map((demand) => ({
|
||||
id: demand.id,
|
||||
title: demand.title,
|
||||
slug: demand.slug,
|
||||
kind: demand.kind,
|
||||
status: demand.status,
|
||||
cost: demand.cost ?? null,
|
||||
})),
|
||||
)
|
||||
: Promise.resolve([])
|
||||
const [municipalitySummary, authorNames, demands] = await Promise.all([
|
||||
municipalitySummaryPromise,
|
||||
authorNamesPromise,
|
||||
demandSummariesPromise,
|
||||
])
|
||||
|
||||
return toActionPlanDetailViewModel(context.document, activeTab, authorNames, municipalitySummary)
|
||||
return {
|
||||
...toActionPlanDetailViewModel(context.document, activeTab, authorNames, municipalitySummary),
|
||||
demands,
|
||||
demandCostTotal: demands.reduce((total, demand) => total + (demand.cost ?? 0), 0),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import 'server-only'
|
||||
|
||||
import {
|
||||
actionPlanDemandDraftsSchema,
|
||||
actionPlanKinds,
|
||||
actionPlanOrigins,
|
||||
actionPlanStatuses,
|
||||
MAX_ACTION_PLAN_DEMAND_DRAFTS,
|
||||
type ActionPlanDemandDraft,
|
||||
type ActionPlanCreateInput,
|
||||
type ActionPlanUpdateInput,
|
||||
} from '@/lib/schemas/actionPlan'
|
||||
|
|
@ -49,10 +53,7 @@ const parseTasksFormData = (formData: FormData): ParsedActionPlanTask[] => {
|
|||
throw new FormDataBoundaryError('tasksJson', `Informe o título da tarefa ${index + 1}.`)
|
||||
}
|
||||
if (title.length > 200) {
|
||||
throw new FormDataBoundaryError(
|
||||
'tasksJson',
|
||||
`O título da tarefa ${index + 1} é muito longo.`,
|
||||
)
|
||||
throw new FormDataBoundaryError('tasksJson', `O título da tarefa ${index + 1} é muito longo.`)
|
||||
}
|
||||
const responsible =
|
||||
typeof record.responsible === 'number' &&
|
||||
|
|
@ -76,6 +77,25 @@ const parseTasksFormData = (formData: FormData): ParsedActionPlanTask[] => {
|
|||
})
|
||||
}
|
||||
|
||||
const parseDemandDraftsFormData = (formData: FormData): ActionPlanDemandDraft[] => {
|
||||
const raw = boundedJsonFormValue(formData, 'demandsJson', 80_000)
|
||||
if (raw === undefined) return []
|
||||
const parsed = actionPlanDemandDraftsSchema.safeParse(raw)
|
||||
if (parsed.success) return parsed.data
|
||||
|
||||
const demandIndex = parsed.error.issues[0]?.path[0]
|
||||
if (typeof demandIndex === 'number') {
|
||||
throw new FormDataBoundaryError('demandsJson', `Demanda ${demandIndex + 1} inválida.`)
|
||||
}
|
||||
if (!Array.isArray(raw) || raw.length > MAX_ACTION_PLAN_DEMAND_DRAFTS) {
|
||||
throw new FormDataBoundaryError(
|
||||
'demandsJson',
|
||||
`Informe no máximo ${MAX_ACTION_PLAN_DEMAND_DRAFTS} demandas válidas.`,
|
||||
)
|
||||
}
|
||||
throw new FormDataBoundaryError('demandsJson', 'Lista de demandas inválida.')
|
||||
}
|
||||
|
||||
const parseSharedActionPlanFormData = (formData: FormData) => {
|
||||
const responsible = nullableRelationshipFormValue(formData, 'responsible')
|
||||
const leadership = nullableRelationshipFormValue(formData, 'leadership')
|
||||
|
|
@ -83,6 +103,7 @@ const parseSharedActionPlanFormData = (formData: FormData) => {
|
|||
return {
|
||||
title: optionalFormText(formData, 'title') ?? '',
|
||||
kind: optionalFormText(formData, 'kind') as (typeof actionPlanKinds)[number],
|
||||
origin: optionalFormText(formData, 'origin') as (typeof actionPlanOrigins)[number] | undefined,
|
||||
status: optionalFormText(formData, 'status') as (typeof actionPlanStatuses)[number] | undefined,
|
||||
description: optionalFormText(formData, 'description'),
|
||||
deputyPresent: checkboxFormValue(formData, 'deputyPresent'),
|
||||
|
|
@ -95,10 +116,17 @@ const parseSharedActionPlanFormData = (formData: FormData) => {
|
|||
responsible,
|
||||
leadership,
|
||||
tasks: parseTasksFormData(formData),
|
||||
demands: parseDemandDraftsFormData(formData),
|
||||
}
|
||||
}
|
||||
|
||||
export const parseActionPlanCreateFormData = (formData: FormData): ActionPlanCreateInput => {
|
||||
export type ParsedActionPlanCreateFormData = ActionPlanCreateInput & {
|
||||
demands: ActionPlanDemandDraft[]
|
||||
}
|
||||
|
||||
export const parseActionPlanCreateFormData = (
|
||||
formData: FormData,
|
||||
): ParsedActionPlanCreateFormData => {
|
||||
const shared = parseSharedActionPlanFormData(formData)
|
||||
const advisorIds = repeatedRelationshipFormValues(formData, 'advisors')
|
||||
|
||||
|
|
@ -112,7 +140,13 @@ export const parseActionPlanCreateFormData = (formData: FormData): ActionPlanCre
|
|||
}
|
||||
}
|
||||
|
||||
export const parseActionPlanUpdateFormData = (formData: FormData): ActionPlanUpdateInput => {
|
||||
export type ParsedActionPlanUpdateFormData = ActionPlanUpdateInput & {
|
||||
demands: ActionPlanDemandDraft[]
|
||||
}
|
||||
|
||||
export const parseActionPlanUpdateFormData = (
|
||||
formData: FormData,
|
||||
): ParsedActionPlanUpdateFormData => {
|
||||
const shared = parseSharedActionPlanFormData(formData)
|
||||
const advisorIds = repeatedRelationshipFormValues(formData, 'advisors')
|
||||
// Checkboxes only submit when checked, so a hidden marker distinguishes
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ActionPlan, CampaignUser, Contact, Organization, Municipality } from '@/payload-types'
|
||||
import type { ActionPlanOrigin } from '@/lib/schemas/actionPlan'
|
||||
import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi'
|
||||
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ export const actionPlanFormSelect = {
|
|||
title: true,
|
||||
slug: true,
|
||||
kind: true,
|
||||
origin: true,
|
||||
status: true,
|
||||
description: true,
|
||||
deputyPresent: true,
|
||||
|
|
@ -114,6 +116,7 @@ export type ActionPlanFormViewModel = {
|
|||
title: string
|
||||
slug: string
|
||||
kind: ActionPlan['kind']
|
||||
origin: ActionPlanOrigin
|
||||
status: ActionPlan['status']
|
||||
description: string | null
|
||||
deputyPresent: boolean
|
||||
|
|
@ -130,15 +133,14 @@ export type ActionPlanFormViewModel = {
|
|||
}
|
||||
|
||||
const relationshipIds = (value: unknown): number[] =>
|
||||
(Array.isArray(value) ? value : [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null)
|
||||
(Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null)
|
||||
|
||||
export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewModel => ({
|
||||
id: plan.id,
|
||||
title: plan.title,
|
||||
slug: plan.slug,
|
||||
kind: plan.kind,
|
||||
origin: plan.origin ?? 'dado',
|
||||
status: plan.status,
|
||||
description: plan.description ?? null,
|
||||
deputyPresent: Boolean(plan.deputyPresent),
|
||||
|
|
@ -187,6 +189,7 @@ export const actionPlanDetailContextSelect = {
|
|||
title: true,
|
||||
slug: true,
|
||||
kind: true,
|
||||
origin: true,
|
||||
status: true,
|
||||
description: true,
|
||||
deputyPresent: true,
|
||||
|
|
@ -254,6 +257,7 @@ export type ActionPlanDetailViewModel = {
|
|||
title: string
|
||||
slug: string
|
||||
kind: ActionPlan['kind']
|
||||
origin: ActionPlanOrigin
|
||||
status: ActionPlan['status']
|
||||
description: string | null
|
||||
deputyPresent: boolean
|
||||
|
|
@ -297,8 +301,7 @@ const mapActionPlanUpdates = (
|
|||
return {
|
||||
id: update.id ?? null,
|
||||
body: update.body,
|
||||
authorName:
|
||||
populatedName ?? (authorId ? (authorNamesById.get(authorId) ?? null) : null),
|
||||
authorName: populatedName ?? (authorId ? (authorNamesById.get(authorId) ?? null) : null),
|
||||
createdAt: update.createdAt ?? null,
|
||||
}
|
||||
})
|
||||
|
|
@ -325,6 +328,7 @@ export const toActionPlanDetailViewModel = (
|
|||
title: plan.title,
|
||||
slug: plan.slug,
|
||||
kind: plan.kind,
|
||||
origin: plan.origin ?? 'dado',
|
||||
status: plan.status,
|
||||
description: plan.description ?? null,
|
||||
deputyPresent: Boolean(plan.deputyPresent),
|
||||
|
|
@ -356,8 +360,7 @@ export const toActionPlanDetailViewModel = (
|
|||
total: plan.taskTotal ?? 0,
|
||||
},
|
||||
tasks: activeTab === 'tasks' ? mapActionPlanTasks(plan) : [],
|
||||
updates:
|
||||
activeTab === 'updates' ? mapActionPlanUpdates(plan, authorNamesById) : [],
|
||||
updates: activeTab === 'updates' ? mapActionPlanUpdates(plan, authorNamesById) : [],
|
||||
result: mapActionPlanResult(plan),
|
||||
createdByName: relationshipName(plan.createdBy),
|
||||
updatedAt: plan.updatedAt,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@ export {
|
|||
canUpdateVotePledge,
|
||||
} from '@/utilities/access/votePledges'
|
||||
|
||||
export {
|
||||
canCreateAllocationDecision,
|
||||
canMutateAllocationDecision,
|
||||
canReadAllocationDecision,
|
||||
} from '@/utilities/access/allocationDecisions'
|
||||
|
||||
export {
|
||||
canCreateMunicipalityUpdate,
|
||||
canMutateMunicipalityUpdate,
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import type { Payload } from 'payload'
|
|||
|
||||
import type { CampaignDemandKind, CampaignDemandStatus } from '@/lib/schemas/campaignDemand'
|
||||
import { campaignDemandKinds, campaignDemandStatuses } from '@/lib/schemas/campaignDemand'
|
||||
import type { CampaignDemand, CampaignUser } from '@/payload-types'
|
||||
import type { ActionPlan, CampaignDemand, CampaignUser } from '@/payload-types'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
|
||||
|
||||
export const demandPageSize = 25
|
||||
|
||||
|
|
@ -94,7 +94,10 @@ const resolveMunicipalityAndRequesterNames = async (
|
|||
|
||||
return {
|
||||
municipalityBy: new Map(
|
||||
municipalities.docs.map((municipality) => [municipality.id, { name: municipality.name, slug: municipality.slug }]),
|
||||
municipalities.docs.map((municipality) => [
|
||||
municipality.id,
|
||||
{ name: municipality.name, slug: municipality.slug },
|
||||
]),
|
||||
),
|
||||
requesterBy: new Map(
|
||||
leaderships.docs.map((leadership) => {
|
||||
|
|
@ -193,6 +196,7 @@ export type DemandDetailViewModel = DemandRowViewModel & {
|
|||
createdAt: string | null
|
||||
}>
|
||||
canLeaderEdit: boolean
|
||||
actionPlan: { title: string; slug: string } | null
|
||||
}
|
||||
|
||||
export const loadDemandDetail = async (
|
||||
|
|
@ -212,7 +216,9 @@ export const loadDemandDetail = async (
|
|||
const demand = result.docs[0] as CampaignDemand | undefined
|
||||
if (!demand) return null
|
||||
|
||||
const { municipalityBy, requesterBy } = await resolveMunicipalityAndRequesterNames(payload, [demand])
|
||||
const { municipalityBy, requesterBy } = await resolveMunicipalityAndRequesterNames(payload, [
|
||||
demand,
|
||||
])
|
||||
const row = toDemandRow(demand, municipalityBy, requesterBy)
|
||||
|
||||
const authorIDs = [
|
||||
|
|
@ -263,5 +269,8 @@ export const loadDemandDetail = async (
|
|||
}))
|
||||
: [],
|
||||
canLeaderEdit: user.role === 'leader' && demand.status === 'aberta',
|
||||
actionPlan: isPopulatedRelationship<ActionPlan>(demand.actionPlan)
|
||||
? { title: demand.actionPlan.title, slug: demand.actionPlan.slug }
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ import type { Payload } from 'payload'
|
|||
|
||||
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
|
||||
export type ActionPlanRelationOption = RelationOption & {
|
||||
municipalityId: number
|
||||
}
|
||||
|
||||
/** Municipalities the actor may operate on (coordinator: all 436; advisor: administered). */
|
||||
export const loadMunicipalityOptions = async (
|
||||
|
|
@ -40,6 +45,28 @@ export const loadOrganizationOptions = async (
|
|||
return result.docs.map((organization) => ({ id: organization.id, name: organization.name }))
|
||||
}
|
||||
|
||||
export const loadActionPlanOptions = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
): Promise<ActionPlanRelationOption[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'actionPlan',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'title',
|
||||
select: { title: true, municipality: true },
|
||||
where: {},
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return result.docs.flatMap((plan) => {
|
||||
const municipalityId = relationshipId(plan.municipality)
|
||||
return municipalityId ? [{ id: plan.id, name: plan.title, municipalityId }] : []
|
||||
})
|
||||
}
|
||||
|
||||
export const loadStateDeputyOptions = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type { Payload } from 'payload'
|
||||
|
||||
import type { MunicipalityUpdateKind } from '@/lib/schemas/municipalityUpdate'
|
||||
import type {
|
||||
MunicipalitySignalType,
|
||||
MunicipalityUpdateKind,
|
||||
} from '@/lib/schemas/municipalityUpdate'
|
||||
import { municipalityUpdateKinds } from '@/lib/schemas/municipalityUpdate'
|
||||
import type { CampaignUser, MunicipalityUpdate } from '@/payload-types'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
|
|
@ -18,6 +21,9 @@ export type MunicipalityUpdateViewModel = {
|
|||
body: string | null
|
||||
activeVolunteers: number | null
|
||||
newSupports: number | null
|
||||
signalType: MunicipalitySignalType | null
|
||||
signalSource: string | null
|
||||
triangulated: boolean
|
||||
}
|
||||
|
||||
export type MunicipalityUpdateFeedState = {
|
||||
|
|
@ -104,6 +110,9 @@ export const loadMunicipalityUpdatesFeed = async (
|
|||
body: doc.body ?? null,
|
||||
activeVolunteers: doc.activeVolunteers ?? null,
|
||||
newSupports: doc.newSupports ?? null,
|
||||
signalType: doc.signalType ?? null,
|
||||
signalSource: doc.signalSource ?? null,
|
||||
triangulated: Boolean(doc.triangulated),
|
||||
}
|
||||
}),
|
||||
totalDocs: result.totalDocs,
|
||||
|
|
@ -117,4 +126,7 @@ export const loadMunicipalityUpdatesPreview = async (
|
|||
user: CampaignUser,
|
||||
municipalityID: number,
|
||||
): Promise<MunicipalityUpdateViewModel[]> =>
|
||||
(await loadMunicipalityUpdatesFeed(payload, user, municipalityID, { page: 1 })).updates.slice(0, 3)
|
||||
(await loadMunicipalityUpdatesFeed(payload, user, municipalityID, { page: 1 })).updates.slice(
|
||||
0,
|
||||
3,
|
||||
)
|
||||
|
|
|
|||
63
tests/e2e/campaignActionPlan.e2e.spec.ts
Normal file
63
tests/e2e/campaignActionPlan.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
test.describe('Planos de ação — registro-fundação', () => {
|
||||
test.setTimeout(90_000)
|
||||
|
||||
test('registra origem, cria demandas vinculadas e exibe a explicação dos sinais', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const password = fixtures.value('senha')
|
||||
const coordinator = await campaign.payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
email: `${fixtures.value('coordinator')}@example.com`,
|
||||
password,
|
||||
role: 'coordinator',
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
const planTitle = fixtures.value('Giro territorial')
|
||||
const transportDemand = fixtures.value('Transporte para equipe')
|
||||
const materialDemand = fixtures.value('Material de rua')
|
||||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/planos/novo`)
|
||||
|
||||
await page.getByLabel('Título *').fill(planTitle)
|
||||
await page.getByLabel('Tipo de ação *').selectOption('caminhada')
|
||||
await page.getByLabel('Origem da ação').selectOption('obrigacao_politica')
|
||||
await page.getByLabel('Praça *').selectOption(String(municipality.id))
|
||||
|
||||
await page.getByRole('button', { name: 'Adicionar demanda' }).click()
|
||||
await page.getByLabel('Título da demanda 1').fill(transportDemand)
|
||||
await page.getByLabel('Tipo da demanda 1').selectOption('transporte')
|
||||
await page.getByRole('button', { name: 'Adicionar demanda' }).click()
|
||||
await page.getByLabel('Título da demanda 2').fill(materialDemand)
|
||||
await page.getByLabel('Tipo da demanda 2').selectOption('material')
|
||||
|
||||
await page.getByRole('button', { name: 'Criar plano' }).click()
|
||||
await expect(page).toHaveURL(/\/campanha\/planos\/[^/?]+$/)
|
||||
await expect(page.getByRole('heading', { name: planTitle })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('Obrigação política', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: transportDemand })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: materialDemand })).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: 'Adicionar demanda' }).click()
|
||||
await expect(page.getByLabel('Plano de ação relacionado')).toHaveValue(/\d+/)
|
||||
await expect(page.getByLabel('Município')).toHaveValue(String(municipality.id))
|
||||
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=updates`)
|
||||
await page.getByLabel('Tipo', { exact: true }).selectOption('sinal')
|
||||
await expect(page.getByText('Invasão:', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Adversário ocupando ou ganhando espaço antes dominado pela campanha.'),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Esfriamento:', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Visita adversária:', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Proposta a broker:', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
@ -21,7 +21,9 @@ type OwnedCollection =
|
|||
| 'leadership'
|
||||
| 'votePledge'
|
||||
| 'campaignDemand'
|
||||
| 'allocationDecision'
|
||||
| 'municipalityUpdate'
|
||||
| 'actionPlan'
|
||||
| 'campaignInvite'
|
||||
| 'consent'
|
||||
| 'supporter'
|
||||
|
|
@ -30,7 +32,9 @@ const deletionOrder: OwnedCollection[] = [
|
|||
'campaignInvite',
|
||||
'votePledge',
|
||||
'campaignDemand',
|
||||
'allocationDecision',
|
||||
'municipalityUpdate',
|
||||
'actionPlan',
|
||||
'leadership',
|
||||
'supporter',
|
||||
'organization',
|
||||
|
|
@ -129,52 +133,62 @@ class CampaignE2EOwnership {
|
|||
}
|
||||
|
||||
private async discoverOwnedRows(): Promise<void> {
|
||||
const [users, campaignUsers, contacts, organizations, consents] = await Promise.all([
|
||||
this.rootPayload.find({
|
||||
collection: 'users',
|
||||
where: { email: { contains: this.runID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
this.rootPayload.find({
|
||||
collection: 'campaignUser',
|
||||
where: {
|
||||
or: [
|
||||
{ name: { contains: this.runID } },
|
||||
{ email: { contains: this.runID } },
|
||||
{ username: { contains: this.runID } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
this.rootPayload.find({
|
||||
collection: 'contact',
|
||||
where: {
|
||||
or: [{ name: { contains: this.runID } }, { email: { contains: this.runID } }],
|
||||
},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
this.rootPayload.find({
|
||||
collection: 'organization',
|
||||
where: {
|
||||
or: [{ name: { contains: this.runID } }, { slug: { contains: this.runID } }],
|
||||
},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
this.rootPayload.find({
|
||||
collection: 'consent',
|
||||
where: { key: { contains: this.runID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
])
|
||||
const [users, campaignUsers, contacts, organizations, actionPlans, consents] =
|
||||
await Promise.all([
|
||||
this.rootPayload.find({
|
||||
collection: 'users',
|
||||
where: { email: { contains: this.runID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
this.rootPayload.find({
|
||||
collection: 'campaignUser',
|
||||
where: {
|
||||
or: [
|
||||
{ name: { contains: this.runID } },
|
||||
{ email: { contains: this.runID } },
|
||||
{ username: { contains: this.runID } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
this.rootPayload.find({
|
||||
collection: 'contact',
|
||||
where: {
|
||||
or: [{ name: { contains: this.runID } }, { email: { contains: this.runID } }],
|
||||
},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
this.rootPayload.find({
|
||||
collection: 'organization',
|
||||
where: {
|
||||
or: [{ name: { contains: this.runID } }, { slug: { contains: this.runID } }],
|
||||
},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
this.rootPayload.find({
|
||||
collection: 'actionPlan',
|
||||
where: {
|
||||
or: [{ title: { contains: this.runID } }, { slug: { contains: this.runID } }],
|
||||
},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
this.rootPayload.find({
|
||||
collection: 'consent',
|
||||
where: { key: { contains: this.runID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
])
|
||||
for (const user of users.docs) this.own('users', user.id)
|
||||
for (const user of campaignUsers.docs) this.own('campaignUser', user.id)
|
||||
for (const contact of contacts.docs) this.own('contact', contact.id)
|
||||
for (const organization of organizations.docs) this.own('organization', organization.id)
|
||||
for (const actionPlan of actionPlans.docs) this.own('actionPlan', actionPlan.id)
|
||||
for (const consent of consents.docs) this.own('consent', consent.id)
|
||||
|
||||
const userIDs = this.ids('campaignUser')
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ type CampaignCollection =
|
|||
| 'municipalityUpdate'
|
||||
| 'votePledge'
|
||||
| 'campaignDemand'
|
||||
| 'allocationDecision'
|
||||
| 'actionPlan'
|
||||
| 'leadership'
|
||||
| 'supporter'
|
||||
|
|
@ -105,6 +106,7 @@ const emptyOwnedIDs = (): OwnedIDs => ({
|
|||
municipalityUpdate: new Set(),
|
||||
votePledge: new Set(),
|
||||
campaignDemand: new Set(),
|
||||
allocationDecision: new Set(),
|
||||
actionPlan: new Set(),
|
||||
leadership: new Set(),
|
||||
supporter: new Set(),
|
||||
|
|
@ -868,6 +870,7 @@ export class CampaignFixtures {
|
|||
|
||||
const lockedDocumentConditions = [
|
||||
['action_plan_id', this.owned.actionPlan],
|
||||
['allocation_decision_id', this.owned.allocationDecision],
|
||||
['campaign_invite_id', this.owned.campaignInvite],
|
||||
['campaign_user_id', this.owned.campaignUser],
|
||||
['campaign_demand_id', this.owned.campaignDemand],
|
||||
|
|
@ -961,6 +964,7 @@ export class CampaignFixtures {
|
|||
'campaignInvite',
|
||||
'votePledge',
|
||||
'campaignDemand',
|
||||
'allocationDecision',
|
||||
'municipalityUpdate',
|
||||
'actionPlan',
|
||||
'leadership',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createActionPlanRecord } from '@/app/(campaign)/campanha/actions/actionPlan'
|
||||
import { actionPlanCreateSchema, actionPlanUpdateSchema } from '@/lib/schemas/actionPlan'
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
canUpdateActionPlan,
|
||||
getAccessibleLeadershipIds,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { parseActionPlanCreateFormData } from '@/utilities/actionPlanFormData'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import { stub } from '../helpers/stub'
|
||||
|
|
@ -80,6 +82,144 @@ describe('action plan domain', () => {
|
|||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the recorded origin of a new action plan', () => {
|
||||
const parsed = actionPlanCreateSchema.parse({
|
||||
...validPlanInput(1),
|
||||
origin: 'pedido_broker',
|
||||
})
|
||||
|
||||
expect(parsed.origin).toBe('pedido_broker')
|
||||
})
|
||||
|
||||
it('parses the origin selected in the action plan form', () => {
|
||||
const formData = new FormData()
|
||||
formData.set('title', 'Caminhada no centro')
|
||||
formData.set('kind', 'caminhada')
|
||||
formData.set('status', 'rascunho')
|
||||
formData.set('municipality', '1')
|
||||
formData.set('origin', 'obrigacao_politica')
|
||||
formData.set('tasksJson', '[]')
|
||||
|
||||
expect(parseActionPlanCreateFormData(formData).origin).toBe('obrigacao_politica')
|
||||
})
|
||||
|
||||
it('parses several demand drafts from the action plan form', () => {
|
||||
const formData = new FormData()
|
||||
formData.set('title', 'Caminhada no centro')
|
||||
formData.set('kind', 'caminhada')
|
||||
formData.set('status', 'rascunho')
|
||||
formData.set('municipality', '1')
|
||||
formData.set('tasksJson', '[]')
|
||||
formData.set(
|
||||
'demandsJson',
|
||||
JSON.stringify([
|
||||
{ title: 'Panfletos', kind: 'material' },
|
||||
{ title: 'Van', kind: 'transporte', description: 'Levar a equipe.' },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(parseActionPlanCreateFormData(formData).demands).toEqual([
|
||||
{ title: 'Panfletos', kind: 'material' },
|
||||
{ title: 'Van', kind: 'transporte', description: 'Levar a equipe.' },
|
||||
])
|
||||
})
|
||||
|
||||
it('creates several demands with the action plan in one workflow', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
const plan = await createActionPlanRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
{
|
||||
...validPlanInput(municipality.id),
|
||||
title: fixtures.value('Plano com demandas'),
|
||||
origin: 'dado',
|
||||
},
|
||||
[
|
||||
{ title: fixtures.value('Panfletos'), kind: 'material' },
|
||||
{
|
||||
title: fixtures.value('Van para equipe'),
|
||||
kind: 'transporte',
|
||||
description: 'Transporte para o dia da atividade.',
|
||||
},
|
||||
],
|
||||
)
|
||||
fixtures.own('actionPlan', plan.id)
|
||||
|
||||
const demands = await payload.find({
|
||||
collection: 'campaignDemand',
|
||||
where: { actionPlan: { equals: plan.id } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
demands.docs.forEach((demand) => fixtures.own('campaignDemand', demand.id))
|
||||
|
||||
expect(demands.docs).toHaveLength(2)
|
||||
expect(demands.docs.map((demand) => demand.title)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Panfletos'),
|
||||
expect.stringContaining('Van para equipe'),
|
||||
]),
|
||||
)
|
||||
expect(
|
||||
demands.docs.every((demand) => {
|
||||
const demandMunicipality =
|
||||
typeof demand.municipality === 'number' ? demand.municipality : demand.municipality.id
|
||||
return demandMunicipality === municipality.id
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rolls back the plan and every nested demand when one demand conflicts', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const duplicateTitle = fixtures.value('Demanda já existente')
|
||||
await fixtures.createCampaignDemand({
|
||||
title: duplicateTitle,
|
||||
municipality: municipality.id,
|
||||
createdBy: coordinator.id,
|
||||
})
|
||||
const planTitle = fixtures.value('Plano que deve reverter')
|
||||
|
||||
await expect(
|
||||
createActionPlanRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
{
|
||||
...validPlanInput(municipality.id),
|
||||
title: planTitle,
|
||||
},
|
||||
[
|
||||
{ title: fixtures.value('Primeira demanda temporária'), kind: 'material' },
|
||||
{ title: duplicateTitle, kind: 'transporte' },
|
||||
],
|
||||
),
|
||||
).rejects.toThrow()
|
||||
|
||||
const [plans, nestedDemands] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'actionPlan',
|
||||
where: { title: { equals: planTitle } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'campaignDemand',
|
||||
where: { title: { contains: 'Primeira demanda temporária' } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
])
|
||||
expect(plans.docs).toHaveLength(0)
|
||||
expect(nestedDemands.docs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('scopes create/read/update by campaign role', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
|
|
|
|||
99
tests/int/campaignAllocationDecision.int.spec.ts
Normal file
99
tests/int/campaignAllocationDecision.int.spec.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { getPayload, type Payload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('campaign allocation decisions', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('records an immutable ex-ante decision in the advisor municipality scope', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
|
||||
|
||||
const decision = await payload.create({
|
||||
collection: 'allocationDecision',
|
||||
data: {
|
||||
municipality: municipality.id,
|
||||
patternId: 'P3-cobertura-baixa',
|
||||
outcome: 'aceita',
|
||||
rationale: 'A cobertura dos compromissos está abaixo do esperado.',
|
||||
snapshot: { coverage: 0.42, scenario: 'central' },
|
||||
},
|
||||
depth: 0,
|
||||
user: advisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
fixtures.own('allocationDecision', decision.id)
|
||||
|
||||
expect(
|
||||
typeof decision.decidedBy === 'number' ? decision.decidedBy : decision.decidedBy?.id,
|
||||
).toBe(advisor.id)
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'allocationDecision',
|
||||
id: decision.id,
|
||||
data: { rationale: 'Tentativa de reescrever a decisão.' },
|
||||
user: advisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('requires an alternative reading when a suggestion is discarded', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'allocationDecision',
|
||||
data: {
|
||||
municipality: municipality.id,
|
||||
patternId: 'P8-pressao-adversaria',
|
||||
outcome: 'descarta',
|
||||
rationale: 'A leitura automática não representa o contexto local.',
|
||||
snapshot: { pressureRank: 2 },
|
||||
},
|
||||
user: coordinator,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow('leitura alternativa')
|
||||
})
|
||||
|
||||
it('denies allocation decisions to leaders', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const leader = await fixtures.createCampaignUser('leader')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'allocationDecision',
|
||||
data: {
|
||||
municipality: municipality.id,
|
||||
patternId: 'P1-fila',
|
||||
outcome: 'aceita',
|
||||
rationale: 'Tentativa fora do papel permitido.',
|
||||
snapshot: { rank: 1 },
|
||||
},
|
||||
user: leader,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
@ -99,6 +99,23 @@ describe('campaign municipality update domain', () => {
|
|||
body: ' ',
|
||||
}).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
municipalityUpdateCreateSchema.safeParse({
|
||||
municipality: 1,
|
||||
kind: 'sinal',
|
||||
body: 'Ex-prefeito retirou o apoio na feira.',
|
||||
signalType: 'esfriamento',
|
||||
signalSource: 'Assessora local',
|
||||
}).success,
|
||||
).toBe(true)
|
||||
expect(
|
||||
municipalityUpdateCreateSchema.safeParse({
|
||||
municipality: 1,
|
||||
kind: 'sinal',
|
||||
body: 'Visita adversária confirmada.',
|
||||
signalSource: 'Equipe local',
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('strips forged author and timestamps from input', () => {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,35 @@ describe('vote pledges (declared by staff, estimated by staff)', () => {
|
|||
expect(updated.declaredVotes).toBe(220)
|
||||
})
|
||||
|
||||
it('preserves the vote pledge trajectory in native Payload versions', async () => {
|
||||
const { municipality, advisor, leadership } = await createEngagedLeadership()
|
||||
const pledge = await declareVotesRecord(payload, advisor, {
|
||||
municipality: municipality.id,
|
||||
leadership: leadership.id,
|
||||
declaredVotes: 80,
|
||||
})
|
||||
campaignFixtures().own('votePledge', pledge.id)
|
||||
|
||||
await declareVotesRecord(payload, advisor, {
|
||||
municipality: municipality.id,
|
||||
leadership: leadership.id,
|
||||
declaredVotes: 125,
|
||||
})
|
||||
|
||||
const versions = await payload.findVersions({
|
||||
collection: 'votePledge',
|
||||
where: { parent: { equals: pledge.id } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
sort: 'createdAt',
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
expect(versions.docs.map((version) => version.version.declaredVotes)).toEqual(
|
||||
expect.arrayContaining([80, 125]),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a declaration in a municipality the leadership is not linked to', async () => {
|
||||
const { advisor, fixtures, leadership } = await createEngagedLeadership()
|
||||
const otherMunicipality = await fixtures.getMunicipality()
|
||||
|
|
|
|||
Loading…
Reference in a new issue