mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Deliver A9 plaza expectedVotes and register A9+ follow-up.
Add staff-only expectedVotes with migration, edit flow, and expectedVotes ?? pledge fallback across map, overview, and dashboard; document delivery and the A9+ loader dedup fill-in on the roadmap.
This commit is contained in:
parent
be1106f887
commit
b333609726
29 changed files with 10874 additions and 74 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **A9 entregue em código (2026-07-21; deploy pendente com remodelagem):** `plaza.expectedVotes` staff-only, fallback `expectedVotes ?? effectiveTotal` em mapa 2026/overview/dashboard, UI `/editar` + leitura lista/detalhe; migration `20260721_133444_add_plaza_expected_votes`. Plano: [`estimativa-votos-praca.md`](../../../docs/plans/estimativa-votos-praca.md). Fill-in pós-`/simplify`: **A9+** [`escala-dry-pos-a9.md`](../../../docs/plans/escala-dry-pos-a9.md) (deduplicar `aggregatePledgesByPlaza` na lista). **B9** desbloqueado.
|
||||
- **REMODELAGEM ENTREGUE EM CÓDIGO (2026-07-21; deploy pendente — migração `20260721_020109_remodel_plazas` é destrutiva para a vertical campanha, revisar SQL antes do build de produção). Suítes verdes: unit 187, int 306 (incl. assimetria declarado×estimado, access por papel, workflow de demandas), e2e 9. Critique/polish visual fino registrado como R6 no roadmap.** Feedback da coordenação invalidou o modelo de núcleo abstrato. Decisões travadas: 436 Praças pré-definidas (415 municípios + 19 zonas de Salvador + 2 de Camaçari; zonas compartilhadas cortadas na divisa municipal); código `plaza`; roles renomeados para `coordinator`/`advisor`/`leader` (labels "Coordenador Geral"/"Assessor"/"Liderança"); assessor vê só as Praças que administra; liderança = 1 registro por pessoa com N Praças e N organizações; votos por liderança×Praça (`declaredVotes` visível à liderança, `estimatedVotes` staff-only); demandas com workflow assessor→coordenador geral + comprovantes staff-only; tendência política manual (staff); comparativo multi-candidato/multi-ano por Praça; mapa divergente vermelho↔branco↔azul. Reset dos dados de campanha em produção (site público intocado). Plano-mestre: [`docs/plans/remodelagem-pracas.md`](../../../docs/plans/remodelagem-pracas.md). As entradas abaixo descrevem a era Núcleos — padrões de infra (locks, transações, consent por chave, shells, mapa, dados eleitorais) continuam válidos; superfícies e modelo de Núcleo foram substituídos.
|
||||
- **MVP Ciclo 1 entregue e enviado em `main` (2026-07-18):** ondas 1–8 (auth/RBAC, domínio+UI de Núcleo/Liderança/Inteligência/Atualizações/Convites, dashboard, hardening) + refactors de composição Next.js, primitivos compartilhados, locks PostgreSQL, fixtures E2E e migration consolidada `20260718_010733_consolidate_campaign_schema`.
|
||||
- **Ciclo 2 entregue, mesclado e deployado (2026-07-18):** A1 território multi-município/bairro, A3 baseline TSE 2022 (modelo + import), B1 overview da lista, C1 compartilhar página e D1 PWA — detalhes no item datado abaixo.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Edição rápida na lista de Praças
|
||||
|
||||
Status: rascunho
|
||||
Status: rascunho (A9 entregue — pronto para implementar)
|
||||
Atualizado em: 2026-07-21
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B9)
|
||||
Impeccable: B — encaixe em `PlazaList` / cards mobile em `/campanha/pracas` (sem rota nova)
|
||||
|
|
|
|||
101
docs/plans/escala-dry-pos-a9.md
Normal file
101
docs/plans/escala-dry-pos-a9.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Escala e DRY pós-A9 (loader da lista de Praças)
|
||||
|
||||
Status: registrado no roadmap (Fase 1 pendente)
|
||||
Atualizado em: 2026-07-21 (`capture-review-debts` pós-A9 + `/simplify`)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, fill-in **A9+** pós-A9)
|
||||
Impeccable: A — N/A (sem superfície UI; otimização de loader)
|
||||
Appetite: ~0,5–1 dia eng (1 fase; PR único)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
O A9 ([estimativa-votos-praca.md](estimativa-votos-praca.md)) entrega `plaza.expectedVotes` (staff-only), `setPlazaExpectedVotes`, fallback `expectedVotes ?? effectiveTotal` em mapa/overview/dashboard, UI em `/editar` + leitura na lista/detalhe, migration `20260721_133444_add_plaza_expected_votes`, e helpers `resolvePlazaStaffVoteTotal` / `rollupPlazaStaffVotes` / `sumStaffPledgeEffectiveTotal` em `votePledgeData.ts`.
|
||||
|
||||
Uma passagem `/simplify` pós-entrega já limpou o que cabia em cleanup: form `optionalIntegerFormValue` único; remoção de `hasStaffVoteData`; `StaffPlazaVotesDisplay` compartilhado; JSDoc da métrica 2026; testes unit do rollup.
|
||||
|
||||
O revisor de **performance** marcou como **maior que simplify** o follow-up abaixo. Sem registro, cada abertura de `/campanha/pracas` (staff) dispara **três** agregações de pledges sobre conjuntos sobrepostos.
|
||||
|
||||
1. **Query triplicada de pledges na página `/campanha/pracas`.** `page.tsx` chama em paralelo `loadPlazaListPageData` (pledges da página atual), `loadPlazaListOverviewData` (pledges de **todo** o conjunto filtrado) e `loadPlazaMapBundle` (pledges de **todas** as Praças do escopo do mapa). Cada loader invoca `aggregatePledgesByPlaza` de forma independente — padrão análogo ao E6 F1 em núcleos ([escala-dry-pos-e1.md](escala-dry-pos-e1.md)), mas na vertical Praças pós-remodelagem.
|
||||
|
||||
**Já resolvido no simplify/critique (não reabrir):** parsing duplo do form `expectedVotes`; `hasStaffVoteData` derivado de `staffVoteTotal > 0`; `rollupPlazaStaffVotes` + `sumStaffPledgeEffectiveTotal`; `StaffPlazaVotesDisplay`; assinatura estreita de `resolvePlazaStaffVoteTotal`; testes unit do rollup.
|
||||
|
||||
**Explicitamente fora (triage `capture-review-debts` 2026-07-21):** semântica lista (só `expectedVotes` manual) vs mapa/overview (fallback) — decisão de produto A9; merge de forms Estratégia + Votos no `/editar`; grid cosmético do `PlazaStrategyCard`; `loadPlazaPledges` em abas distintas do detalhe (uma aba por request); débitos adiados/não escopo do plano A9 (nota/autor, chip auto-sugerir, histórico, filtro `?expectedVotes=`); critique Impeccable formal por superfície → **R6**.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Abrir `/campanha/pracas` com filtro amplo não executa **três** `aggregatePledgesByPlaza` independentes quando um mapa de agregados compartilhado basta.
|
||||
- Overview, mapa 2026 e coluna da página reutilizam o mesmo `Map<plazaId, PlazaPledgeAggregate>` (e, onde os filtros coincidirem, o mesmo `find` de Praças).
|
||||
- Paridade funcional: KPIs, métrica 2026 e sublinha “Nas lideranças” permanecem idênticos ao comportamento pós-A9.
|
||||
- Guardrails: sem migration, sem collection, sem Consent, sem server action nova; access inalterado (`overrideAccess: false` nos reads de Praça; agregado de pledges continua admin-bypass intencional com ids já access-checked).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Um fill-in A9+, uma fase (F1).** Mesmo racional de A7/C6/A8+: um slug de plano, PR único. **Rejeitado:** item de roadmap separado `A10` — follow-up pós-entrega, não feature nova.
|
||||
- **Dependência dura de A9.** Só faz sentido com `expectedVotes`, `rollupPlazaStaffVotes` e os três loaders já no produto; não reabre escopo de schema ou UI de edição.
|
||||
- **F1 não remove overview nem mapa.** Otimiza como os dados chegam; paridade com a plano A9 permanece. **Rejeitado:** unificar lista+overview num único `find` unpaginado sem validar regressão de paginação/`depth`.
|
||||
- **Cortável** se a lista com mapa permanecer aceitável em campo com dezenas de assessores; vira não-cortável quando B7 (filtro) + mapa sempre visível multiplicarem o conjunto filtrado (436 Praças para coordenador).
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (`loadPlazaListPageBundle`, `PlazaListPledgeAggregates`); strings visíveis inalteradas em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Bundle único vs compartilhar só o mapa de pledges?** **Opções:** (A) `loadPlazaListPageBundle` retorna `{ list, overview, mapInputs, pledgeAggregates }`; (B) só deduplicar `aggregatePledgesByPlaza` num helper `loadPlazaPledgeAggregatesForListPage` consumido pelos três loaders. **Recomendação:** (A) se os três `find` de Praças puderem compartilhar escopo sem duplicar lógica de filtro; senão (B) como primeiro passo — ganho imediato no hot path de pledges.
|
||||
- **Overview unpaginado vs página paginada?** **Recomendação:** manter dois `find` de Praças (conjunto filtrado inteiro vs página) — o gargalo registrado é o **terceiro** agregado de pledges, não a paginação em si; unificar `find` só se profiling mostrar custo dominante em Praças, não em pledges.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A9["A9 expectedVotes ✓"] --> F1
|
||||
F1["Fase 1 — loadPlazaListPageBundle<br/>1× aggregatePledgesByPlaza"]
|
||||
F1 --> List["PlazaList página"]
|
||||
F1 --> Overview["PlazaListOverview KPIs"]
|
||||
F1 --> Map["PlazaMapBundle 2026"]
|
||||
B7["B7 mapa filtrado"] -.mesmo bundle.-> F1
|
||||
```
|
||||
|
||||
### Fase 1 — Loader compartilhado da lista de Praças
|
||||
|
||||
- Introduzir `loadPlazaListPageBundle` (ou equivalente em `plazaPageData.ts` + ajuste fino em `plazaMapData.ts`) que:
|
||||
- Resolve filtros uma vez (`parsePlazaListParams` / `buildPlazaListWhere`).
|
||||
- Carrega Praças do escopo necessário (página + overview + mapa) com o mínimo de round-trips.
|
||||
- Chama `aggregatePledgesByPlaza` **uma vez** sobre a união de ids necessários (ou sobre o conjunto filtrado inteiro se mais simples e dentro do appetite).
|
||||
- Expõe `pledgeAggregates` reutilizado por `toPlazaListViewModel`, `rollupPlazaStaffVotes` e `loadPlazaMapBundle` (refatorar para aceitar agregados pré-computados ou extrair helper puro).
|
||||
- Atualizar `src/app/(campaign)/campanha/(app)/pracas/page.tsx` para consumir o bundle.
|
||||
- Testes: int ou unit do bundle — paridade de `staffVoteTotal` e métrica 2026 antes/depois; advisor scope inalterado.
|
||||
|
||||
**Migration:** nenhuma.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Dura:** A9 [estimativa-votos-praca.md](estimativa-votos-praca.md) — campo, agregadores e superfícies (merge em `main`).
|
||||
- **Suave:** B7 [mapa-pracas-filtrado.md](mapa-pracas-filtrado.md) — mesmo URL de filtro; F1 facilita B7 ao centralizar o where.
|
||||
- Reusa: `aggregatePledgesByPlaza`, `rollupPlazaStaffVotes`, `resolvePlazaStaffVoteTotal`, precedente E6 F1 em [escala-dry-pos-e1.md](escala-dry-pos-e1.md).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- SQL aggregate de pledges (`COUNT(*) FILTER`) — volume ainda baixo; reavaliar no 3º hot path ou com evidência de latency.
|
||||
- Unificar `loadPlazaPledges` entre abas Overview/Lideranças no detalhe — defer (uma aba por request).
|
||||
- Edição inline na lista → **B9** ([edicao-rapida-lista-pracas.md](edicao-rapida-lista-pracas.md)).
|
||||
- Merge de forms no `/editar` — fora do triage.
|
||||
- Impeccable critique das superfícies A9 → **R6**.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Unificar lista+overview+mapa num único `find` unpaginado de 436 Praças sempre.** Mitigação: F1 foca em deduplicar **pledges**; `find` de Praças permanece paginado onde já é.
|
||||
- **Cache cross-request de agregados.** Mitigação: bundle por request RSC apenas; sem Redis/memória global neste fill-in.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **SQL aggregate espelhando `buildPlazaListWhere`.** Revisitar quando profiling mostrar `aggregatePledgesByPlaza` dominando TTFB com filtros amplos + mapa sempre montado.
|
||||
- **Compartilhar bundle com dashboard geral.** Revisitar se o dashboard passar a carregar mapa+lista na mesma rota.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha A, A9 entregue + fill-in A9+)
|
||||
- `docs/plans/estimativa-votos-praca.md` — plano pai A9
|
||||
- `docs/plans/escala-dry-pos-e1.md` — precedente E6 F1 (lista+overview núcleos)
|
||||
- `src/app/(campaign)/campanha/(app)/pracas/page.tsx` — `Promise.all` dos três loaders
|
||||
- `src/utilities/plazaPageData.ts` — `loadPlazaListPageData`, `loadPlazaListOverviewData`
|
||||
- `src/utilities/plazaMapData.ts` — `loadPlazaMapBundle`, métrica 2026
|
||||
- `src/utilities/votePledgeData.ts` — `aggregatePledgesByPlaza`, `rollupPlazaStaffVotes`
|
||||
- AGENTS.md — naming EN/pt-BR; access staff; sem PII
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Estimativa de votos da Praça (total esperado)
|
||||
|
||||
Status: rascunho
|
||||
Status: entregue 2026-07-21 (branch; deploy pendente com remodelagem)
|
||||
Atualizado em: 2026-07-21
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A9)
|
||||
Impeccable: B — campo + migration + action + encaixe em `/editar` / detalhe / lista (leitura); edição inline na lista = B9
|
||||
|
|
@ -109,9 +109,16 @@ Componentes:
|
|||
- **Aviso vs `voteGoals.minimum`.** Revisitar após primeiras semanas com totais preenchidos.
|
||||
- **Auto-sugerir `expectedVotes` a partir dos pledges (opt-in chip).** Revisitar se muitos totais ≈ soma das lideranças.
|
||||
|
||||
## Pós-entrega (`/simplify`)
|
||||
|
||||
Débitos **maiores que cleanup** da sessão de entrega → fill-in **A9+** [escala-dry-pos-a9.md](escala-dry-pos-a9.md) (loader compartilhado da lista — deduplicar `aggregatePledgesByPlaza` na página `/campanha/pracas`).
|
||||
|
||||
**Já resolvido no simplify (não reabrir):** form `expectedVotes`; `rollupPlazaStaffVotes` / `sumStaffPledgeEffectiveTotal`; `StaffPlazaVotesDisplay`; remoção de `hasStaffVoteData`; testes unit do rollup.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha A / A9 → B9)
|
||||
- `docs/plans/escala-dry-pos-a9.md` — fill-in A9+ (loader lista)
|
||||
- `src/collections/Plaza.ts` — `voteGoals`, access staff
|
||||
- `src/utilities/votePledgeData.ts` — `effectiveTotal`
|
||||
- `src/utilities/plazaMapData.ts` — métrica 2026
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-21 (Pixel Meta entregue; B10 click-nav; B11 escala % mapa; A9→B9; B8; Fill-in filtros; B7)
|
||||
Atualizado em: 2026-07-21 (A9 entregue; A9+ loader lista; B9 desbloqueado; Pixel Meta)
|
||||
|
||||
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).
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ flowchart TD
|
|||
TSE2026["TSE candidaturas 2026<br/>(após 15/08)"] --> A6["A6 Dobradinha 2026<br/>(reenquadrada p/ Praça)"]
|
||||
R4 -.baseline por Praça.-> A6
|
||||
R2 --> D2["D2 Push + sino"]
|
||||
R2 --> A9["A9 Estimativa votos da Praça"]
|
||||
R2 --> A9["A9 Estimativa votos ✓"]
|
||||
A9 --> B9["B9 Edição rápida lista Praças"]
|
||||
R2 -.-> B7["B7 Mapa filtrado pela lista"]
|
||||
R2 -.-> B8["B8 Polígonos Praças-zona<br/>(SSA/CMS)"]
|
||||
|
|
@ -81,11 +81,11 @@ flowchart TD
|
|||
C2prod --> C5["C5 GOTV (validar)"]
|
||||
```
|
||||
|
||||
Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2): **B7**, **B10**, **B11**, fill-ins da lista. **A9** bloqueia **B9** (seta dura).
|
||||
Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2): **B7**, **B10**, **B11**, fill-ins da lista. **A9** entregue — **B9** desbloqueado (seta dura A9 → B9).
|
||||
|
||||
### Sequência por janela
|
||||
|
||||
**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; **A9** estimativa de votos da Praça → **B9** edição rápida na lista (Assessores / Tendência / total esperado); **B7** mapa filtrado, **B10** hover+click-nav e **B11** escala % dos válidos em paralelo; R6 critique/polish; Onda 0 jurídica em paralelo.
|
||||
**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos da Praça~~ entregue; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; **B9** edição rápida na lista (Assessores / Tendência / total esperado); **B7** mapa filtrado, **B10** hover+click-nav e **B11** escala % dos válidos em paralelo; R6 critique/polish; Onda 0 jurídica em paralelo.
|
||||
|
||||
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; D2 se sobrar folga.
|
||||
|
||||
|
|
@ -95,15 +95,16 @@ Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2):
|
|||
|
||||
### Cortes seguros / não cortáveis
|
||||
|
||||
**Não cortáveis:** Onda 0 (jurídico/Consent); R1–R2 (sem eles a vertical não reflete a operação real); C2 dados reais; assimetria declarado×estimado (relação de campo); **A9** (sem total esperado da Praça, lista/mapa mentem onde não há liderança — schema do modelo operacional).
|
||||
**Não cortáveis:** Onda 0 (jurídico/Consent); R1–R2 (sem eles a vertical não reflete a operação real); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**A9**~~ (total esperado da Praça — entregue 2026-07-21).
|
||||
|
||||
**Cortes seguros** (se o prazo apertar, nesta ordem): R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; B6; B7 (mapa ainda mostra escopo de access sem filtro de UI); **B11** (mapa continua em escala absoluta); **B10** (mapa legível sem hover); **B9** (com A9, edição do total fica só no `/editar`); **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); débitos/fill-ins.
|
||||
**Cortes seguros** (se o prazo apertar, nesta ordem): R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; B6; B7 (mapa ainda mostra escopo de access sem filtro de UI); **B11** (mapa continua em escala absoluta); **B10** (mapa legível sem hover); **B9** (edição do total fica só no `/editar`); **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); débitos/fill-ins.
|
||||
|
||||
## Já entregue (resumo)
|
||||
|
||||
- **Era Núcleos (2026-07-15 → 2026-07-20)** — MVP + Ciclo 2 (auth `campaignUser`, território A1/A2, baseline TSE A3/A4, overview B1, share C1, PWA D1, geometrias B2, Leaflet B3), C2 apoiadores (eng.), C3 agenda, C6–C11 escala, E1+E3 metas/estratégia, E2 série TSE 2014/2018/2022, A5 conversão/classificação/alavancagem/mobilização, A7 F1–F2, A8 perfis IBGE, fill-ins (reset senha/perfil, visitados recentes, Field Desk polish). Infra e padrões (locks, transações, consent por chave, shells, mapa, dados eleitorais) **são reaproveitados pela remodelagem**; as superfícies e o modelo de Núcleo são substituídos.
|
||||
- **Plataforma** — local Postgres + guards, migrations baselined, posts/tags do site público com cache `posts`, Onda 0 textos provisórios + `/privacidade`.
|
||||
- **Site público (2026-07-21)** — **Pixel do Meta nos abaixo-assinados** (`tracking.facebookPixelId` no admin `petition`, `PageView`/`Lead` na página pública via `MetaPixel` + `trackMetaLead`; migration `20260721_133531_add_petition_facebook_pixel_id`) — [plano](plans/pixel-meta-abaixo-assinado.md).
|
||||
- **A9 (2026-07-21)** — **Estimativa de votos da Praça** (`plaza.expectedVotes` staff-only; fallback `expectedVotes ?? effectiveTotal` em mapa 2026/overview/dashboard; UI `/editar` + leitura lista/detalhe; migration `20260721_133444_add_plaza_expected_votes`) — [plano](plans/estimativa-votos-praca.md). Fill-in pós-`/simplify`: **A9+** [escala-dry-pos-a9.md](plans/escala-dry-pos-a9.md).
|
||||
|
||||
## Supersedidos pela remodelagem (2026-07-20)
|
||||
|
||||
|
|
@ -124,18 +125,17 @@ Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2):
|
|||
## Próximos — sobreviventes e pós-remodelagem
|
||||
|
||||
- **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · reenquadrar para Praça · [plano](plans/insight-dobradinha-2026.md)
|
||||
- **A9** Estimativa de votos da Praça (`expectedVotes` — total esperado staff-only, distinto da soma de pledges e de `voteGoals`) · depende de R2 · bloqueia B9 · appetite ~1–1,5 dia · [plano](plans/estimativa-votos-praca.md)
|
||||
- **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md)
|
||||
- **B6** `BahiaMap` setStyle incremental ao trocar métrica/ano · gatilho: troca frequente no mapa de Praças · [plano](plans/escala-dry-pos-b3.md)
|
||||
- **B7** Mapa das Praças filtrado pela mesma URL da lista (`buildPlazaListWhere`) · depende de R2 (entregue) · suave: A9 (métrica 2026) · paralelizável agora · [plano](plans/mapa-pracas-filtrado.md)
|
||||
- **B7** Mapa das Praças filtrado pela mesma URL da lista (`buildPlazaListWhere`) · depende de R2 (entregue) · A9 ✓ (métrica 2026) · paralelizável agora · [plano](plans/mapa-pracas-filtrado.md)
|
||||
- **B8** Polígonos das Praças-zona (Salvador ZE 1–19 / Camaçari ZE 170–171): F1 catálogo zona→bairros + UI na Praça; F2 dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável (F2) · [plano](plans/poligonos-pracas-zona.md)
|
||||
- **B9** Edição rápida na lista de Praças (Assessores, Tendência, **Votos estimados = `expectedVotes`**) · **depende de A9** · appetite ~1–1,5 dia · [plano](plans/edicao-rapida-lista-pracas.md)
|
||||
- **B10** Hover/tap no Mapa das Praças: destaque + votos; desktop click navega; mobile 2º tap na selecionada navega · depende de R2 · suave: A9/B6/B7/B8 · paralelizável agora · appetite ~0,5–1 dia · [plano](plans/hover-mapa-pracas.md)
|
||||
- **B11** Escala de cor por **% dos votos válidos** da Praça (0–100%, não pelo total da maior Praça) · depende de R2 (entregue) · suave: A9 (numerador 2026) · paralelizável agora · appetite ~0,5–1 dia · [plano](plans/escala-percentual-mapa-pracas.md)
|
||||
- **B9** Edição rápida na lista de Praças (Assessores, Tendência, **Votos estimados = `expectedVotes`**) · depende de A9 ✓ · appetite ~1–1,5 dia · [plano](plans/edicao-rapida-lista-pracas.md)
|
||||
- **B10** Hover/tap no Mapa das Praças: destaque + votos; desktop click navega; mobile 2º tap na selecionada navega · depende de R2 · suave: A9 ✓/B6/B7/B8 · paralelizável agora · appetite ~0,5–1 dia · [plano](plans/hover-mapa-pracas.md)
|
||||
- **B11** Escala de cor por **% dos votos válidos** da Praça (0–100%, não pelo total da maior Praça) · depende de R2 (entregue) · suave: A9 ✓ (numerador 2026) · paralelizável agora · appetite ~0,5–1 dia · [plano](plans/escala-percentual-mapa-pracas.md)
|
||||
- **C5** operação dia D / GOTV _(validar com produto)_ · design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) · depende de C2 dados reais
|
||||
- **D2** push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md)
|
||||
- **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · gatilho: antes de 16/08 · (opcional: mover filtros acima do mapa — ver B7)
|
||||
- **Fill-ins:** filtros auto na lista de Praças (debounce 1s no `q`, selects já imediatos; remove Buscar) · [plano](plans/filtros-auto-pracas.md) · O0+ ([plano](plans/escala-dry-pos-onda0.md)) · RS+ auth read leve + shells de senha ([plano](plans/escala-dry-pos-reset-senha-perfil.md)) · higiene PascalCase
|
||||
- **Fill-ins:** filtros auto na lista de Praças (debounce 1s no `q`, selects já imediatos; remove Buscar) · [plano](plans/filtros-auto-pracas.md) · **A9+** loader compartilhado lista/mapa ([plano](plans/escala-dry-pos-a9.md)) · O0+ ([plano](plans/escala-dry-pos-onda0.md)) · RS+ auth read leve + shells de senha ([plano](plans/escala-dry-pos-reset-senha-perfil.md)) · higiene PascalCase
|
||||
|
||||
## Bloqueadores atuais
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
|
|||
|
||||
import {
|
||||
assignPlazaAdvisors,
|
||||
setPlazaExpectedVotes,
|
||||
setPlazaPoliticalTrend,
|
||||
updatePlazaStrategy,
|
||||
} from '@/app/(campaign)/campanha/actions/plaza'
|
||||
|
|
@ -57,6 +58,32 @@ export const updatePlazaStrategyFormAction = async (
|
|||
}
|
||||
}
|
||||
|
||||
export const setPlazaExpectedVotesFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> => {
|
||||
try {
|
||||
const plaza = requiredRelationshipFormValue(formData, 'plazaId')
|
||||
const expectedVotes =
|
||||
optionalIntegerFormValue(formData, 'expectedVotes', {
|
||||
minimum: 0,
|
||||
maximum: 1_000_000,
|
||||
}) ?? null
|
||||
|
||||
await setPlazaExpectedVotes({ plaza, expectedVotes })
|
||||
revalidatePath('/campanha/pracas', 'page')
|
||||
revalidatePath('/campanha/pracas/[slug]', 'page')
|
||||
return { status: 'success', message: 'Votos estimados atualizados.' }
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages: ['Somente a coordenação e a assessoria podem editar a Praça.'],
|
||||
genericMessage:
|
||||
'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const setPlazaPoliticalTrendFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { getEligibleAdvisorOptions } from '@/utilities/plazaViewModels'
|
||||
import {
|
||||
assignPlazaAdvisorsFormAction,
|
||||
setPlazaExpectedVotesFormAction,
|
||||
setPlazaPoliticalTrendFormAction,
|
||||
updatePlazaStrategyFormAction,
|
||||
} from './formActions'
|
||||
|
|
@ -58,7 +59,7 @@ export default async function PlazaEditPage({ params }: PlazaEditPageProps) {
|
|||
<h1 className="text-2xl font-semibold tracking-tight">Editar {view.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
A geografia da Praça é pré-definida e não pode ser alterada. Aqui você edita metas,
|
||||
inteligência e tendência política.
|
||||
votos estimados, inteligência e tendência política.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
|
@ -75,6 +76,7 @@ export default async function PlazaEditPage({ params }: PlazaEditPageProps) {
|
|||
plazaID={view.id}
|
||||
strategy={view.strategy}
|
||||
strategyFormAction={updatePlazaStrategyFormAction}
|
||||
expectedVotesFormAction={setPlazaExpectedVotesFormAction}
|
||||
trendFormAction={setPlazaPoliticalTrendFormAction}
|
||||
/>
|
||||
</CampaignPageShell>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import {
|
|||
import { formatPlazaGeographyLabel, plazaKindLabels } from '@/utilities/plazaUi'
|
||||
import { loadPlazaUpdatesFeed, parsePlazaUpdateFeedParams } from '@/utilities/plazaUpdatePageData'
|
||||
import { loadAdvisorSummaries } from '@/utilities/plazaViewModels'
|
||||
import { loadLeaderPledges, loadPlazaPledges } from '@/utilities/votePledgeData'
|
||||
import { loadLeaderPledges, loadPlazaPledges, sumStaffPledgeEffectiveTotal } from '@/utilities/votePledgeData'
|
||||
import { declareVotesFormAction, estimateVotesFormAction } from './pledgeFormActions'
|
||||
import { createPlazaUpdateFormAction } from './updateFormActions'
|
||||
|
||||
|
|
@ -155,9 +155,15 @@ const OverviewTab = async ({
|
|||
}) => {
|
||||
if (isStaffView && view.strategy) {
|
||||
const pledges = await loadPlazaPledges(payload, user, view.id)
|
||||
const leadershipVoteTotal = sumStaffPledgeEffectiveTotal(pledges)
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PlazaStrategyCard strategy={view.strategy} plazaSlug={view.slug} canEdit />
|
||||
<PlazaStrategyCard
|
||||
strategy={view.strategy}
|
||||
plazaSlug={view.slug}
|
||||
canEdit
|
||||
leadershipVoteTotal={leadershipVoteTotal}
|
||||
/>
|
||||
<PlazaPledgesPanel pledges={pledges} estimateFormAction={estimateVotesFormAction} />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import type { Payload } from 'payload'
|
|||
|
||||
import {
|
||||
plazaAdvisorsAssignmentSchema,
|
||||
plazaExpectedVotesSchema,
|
||||
plazaPoliticalTrendSchema,
|
||||
plazaStrategyUpdateSchema,
|
||||
type PlazaAdvisorsAssignmentInput,
|
||||
type PlazaExpectedVotesInput,
|
||||
type PlazaPoliticalTrendInput,
|
||||
type PlazaStrategyUpdateInput,
|
||||
} from '@/lib/schemas/plaza'
|
||||
|
|
@ -84,6 +86,30 @@ export const setPlazaPoliticalTrend = async (input: PlazaPoliticalTrendInput) =>
|
|||
return setPlazaPoliticalTrendRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
/** Staff-only total expected votes for the plaza (distinct from pledge aggregates). */
|
||||
export const setPlazaExpectedVotesRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: PlazaExpectedVotesInput,
|
||||
) => {
|
||||
const { plaza, expectedVotes } = plazaExpectedVotesSchema.parse(input)
|
||||
const currentActor = await getFreshStaffActor(payload, actor)
|
||||
|
||||
return payload.update({
|
||||
collection: 'plaza',
|
||||
id: plaza,
|
||||
data: { expectedVotes },
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
}
|
||||
|
||||
export const setPlazaExpectedVotes = async (input: PlazaExpectedVotesInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return setPlazaExpectedVotesRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
/** Advisor assignment is coordinator-only; the collection hook validates eligibility. */
|
||||
export const assignPlazaAdvisorsRecord = async (
|
||||
payload: Payload,
|
||||
|
|
|
|||
|
|
@ -324,6 +324,21 @@ export const Plaza: CollectionConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'expectedVotes',
|
||||
type: 'number',
|
||||
label: 'Votos estimados',
|
||||
min: 0,
|
||||
access: {
|
||||
read: canReadCampaignStaffField,
|
||||
create: canManageCampaignStaffField,
|
||||
update: canManageCampaignStaffField,
|
||||
},
|
||||
admin: {
|
||||
description:
|
||||
'Total esperado da Praça — distinto das metas de cenário e da soma das lideranças.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'politicalTrend',
|
||||
type: 'group',
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export const CampaignDashboard = ({
|
|||
metrics={[
|
||||
{
|
||||
label: 'Votos estimados',
|
||||
value: view.pledgeCount ? voteFormatter.format(view.effectiveVotesTotal) : '—',
|
||||
value: view.staffVoteTotal > 0 ? voteFormatter.format(view.staffVoteTotal) : '—',
|
||||
emphasize: true,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Link from 'next/link'
|
|||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { StaffPlazaVotesDisplay } from '@/components/campaign/StaffPlazaVotesDisplay'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -19,7 +20,6 @@ import {
|
|||
} from '@/utilities/plazaUi'
|
||||
import type { PlazaAdvisorSummary, PlazaListViewModel } from '@/utilities/plazaViewModels'
|
||||
|
||||
const voteFormatter = new Intl.NumberFormat('pt-BR')
|
||||
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
|
||||
|
||||
const getInitials = (name: string): string =>
|
||||
|
|
@ -82,10 +82,12 @@ export const PlazaList = ({ plazas, advisorNamesById, isStaffView }: PlazaListPr
|
|||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Votos estimados</dt>
|
||||
<dd className="font-medium tabular-nums">
|
||||
{plaza.pledges.pledgeCount
|
||||
? voteFormatter.format(plaza.pledges.effectiveTotal)
|
||||
: '—'}
|
||||
<dd>
|
||||
<StaffPlazaVotesDisplay
|
||||
expectedVotes={plaza.expectedVotes}
|
||||
leadershipEffectiveTotal={plaza.pledges.effectiveTotal}
|
||||
valueClassName="font-medium tabular-nums"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -151,19 +153,11 @@ export const PlazaList = ({ plazas, advisorNamesById, isStaffView }: PlazaListPr
|
|||
<AdvisorAvatars names={names} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium tabular-nums">
|
||||
{plaza.pledges.pledgeCount
|
||||
? voteFormatter.format(plaza.pledges.effectiveTotal)
|
||||
: '—'}
|
||||
</span>
|
||||
{plaza.pledges.missingEstimateCount > 0 ? (
|
||||
<Badge variant="estimate-pending" className="mt-1 block w-fit">
|
||||
{plaza.pledges.missingEstimateCount}{' '}
|
||||
{plaza.pledges.missingEstimateCount === 1
|
||||
? 'declaração sem estimativa'
|
||||
: 'declarações sem estimativa'}
|
||||
</Badge>
|
||||
) : null}
|
||||
<StaffPlazaVotesDisplay
|
||||
expectedVotes={plaza.expectedVotes}
|
||||
leadershipEffectiveTotal={plaza.pledges.effectiveTotal}
|
||||
valueClassName="font-medium tabular-nums"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{plaza.lastUpdateAt
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const PlazaListOverview = ({ view }: { view: PlazaListOverviewData }) =>
|
|||
metrics={[
|
||||
{
|
||||
label: 'Votos estimados no conjunto',
|
||||
value: view.pledgeCount ? voteFormatter.format(view.effectiveVotesTotal) : '—',
|
||||
value: view.staffVoteTotal > 0 ? voteFormatter.format(view.staffVoteTotal) : '—',
|
||||
emphasize: true,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import Link from 'next/link'
|
|||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { StaffPlazaVotesDisplay } from '@/components/campaign/StaffPlazaVotesDisplay'
|
||||
import { plazaPriorityLabels, politicalTrendLabels } from '@/utilities/plazaUi'
|
||||
import type { PlazaDetailViewModel } from '@/utilities/plazaViewModels'
|
||||
|
||||
|
|
@ -19,10 +20,12 @@ export const PlazaStrategyCard = ({
|
|||
strategy,
|
||||
plazaSlug,
|
||||
canEdit,
|
||||
leadershipVoteTotal,
|
||||
}: {
|
||||
strategy: NonNullable<PlazaDetailViewModel['strategy']>
|
||||
plazaSlug: string
|
||||
canEdit: boolean
|
||||
leadershipVoteTotal: number
|
||||
}) => {
|
||||
const goals = strategy.voteGoals
|
||||
const trend = strategy.politicalTrend
|
||||
|
|
@ -62,7 +65,16 @@ export const PlazaStrategyCard = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-3 sm:grid-cols-3">
|
||||
<dl className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg bg-muted/40 px-3 py-2 sm:col-span-2">
|
||||
<dt className="text-xs font-medium text-muted-foreground">Votos estimados</dt>
|
||||
<dd>
|
||||
<StaffPlazaVotesDisplay
|
||||
expectedVotes={strategy.expectedVotes}
|
||||
leadershipEffectiveTotal={leadershipVoteTotal}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
{(
|
||||
[
|
||||
['Meta Bom', goals.good],
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type PlazaStrategyFormProps = {
|
|||
plazaID: number
|
||||
strategy: NonNullable<PlazaDetailViewModel['strategy']>
|
||||
strategyFormAction: FormAction
|
||||
expectedVotesFormAction: FormAction
|
||||
trendFormAction: FormAction
|
||||
}
|
||||
|
||||
|
|
@ -86,9 +87,14 @@ export const PlazaStrategyForm = ({
|
|||
plazaID,
|
||||
strategy,
|
||||
strategyFormAction,
|
||||
expectedVotesFormAction,
|
||||
trendFormAction,
|
||||
}: PlazaStrategyFormProps) => {
|
||||
const [strategyState, submitStrategy, strategyPending] = useActionState(strategyFormAction, {})
|
||||
const [expectedVotesState, submitExpectedVotes, expectedVotesPending] = useActionState(
|
||||
expectedVotesFormAction,
|
||||
{},
|
||||
)
|
||||
const [trendState, submitTrend, trendPending] = useActionState(trendFormAction, {})
|
||||
|
||||
return (
|
||||
|
|
@ -171,6 +177,49 @@ export const PlazaStrategyForm = ({
|
|||
</Button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
action={submitExpectedVotes}
|
||||
aria-labelledby="plaza-expected-votes-title"
|
||||
className="flex flex-col gap-4 rounded-xl border p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 id="plaza-expected-votes-title" className="text-base font-medium">
|
||||
Votos estimados
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total esperado da Praça — distinto das metas de cenário e da soma das lideranças. Deixe
|
||||
em branco para remover.
|
||||
</p>
|
||||
</div>
|
||||
<input type="hidden" name="plazaId" value={plazaID} />
|
||||
<Field>
|
||||
<FieldLabel htmlFor="plaza-expected-votes">Total da Praça</FieldLabel>
|
||||
<Input
|
||||
id="plaza-expected-votes"
|
||||
name="expectedVotes"
|
||||
type="number"
|
||||
min={0}
|
||||
inputMode="numeric"
|
||||
defaultValue={strategy.expectedVotes ?? undefined}
|
||||
className="min-h-11 sm:max-w-xs"
|
||||
/>
|
||||
</Field>
|
||||
{expectedVotesState.message && expectedVotesState.status !== 'success' ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{expectedVotesState.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{expectedVotesState.status === 'success' ? (
|
||||
<Alert>
|
||||
<AlertDescription>{expectedVotesState.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" disabled={expectedVotesPending} className="min-h-11 self-start">
|
||||
{expectedVotesPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
Salvar votos estimados
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<form action={submitTrend} className="flex flex-col gap-4 rounded-xl border p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-base font-medium">Tendência política</h2>
|
||||
|
|
|
|||
24
src/components/campaign/StaffPlazaVotesDisplay.tsx
Normal file
24
src/components/campaign/StaffPlazaVotesDisplay.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const voteFormatter = new Intl.NumberFormat('pt-BR')
|
||||
|
||||
export const StaffPlazaVotesDisplay = ({
|
||||
expectedVotes,
|
||||
leadershipEffectiveTotal,
|
||||
valueClassName = 'text-lg font-medium tabular-nums',
|
||||
sublineClassName = 'text-xs text-muted-foreground',
|
||||
}: {
|
||||
expectedVotes: number | null
|
||||
leadershipEffectiveTotal: number
|
||||
valueClassName?: string
|
||||
sublineClassName?: string
|
||||
}) => (
|
||||
<>
|
||||
<span className={valueClassName}>
|
||||
{expectedVotes == null ? '—' : voteFormatter.format(expectedVotes)}
|
||||
</span>
|
||||
{leadershipEffectiveTotal > 0 ? (
|
||||
<span className={`mt-1 block ${sublineClassName}`}>
|
||||
Nas lideranças: {voteFormatter.format(leadershipEffectiveTotal)}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
|
@ -37,6 +37,12 @@ export const plazaAdvisorsAssignmentSchema = z.object({
|
|||
.transform((ids) => [...new Set(ids)]),
|
||||
})
|
||||
|
||||
export const plazaExpectedVotesSchema = z.object({
|
||||
plaza: positiveRelationshipId,
|
||||
expectedVotes: z.number().int().min(0).max(1_000_000).nullable(),
|
||||
})
|
||||
|
||||
export type PlazaStrategyUpdateInput = z.input<typeof plazaStrategyUpdateSchema>
|
||||
export type PlazaPoliticalTrendInput = z.input<typeof plazaPoliticalTrendSchema>
|
||||
export type PlazaAdvisorsAssignmentInput = z.input<typeof plazaAdvisorsAssignmentSchema>
|
||||
export type PlazaExpectedVotesInput = z.input<typeof plazaExpectedVotesSchema>
|
||||
|
|
|
|||
10375
src/migrations/20260721_133444_add_plaza_expected_votes.json
Normal file
10375
src/migrations/20260721_133444_add_plaza_expected_votes.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260721_133444_add_plaza_expected_votes.ts
Normal file
11
src/migrations/20260721_133444_add_plaza_expected_votes.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "plaza" ADD COLUMN "expected_votes" numeric;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "plaza" DROP COLUMN "expected_votes";`)
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import * as migration_20260719_054706_add_privacy_policy_global from './20260719
|
|||
import * as migration_20260719_054707_seed_onda0_consent_and_privacy from './20260719_054707_seed_onda0_consent_and_privacy';
|
||||
import * as migration_20260719_061302_add_campaign_user_avatar from './20260719_061302_add_campaign_user_avatar';
|
||||
import * as migration_20260721_020109_remodel_plazas from './20260721_020109_remodel_plazas';
|
||||
import * as migration_20260721_133444_add_plaza_expected_votes from './20260721_133444_add_plaza_expected_votes';
|
||||
import * as migration_20260721_133531_add_petition_facebook_pixel_id from './20260721_133531_add_petition_facebook_pixel_id';
|
||||
|
||||
export const migrations = [
|
||||
|
|
@ -109,9 +110,14 @@ export const migrations = [
|
|||
down: migration_20260721_020109_remodel_plazas.down,
|
||||
name: '20260721_020109_remodel_plazas',
|
||||
},
|
||||
{
|
||||
up: migration_20260721_133444_add_plaza_expected_votes.up,
|
||||
down: migration_20260721_133444_add_plaza_expected_votes.down,
|
||||
name: '20260721_133444_add_plaza_expected_votes',
|
||||
},
|
||||
{
|
||||
up: migration_20260721_133531_add_petition_facebook_pixel_id.up,
|
||||
down: migration_20260721_133531_add_petition_facebook_pixel_id.down,
|
||||
name: '20260721_133531_add_petition_facebook_pixel_id'
|
||||
name: '20260721_133531_add_petition_facebook_pixel_id',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -405,6 +405,10 @@ export interface Plaza {
|
|||
regular?: number | null;
|
||||
minimum?: number | null;
|
||||
};
|
||||
/**
|
||||
* Total esperado da Praça — distinto das metas de cenário e da soma das lideranças.
|
||||
*/
|
||||
expectedVotes?: number | null;
|
||||
/**
|
||||
* Leitura de conjuntura registrada pela coordenação (alianças, prefeitos, disputas locais) — não é série numérica.
|
||||
*/
|
||||
|
|
@ -1234,6 +1238,7 @@ export interface PlazaSelect<T extends boolean = true> {
|
|||
regular?: T;
|
||||
minimum?: T;
|
||||
};
|
||||
expectedVotes?: T;
|
||||
politicalTrend?:
|
||||
| T
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
|
|||
import {
|
||||
aggregatePledgesByPlaza,
|
||||
loadLeaderPledges,
|
||||
rollupPlazaStaffVotes,
|
||||
type LeaderPledgeRow,
|
||||
} from '@/utilities/votePledgeData'
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ export type StaffDashboardView = {
|
|||
plazaCount: number
|
||||
withAdvisorCount: number
|
||||
highPriorityCount: number
|
||||
effectiveVotesTotal: number
|
||||
staffVoteTotal: number
|
||||
declaredVotesTotal: number
|
||||
pledgeCount: number
|
||||
missingEstimateCount: number
|
||||
|
|
@ -76,7 +77,7 @@ const buildStaffDashboard = async (
|
|||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { name: true, slug: true, advisors: true, priority: true },
|
||||
select: { name: true, slug: true, advisors: true, priority: true, expectedVotes: true },
|
||||
where: {},
|
||||
user,
|
||||
overrideAccess: false,
|
||||
|
|
@ -85,16 +86,12 @@ const buildStaffDashboard = async (
|
|||
const plazaById = new Map(plazas.docs.map((plaza) => [plaza.id, plaza]))
|
||||
|
||||
const pledgeAggregates = await aggregatePledgesByPlaza(payload, plazaIDs)
|
||||
let effectiveVotesTotal = 0
|
||||
let declaredVotesTotal = 0
|
||||
let pledgeCount = 0
|
||||
let missingEstimateCount = 0
|
||||
for (const aggregate of pledgeAggregates.values()) {
|
||||
effectiveVotesTotal += aggregate.effectiveTotal
|
||||
declaredVotesTotal += aggregate.declaredTotal
|
||||
pledgeCount += aggregate.pledgeCount
|
||||
missingEstimateCount += aggregate.missingEstimateCount
|
||||
}
|
||||
const {
|
||||
staffVoteTotal,
|
||||
declaredVotesTotal,
|
||||
pledgeCount,
|
||||
missingEstimateCount,
|
||||
} = rollupPlazaStaffVotes(plazas.docs, pledgeAggregates)
|
||||
|
||||
const missingEstimatePledges = plazaIDs.length
|
||||
? await payload.find({
|
||||
|
|
@ -149,7 +146,7 @@ const buildStaffDashboard = async (
|
|||
plazaCount: plazas.docs.length,
|
||||
withAdvisorCount: plazas.docs.filter((plaza) => (plaza.advisors ?? []).length > 0).length,
|
||||
highPriorityCount: plazas.docs.filter((plaza) => plaza.priority === 'alta').length,
|
||||
effectiveVotesTotal,
|
||||
staffVoteTotal,
|
||||
declaredVotesTotal,
|
||||
pledgeCount,
|
||||
missingEstimateCount,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ import {
|
|||
loadCandidateVotesByCityZone,
|
||||
sumVotesForGeography,
|
||||
} from '@/utilities/plazaElectoralBaseline'
|
||||
import { aggregatePledgesByPlaza } from '@/utilities/votePledgeData'
|
||||
import {
|
||||
aggregatePledgesByPlaza,
|
||||
emptyPlazaPledgeAggregate,
|
||||
resolvePlazaStaffVoteTotal,
|
||||
} from '@/utilities/votePledgeData'
|
||||
|
||||
export const PLAZA_MAP_YEARS = [...HISTORICAL_SERIES_YEARS, 2026] as const
|
||||
export type PlazaMapYear = (typeof PLAZA_MAP_YEARS)[number]
|
||||
|
|
@ -37,7 +41,7 @@ export type PlazaMapComparison = {
|
|||
}
|
||||
|
||||
export type PlazaMapBundle = {
|
||||
/** year (as string) → codarea → value. 2026 = estimated ?? declared pledges. */
|
||||
/** year (as string) → codarea → value. 2026 = expectedVotes ?? pledge effective total. */
|
||||
valuesByYear: Record<string, Record<string, number>>
|
||||
/** Zone plazas in scope (Salvador/Camaçari) with per-year values. */
|
||||
zoneBreakdown: PlazaZoneBreakdownRow[]
|
||||
|
|
@ -51,6 +55,7 @@ type ScopedPlaza = {
|
|||
name: string
|
||||
kind: 'municipio' | 'zona'
|
||||
ibgeCode: string
|
||||
expectedVotes: number | null
|
||||
geography: PlazaElectionGeography
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +65,7 @@ const loadScopedPlazas = async (payload: Payload, user: CampaignUser): Promise<S
|
|||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { slug: true, name: true, kind: true, ibgeCode: true },
|
||||
select: { slug: true, name: true, kind: true, ibgeCode: true, expectedVotes: true },
|
||||
where: {},
|
||||
user,
|
||||
overrideAccess: false,
|
||||
|
|
@ -76,6 +81,7 @@ const loadScopedPlazas = async (payload: Payload, user: CampaignUser): Promise<S
|
|||
name: plaza.name,
|
||||
kind: plaza.kind,
|
||||
ibgeCode: plaza.ibgeCode,
|
||||
expectedVotes: plaza.expectedVotes ?? null,
|
||||
geography: plazaElectionGeography(entry),
|
||||
},
|
||||
]
|
||||
|
|
@ -128,8 +134,8 @@ export const loadPlazaMapBundle = async (
|
|||
)
|
||||
const pledgeValues: Record<string, number> = {}
|
||||
for (const plaza of plazas) {
|
||||
const aggregate = pledgeAggregates.get(plaza.id)
|
||||
const votes = aggregate?.effectiveTotal ?? 0
|
||||
const aggregate = pledgeAggregates.get(plaza.id) ?? emptyPlazaPledgeAggregate
|
||||
const votes = resolvePlazaStaffVoteTotal(plaza.expectedVotes, aggregate.effectiveTotal)
|
||||
if (votes > 0) pledgeValues[plaza.ibgeCode] = (pledgeValues[plaza.ibgeCode] ?? 0) + votes
|
||||
if (plaza.kind === 'zona') {
|
||||
const bySlug = zoneVotesBySlug.get(plaza.slug) ?? {}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import {
|
|||
type PlazaListViewModel,
|
||||
} from '@/utilities/plazaViewModels'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
import { aggregatePledgesByPlaza } from '@/utilities/votePledgeData'
|
||||
import {
|
||||
aggregatePledgesByPlaza,
|
||||
rollupPlazaStaffVotes,
|
||||
} from '@/utilities/votePledgeData'
|
||||
|
||||
type PlazaListSearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
|
|
@ -74,7 +77,7 @@ export const loadPlazaListPageData = async (
|
|||
|
||||
export type PlazaListOverviewData = {
|
||||
plazaCount: number
|
||||
effectiveVotesTotal: number
|
||||
staffVoteTotal: number
|
||||
declaredVotesTotal: number
|
||||
pledgeCount: number
|
||||
missingEstimateCount: number
|
||||
|
|
@ -97,7 +100,7 @@ export const loadPlazaListOverviewData = async (
|
|||
limit: 0,
|
||||
pagination: false,
|
||||
where: buildPlazaListWhere(state),
|
||||
select: { advisors: true, priority: true },
|
||||
select: { advisors: true, priority: true, expectedVotes: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
|
@ -105,21 +108,16 @@ export const loadPlazaListOverviewData = async (
|
|||
|
||||
const plazaIDs = result.docs.map((plaza) => plaza.id)
|
||||
const pledgeAggregates = await aggregatePledgesByPlaza(payload, plazaIDs)
|
||||
|
||||
let effectiveVotesTotal = 0
|
||||
let declaredVotesTotal = 0
|
||||
let pledgeCount = 0
|
||||
let missingEstimateCount = 0
|
||||
for (const aggregate of pledgeAggregates.values()) {
|
||||
effectiveVotesTotal += aggregate.effectiveTotal
|
||||
declaredVotesTotal += aggregate.declaredTotal
|
||||
pledgeCount += aggregate.pledgeCount
|
||||
missingEstimateCount += aggregate.missingEstimateCount
|
||||
}
|
||||
const {
|
||||
staffVoteTotal,
|
||||
declaredVotesTotal,
|
||||
pledgeCount,
|
||||
missingEstimateCount,
|
||||
} = rollupPlazaStaffVotes(result.docs, pledgeAggregates)
|
||||
|
||||
return {
|
||||
plazaCount: result.docs.length,
|
||||
effectiveVotesTotal,
|
||||
staffVoteTotal,
|
||||
declaredVotesTotal,
|
||||
pledgeCount,
|
||||
missingEstimateCount,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const plazaListSelect = {
|
|||
advisors: true,
|
||||
priority: true,
|
||||
lastUpdateAt: true,
|
||||
expectedVotes: true,
|
||||
} as const
|
||||
|
||||
export type PlazaListViewModel = {
|
||||
|
|
@ -32,6 +33,7 @@ export type PlazaListViewModel = {
|
|||
advisorIDs: number[]
|
||||
priority: 'alta' | 'normal'
|
||||
lastUpdateAt: string | null
|
||||
expectedVotes: number | null
|
||||
pledges: PlazaPledgeAggregate
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +52,7 @@ export const toPlazaListViewModel = (
|
|||
advisorIDs: (plaza.advisors ?? []).map(relationshipId).filter((id): id is number => id !== null),
|
||||
priority: plaza.priority === 'alta' ? 'alta' : 'normal',
|
||||
lastUpdateAt: plaza.lastUpdateAt ?? null,
|
||||
expectedVotes: plaza.expectedVotes ?? null,
|
||||
pledges: pledges ?? { ...emptyPlazaPledgeAggregate },
|
||||
})
|
||||
|
||||
|
|
@ -82,6 +85,7 @@ export type PlazaDetailViewModel = {
|
|||
/** Staff-only block — null for the leader view model. */
|
||||
strategy: {
|
||||
priority: 'alta' | 'normal'
|
||||
expectedVotes: number | null
|
||||
voteGoals: VoteGoalsViewModel
|
||||
politicalTrend: PlazaPoliticalTrendViewModel
|
||||
strengths: string[]
|
||||
|
|
@ -114,6 +118,7 @@ export const toPlazaDetailViewModel = (
|
|||
? null
|
||||
: {
|
||||
priority: plaza.priority === 'alta' ? 'alta' : 'normal',
|
||||
expectedVotes: plaza.expectedVotes ?? null,
|
||||
voteGoals: toVoteGoalsViewModel(plaza.voteGoals),
|
||||
politicalTrend: {
|
||||
status: plaza.politicalTrend?.status ?? null,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,42 @@ export const emptyPlazaPledgeAggregate: PlazaPledgeAggregate = {
|
|||
missingEstimateCount: 0,
|
||||
}
|
||||
|
||||
/** Staff-facing plaza total: manual expected votes override pledge aggregate when set. */
|
||||
export const resolvePlazaStaffVoteTotal = (
|
||||
expectedVotes: number | null | undefined,
|
||||
pledgeEffectiveTotal: number,
|
||||
): number => expectedVotes ?? pledgeEffectiveTotal
|
||||
|
||||
export const sumStaffPledgeEffectiveTotal = (
|
||||
rows: ReadonlyArray<{ declaredVotes: number; estimatedVotes: number | null }>,
|
||||
): number =>
|
||||
rows.reduce((sum, row) => sum + (row.estimatedVotes ?? row.declaredVotes), 0)
|
||||
|
||||
export type PlazaStaffVoteRollup = {
|
||||
staffVoteTotal: number
|
||||
declaredVotesTotal: number
|
||||
pledgeCount: number
|
||||
missingEstimateCount: number
|
||||
}
|
||||
|
||||
export const rollupPlazaStaffVotes = (
|
||||
plazas: ReadonlyArray<{ id: number; expectedVotes?: number | null }>,
|
||||
pledgeAggregates: Map<number, PlazaPledgeAggregate>,
|
||||
): PlazaStaffVoteRollup => {
|
||||
let staffVoteTotal = 0
|
||||
let declaredVotesTotal = 0
|
||||
let pledgeCount = 0
|
||||
let missingEstimateCount = 0
|
||||
for (const plaza of plazas) {
|
||||
const aggregate = pledgeAggregates.get(plaza.id) ?? emptyPlazaPledgeAggregate
|
||||
staffVoteTotal += resolvePlazaStaffVoteTotal(plaza.expectedVotes, aggregate.effectiveTotal)
|
||||
declaredVotesTotal += aggregate.declaredTotal
|
||||
pledgeCount += aggregate.pledgeCount
|
||||
missingEstimateCount += aggregate.missingEstimateCount
|
||||
}
|
||||
return { staffVoteTotal, declaredVotesTotal, pledgeCount, missingEstimateCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-only aggregate over an already access-checked plaza id set.
|
||||
* Intentional admin bypass: callers pass plaza ids the actor may read.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { beforeAll, describe, expect, it } from 'vitest'
|
|||
|
||||
import {
|
||||
assignPlazaAdvisorsRecord,
|
||||
setPlazaExpectedVotesRecord,
|
||||
setPlazaPoliticalTrendRecord,
|
||||
updatePlazaStrategyRecord,
|
||||
} from '@/app/(campaign)/campanha/actions/plaza'
|
||||
|
|
@ -150,6 +151,10 @@ describe('plaza catalog seed and per-role access', () => {
|
|||
status: 'desfavoravel',
|
||||
note: 'Prefeito rompeu com a chapa.',
|
||||
})
|
||||
await setPlazaExpectedVotesRecord(payload, coordinator, {
|
||||
plaza: plaza.id,
|
||||
expectedVotes: 1200,
|
||||
})
|
||||
fixtures.touchPlaza(plaza.id)
|
||||
|
||||
const leaderRead = await payload.findByID({
|
||||
|
|
@ -163,6 +168,7 @@ describe('plaza catalog seed and per-role access', () => {
|
|||
expect(doc.name).toBe(plaza.name)
|
||||
expect(doc.priority ?? null).toBeNull()
|
||||
expect(doc.voteGoals ?? null).toBeNull()
|
||||
expect(doc.expectedVotes ?? null).toBeNull()
|
||||
expect(doc.politicalTrend ?? null).toBeNull()
|
||||
// Read-denied arrays come back empty — the staff content must not leak.
|
||||
expect(doc.strengths ?? []).toEqual([])
|
||||
|
|
@ -176,6 +182,7 @@ describe('plaza catalog seed and per-role access', () => {
|
|||
overrideAccess: false,
|
||||
})
|
||||
expect(staffRead.priority).toBe('alta')
|
||||
expect(staffRead.expectedVotes).toBe(1200)
|
||||
expect(staffRead.politicalTrend?.status).toBe('desfavoravel')
|
||||
expect(staffRead.politicalTrend?.recordedBy).toBeTruthy()
|
||||
expect(staffRead.politicalTrend?.recordedAt).toBeTruthy()
|
||||
|
|
@ -236,4 +243,41 @@ describe('plaza catalog seed and per-role access', () => {
|
|||
)
|
||||
expect(scope).toEqual([plaza.id])
|
||||
})
|
||||
|
||||
it('lets staff set expectedVotes with role and plaza scope enforced', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const administered = await fixtures.getPlaza()
|
||||
const outside = await fixtures.getPlaza()
|
||||
await fixtures.assignPlazaAdvisors(administered.id, [advisor.id])
|
||||
|
||||
const updated = await setPlazaExpectedVotesRecord(payload, coordinator, {
|
||||
plaza: administered.id,
|
||||
expectedVotes: 2500,
|
||||
})
|
||||
expect(updated.expectedVotes).toBe(2500)
|
||||
fixtures.touchPlaza(administered.id)
|
||||
|
||||
const advisorUpdated = await setPlazaExpectedVotesRecord(payload, advisor, {
|
||||
plaza: administered.id,
|
||||
expectedVotes: 3000,
|
||||
})
|
||||
expect(advisorUpdated.expectedVotes).toBe(3000)
|
||||
fixtures.touchPlaza(administered.id)
|
||||
|
||||
await expect(
|
||||
setPlazaExpectedVotesRecord(payload, advisor, {
|
||||
plaza: outside.id,
|
||||
expectedVotes: 100,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
const cleared = await setPlazaExpectedVotesRecord(payload, coordinator, {
|
||||
plaza: administered.id,
|
||||
expectedVotes: null,
|
||||
})
|
||||
expect(cleared.expectedVotes).toBeNull()
|
||||
fixtures.touchPlaza(administered.id)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ describe('campaign visual foundation', () => {
|
|||
advisorIDs: [advisor.id],
|
||||
priority: 'alta',
|
||||
lastUpdateAt: null,
|
||||
expectedVotes: 1500,
|
||||
pledges: {
|
||||
declaredTotal: 1200,
|
||||
effectiveTotal: 1200,
|
||||
|
|
@ -138,6 +139,7 @@ describe('campaign visual foundation', () => {
|
|||
advisorIDs: [],
|
||||
priority: 'normal',
|
||||
lastUpdateAt: null,
|
||||
expectedVotes: null,
|
||||
pledges: { ...emptyPlazaPledgeAggregate },
|
||||
},
|
||||
]
|
||||
|
|
@ -153,8 +155,8 @@ describe('campaign visual foundation', () => {
|
|||
expect(html).toContain('Seabra')
|
||||
expect(html).toContain('Chapada Diamantina')
|
||||
expect(html).toContain('href="/campanha/pracas/seabra"')
|
||||
expect(html).toContain('1.200')
|
||||
expect(html).toContain('declaração sem estimativa')
|
||||
expect(html).toContain('1.500')
|
||||
expect(html).toContain('Nas lideranças: 1.200')
|
||||
expect(html).toContain('Prioritária')
|
||||
expect(html).toContain('Coberta')
|
||||
expect(html).toContain('Sem assessor')
|
||||
|
|
@ -176,6 +178,7 @@ describe('campaign visual foundation', () => {
|
|||
advisorIDs: [],
|
||||
priority: 'alta',
|
||||
lastUpdateAt: null,
|
||||
expectedVotes: null,
|
||||
pledges: { ...emptyPlazaPledgeAggregate },
|
||||
},
|
||||
],
|
||||
|
|
|
|||
44
tests/unit/plazaStaffVoteTotal.unit.spec.ts
Normal file
44
tests/unit/plazaStaffVoteTotal.unit.spec.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
emptyPlazaPledgeAggregate,
|
||||
resolvePlazaStaffVoteTotal,
|
||||
rollupPlazaStaffVotes,
|
||||
} from '@/utilities/votePledgeData'
|
||||
|
||||
describe('resolvePlazaStaffVoteTotal', () => {
|
||||
it('falls back to pledge effective total when expectedVotes is null', () => {
|
||||
expect(resolvePlazaStaffVoteTotal(null, 420)).toBe(420)
|
||||
})
|
||||
|
||||
it('uses expectedVotes when set, ignoring pledge aggregate', () => {
|
||||
expect(resolvePlazaStaffVoteTotal(1500, 420)).toBe(1500)
|
||||
})
|
||||
|
||||
it('returns zero when both expectedVotes and pledges are empty', () => {
|
||||
expect(resolvePlazaStaffVoteTotal(null, 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollupPlazaStaffVotes', () => {
|
||||
it('sums per-plaza staff totals and pledge metadata', () => {
|
||||
const aggregates = new Map([
|
||||
[1, { ...emptyPlazaPledgeAggregate, effectiveTotal: 100, declaredTotal: 80, pledgeCount: 2, missingEstimateCount: 1 }],
|
||||
[2, { ...emptyPlazaPledgeAggregate, effectiveTotal: 50, declaredTotal: 50, pledgeCount: 1, missingEstimateCount: 0 }],
|
||||
])
|
||||
expect(
|
||||
rollupPlazaStaffVotes(
|
||||
[
|
||||
{ id: 1, expectedVotes: null },
|
||||
{ id: 2, expectedVotes: 200 },
|
||||
],
|
||||
aggregates,
|
||||
),
|
||||
).toEqual({
|
||||
staffVoteTotal: 300,
|
||||
declaredVotesTotal: 130,
|
||||
pledgeCount: 3,
|
||||
missingEstimateCount: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue