Merge branch 'cursor/5f01d520'
This commit is contained in:
commit
bb6a321082
28 changed files with 12686 additions and 21 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **E16 entregue em código (2026-07-25):** Dossiê do município (pré-agenda) — aba `?tab=dossie` no detalhe (`MunicipalityDossier.tsx`, server component em Suspense) compondo os loaders existentes via `municipalityDossierData.ts` (baseline TSE + rank A11, conta da cadeira E8, pledges, rede de lideranças com frescor `updatedAt`, agenda `actionPlan` `overrideAccess: false`, sinais C12, perfil IBGE A8 com caveat para zonas — reentrada do artefato órfão); caps 8 lideranças / 5 sinais / 3+2 planos com "ver tudo" por seção; novo campo staff-only `municipality.budgetNotes` ("Emendas aportadas", G11 manual-first) via migration `20260725_022155_add_municipality_budget_notes` (o `.json` da migration também sara a cadeia de snapshots pós-`campaign_goals`); form de estratégia + zod + view model estendidos; **print CSS** (chrome `print:hidden`, shells `print:h-auto print:overflow-visible`, tipografia A4 em `styles.css`) — dossiê imprime 1–2 páginas com capa autocontida ("Gerado em"). Int test `municipalityDossierData.int.spec.ts` (mock do `unstable_cache` do baseline). Gate verde (tsc/lint/knip/363 tests/build); Aikido bloqueado por sessão expirada (re-login navegador pendente). Plano: [`dossie-municipio.md`](../../../docs/plans/dossie-municipio.md).
|
||||
- **B19 registrado (2026-07-24):** gerenciar assessores em `/campanha/assessores` (lista + novo + detalhe) — só Coordenador Geral e Candidato; criar/editar contas `advisor`, carteira de municípios, reenviar senha; alinha access update/phone do `candidate`. Sem migration. Plano: [`gerenciar-assessores.md`](../../../docs/plans/gerenciar-assessores.md).
|
||||
- **E8 entregue em código (2026-07-24):** conta da cadeira — artefato TSE commitado estendido para v2 (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022` presidente/governador T1 2022, via `pnpm build:election-aggregates`; teto de bytes revisado 400→700 KB) + curadoria `src/lib/campoParties.ts` (ano→siglas do campo, validada contra `electionPartySpectrum` por unit test); global `campaignGoals` (meta estadual 150 mil + margem, migration `20260724_180000_add_campaign_goals_global`, access staff-read/coordinator-write, **sem** hook de revalidação — `/campanha` é dinâmico com auth); `municipalityPotential.ts` (válidos projetados, teto do campo, captura, share intracampo, roll-off 2022-only, decomposição proporcional da meta + sanity por TI) e `goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = Σ pledges`, nunca a expectativa da mesa — semântica travada contra o conflito da auditoria); UI Impeccable classe B: linha "Cobertura da meta" no dashboard e no overview da lista, nova coluna na lista de municípios (renomeando "Cobertura"→"Assessoria" para eliminar ambiguidade), card "Conta da cadeira" no detalhe (fixo em `central`, sem seletor de cenário). `/simplify` pós-entrega: removida a checagem de sanidade por TI do caminho quente por requisição (fica exportada/testada para o E12 consumir), lookup de potencial por município virou O(1) (`Map` em vez de `.find`), loaders quase-idênticos de votos majoritários/deputado federal unificados, tipo de tally duplicado unificado, formatadores de percentual/progresso deduplicados entre dashboard/overview/card. Desbloqueia **E9/E10/E12/E13** no roadmap. **Follow-up mesmo dia (feedback do usuário):** tooltips hover/foco nos 4 números de diagnóstico do card (`MunicipalityHoverTooltip`, sem ícone de interrogação); seed local passou a importar presidente/governador também para 2014/2018 (artefato/fórmulas continuam 2022-only); `/impeccable critique` do card (2×P1 touch dead-end + colisão de layout, 2×P2 idiomas de disclosure/cópia densa, 1×P3 progress bar) endereçado por completo — tooltip agora abre/fecha por toque com dismiss por toque fora, nunca cobre "Cobertura da meta", cópia reescrita em frase-líder + fórmula sem cross-reference, barra de progresso mais visível a 0%; registrado **E18** (documentação de conceitos de inteligência de campanha, plano futuro). Plano: [`conta-da-cadeira.md`](../../../docs/plans/conta-da-cadeira.md).
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -20,6 +20,15 @@
|
|||
],
|
||||
"createdAt": "2026-07-23T22:40:00.000Z",
|
||||
"reason": "Meta's documented noscript tracking pixel: a hidden 1x1 <img> whose src is a templated facebook.com/tr URL — not a rendered image"
|
||||
},
|
||||
{
|
||||
"rule": "design-system-color",
|
||||
"value": "#c86112",
|
||||
"files": [
|
||||
"src/app/(frontend)/styles.css"
|
||||
],
|
||||
"createdAt": "2026-07-25T02:48:38.641Z",
|
||||
"reason": "Pre-existing public-site body brand gradient (live in production, predates the hook); flagged while an unrelated print block was added"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
|
|||
|
||||
**Recently resolved (2026-07-24):** **E4R** Import único da planilha de projeção — `pnpm db:seed:projecao` (`scripts/import-projecao.mjs`) lê o xlsx canônico em `docs/sheets/`, faz match via `canonicalizeMunicipalityName` + `municipalityCatalog`, e **sempre sobrescreve** `municipality.expectedVotes` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). **Fase 2 (mesmo dia, decisão do produto — não é mais zero PII):** o seed também importa as colunas de estratégia, com PRIORITÁRIAS sobrepondo MAPA GERAL quando não-vazia — SITUAÇÃO→`politicalTrend` (QUEDA/MANTÉM/AUMENTO→desfavorável/neutra/favorável, com nota de proveniência), DOBRADINHAS→`stateDeputy`+`stateDeputies` (união; célula crua em `dobradinhaNotes`), LIDERANÇAS→`contact` name-only **sem telefone** + `leadership` (dedup estadual por slug do nome; `Contact.phone` virou opcional via migration `20260724_175500_contact_phone_optional`, UI continua exigindo), ASSESSOR RESPONSÁVEL→`campaignUser`+`advisors` (união; Edizio=coordinator, Solla=candidate — `candidate` agora é elegível como assessor em `eligibleCampaignStaffWhere`; novos usuários com email `<slug>@planilha.invalid` + senha aleatória, sem login até um admin trocar), ENCAMINHAMENTOS→`nextSteps`, OBSERVAÇÃO→`strengths`/`risks` (classificação curada por slug). Relações são união (nunca removem vínculos da UI); re-run idempotente; nomes de atores políticos entraram sem trilha de consentimento — regularizar com a assessoria jurídica. No mesmo dia, o grupo duplicado `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido** — collection, zod, view models, form/card e utilitário `voteGoals.ts` — via migration `20260724_133600_drop_municipality_vote_goals` (backfill metas→estimativas onde a estimativa estava vazia): `expectedVotes` (pessimista/média/otimista) é a única série por cenário do município. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md).
|
||||
|
||||
**Recently resolved (2026-07-25):** **E16** Dossiê do município (pré-agenda, field request O6) — staff-only `dossie` tab on the municipality detail page (6th value in `municipalityDetailTabs`, same query-param pattern): `src/utilities/municipalityDossierData.ts` composes the existing loaders in one pass (TSE baseline + A11 rank via `municipalityElectionGeographyForSlug`, E8 goal account, pledges, leaderships sorted by a new `LeadershipRowViewModel.updatedAt` freshness field, updates feed, two scoped `actionPlan` reads with `overrideAccess: false`, and A8 demographics — the previously consumer-less `bahiaMunicipalityDemographics.ts` re-enters through the "Perfil" section, with an explicit whole-city caveat for Salvador zona municipalities). Section caps 8 lideranças / 5 sinais / 3 upcoming + 2 realizado plans, each with a "ver tudo" link to its tab. New staff-only `municipality.budgetNotes` textarea ("Emendas aportadas", G11 manual-first) via migration `20260725_022155_add_municipality_budget_notes` — its committed `.json` snapshot also heals the drizzle snapshot chain after the snapshot-less `campaign_goals` migration; strategy form/zod/action extended; the section renders only when filled. **Print view is CSS-only:** app chrome gets `print:hidden`, the `h-svh`/`overflow-hidden` shells get `print:h-auto print:overflow-visible` (otherwise only page 1 prints), and `styles.css` gains A4 print typography; the dossier cover is self-contained (name, TI, badges, "Gerado em") so the printed page keeps its identity. Int test mocks `loadMunicipalityElectoralBaseline` (its `unstable_cache` wrapper needs the Next runtime). Plano: [`docs/plans/dossie-municipio.md`](docs/plans/dossie-municipio.md).
|
||||
|
||||
**Recently resolved (2026-07-24):** **E8** Conta da cadeira (metas derivadas, potencial por município, cobertura) — committed TSE artifact extended to v2 (`pnpm build:election-aggregates` now writes `campoFederalVotesByYear`, `federalTallyByYear`, and 2022 `majoritarian2022` presidente/governador T1 per municipality, alongside the existing `votesByYear`/`validVotesByYear`; byte budget raised 400→700 KB in the unit test); `src/lib/campoParties.ts` (curated field-party siglas per election year, unit-tested against `electionPartySpectrum.ts`'s `esquerda` bucket — not a second party classifier); global `campaignGoals` (`src/globals/CampaignGoals.ts`, migration `20260724_180000_add_campaign_goals_global`, `stateGoal` default 150,000 + `margin`/`baseYear`/`note`, `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`, **no** `afterChange` hook — `/campanha` is dynamic with auth so nothing caches it); pure derived utilities `src/utilities/municipalityPotential.ts` (projected valid votes, field ceiling, capture rate, intra-field share, 2022-only roll-off, proportional goal decomposition + TI sanity check) and `src/utilities/goalCoverage.ts` — **locked semantics:** `meta = expectedVotes[scenario] ?? suggestedGoal`, `comprometido = aggregate.effectiveByScenario[scenario]` (pledges only — the mesa's own expectation never substitutes for the pledge sum in the coverage denominator, unlike `resolveMunicipalityStaffVoteTotal`); orchestration in `src/utilities/municipalityGoalAccount.ts` (`cache()`-deduplicated across dashboard/list/detail). UI (Impeccable class B): "Cobertura da meta" row on the dashboard and the list overview `CampaignMetricStrip`, a new list column (renaming the old "Cobertura" column to "Assessoria" to remove ambiguity) via a shared `MunicipalityListGoalCoverageCell` (compact/default layouts), and a new "Conta da cadeira" card on the municipality detail page (fixed to the `central` scenario, replacing the "Votos estimados" block that used to live in `MunicipalityStrategyCard`). Unblocks roadmap items **E9/E10/E12/E13**. Plano: [`docs/plans/conta-da-cadeira.md`](docs/plans/conta-da-cadeira.md).
|
||||
|
||||
**Recently resolved (2026-07-23):** **A10** Cenários de estimativa (pessimista/média/otimista) — `votePledge.estimatedVotes` e `municipality.expectedVotes` viram groups (`pessimistic|central|optimistic`); migration `20260723_124200_add_vote_estimate_scenarios` (backfill scalar→`central`); agregação por cenário em `votePledgeData` com default `central`; mapa 2026 com `values2026ByScenario` + seletor local; lista/overview/dashboard com média + faixa secundária; liderança continua com um `declaredVotes` sem ver estimativas. Plano: [`docs/plans/cenarios-estimativa-votos.md`](docs/plans/cenarios-estimativa-votos.md). Desbloqueia **E8**.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
# E16 — Dossiê do município (pré-agenda)
|
||||
|
||||
Status: rascunho (O6 — pedido explícito da sessão de campo de 2026-07-23)
|
||||
Atualizado em: 2026-07-24
|
||||
Status: entregue em código (2026-07-25)
|
||||
Atualizado em: 2026-07-25 (as-built: aba `?tab=dossie` staff-only com `MunicipalityDossier.tsx` + `municipalityDossierData.ts` compondo os loaders existentes — baseline TSE/rank A11, conta da cadeira E8, rede com frescor `updatedAt`, agenda `actionPlan`, sinais, perfil A8 com caveat para zonas; caps 8/5/3+2 com "ver tudo"; `municipality.budgetNotes` via migration `20260725_022155_add_municipality_budget_notes` — o `.json` também sara a cadeia de snapshots pós-`campaign_goals`; print CSS: chrome `print:hidden`, shells `print:h-auto print:overflow-visible`, tipografia A4; int test com mock do `unstable_cache` do baseline. Revisão pré-implementação 2026-07-24: `budgetNotes` ganhou migration própria — o veículo C12 caducou; registrado o estado zero-consumidor de A8, resolvido pela seção "Perfil")
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Inteligência de campanha, E16; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md), gap G11)
|
||||
Impeccable: B — visão nova dentro do detalhe do município existente (tab/rota `dossie` + print), sem sistema novo
|
||||
Appetite: ~1 dia eng; sem migration na v1 (compõe dados existentes; campo de emendas é fase G11 manual)
|
||||
Appetite: ~1 dia eng; uma migration pequena (`budgetNotes` — ver questão resolvida abaixo); o resto compõe dados existentes (fonte estruturada de emendas segue G11 adiado)
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
|
@ -26,7 +26,7 @@ A sessão real de 2026-07-23 ([CUSTOMER.md](../CUSTOMER.md)) trouxe O6 como o ú
|
|||
|
||||
- **Tab/rota "Dossiê"** no detalhe do município (staff-only): seções na ordem de leitura de campo — capa (nome, TI, prioridade, nível quando existir), conta local (série 2014/2018/2022, % da própria votação via A11, captura/cobertura quando E8 existir), rede (lideranças ativas + responsável + frescor), conjuntura (`politicalTrend` + forças/riscos + disputa local), dobradinhas (M4 + notas), agenda/planos recentes e sinais recentes (últimos `municipalityUpdate`), perfil (demografia A8), emendas (quando houver dado — v1 mostra a seção só se preenchida).
|
||||
- **Visão print:** `print:`-friendly (CSS print da mesma rota; sem lib de PDF) — 1–2 páginas A4, cabeçalho com data de geração ("dossiê de <data>" — dado envelhece).
|
||||
- **Emendas manual-first (G11):** nota staff curta no `MunicipalityStrategyCard` existente se couber em campo atual (`nextSteps`/`dobradinhaNotes`) **ou** campo `budgetNotes` staff-only novo — decidir na implementação com custo de migration à vista (questão em aberto abaixo).
|
||||
- **Emendas manual-first (G11):** campo `budgetNotes` staff-only novo em `municipality` (migration pequena própria — decisão registrada na questão resolvida abaixo), editado no formulário de estratégia; a seção "Emendas aportadas" do dossiê só aparece quando preenchido.
|
||||
- Access: tudo staff (coordinator/advisor/candidate); leader não acessa (lockdown); campos staff-only seguem a redaction dos view models.
|
||||
|
||||
## Decisões travadas
|
||||
|
|
@ -38,7 +38,7 @@ A sessão real de 2026-07-23 ([CUSTOMER.md](../CUSTOMER.md)) trouxe O6 como o ú
|
|||
|
||||
## Questões em aberto
|
||||
|
||||
- **Onde mora o dado de emendas na v1?** Opções: reusar campo texto existente (`nextSteps`) com convenção | campo staff-only novo `budgetNotes` (migration pequena). **Recomendação:** campo novo `budgetNotes` na migration de C12 se E16 for aprovado antes dela rodar (mesma janela 2 — zero migration extra); senão, convenção em `nextSteps` até C12.
|
||||
- ~~**Onde mora o dado de emendas na v1?**~~ **Resolvida (2026-07-24):** campo staff-only novo `budgetNotes` em `municipality`, com migration própria pequena (`add_municipality_budget_notes`). A opção "carona na migration de C12" caducou — C12 foi entregue com `20260724_180000_add_campaign_foundation_records` antes de E16 começar. **Rejeitada:** convenção dentro de `nextSteps` (polui a semântica do campo e quebra a regra "seção Emendas só aparece se preenchida").
|
||||
- **Dossiê mostra nível N0–N4 (E14)?** **Recomendação:** sim quando existir, staff-only — mas nunca no print destinado a circulação além da coordenação (vocabulário duplo). v1: print omite nível por default.
|
||||
|
||||
## Abordagem proposta
|
||||
|
|
@ -77,10 +77,25 @@ Componentes: `src/utilities/municipalityDossierData.ts` (novo — orquestra load
|
|||
- **Economia local virar pesquisa.** v1 usa o que A8 tem (demografia); "economia" além disso é nota de conjuntura manual — não construir base socioeconômica nova.
|
||||
- **Print pixel-perfect.** CSS print razoável basta; não é material de campanha, é documento de trabalho.
|
||||
|
||||
## Já resolvido no simplify (não reabrir)
|
||||
|
||||
- Dados mortos `votePosition2022` / `pledgeCoverage` removidos de `MunicipalityDossierData` (rank já vem do baseline card).
|
||||
- Agenda reusa `buildActionPlanListWhere` + `actionPlanListSelect` + `toActionPlanListViewModel` + `pagination: false`.
|
||||
- `loadFreshestMunicipalityLeaderships` (sort `-updatedAt` + limit no Postgres) em vez de fetch-all-and-slice.
|
||||
- Conta da cadeira encadeada no `Promise.all` (pledges → goal), concorrente com baseline/rede/sinais/agenda.
|
||||
- Datas/números via `formatBahiaDateTimeLabel` / `formatElectionNumber`; Prettier em `migrations/index.ts` e fontes novas.
|
||||
|
||||
## Explicitamente fora (triage pós-simplify 2026-07-25)
|
||||
|
||||
- Memoização em module-scope de `computeStatewideGoalDecomposition` — pré-existente E8, fora do diff do dossiê (revisitar no plano [conta-da-cadeira.md](conta-da-cadeira.md) se o hot path do dashboard doer).
|
||||
- Encolher o page size do feed de sinais de 10 → 5 — o loader compartilhado precisa de `totalDocs`; micro-otimização sem evidência.
|
||||
- Critique do `MunicipalityGoalAccountCard` (tooltips touch) — sessão E8, já hardenido no follow-up #3 daquele plano; não é dívida do E16.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **G11 fonte estruturada de emendas** (Câmara/Portal da Transparência → valor por município/ano). Gatilho: E16 em uso real + coordenação pedindo o dado sistemático.
|
||||
- **Compartilhamento do dossiê via WhatsApp (kit).** Gatilho: pedido de campo; hoje o print resolve.
|
||||
- **Helper compartilhado da proveniência da meta** (`usesMesaEstimate` / rótulo "estimativa da mesa" vs "meta sugerida"). Hoje 2 call sites (`MunicipalityGoalAccountCard` + dossiê) abaixo do limiar de abstração do repo. Gatilho: 3º consumidor da mesma derivação/cópia.
|
||||
|
||||
## Referências
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-24 (B19 gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueia E9/E10/E12/E13; **E18** documentação de conceitos registrado como adjacente ao programa; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
|
||||
Atualizado em: 2026-07-25 (**E16 ✓** dossiê do município — aba + print + `budgetNotes` G11; anterior 2026-07-24: B19 gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueia E9/E10/E12/E13; **E18** documentação de conceitos registrado como adjacente ao programa; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
|
||||
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)
|
||||
|
|
@ -48,6 +48,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
|
|||
- **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).
|
||||
- **E8 (2026-07-24)** — conta da cadeira: artefato TSE commitado estendido para v2 (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022` presidente/governador T1, via `pnpm build:election-aggregates`); curadoria `src/lib/campoParties.ts` (ano→siglas do campo, validada contra `electionPartySpectrum`); global `campaignGoals` (meta estadual 150 mil + margem, access staff-read/coordinator-write, migration `20260724_180000_add_campaign_goals_global`); `municipalityPotential.ts` (válidos projetados, teto do campo, captura, share intracampo, roll-off 2022-only, decomposição proporcional da meta + sanity por TI) e `goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = Σ pledges`, nunca a expectativa da mesa); encaixado no dashboard, na visão geral e na lista de municípios (nova coluna "Cobertura da meta"), e num novo card "Conta da cadeira" no detalhe. Plano: [conta-da-cadeira.md](plans/conta-da-cadeira.md). Desbloqueia **E9**, **E10**, **E12**, **E13** (e melhora **B13**/**E16**).
|
||||
- **E16 (2026-07-25)** — dossiê do município (pré-agenda, pedido O6): aba `?tab=dossie` staff-only no detalhe compondo os loaders existentes (`municipalityDossierData.ts` + `MunicipalityDossier.tsx`) — capa com data de geração, série TSE/rank (A11), conta da cadeira (E8), rede de lideranças com frescor, conjuntura/dobradinhas/encaminhamentos, agenda (`actionPlan`), sinais recentes (C12), perfil IBGE (A8 — reentrada do artefato, caveat para zonas); caps 8/5/3+2 com "ver tudo"; campo staff-only `municipality.budgetNotes` ("Emendas aportadas", G11 manual-first, migration `20260725_022155`); **visão print** via CSS (chrome oculto, shells destravados, tipografia A4 — 1–2 páginas). Plano: [dossie-municipio.md](plans/dossie-municipio.md).
|
||||
|
||||
## Próximos — Campanha (`/campanha`)
|
||||
|
||||
|
|
@ -58,7 +59,6 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
| ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de |
|
||||
| --- | ---------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | ------------------------------ |
|
||||
| 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 ✓ |
|
||||
| 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 |
|
||||
| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 |
|
||||
|
|
@ -137,7 +137,7 @@ flowchart TD
|
|||
E8i["E8 ✓ Conta da cadeira"]
|
||||
E9i["E9 Fila de alocação"]
|
||||
C12i["C12 ✓ Registro-fundação"]
|
||||
E16i["E16 Dossiê do município"]
|
||||
E16i["E16 ✓ Dossiê do município"]
|
||||
E10i["E10 Classificação relativa"]
|
||||
B13i["B13 Escala relativa no mapa"]
|
||||
E14i["E14 Níveis N0–N4"]
|
||||
|
|
@ -179,13 +179,13 @@ flowchart TD
|
|||
B8F2 -.ZE Salvador.-> B14n
|
||||
```
|
||||
|
||||
Paralelizáveis agora: **E9** (E8 ✓ entregue), **E10**/**E12** (E8 ✓ desbloqueou ambos), **E16** (compõe o existente), **E18** (E8 ✓ já dá conteúdo para v1), **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), **B19** (gerenciar assessores — onboarding sem `/admin`; sem deps duras), 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 E9).
|
||||
Paralelizáveis agora: **E9** (E8 ✓ entregue), **E10**/**E12** (E8 ✓ desbloqueou ambos), **E18** (E8 ✓ já dá conteúdo para v1), **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), **B19** (gerenciar assessores — onboarding sem `/admin`; sem deps duras), 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 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); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + default `sort=votos`) entregues 2026-07-24; **E18** documentação de conceitos (`/campanha/conceitos`, v1 só E8) se sobrar folga — pedido do usuário na mesma sessão do E8; **B19** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras); **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; **E9** 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; **E9** fila de alocação (E8 ✓ e **C12 ✓** já entregues); **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8 ✓/C12 ✓); D2 se sobrar folga.
|
||||
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **E9** fila de alocação (E8 ✓ e **C12 ✓** já entregues); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); 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**.
|
||||
|
||||
|
|
@ -195,7 +195,7 @@ Paralelizáveis agora: **E9** (E8 ✓ entregue), **E10**/**E12** (E8 ✓ desbloq
|
|||
|
||||
**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); **E9** (a fila de alocação é o mínimo de "inteligência, não planilha"); **B19** gerenciar assessores (onboarding Onda 0 §4 — CG/candidato não entram em `/admin`; sem esta UI o time só nasce via seed ou admin Payload).
|
||||
|
||||
**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter fila E9 sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B17** seletor de colunas (tabela completa continua; scroll horizontal); **B16** filtros no header (barra de filtros atual continua a funcionar); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); **E18** documentação de conceitos (os tooltips inline do E8 já explicam o essencial; página é reforço); **E16** dossiê (último dos extras — pedido explícito de campo); débitos/fill-ins.
|
||||
**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter fila E9 sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B17** seletor de colunas (tabela completa continua; scroll horizontal); **B16** filtros no header (barra de filtros atual continua a funcionar); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); **E18** documentação de conceitos (os tooltips inline do E8 já explicam o essencial; página é reforço); ~~**E16** dossiê~~ (entregue 2026-07-25); débitos/fill-ins.
|
||||
|
||||
## Bloqueadores atuais
|
||||
|
||||
|
|
|
|||
|
|
@ -17,10 +17,12 @@ export default async function CampaignAppLayout({ children }: { children: React.
|
|||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider className="h-svh min-h-0 overflow-hidden">
|
||||
// print: unlock the h-svh/overflow-hidden shells and drop the app chrome,
|
||||
// otherwise only the first page of the municipality dossier prints (E16).
|
||||
<SidebarProvider className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
|
||||
<CampaignSidebar user={campaignUserShellView(user)} />
|
||||
<SidebarInset className="h-svh min-h-0 overflow-hidden">
|
||||
<header className="flex min-h-14 shrink-0 items-center gap-3 bg-primary px-4 text-primary-foreground md:hidden">
|
||||
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
|
||||
<header className="flex min-h-14 shrink-0 items-center gap-3 bg-primary px-4 text-primary-foreground md:hidden print:hidden">
|
||||
<SidebarTrigger
|
||||
label="Abrir ou fechar menu da campanha"
|
||||
className="text-primary-foreground"
|
||||
|
|
@ -34,7 +36,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
|
|||
</header>
|
||||
<div
|
||||
data-slot="campaign-content-scroll"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-24 md:p-6 md:pb-6"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-24 md:p-6 md:pb-6 print:h-auto print:overflow-visible print:p-0"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const updateMunicipalityStrategyFormAction = async (
|
|||
stateDeputies: repeatedRelationshipFormValues(formData, 'stateDeputies'),
|
||||
dobradinhaNotes: nullableFormText(formData, 'dobradinhaNotes'),
|
||||
nextSteps: nullableFormText(formData, 'nextSteps'),
|
||||
budgetNotes: nullableFormText(formData, 'budgetNotes'),
|
||||
})
|
||||
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
|
||||
return { status: 'success', message: 'Estratégia atualizada.' }
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
|||
import { CandidateComparePicker } from '@/components/campaign/CandidateComparePicker'
|
||||
import { MunicipalityBaselineCard } from '@/components/campaign/MunicipalityBaselineCard'
|
||||
import { MunicipalityCandidateComparisonTable } from '@/components/campaign/MunicipalityCandidateComparisonTable'
|
||||
import { MunicipalityDossier } from '@/components/campaign/MunicipalityDossier'
|
||||
import { MunicipalityGoalAccountCard } from '@/components/campaign/MunicipalityGoalAccountCard'
|
||||
import { MunicipalityLeadershipsPanel } from '@/components/campaign/MunicipalityLeadershipsPanel'
|
||||
import { MunicipalityPledgesPanel } from '@/components/campaign/MunicipalityPledgesPanel'
|
||||
|
|
@ -34,6 +35,7 @@ import {
|
|||
import { municipalityElectionGeographyForSlug } from '@/utilities/municipalityElectionGeography'
|
||||
import { loadMunicipalityElectoralBaseline } from '@/utilities/municipalityElectoralBaseline'
|
||||
import { loadMunicipalityGoalAccount } from '@/utilities/municipalityGoalAccount'
|
||||
import { loadMunicipalityDossierData } from '@/utilities/municipalityDossierData'
|
||||
import {
|
||||
getMunicipalityDetailViewModel,
|
||||
MunicipalityNotFoundError,
|
||||
|
|
@ -90,7 +92,8 @@ export default async function MunicipalityDetailPage({
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
{/* print: the dossier tab carries its own capa; page chrome stays out. */}
|
||||
<header className="flex flex-col gap-2 print:hidden">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{view.name}</h1>
|
||||
<Badge variant="scope">{municipalityKindLabels[view.kind]}</Badge>
|
||||
|
|
@ -116,6 +119,18 @@ export default async function MunicipalityDetailPage({
|
|||
<OverviewTab view={view} payloadUser={{ payload, user }} />
|
||||
) : null}
|
||||
|
||||
{activeTab === 'dossie' ? (
|
||||
// Composes every vertical of the município in one read — streams like
|
||||
// the elections tab since it includes the same TSE baseline read.
|
||||
<Suspense fallback={<MunicipalityTabFallback />}>
|
||||
<DossierTab
|
||||
view={view}
|
||||
advisorSummaries={advisorSummaries}
|
||||
payloadUser={{ payload, user }}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'elections' ? (
|
||||
// Streams below the header/tab chrome — the TSE baselines and the
|
||||
// comparison table are the slowest reads on this page.
|
||||
|
|
@ -245,6 +260,19 @@ const GoalAccountCard = async ({
|
|||
)
|
||||
}
|
||||
|
||||
const DossierTab = async ({
|
||||
view,
|
||||
advisorSummaries,
|
||||
payloadUser: { payload, user },
|
||||
}: {
|
||||
view: Awaited<ReturnType<typeof getMunicipalityDetailViewModel>>
|
||||
advisorSummaries: Awaited<ReturnType<typeof loadAdvisorSummaries>>
|
||||
payloadUser: PayloadUser
|
||||
}) => {
|
||||
const data = await loadMunicipalityDossierData(payload, user, view)
|
||||
return <MunicipalityDossier view={view} advisors={advisorSummaries} data={data} />
|
||||
}
|
||||
|
||||
const MunicipalityTabFallback = () => (
|
||||
<div aria-hidden="true" className="flex flex-col gap-4">
|
||||
<div className="h-40 w-full animate-pulse rounded-xl border bg-muted/40" />
|
||||
|
|
|
|||
|
|
@ -404,3 +404,40 @@ body,
|
|||
display: none; /* Chrome, Safari */
|
||||
}
|
||||
}
|
||||
|
||||
/* E16 — municipality dossier print view: compact A4 typography (one step down
|
||||
the DESIGN.md ramp: body text-sm→text-xs, section titles text-base→text-sm),
|
||||
sections kept whole across page breaks. Chrome hiding lives in `print:`
|
||||
utilities on the campaign layout/components; this block only tunes the
|
||||
dossier itself. */
|
||||
@media print {
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 14mm;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[data-dossier] {
|
||||
font-size: 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
[data-dossier] section {
|
||||
break-inside: avoid;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
[data-dossier] h2 {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
[data-dossier] a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -443,6 +443,21 @@ export const Municipality: CollectionConfig = {
|
|||
description: 'Próximos passos operacionais para o município.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'budgetNotes',
|
||||
type: 'textarea',
|
||||
label: 'Emendas aportadas',
|
||||
maxLength: 4000,
|
||||
access: {
|
||||
read: canReadCampaignStaffField,
|
||||
create: canManageCampaignStaffField,
|
||||
update: canManageCampaignStaffField,
|
||||
},
|
||||
admin: {
|
||||
description:
|
||||
'Emendas parlamentares aportadas no município (valores, anos, situação) — nota manual da coordenação (G11).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lastUpdateAt',
|
||||
type: 'date',
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const CampaignBottomNav = ({ role }: { role: CampaignUser['role'] }) => {
|
|||
return (
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
className="fixed inset-x-0 bottom-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden"
|
||||
className="fixed inset-x-0 bottom-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden print:hidden"
|
||||
>
|
||||
<ul
|
||||
className="m-0 grid list-none p-0"
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="none" className="h-svh shrink-0 border-r border-sidebar-border">
|
||||
<Sidebar collapsible="none" className="h-svh shrink-0 border-r border-sidebar-border print:hidden">
|
||||
<SidebarHeader className="border-b border-sidebar-border">
|
||||
<Link
|
||||
href="/campanha"
|
||||
|
|
|
|||
371
src/components/campaign/MunicipalityDossier.tsx
Normal file
371
src/components/campaign/MunicipalityDossier.tsx
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { ActionPlanStatusBadge } from '@/components/campaign/ActionPlanStatusBadge'
|
||||
import { MunicipalityBaselineCard } from '@/components/campaign/MunicipalityBaselineCard'
|
||||
import { MunicipalityStrategyCard } from '@/components/campaign/MunicipalityStrategyCard'
|
||||
import { MunicipalityUpdateFeed } from '@/components/campaign/MunicipalityUpdateFeed'
|
||||
import { SupportStatusBadge } from '@/components/campaign/SupportStatusBadge'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import type { MunicipalityAgeBandKey } from '@/lib/bahiaMunicipalityDemographics'
|
||||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
|
||||
import type { ActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
|
||||
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
|
||||
import {
|
||||
formatGoalCoverageDeficitLabel,
|
||||
formatGoalCoverageRatioLabel,
|
||||
formatRatioAsPercentLabel,
|
||||
} from '@/utilities/goalCoverage'
|
||||
import type { MunicipalityDossierData } from '@/utilities/municipalityDossierData'
|
||||
import {
|
||||
formatMunicipalityGeographyLabel,
|
||||
municipalityKindLabels,
|
||||
municipalityPriorityLabels,
|
||||
politicalTrendBadgeVariant,
|
||||
politicalTrendLabels,
|
||||
} from '@/utilities/municipalityUi'
|
||||
import type {
|
||||
MunicipalityAdvisorSummary,
|
||||
MunicipalityDetailViewModel,
|
||||
} from '@/utilities/municipalityViewModels'
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
|
||||
|
||||
const AGE_BAND_LABELS: Record<MunicipalityAgeBandKey, string> = {
|
||||
'0-17': '0–17 anos',
|
||||
'18-29': '18–29 anos',
|
||||
'30-59': '30–59 anos',
|
||||
'60+': '60+ anos',
|
||||
}
|
||||
|
||||
const SectionHeader = ({
|
||||
id,
|
||||
title,
|
||||
seeAllHref,
|
||||
seeAllLabel,
|
||||
}: {
|
||||
id: string
|
||||
title: string
|
||||
seeAllHref?: string
|
||||
seeAllLabel?: string
|
||||
}) => (
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 id={id} className="text-base font-medium">
|
||||
{title}
|
||||
</h2>
|
||||
{seeAllHref ? (
|
||||
<Link
|
||||
href={seeAllHref}
|
||||
scroll={false}
|
||||
className="text-sm text-primary underline-offset-4 hover:underline print:hidden"
|
||||
>
|
||||
{seeAllLabel ?? 'Ver tudo'}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
const PlanListItem = ({ plan }: { plan: ActionPlanListViewModel }) => (
|
||||
<li className="flex flex-col gap-1 rounded-lg bg-muted/40 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/campanha/planos/${plan.slug}`}
|
||||
className="text-sm font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{plan.title}
|
||||
</Link>
|
||||
<Badge variant="secondary">{actionPlanKindLabels[plan.kind]}</Badge>
|
||||
<ActionPlanStatusBadge status={plan.status} />
|
||||
{plan.deputyPresent ? <Badge>Deputado presente</Badge> : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{plan.startAt ? formatBahiaDateTimeLabel(plan.startAt) : 'Sem data definida'}
|
||||
{plan.locality ? ` · ${plan.locality}` : ''}
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
|
||||
/**
|
||||
* E16 — "o que ler antes de entrar na cidade" (O6). A one-screen / 1–2 A4
|
||||
* page pre-visit read: capa, conta local (série TSE + conta da cadeira),
|
||||
* rede, conjuntura (estratégia, dobradinhas, emendas), agenda, sinais e
|
||||
* perfil do eleitorado. Composes the same loaders/cards the other tabs use —
|
||||
* numbers, short lists and prose; no charts (anti-goal do discovery).
|
||||
*/
|
||||
export const MunicipalityDossier = ({
|
||||
view,
|
||||
advisors,
|
||||
data,
|
||||
}: {
|
||||
view: MunicipalityDetailViewModel
|
||||
advisors: MunicipalityAdvisorSummary[]
|
||||
data: MunicipalityDossierData
|
||||
}) => {
|
||||
const strategy = view.strategy
|
||||
const trendStatus = strategy?.politicalTrend.status ?? null
|
||||
const goalAccount = data.goalAccount
|
||||
const usesMesaEstimate = strategy?.expectedVotes.central != null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" data-dossier>
|
||||
{/* Capa — self-contained so the printed dossier keeps its identity
|
||||
after the page header/tab chrome is hidden by print CSS. */}
|
||||
<section
|
||||
aria-labelledby="dossier-cover-title"
|
||||
className="flex flex-col gap-2 rounded-xl border bg-muted/30 p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 id="dossier-cover-title" className="text-lg font-semibold tracking-tight">
|
||||
Dossiê — {view.name}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Gerado em {formatBahiaDateTimeLabel(new Date().toISOString())}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{municipalityKindLabels[view.kind]} · {formatMunicipalityGeographyLabel(view)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{strategy?.priority === 'alta' ? (
|
||||
<Badge variant="destructive">{municipalityPriorityLabels.alta}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Prioridade normal</Badge>
|
||||
)}
|
||||
{trendStatus ? (
|
||||
<Badge variant={politicalTrendBadgeVariant[trendStatus]}>
|
||||
Tendência {politicalTrendLabels[trendStatus].toLowerCase()}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Tendência não registrada</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{advisors.length
|
||||
? `Assessoria: ${advisors.map((advisor) => advisor.name).join(', ')}`
|
||||
: 'Sem assessor designado.'}
|
||||
{view.lastUpdateAt
|
||||
? ` · Última atualização em ${dateFormatter.format(new Date(view.lastUpdateAt))}`
|
||||
: ''}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Conta local — série TSE (reuses the elections-tab card) + conta da cadeira. */}
|
||||
<MunicipalityBaselineCard baseline={data.baseline} municipalitySlug={view.slug} />
|
||||
|
||||
{goalAccount ? (
|
||||
<section
|
||||
aria-labelledby="dossier-goal-title"
|
||||
className="flex flex-col gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<SectionHeader id="dossier-goal-title" title="Conta da cadeira" />
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Meta usada</dt>
|
||||
<dd className="tabular-nums">
|
||||
{formatElectionNumber(goalAccount.goalCoverage.goal)}
|
||||
</dd>
|
||||
<dd className="text-xs text-muted-foreground">
|
||||
{usesMesaEstimate ? 'estimativa da mesa' : 'meta sugerida'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Comprometido (pledges)</dt>
|
||||
<dd className="tabular-nums">
|
||||
{formatElectionNumber(goalAccount.goalCoverage.committed)}
|
||||
</dd>
|
||||
<dd className="text-xs text-muted-foreground">
|
||||
{data.pledgeAggregate.pledgeCount
|
||||
? `${formatElectionNumber(data.pledgeAggregate.pledgeCount)} declaração(ões)`
|
||||
: 'nenhuma declaração'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Cobertura da meta</dt>
|
||||
<dd className="tabular-nums">
|
||||
{formatGoalCoverageRatioLabel(goalAccount.goalCoverage)}
|
||||
</dd>
|
||||
<dd className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatGoalCoverageDeficitLabel(goalAccount.goalCoverage)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Captura (2022)</dt>
|
||||
<dd className="tabular-nums">
|
||||
{formatRatioAsPercentLabel(goalAccount.potential.captureRate2022)}
|
||||
</dd>
|
||||
<dd className="text-xs text-muted-foreground">
|
||||
do teto do campo:{' '}
|
||||
{formatElectionNumber(Math.round(goalAccount.potential.projectedFieldCeiling))}{' '}
|
||||
(proj.)
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Rede — quem receber/procurar na cidade, com frescor do cadastro. */}
|
||||
<section
|
||||
aria-labelledby="dossier-network-title"
|
||||
className="flex flex-col gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<SectionHeader
|
||||
id="dossier-network-title"
|
||||
title={`Rede de lideranças (${formatElectionNumber(data.leaderships.totalCount)})`}
|
||||
seeAllHref={`/campanha/municipios/${view.slug}?tab=leaderships`}
|
||||
seeAllLabel="Ver todas"
|
||||
/>
|
||||
{data.leaderships.rows.length ? (
|
||||
<ul className="flex flex-col divide-y">
|
||||
{data.leaderships.rows.map((leadership) => (
|
||||
<li
|
||||
key={leadership.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 py-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/campanha/liderancas/${leadership.id}`}
|
||||
className="text-sm font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{leadership.name}
|
||||
</Link>
|
||||
{leadership.supportStatus ? (
|
||||
<SupportStatusBadge status={leadership.supportStatus} />
|
||||
) : null}
|
||||
{leadership.sector ? (
|
||||
<span className="text-xs text-muted-foreground">{leadership.sector}</span>
|
||||
) : null}
|
||||
{leadership.phone ? (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{leadership.phone}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{leadership.organizationNames.length ? (
|
||||
<span>{leadership.organizationNames.join(', ')}</span>
|
||||
) : null}
|
||||
<span>atualizada em {dateFormatter.format(new Date(leadership.updatedAt))}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma liderança vinculada a este município ainda.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Conjuntura, dobradinhas, encaminhamentos e emendas — reuses the
|
||||
overview strategy card (emendas render only when preenchidas). */}
|
||||
{strategy ? (
|
||||
<MunicipalityStrategyCard
|
||||
strategy={strategy}
|
||||
municipalitySlug={view.slug}
|
||||
canEdit={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Agenda — próximos eventos e últimas ações realizadas. */}
|
||||
<section
|
||||
aria-labelledby="dossier-agenda-title"
|
||||
className="flex flex-col gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<SectionHeader
|
||||
id="dossier-agenda-title"
|
||||
title="Agenda no município"
|
||||
seeAllHref={`/campanha/planos?municipality=${view.id}`}
|
||||
seeAllLabel="Ver agenda completa"
|
||||
/>
|
||||
{data.upcomingPlans.length ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-medium">Próximos eventos</h3>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{data.upcomingPlans.map((plan) => (
|
||||
<PlanListItem key={plan.id} plan={plan} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nenhum evento futuro planejado.</p>
|
||||
)}
|
||||
{data.recentPlans.length ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-medium">Últimas ações realizadas</h3>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{data.recentPlans.map((plan) => (
|
||||
<PlanListItem key={plan.id} plan={plan} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Sinais recentes — reuses the updates-tab feed presentation. */}
|
||||
<section
|
||||
aria-labelledby="dossier-signals-title"
|
||||
className="flex flex-col gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<SectionHeader
|
||||
id="dossier-signals-title"
|
||||
title={`Sinais e atualizações recentes (${formatElectionNumber(data.signals.totalCount)})`}
|
||||
seeAllHref={`/campanha/municipios/${view.slug}?tab=updates`}
|
||||
/>
|
||||
<MunicipalityUpdateFeed updates={data.signals.rows} />
|
||||
</section>
|
||||
|
||||
{/* Perfil do eleitorado — IBGE Censo 2022 (A8). */}
|
||||
{data.demographics ? (
|
||||
<DemographicsSection demographics={data.demographics} kind={view.kind} city={view.city} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DemographicsSection = ({
|
||||
demographics,
|
||||
kind,
|
||||
city,
|
||||
}: {
|
||||
demographics: NonNullable<MunicipalityDossierData['demographics']>
|
||||
kind: MunicipalityDetailViewModel['kind']
|
||||
city: string
|
||||
}) => (
|
||||
<section
|
||||
aria-labelledby="dossier-profile-title"
|
||||
className="flex flex-col gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<SectionHeader id="dossier-profile-title" title="Perfil da população (IBGE 2022)" />
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">População</dt>
|
||||
<dd className="tabular-nums">{formatElectionNumber(demographics.population)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Idade mediana</dt>
|
||||
<dd className="tabular-nums">
|
||||
{demographics.medianAge != null
|
||||
? `${formatElectionNumber(demographics.medianAge)} anos`
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Mulheres</dt>
|
||||
<dd className="tabular-nums">{formatRatioAsPercentLabel(demographics.sexShareFemale)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
{(Object.keys(AGE_BAND_LABELS) as Array<keyof typeof AGE_BAND_LABELS>).map((band) => (
|
||||
<div key={band}>
|
||||
<dt className="text-xs text-muted-foreground">{AGE_BAND_LABELS[band]}</dt>
|
||||
<dd className="tabular-nums">{formatElectionNumber(demographics.ageBands[band])}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{kind === 'zona' ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Dados do município inteiro de {city} — o IBGE não divide o Censo por zona eleitoral.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
|
|
@ -124,6 +124,14 @@ export const MunicipalityStrategyCard = ({
|
|||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{strategy.budgetNotes ? (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Emendas aportadas</h3>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{strategy.budgetNotes}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,17 @@ export const MunicipalityStrategyForm = ({
|
|||
defaultValue={strategy.nextSteps ?? undefined}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="municipality-budget-notes">Emendas aportadas</FieldLabel>
|
||||
<Textarea
|
||||
id="municipality-budget-notes"
|
||||
name="budgetNotes"
|
||||
rows={3}
|
||||
maxLength={4000}
|
||||
placeholder="Emendas parlamentares no município: valores, anos e situação…"
|
||||
defaultValue={strategy.budgetNotes ?? undefined}
|
||||
/>
|
||||
</Field>
|
||||
{strategyState.message && strategyState.status !== 'success' ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{strategyState.message}</AlertDescription>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
|
||||
const tabLabels: Record<MunicipalityDetailTab, string> = {
|
||||
overview: 'Visão geral',
|
||||
dossie: 'Dossiê',
|
||||
elections: 'Eleições',
|
||||
leaderships: 'Lideranças',
|
||||
updates: 'Atualizações',
|
||||
|
|
@ -27,7 +28,7 @@ export const MunicipalityTabNav = ({
|
|||
}) => (
|
||||
<nav
|
||||
aria-label="Seções da Praça"
|
||||
className="h-14 min-w-0 overflow-x-auto overflow-y-hidden border-b"
|
||||
className="h-14 min-w-0 overflow-x-auto overflow-y-hidden border-b print:hidden"
|
||||
>
|
||||
<ul
|
||||
role="list"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const municipalityStrategyUpdateSchema = z.object({
|
|||
stateDeputies: stateDeputiesArraySchema.optional(),
|
||||
dobradinhaNotes: trimmedNullableText(4000),
|
||||
nextSteps: trimmedNullableText(4000),
|
||||
budgetNotes: trimmedNullableText(4000),
|
||||
})
|
||||
|
||||
export const municipalityPoliticalTrendSchema = z.object({
|
||||
|
|
|
|||
11849
src/migrations/20260725_022155_add_municipality_budget_notes.json
Normal file
11849
src/migrations/20260725_022155_add_municipality_budget_notes.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,23 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* E16 dossiê — staff-only `municipality.budgetNotes` (emendas aportadas,
|
||||
* manual-first per gap G11).
|
||||
*
|
||||
* The generated diff also wanted to CREATE TABLE "campaign_goals": that table
|
||||
* was added by the hand-written `20260724_180000_add_campaign_goals_global`
|
||||
* (which shipped without a drizzle snapshot), so the differ's "before" state
|
||||
* did not know it. The statement was removed here — the table already exists
|
||||
* everywhere that migration ran — while this migration's committed `.json`
|
||||
* snapshot now includes `campaign_goals`, healing the snapshot chain for
|
||||
* future generated migrations.
|
||||
*/
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "municipality" ADD COLUMN "budget_notes" varchar;`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "municipality" DROP COLUMN "budget_notes";`)
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import * as migration_20260724_133600_drop_municipality_vote_goals from './20260
|
|||
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'
|
||||
import * as migration_20260724_180000_add_campaign_goals_global from './20260724_180000_add_campaign_goals_global'
|
||||
import * as migration_20260725_022155_add_municipality_budget_notes from './20260725_022155_add_municipality_budget_notes'
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -174,4 +175,9 @@ export const migrations = [
|
|||
down: migration_20260724_180000_add_campaign_goals_global.down,
|
||||
name: '20260724_180000_add_campaign_goals_global',
|
||||
},
|
||||
{
|
||||
up: migration_20260725_022155_add_municipality_budget_notes.up,
|
||||
down: migration_20260725_022155_add_municipality_budget_notes.down,
|
||||
name: '20260725_022155_add_municipality_budget_notes',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -451,6 +451,10 @@ export interface Municipality {
|
|||
* Próximos passos operacionais para o município.
|
||||
*/
|
||||
nextSteps?: string | null;
|
||||
/**
|
||||
* Emendas parlamentares aportadas no município (valores, anos, situação) — nota manual da coordenação (G11).
|
||||
*/
|
||||
budgetNotes?: string | null;
|
||||
/**
|
||||
* Derivado automaticamente do feed de atualizações do município.
|
||||
*/
|
||||
|
|
@ -1410,6 +1414,7 @@ export interface MunicipalitySelect<T extends boolean = true> {
|
|||
stateDeputies?: T;
|
||||
dobradinhaNotes?: T;
|
||||
nextSteps?: T;
|
||||
budgetNotes?: T;
|
||||
lastUpdateAt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export type LeadershipRowViewModel = {
|
|||
organizationNames: string[]
|
||||
stateDeputyNames: string[]
|
||||
hasAppAccess: boolean
|
||||
/** Last write to the leadership row — the dossier's freshness readout. */
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const contactNameAndPhone = (
|
||||
|
|
@ -105,6 +107,7 @@ const toLeadershipRows = async (
|
|||
.filter((id): id is number => id !== null)
|
||||
.map((id) => stateDeputyNames.get(id) ?? 'Dobradinha'),
|
||||
hasAppAccess: relationshipId(doc.user) !== null,
|
||||
updatedAt: doc.updatedAt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -127,6 +130,32 @@ export const loadMunicipalityLeaderships = async (
|
|||
return toLeadershipRows(payload, result.docs as Leadership[])
|
||||
}
|
||||
|
||||
/**
|
||||
* Dossier variant: only the `limit` most recently updated rows (sorted in
|
||||
* Postgres), plus the full count — avoids fetching every leadership of a
|
||||
* heavy município just to slice a handful.
|
||||
*/
|
||||
export const loadFreshestMunicipalityLeaderships = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
municipalityID: number,
|
||||
limit: number,
|
||||
): Promise<{ rows: LeadershipRowViewModel[]; totalCount: number }> => {
|
||||
const result = await payload.find({
|
||||
collection: 'leadership',
|
||||
where: { municipalities: { in: [municipalityID] } },
|
||||
depth: 1,
|
||||
limit,
|
||||
sort: '-updatedAt',
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
return {
|
||||
rows: await toLeadershipRows(payload, result.docs as Leadership[]),
|
||||
totalCount: result.totalDocs,
|
||||
}
|
||||
}
|
||||
|
||||
export type LeadershipListState = {
|
||||
page: number
|
||||
q?: string
|
||||
|
|
@ -230,7 +259,9 @@ export const loadLeadershipDetail = async (
|
|||
|
||||
return {
|
||||
...row,
|
||||
municipalityIDs: (doc.municipalities ?? []).map(relationshipId).filter((id): id is number => id !== null),
|
||||
municipalityIDs: (doc.municipalities ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null),
|
||||
organizationIDs: (doc.organizations ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export const municipalityDetailTabs = [
|
||||
'overview',
|
||||
'dossie',
|
||||
'elections',
|
||||
'leaderships',
|
||||
'updates',
|
||||
|
|
@ -11,6 +12,7 @@ export type MunicipalityDetailSearchParams = Record<string, string | string[] |
|
|||
|
||||
const tabQueryKeys: Record<MunicipalityDetailTab, readonly string[]> = {
|
||||
overview: [],
|
||||
dossie: [],
|
||||
elections: ['compare'],
|
||||
leaderships: ['leadershipQ', 'leadershipPage'],
|
||||
updates: ['updateKind', 'updatePage', 'newUpdate'],
|
||||
|
|
|
|||
130
src/utilities/municipalityDossierData.ts
Normal file
130
src/utilities/municipalityDossierData.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import {
|
||||
demographicsForCode,
|
||||
type MunicipalityDemographics,
|
||||
} from '@/lib/bahiaMunicipalityDemographics'
|
||||
import type { ActionPlan, CampaignUser } from '@/payload-types'
|
||||
import { buildActionPlanListWhere } from '@/utilities/actionPlanUi'
|
||||
import {
|
||||
actionPlanListSelect,
|
||||
toActionPlanListViewModel,
|
||||
type ActionPlanListViewModel,
|
||||
} from '@/utilities/actionPlanViewModels'
|
||||
import {
|
||||
loadFreshestMunicipalityLeaderships,
|
||||
type LeadershipRowViewModel,
|
||||
} from '@/utilities/leadershipData'
|
||||
import { municipalityElectionGeographyForSlug } from '@/utilities/municipalityElectionGeography'
|
||||
import {
|
||||
loadMunicipalityElectoralBaseline,
|
||||
type MunicipalityElectoralBaseline,
|
||||
} from '@/utilities/municipalityElectoralBaseline'
|
||||
import {
|
||||
loadMunicipalityGoalAccount,
|
||||
type MunicipalityGoalAccount,
|
||||
} from '@/utilities/municipalityGoalAccount'
|
||||
import {
|
||||
loadMunicipalityUpdatesFeed,
|
||||
type MunicipalityUpdateViewModel,
|
||||
} from '@/utilities/municipalityUpdatePageData'
|
||||
import type { MunicipalityDetailViewModel } from '@/utilities/municipalityViewModels'
|
||||
import {
|
||||
aggregateMunicipalityPledgesFromRows,
|
||||
loadMunicipalityPledges,
|
||||
type MunicipalityPledgeAggregate,
|
||||
} from '@/utilities/votePledgeData'
|
||||
|
||||
/**
|
||||
* E16 dossiê — section caps. The dossier is a 1–2 page pre-visit read, not a
|
||||
* report: each section shows at most this many rows and links to its full tab.
|
||||
*/
|
||||
export const DOSSIER_LEADERSHIP_LIMIT = 8
|
||||
export const DOSSIER_SIGNAL_LIMIT = 5
|
||||
const DOSSIER_UPCOMING_PLAN_LIMIT = 3
|
||||
const DOSSIER_RECENT_PLAN_LIMIT = 2
|
||||
|
||||
export type MunicipalityDossierData = {
|
||||
baseline: MunicipalityElectoralBaseline | null
|
||||
goalAccount: MunicipalityGoalAccount | null
|
||||
pledgeAggregate: MunicipalityPledgeAggregate
|
||||
leaderships: { rows: LeadershipRowViewModel[]; totalCount: number }
|
||||
signals: { rows: MunicipalityUpdateViewModel[]; totalCount: number }
|
||||
upcomingPlans: ActionPlanListViewModel[]
|
||||
recentPlans: ActionPlanListViewModel[]
|
||||
demographics: MunicipalityDemographics | null
|
||||
}
|
||||
|
||||
const loadDossierPlans = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
municipalityID: number,
|
||||
): Promise<{ upcoming: ActionPlanListViewModel[]; recent: ActionPlanListViewModel[] }> => {
|
||||
const now = new Date()
|
||||
const findPlans = (tab: 'proximos' | 'realizados', limit: number, sort: string) =>
|
||||
payload.find({
|
||||
collection: 'actionPlan',
|
||||
where: buildActionPlanListWhere({ page: 1, tab, municipality: municipalityID }, now),
|
||||
depth: 0,
|
||||
limit,
|
||||
pagination: false,
|
||||
sort,
|
||||
select: actionPlanListSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const [upcoming, recent] = await Promise.all([
|
||||
findPlans('proximos', DOSSIER_UPCOMING_PLAN_LIMIT, 'startAt'),
|
||||
findPlans('realizados', DOSSIER_RECENT_PLAN_LIMIT, '-startAt'),
|
||||
])
|
||||
|
||||
return {
|
||||
upcoming: upcoming.docs.map((plan) => toActionPlanListViewModel(plan as ActionPlan)),
|
||||
recent: recent.docs.map((plan) => toActionPlanListViewModel(plan as ActionPlan)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the pre-visit dossier from the same loaders the detail tabs use —
|
||||
* one pass, no second source of truth (decisão travada do plano E16).
|
||||
*/
|
||||
export const loadMunicipalityDossierData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
view: MunicipalityDetailViewModel,
|
||||
): Promise<MunicipalityDossierData> => {
|
||||
const geography = municipalityElectionGeographyForSlug(view.slug)
|
||||
|
||||
const [baseline, pledgesAndGoal, leaderships, feed, plans] = await Promise.all([
|
||||
geography ? loadMunicipalityElectoralBaseline(user, geography) : null,
|
||||
loadMunicipalityPledges(payload, user, view.id).then(async (pledges) => {
|
||||
const pledgeAggregate = aggregateMunicipalityPledgesFromRows(pledges)
|
||||
const goalAccount = view.strategy
|
||||
? await loadMunicipalityGoalAccount(
|
||||
payload,
|
||||
user,
|
||||
{ slug: view.slug, expectedVotes: view.strategy.expectedVotes },
|
||||
pledgeAggregate,
|
||||
)
|
||||
: null
|
||||
return { pledgeAggregate, goalAccount }
|
||||
}),
|
||||
loadFreshestMunicipalityLeaderships(payload, user, view.id, DOSSIER_LEADERSHIP_LIMIT),
|
||||
loadMunicipalityUpdatesFeed(payload, user, view.id, { page: 1 }),
|
||||
loadDossierPlans(payload, user, view.id),
|
||||
])
|
||||
|
||||
return {
|
||||
baseline,
|
||||
goalAccount: pledgesAndGoal.goalAccount,
|
||||
pledgeAggregate: pledgesAndGoal.pledgeAggregate,
|
||||
leaderships,
|
||||
signals: { rows: feed.updates.slice(0, DOSSIER_SIGNAL_LIMIT), totalCount: feed.totalDocs },
|
||||
upcomingPlans: plans.upcoming,
|
||||
recentPlans: plans.recent,
|
||||
demographics: demographicsForCode(view.ibgeCode) ?? null,
|
||||
}
|
||||
}
|
||||
|
|
@ -116,6 +116,7 @@ export type MunicipalityDetailViewModel = {
|
|||
stateDeputies: StateDeputySummary[]
|
||||
dobradinhaNotes: string | null
|
||||
nextSteps: string | null
|
||||
budgetNotes: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +159,7 @@ export const toMunicipalityDetailViewModel = (
|
|||
stateDeputies,
|
||||
dobradinhaNotes: municipality.dobradinhaNotes ?? null,
|
||||
nextSteps: municipality.nextSteps ?? null,
|
||||
budgetNotes: municipality.budgetNotes ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -217,9 +217,11 @@ describe('municipality catalog seed and per-role access', () => {
|
|||
municipality: administered.id,
|
||||
priority: 'alta',
|
||||
nextSteps: 'Agendar reunião com a executiva municipal.',
|
||||
budgetNotes: 'Emenda de R$ 2 mi para saúde (2025) empenhada.',
|
||||
})
|
||||
expect(updated.priority).toBe('alta')
|
||||
expect(updated.nextSteps).toBe('Agendar reunião com a executiva municipal.')
|
||||
expect(updated.budgetNotes).toBe('Emenda de R$ 2 mi para saúde (2025) empenhada.')
|
||||
fixtures.touchMunicipality(administered.id)
|
||||
|
||||
await expect(
|
||||
|
|
|
|||
111
tests/int/municipalityDossierData.int.spec.ts
Normal file
111
tests/int/municipalityDossierData.int.spec.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// `loadMunicipalityElectoralBaseline` is wrapped in Next's `unstable_cache`,
|
||||
// which needs the Next server runtime (incrementalCache) — unavailable under
|
||||
// vitest. The dossier only forwards its result, so stub the module here.
|
||||
vi.mock('@/utilities/municipalityElectoralBaseline', () => ({
|
||||
loadMunicipalityElectoralBaseline: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
import { createActionPlanRecord } from '@/app/(campaign)/campanha/actions/actionPlan'
|
||||
import { updateMunicipalityStrategyRecord } from '@/app/(campaign)/campanha/actions/municipality'
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
DOSSIER_LEADERSHIP_LIMIT,
|
||||
DOSSIER_SIGNAL_LIMIT,
|
||||
loadMunicipalityDossierData,
|
||||
} from '@/utilities/municipalityDossierData'
|
||||
import {
|
||||
getMunicipalityDetailViewModel,
|
||||
resolveAccessibleMunicipalityContext,
|
||||
} from '@/utilities/municipalityPageData'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('municipality dossier data (E16)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
// The fixture builds ~20 rows sequentially (leaderships, updates, plans),
|
||||
// so this test legitimately outlives the 5s default under parallel load.
|
||||
it(
|
||||
'composes the pre-visit dossier with section caps and full totals',
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
for (let index = 0; index < DOSSIER_LEADERSHIP_LIMIT + 1; index += 1) {
|
||||
const contact = await fixtures.createContact()
|
||||
await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
municipalities: [municipality.id],
|
||||
})
|
||||
}
|
||||
|
||||
for (let index = 0; index < DOSSIER_SIGNAL_LIMIT + 1; index += 1) {
|
||||
await fixtures.createMunicipalityUpdate({
|
||||
municipality: municipality.id,
|
||||
author: coordinator.id,
|
||||
})
|
||||
}
|
||||
|
||||
const upcomingPlan = await createActionPlanRecord(payload, coordinator, {
|
||||
title: fixtures.value('Caminhada dossiê'),
|
||||
kind: 'caminhada',
|
||||
status: 'planejado',
|
||||
startAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
municipality: municipality.id,
|
||||
locality: 'Centro',
|
||||
})
|
||||
fixtures.own('actionPlan', upcomingPlan.id)
|
||||
|
||||
await updateMunicipalityStrategyRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
priority: 'alta',
|
||||
budgetNotes: 'Emenda de bancada 2024 destinada ao hospital municipal.',
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
const context = await resolveAccessibleMunicipalityContext(
|
||||
payload,
|
||||
coordinator,
|
||||
municipality.slug,
|
||||
)
|
||||
const view = await getMunicipalityDetailViewModel(payload, context, coordinator)
|
||||
const dossier = await loadMunicipalityDossierData(payload, coordinator, view)
|
||||
|
||||
expect(dossier.leaderships.rows).toHaveLength(DOSSIER_LEADERSHIP_LIMIT)
|
||||
expect(dossier.leaderships.totalCount).toBe(DOSSIER_LEADERSHIP_LIMIT + 1)
|
||||
expect(dossier.leaderships.rows.every((row) => row.updatedAt)).toBe(true)
|
||||
|
||||
expect(dossier.signals.rows).toHaveLength(DOSSIER_SIGNAL_LIMIT)
|
||||
expect(dossier.signals.totalCount).toBe(DOSSIER_SIGNAL_LIMIT + 1)
|
||||
|
||||
expect(dossier.upcomingPlans.map((plan) => plan.id)).toContain(upcomingPlan.id)
|
||||
expect(dossier.recentPlans).toEqual([])
|
||||
|
||||
// Staff view model exists, so the E8 goal account block is present.
|
||||
expect(dossier.goalAccount).not.toBeNull()
|
||||
expect(dossier.demographics?.population).toBeGreaterThan(0)
|
||||
|
||||
expect(view.strategy?.budgetNotes).toBe(
|
||||
'Emenda de bancada 2024 destinada ao hospital municipal.',
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
@ -104,7 +104,9 @@ describe('campaign visual foundation', () => {
|
|||
'utf8',
|
||||
)
|
||||
|
||||
expect(layoutSource).toContain('className="h-svh min-h-0 overflow-hidden"')
|
||||
expect(layoutSource).toContain(
|
||||
'className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible"',
|
||||
)
|
||||
expect(layoutSource).toContain('data-slot="campaign-content-scroll"')
|
||||
expect(layoutSource).toContain('min-h-0 flex-1 overflow-y-auto overscroll-contain')
|
||||
expect(sidebarSource).toContain('h-svh shrink-0')
|
||||
|
|
|
|||
Loading…
Reference in a new issue