Merge branch 'cursor/5f01d520'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 01:28:24 -03:00
commit bb6a321082
28 changed files with 12686 additions and 21 deletions

View file

@ -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.

View file

@ -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"
}
]
}

View file

@ -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**.

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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.' }

View file

@ -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" />

View file

@ -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;
}
}

View file

@ -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',

View file

@ -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"

View file

@ -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"

View 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>
)

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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"

View file

@ -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({

File diff suppressed because it is too large Load diff

View file

@ -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";`)
}

View file

@ -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',
},
]

View file

@ -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;

View file

@ -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),

View file

@ -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'],

View 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,
}
}

View file

@ -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,
},
})

View file

@ -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(

View 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.',
)
},
)
})

View file

@ -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')