feat(B193): card de município mobile denso com edit-where-you-see e rodapé de atualização expandível

- Card denso (wireframe): posição de voto à direita, barra de cenários com
  marcador do ativo, chips classe/tendência/nível (label à esquerda), 3 grupos
  de avatares com wrap (um por entidade), rodapé Última atualização + chevron
  que expande revelando o último card de atualização + CTA registrar; sem
  atualização → CTA direto; prioridade = borda lateral direita 6px primary.
- Controles ganham prop trigger/triggerClassName (precedente RelationChipCell);
  MunicipalityRelationAvatarStack ganha modo wrap.
- C109 (sheet quebra ao abrir) corrigida: sessões do sheet host keyed por id
  (imune à identidade dos callbacks recriados por render) e footer customizado
  renderizado pelo host dentro da árvore do Drawer (portal perdia o Dialog
  context — reproduzido digitando no sheet).
- Dado: MunicipalityListViewModel.lastUpdate (batch por página, scoped).
- E2E novos (barra/votos, chip nível, borda prioridade, expansão/CTA vazio).
Sem migration/access/Consent.
This commit is contained in:
Francisco Solla 2026-08-10 09:30:34 -03:00
parent dc2f76e1a1
commit 3d8dbae61f
22 changed files with 1061 additions and 325 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-10):** **B193** (Issue #542) — card de município mobile denso + edit-where-you-see + última atualização expandível. O card de `/campanha/municipios` no mobile deixou o grid de labels e virou composição do wireframe: cabeçalho nome+território com **posição de voto 2022 à direita** (percentual em cima; colocação · votos embaixo), **barra de cenários** (pessimista·central·otimista + marcador do ativo — `VoteEstimateScenarioStrip` como trigger da sheet de votos existente), **chips** classe/tendência/nível (tendência e nível com label à esquerda, dentro do trigger — tocar no label abre a sheet; classe com razão via tooltip que abre no toque), **3 grupos de avatares com label** (Assessores/Lideranças/Dobradinhas — um avatar por entidade, wrap, sem cap; leitura de assessores para não-coordenador), rodapé **"Última atualização há X dias" + chevron que expande o limite inferior** revelando o último card de atualização (badges + corpo + autor · data, sem moldura) + CTA "Registrar atualização" (sheet existente); **sem atualização → o rodapé vira CTA direto** (sem chevron/expansão); prioritário = **borda lateral direita 6px** `border-primary` no lugar do flag; Salvador read-only; "Cobertura" (conta da cadeira) sai do card (fica na tabela/detalhe — gate humano); rodapé mede a idade da **atualização** (não o frescor update∨pledge — coluna desktop intacta). **C109 corrigida no caminho (a "sheet quebra ao abrir" era real):** os callbacks dos controles (autosave `onOpenChange`, `handleOpenChange` do nível) são recriados por render — qualquer re-render com a sheet aberta (digitar no sheet!) fazia o host descartar a sessão e o corpo sumia; sessões do host agora são **keyed por id de sessão** (imune a identidade) e o **footer customizado** (submit + `DialogClose`) passou a ser **registrado e renderizado pelo host dentro da árvore do Drawer** (portal perdia o Dialog context — o e2e antigo só abria a sheet de tendência, sem footer custom, por isso a C109 não reproduzia). Controles ganharam prop `trigger`/`triggerClassName` (precedente `RelationChipCell.trigger`); `MunicipalityRelationAvatarStack` ganhou modo wrap. Dado: `MunicipalityListViewModel.lastUpdate` (batch por página via `loadMunicipalityLastUpdates`, scoped por portfólio). E2E novos (390×844): barra abre sheet e reflete valores salvos, chip Nível abre a sheet, borda de prioridade (6px + cor), expansão/CTA vazio. Sem migration/access/Consent. Intenção [`municipios-card-mobile-edit-where-you-see.md`](plans/municipios-card-mobile-edit-where-you-see.md); impl [`municipios-card-mobile-edit-where-you-see-impl.md`](plans/municipios-card-mobile-edit-where-you-see-impl.md).
**Recently resolved (2026-08-10):** **OPS26** (Issue #571) — `worktree next`/`plan` **no terminal** terminam com o TUI do opencode aberto no worktree: a função `worktree()` de `.agents/shell/worktree.sh` marca `TEQO_WORKTREE_TERMINAL=1` e o script imprime a diretiva `launch opencode <dir> --model deepseek/deepseek-v4-flash --auto [--prompt /work-issue]` **antes** do `cd` (que segue sendo a última linha — contrato OPS24 intacto); a função aplica o cd e executa a linha (sem `exec`: sair do TUI volta ao shell no worktree; sem auto-`kill`). `next` envia `/work-issue` (o comando OPS25 roda o ciclo completo); `plan` abre **sem prompt** — a CLI do opencode não tem pre-fill sem submit (`--prompt` sempre auto-envia; fallback acordado: `/plan-` + autocomplete; gap registrado para o repo do opencode). Presets são constantes em `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, `opencodeLaunchDirective` — pura, unit-testada; trocar de modelo é editar a constante). **Sem o marcador (comando `/worktree` do opencode, automação) a diretiva não é impressa** — nunca abre TUI aninhado; `--stay` suprime cd e launch; `kill` intocado. Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue`. Sem migration/dados/access/UI. Intenção [`worktree-go-abre-opencode-com-presets.md`](plans/worktree-go-abre-opencode-com-presets.md); impl [`worktree-go-abre-opencode-com-presets-impl.md`](plans/worktree-go-abre-opencode-com-presets-impl.md).
**Recently resolved (2026-08-10):** **OPS24** (Issue #569) — `--go` virou o **padrão** do worktree CLI e `kill` volta ao main: `worktree next`/`plan` imprimem `cd <dir>` na última linha **por padrão** (antes exigiam `--go`); `--stay` suprime; `--go` explícito continua aceito como no-op (não quebra hábito/automação). `kill` troca a mensagem "Volte ao main: cd …" (que nunca casava com o sed `s/^cd //p` da função shell) pela **linha máquina `cd <main>` como última linha** — depois do drop dos DBs, que rodava antes — e **não aceita `--stay`** (ficar em cwd destruído é exatamente o bug corrigido; `--go` no kill é no-op). Vale nas duas superfícies que aplicam o cd: função `worktree()` de `.agents/shell/worktree.sh` (agora sempre captura/applica, `--stay` retorna cedo) e comando `/worktree` do opencode (agente aplica o `cd` da última linha sempre, exceto `--stay`). Docs: header/uso do script, AGENTS.md ("Per-worktree environments"), skills `worktree-next-issue`/`local-database`. Sem migration/dados/access; derivação pura (`worktree-env.mjs`/`worktree.mjs` libs) intocada. Intenção [`worktree-go-default-e-kill-volta-ao-main.md`](plans/worktree-go-default-e-kill-volta-ao-main.md); impl [`worktree-go-default-e-kill-volta-ao-main-impl.md`](plans/worktree-go-default-e-kill-volta-ao-main-impl.md).

View file

@ -0,0 +1,99 @@
# Impl: B193 — Card de município mobile: composição densa + edit-where-you-see + última atualização expandível
Status: aprovado
Atualizado em: 2026-08-10
Issue: #542
Intenção: docs/plans/municipios-card-mobile-edit-where-you-see.md
Appetite restante: herdado (~1–1,5 dia eng; recomposição de card + fiação de affordances sobre sheets existentes)
## Leitura da intenção
- **Outcome:** no mobile (`md:hidden`), o card de `/campanha/municipios` vira composição densa na ordem do wireframe (cabeçalho nome+território com posição de voto 2022 à direita; barra de cenários com marcador do ativo; chips classe/tendência/nível; pilhas de avatares sem cap; rodapé última atualização com chevron) com edit-where-you-see em bottom sheets já existentes; toque atravessado abre o detalhe; prioritário ganha borda lateral direita ~6px; rodapé expande revelando o último card de atualização + CTA de registrar; sem atualização → CTA direto.
- **O que NÃO negociar:** assimetria staff/leader (estimativas nunca para liderança — página já é `gate: noLeader`); B13: classe nunca sem razão acessível; Salvador (linha agregada) read-only; desktop da lista e demais listas inalterados; não adicionar dado novo ao card; expansão ≠ feed completo; não recriar editors (reusar variantes `sheet`).
- **O que reavaliar:** (1) a intenção assume que os controles `variant="sheet"` servem o card novo como estão — os **triggers são internos** a cada controle, e a composição densa exige trigger customizado por alvo (barra de cenários, chip com label à esquerda, pilha de avatares com wrap); (2) a intenção não diz de onde vem o "último card de atualização" — o view model da lista **não carrega a última atualização** (só `lastUpdateAt`/`lastSignalAt`), então é dado novo no _load_ da página (não no card); (3) o rodapé hoje usa frescor (max atualização/pledge) — a expansão exige idade da **atualização**, não do sinal.
## Abordagem recomendada
```mermaid
flowchart LR
page["municipios/page.tsx<br/>loadMunicipalityListPageBundle"] -->|"+ lastUpdate por página"| vm["MunicipalityListViewModel<br/>+ lastUpdate: MunicipalityUpdateViewModel | null"]
vm --> cards["MunicipalityListMobileCards (novo)"]
cards --> card["MunicipalityMobileCard (client, estado expandido)"]
card --> bar["ExpectedVotesControl + trigger barra (VoteEstimateScenarioStrip)"]
card --> chips["Trend/Level controls + trigger chip label-esquerda"]
card --> avatars["RelationEditor + trigger pilha wrap (3 grupos)"]
card --> footer["Rodapé: expande | CTA direto (UpdateControl)"]
```
**Opções consideradas:**
- **A — Controles ganham prop `trigger` opcional (recomendada).** Cada controle de edição (`MunicipalityListExpectedVotesControl`, `MunicipalityListLevelControl`, `MunicipalityListTrendControl`, `MunicipalityRelationEditor` + os 3 wrappers de relação) passa a aceitar um `trigger` customizado que substitui o interno; o `CampaignCellEditOverlay` já tem o `aria-label` que cobre a11y de qualquer conteúdo. O card compõe label+valor/barra/pilha dentro do botão, então o toque no **label** abre a sheet (aceite), e o trigger continua `relative` acima do overlay `after:inset-0` (invariante B42 documentado no próprio `CampaignCellEditOverlay`).
- **B — Card posiciona label e trigger como irmãos, e o label abre a sheet via handler próprio.** Duplica o estado de abertura do sheet (dois triggers para um overlay) e fura a maquinaria `openSheet/dismissSheet` do host compartilhado — o host guarda `activeOnOpenChangeRef` e o segundo trigger entraria em conflito. Rejeitada: é a C109 que se descartou; quebraria a invariante "um control = um onOpenChange".
- **C — Recriar o editor no card (componentes novos de sheet).** Rejeitada: rabbit hole "recriar editors" nomeado na intenção; anti-goal explícito.
- **D — Barra/pilhas como affordances estáticas + navegação para a página de edição.** Rejeitada: viola edit-where-you-see (editar "sem sair do card", aceite).
**Decisões de engenharia:**
1. **Dado da expansão — última atualização no view model** (custo de reverter: médio — toca loader e tipo).
- Opções: (a) `lastUpdate: MunicipalityUpdateViewModel | null` no `MunicipalityListViewModel`, carregado em batch por página; (b) fetch per-card no client (N+1, e o read precisa de access server-side); (c) `lastUpdateAt` sozinho (não dá o corpo/autoria do card).
- Recomendação: (a) — um `payload.find` em `municipalityUpdate` com `where: { municipality: { in: idsDaPágina } }`, `sort: '-createdAt'`, select enxuto, `overrideAccess: false` (o `canReadMunicipalityUpdate` escopa por portfólio), primeiro doc por município; nomes de autor via `loadCampaignUserNamesByIds` (mesmo padrão de `loadMunicipalityUpdatesFeed`). Alternativas rejeitadas: (b) porque explode o número de reads por página e expõe o access no client; (c) porque o card expandido precisa de corpo/polaridade/autor (aceite).
- Gatilho de revisitação (barato, não agora): se um município tiver centenas de updates, o batch puxa tudo — `DISTINCT ON` via drizzle quando houver medição (mesma política de `supporterListOverviewAggregate`).
2. **Cobertura (conta da cadeira) sai do card mobile.** O wireframe não a inclui; o card atual mostra "Cobertura" (goal coverage). Fica na tabela desktop e no detalhe. Sinalizo no gate como remoção de dado da superfície (não é "dado novo", é composição nova do wireframe).
3. **Rodapé passa a medir a idade da ATUALIZAÇÃO, não o frescor (update∨pledge).**
- O wireframe é explícito ("Última atualização há X dias", "sem nenhuma atualização registrada → CTA direto") e o estado vazio é chaveado na existência de update, não de pledge. O `SignalAgeReadout`/frescor continua na coluna desktop (`lastSignal`), intacto. A cor de "sinal frio" (`estimate-pending`) é mantida no rodapé quando a **atualização** está fria (≥21 dias), preservando o alerta da fila.
- Sinalizo como divergência pequena de produto no gate (a intenção herda o vocabulário frescor; troco para update-only no card).
4. **Borda de prioridade** — `border-r-[6px] border-primary` no `<article>` quando `isPriority && isStaffView && !isCity`, mantendo o `border-b` separador do B184. O `MunicipalityPriorityIndicator` (flag) sai do card mobile; a coluna desktop e o detalhe não mudam.
5. **Pilha de avatares com wrap** — estender `MunicipalityRelationAvatarStack` (owner, não twin) com modo wrap: `flex-wrap`, sem cap (`maxVisible` indefinido = todos), avatares densos (`size-7`); os três grupos (Assessores/Lideranças/Dobradinhas) são label + pilha dentro do trigger de cada relation editor via prop `trigger` (decisão A).
6. **Chip de classe** — mantém `TerritorialClassCardReadout` (só o rótulo + tooltip com a razão; `CampaignHoverTooltip` já abre no toque — `openOnTouch` default true, satisfaz "razão via tooltip/sheet" sem linha extra). Sem sheet nova.
7. **Barra de cenários** — trigger = `VoteEstimateScenarioStrip` com `markerMode="active-only"` + `labelMode="all"` (os três valores; ativo em destaque — igual ao canvas). Cenário ativo vem do `MunicipalityEstimateScenarioProvider` (já envolve a lista staff). Cidade: sem barra.
8. **Expansão** — estado `expanded` por card (`MunicipalityMobileCard` client); rodapé é `<button>` com `aria-expanded` + chevron rotacionando; conteúdo expandido = último card de atualização **sem moldura** (mesmo fundo; badges polaridade/urgente/adversário + corpo + autor · data — projeção enxuta do `CampaignUpdatesFeedItem` sem borda) + CTA "Registrar atualização" = trigger do `MunicipalityListUpdateControl` (sheet existente). Sem update → rodapé = trigger do `MunicipalityListUpdateControl` estilizado como CTA (sem chevron, sem expansão).
### Componentes / mudanças
- **`MunicipalityListViewModel`** (`src/utilities/municipality/municipalityViewModels.ts`): campo novo `lastUpdate: MunicipalityUpdateViewModel | null` (server-only, staff; leader nunca chega à página). `toMunicipalityListViewModel` ganha parâmetro com default.
- **`loadMunicipalityListPageBundle`** (`src/utilities/municipality/municipalityPageData.ts`): batch de última atualização dos ids da página (`isStaff`), mesmo `Promise.all` das demais leituras.
- **`loadMunicipalityLastUpdates`** (novo, em `municipalityUpdatePageData.ts` — dono dos reads de update): `Map<municipalityID, MunicipalityUpdateViewModel>` com `payload.find` + `overrideAccess: false` + nomes de autor.
- **`MunicipalityRelationEditor`** (`src/components/campaign/shared/`): prop opcional `trigger?: (entries: MunicipalityRelationEntry[]) => ReactNode` (default = avatar stack atual). Precedente: `RelationChipCell.trigger`.
- **`MunicipalityListAdvisorsControl` / `MunicipalityListLeadershipsControl` / `MunicipalityStateDeputyRelationCell`**: passthrough de `trigger`.
- **`MunicipalityListExpectedVotesControl` / `MunicipalityListLevelControl` / `MunicipalityListTrendControl`**: prop opcional `trigger?: ReactNode` (substitui o trigger interno).
- **`MunicipalityRelationAvatarStack`**: modo wrap (prop `wrap` + `maxVisible?: number` sem cap) — editar o owner.
- **`MunicipalityListMobileCards.tsx`**: recompõe o card; extrai `MunicipalityMobileCard.tsx` (client, estado de expansão). Mantém `data-view="mobile-cards"` + `<article>` (contrato de e2e B42/B184).
- **Migration:** sem migration (dado derivado; nenhum campo novo em collection).
- **Access / Consent:** nenhum; `canReadMunicipalityUpdate` já escopa por portfólio; `overrideAccess: false` em tudo.
- **UI:** Impeccable C — recomposição do card existente; tokens `data-theme='campaign'`; reusa shells (`CampaignCellEditOverlay`, host de sheet compartilhado, `VoteEstimateScenarioStrip`, badges, `Drawer`); shape → craft → critique → polish.
### Dados → forma (pergunta 3)
- **Barra de cenários:** forma = faixa com três valores + marcador do ativo (`VoteEstimateScenarioStrip` `markerMode="active-only"` `labelMode="all"`). Rejeitadas: % estadual absoluto (proibido pela intenção), barras empilhadas (custo de leitura alto para uma olhada), somente o valor do ativo (esconde o intervalo pessimista→otimista que a decisão desbloqueia — "a barra mostra onde a estimativa está no intervalo").
- **Idade do rodapé:** "há N dias" (padrão `formatMunicipalitySignalAgeLabel` aplicado a `lastUpdate.createdAt`), frio ≥21 dias.
- **Classe:** só o rótulo (chip) + razão em tooltip que abre no toque — flexiona B13 sem linha extra.
## Fases verificáveis
1. **Server/dados** — `loadMunicipalityLastUpdates` + campo no view model + bundle; `pnpm gate:fast` (tsc/lint/unit). Sem migration.
2. **Controles** — prop `trigger` nos 4 controles + `MunicipalityRelationEditor` + passthroughs; modo wrap na pilha. Unit onde o comportamento dos controles for tocado.
3. **Card novo** — `MunicipalityMobileCard` + recomposição da lista (cabeçalho/barra/chips/avatares/rodapé/expansão/CTA/prioridade/cidade).
4. **E2E** — atualizar B42 (alvo do tap-through: área não-control; trigger de tendência mantém nome), novos testes: expansão (último card + CTA abre sheet), sem-atualização → CTA direto, borda de prioridade, barra abre sheet de votos; rodar `pnpm test:e2e` no spec de municípios + atualizações.
5. **Gates finais** — `pnpm gate:push` completo (incl. knip, cycles, format, build) + Aikido nos arquivos editados.
## Rabbit holes / Não escopo (engenharia)
- Não mexer na maquinaria do sheet host / `CampaignCellEditOverlay` (estado saudável — C109 descartado).
- Não criar variante de card desktop nem tocar tabela/colunas (frescor desktop intacto).
- Não adicionar dado novo ao card (nada de meta da cadeira na barra, nada de contagem de apoios).
- Não otimizar o batch de updates com `DISTINCT ON` sem medição (registrado como gatilho).
- Não recriar o card de atualização do feed: projeção enxuta no card, feed completo fica em `/campanha/atualizacoes`.
## Riscos e mitigação
- **Sobreposição de toque (overlay `after:inset-0` × novos alvos):** todo alvo novo passa pelo trigger `relative` do `CampaignCellEditOverlay` ou recebe `relative` explícito (rodapé, CTA); o tap-through do e2e cobre o contrato.
- **`aria-label` substitui conteúdo do trigger:** os triggers mantêm `triggerLabel` verbosos ("Editar tendência política em X — Favorável"); o `VoteEstimateScenarioStrip` dentro do botão mantém `role="img"` + aria-label próprio (anuncio único, sem interativo aninhado).
- **E2E B42/B184 sensíveis a estrutura:** manter `data-view="mobile-cards"` + `article` + bordas `border-b`; o teste B42 de tap-through troca o alvo (label "Tendência" vira trigger — alvo novo: readout de posição de voto ou subtítulo, garantido não-control).
- **Rodapé muda de frescor para update-only:** divergência pequena de produto — apresentada no gate; coluna desktop preserva frescor.
- **Batch de updates grande:** página = 25 municípios; updates são relatos de campo enxutos; select limita payload; gatilho de revisitação registrado.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (composição, edit-where-you-see, expansão, CTA vazio, borda de prioridade, cidade read-only, assimetria)
- [ ] Invariantes AGENTS/engineering-standards (nada de prod; `overrideAccess: false`; sem migration; identificadores em inglês; copy pt-BR)
- [ ] Testes previstos: e2e de expansão/CTA/borda/barra; tap-through re-verificado; unit onde controles mudam

View file

@ -50,10 +50,13 @@ export const MunicipalityAdvisorAvatarStack = ({
advisors,
isPriority,
maxVisible = 3,
wrap = false,
}: {
advisors: MunicipalityAdvisorAvatarEntry[]
isPriority: boolean
maxVisible?: number
/** B193 — dense mobile card mode: every advisor gets an avatar, rows wrap. */
wrap?: boolean
}) => {
if (!advisors.length) return <MissingAdvisorBadge isPriority={isPriority} />
@ -62,6 +65,7 @@ export const MunicipalityAdvisorAvatarStack = ({
entries={advisors.map((advisor) => ({ id: advisor.id, label: advisor.name }))}
emptyState={null}
maxVisible={maxVisible}
wrap={wrap}
/>
)
}

View file

@ -14,6 +14,7 @@ import type {
EligibleAdvisorOption,
MunicipalityAdvisorSummary,
} from '@/utilities/municipality/municipalityViewModels'
import type { ReactNode } from 'react'
const ADVISORS_ENDPOINT = '/campanha/municipios/advisors'
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar os assessores. Tente novamente.'
@ -26,6 +27,10 @@ type MunicipalityListAdvisorsControlProps = {
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
options: EligibleAdvisorOption[]
variant: CampaignCellEditOverlayVariant
/** B193 — dense mobile card trigger (labelled wrapping stack). */
trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode
/** B193 — dense card trigger styling override. */
triggerClassName?: string
}
const entryOf = (option: { id: number; name: string }): MunicipalityRelationEntry => ({
@ -41,6 +46,8 @@ export const MunicipalityListAdvisorsControl = ({
advisorNamesById,
options,
variant,
trigger,
triggerClassName,
}: MunicipalityListAdvisorsControlProps) => {
const createBridge = useMunicipalityAdvisorCreate()
const createdOptions = createBridge?.createdOptions ?? []
@ -106,6 +113,8 @@ export const MunicipalityListAdvisorsControl = ({
}`
}
emptyState={<MissingAdvisorBadge isPriority={isPriority} />}
trigger={trigger}
triggerClassName={triggerClassName}
createLabel={(name) => `Criar assessor “${name}”`}
createMaxLength={160}
onToggle={toggle}

View file

@ -23,6 +23,7 @@ import {
type VoteEstimateScenarioViewModel,
} from '@/lib/voteEstimate'
import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
import type { ReactNode } from 'react'
const AUTOSAVE_MS = 600
@ -39,6 +40,16 @@ type MunicipalityListExpectedVotesControlProps = {
municipalityID: number
expectedVotes: VoteEstimateScenarioViewModel
pledgeCoverage: MunicipalityPledgeCoverageView | null
/**
* B193 — replaces the internal votes display trigger (e.g. the dense mobile
* card's scenario strip). Receives the LIVE autosave value and the active
* scenario, so the strip reflects an in-flight edit; the `aria-label` below
* still names the reading.
*/
trigger?: (
value: VoteEstimateScenarioViewModel,
activeScenario: VoteEstimateScenario,
) => ReactNode
} & (
| { variant: 'popover'; municipalityName?: string }
| { variant: 'sheet'; municipalityName: string }
@ -50,6 +61,7 @@ export const MunicipalityListExpectedVotesControl = ({
expectedVotes,
pledgeCoverage,
variant,
trigger,
}: MunicipalityListExpectedVotesControlProps) => {
const scenarioContext = useMunicipalityEstimateScenarioOptional()
const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO
@ -91,15 +103,19 @@ export const MunicipalityListExpectedVotesControl = ({
contentClassName="w-[15.5rem] p-3"
preventPopoverAutoFocus
trigger={
<StaffMunicipalityVotesDisplay
expectedVotes={value}
pledgeCoverage={pledgeCoverage}
activeScenario={activeScenario}
layout="compact"
align="center"
suppressHoverPreview={open}
valueClassName="font-medium tabular-nums"
/>
trigger ? (
trigger(value, activeScenario)
) : (
<StaffMunicipalityVotesDisplay
expectedVotes={value}
pledgeCoverage={pledgeCoverage}
activeScenario={activeScenario}
layout="compact"
align="center"
suppressHoverPreview={open}
valueClassName="font-medium tabular-nums"
/>
)
}
>
<div className="relative flex flex-col gap-2.5">

View file

@ -13,6 +13,7 @@ import type {
EligibleLeadershipOption,
MunicipalityLeadershipSummary,
} from '@/utilities/municipality/municipalityViewModels'
import type { ReactNode } from 'react'
const LEADERSHIPS_ENDPOINT = '/campanha/municipios/leaderships'
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar as lideranças. Tente novamente.'
@ -24,6 +25,10 @@ type MunicipalityListLeadershipsControlProps = {
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
options: EligibleLeadershipOption[]
variant: CampaignCellEditOverlayVariant
/** B193 — dense mobile card trigger (labelled wrapping stack). */
trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode
/** B193 — dense card trigger styling override. */
triggerClassName?: string
}
const entryOf = (option: { id: number; name: string }): MunicipalityRelationEntry => ({
@ -38,6 +43,8 @@ export const MunicipalityListLeadershipsControl = ({
leadershipNamesById,
options,
variant,
trigger,
triggerClassName,
}: MunicipalityListLeadershipsControlProps) => {
const createBridge = useMunicipalityLeadershipCreate()
const createdOptions = createBridge?.createdOptions ?? []
@ -96,6 +103,8 @@ export const MunicipalityListLeadershipsControl = ({
}`
}
emptyState={<span className="text-sm text-muted-foreground">Nenhuma</span>}
trigger={trigger}
triggerClassName={triggerClassName}
createLabel={(name) => `Criar liderança “${name}”`}
onToggle={toggle}
onCreate={create}

View file

@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { MunicipalityListEngagementLevelResponse } from '@/app/(campaign)/campanha/(app)/municipios/engagement-level/types'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
@ -41,6 +41,13 @@ type MunicipalityListLevelControlProps = {
levelNote: string | null
levelChangedAt: string | null
variant: CampaignCellEditOverlayVariant
/**
* B193 — replaces the internal level badge trigger (e.g. the dense mobile
* card's "Nível" chip). Receives the LIVE saved level/note.
*/
trigger?: (level: EngagementLevel | null, note: string | null) => ReactNode
/** B193 — dense card chip styling override (shorter, pill-shaped). */
triggerClassName?: string
}
/**
@ -56,6 +63,8 @@ export const MunicipalityListLevelControl = ({
levelNote,
levelChangedAt,
variant,
trigger,
triggerClassName,
}: MunicipalityListLevelControlProps) => {
const [open, setOpen] = useState(false)
// Not the route's success payload: that one always names a level, and a row
@ -246,11 +255,14 @@ export const MunicipalityListLevelControl = ({
}
contentClassName="w-80 p-3"
preventPopoverAutoFocus
triggerClassName={triggerClassName}
trigger={
// The sheet has no hover to carry the rest, so the trigger spells the
// level out there. In the table, absence follows the shared cell pattern
// (`—`) while the button's aria-label still announces "Sem nível".
currentLevel || isSheet ? (
trigger ? (
trigger(currentLevel, currentNote)
) : currentLevel || isSheet ? (
<MunicipalityLevelBadge
level={currentLevel}
note={isSheet ? currentNote : null}

View file

@ -1,37 +1,11 @@
'use client'
import Link from 'next/link'
import type { ReactNode } from 'react'
import {
advisorEntriesFromIds,
MissingAdvisorBadge,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
import { MunicipalityListLeadershipsControl } from '@/components/campaign/municipality/MunicipalityListLeadershipsControl'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import {
SignalAgeReadout,
TerritorialClassCardReadout,
} from '@/components/campaign/municipality/MunicipalityListRowReadouts'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalityListUpdateControl } from '@/components/campaign/municipality/MunicipalityListUpdateControl'
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
import {
MunicipalityStateDeputyRelationCell,
type MunicipalityStateDeputyCreateAction,
} from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
import { Badge } from '@/components/ui/Badge'
import { SALVADOR_CITY_AGGREGATE_LABEL } from '@/lib/salvadorCity'
import { MunicipalityMobileCard } from '@/components/campaign/municipality/MunicipalityMobileCard'
import type { MunicipalityStateDeputyCreateAction } from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
import { municipalityGeographyParts } from '@/utilities/municipality/municipalityLabels'
import { municipalityColumnLabels } from '@/utilities/municipality/municipalityListUrl'
import type {
EligibleAdvisorOption,
EligibleLeadershipOption,
@ -39,7 +13,6 @@ import type {
MunicipalityLeadershipSummary,
MunicipalityListViewModel,
} from '@/utilities/municipality/municipalityViewModels'
import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
type MunicipalityStaffFormAction = (
state: CampaignFormActionState,
@ -66,8 +39,10 @@ export type MunicipalityListMobileCardsProps = {
}
/**
* B42 mobile cards with sheet-variant quick edits. Wrapped by
* `MunicipalityListMobileSection`, which hosts a single shared Drawer.
* B193 dense mobile cards with sheet-variant quick edits (edit-where-you-see).
* Wrapped by `MunicipalityListMobileSection`, which hosts a single shared
* Drawer. Each card is its own component so the "Última atualização" expansion
* keeps per-card state.
*/
export const MunicipalityListMobileCards = ({
municipalities,
@ -95,211 +70,23 @@ export const MunicipalityListMobileCards = ({
className="flex flex-col gap-0 -mx-4 md:mx-0 md:gap-4 @min-[48rem]/municipality-list:hidden"
>
{municipalities.length === 0 ? emptySlot : null}
{municipalities.map((municipality) => {
const names = advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById).map(
(advisor) => advisor.name,
)
const position = municipality.votePosition2022
const isPriority = municipality.priority === 'alta'
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
const isCity = municipality.isCity
return (
<article
key={municipality.id}
className="relative flex flex-col gap-3 rounded-none border-b p-4 last:border-b-0 md:rounded-xl md:border md:last:border-b"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<h3 className="flex items-center gap-1.5 font-medium">
<Link
href={`/campanha/municipios/${municipality.slug}`}
className="rounded-md after:absolute after:inset-0 after:rounded-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:after:rounded-xl"
>
{municipality.name}
</Link>
{isCity ? <Badge variant="secondary">Cidade</Badge> : null}
</h3>
<p className="text-sm text-muted-foreground">
{isCity ? (
<>
{SALVADOR_CITY_AGGREGATE_LABEL} · {region}
</>
) : (
<>
<span className="relative">
<TerritoryLink region={region} />
</span>
{zoneSuffix ? ` ${zoneSuffix}` : null}
</>
)}
</p>
{position ? (
<MunicipalityVotePositionReadout position={position} layout="card" />
) : null}
</div>
{!isCity && isPriority && isStaffView ? (
<MunicipalityPriorityIndicator className="relative size-11" />
) : null}
</div>
{isStaffView ? (
<dl className="grid grid-cols-2 gap-2 text-sm">
<div>
<dt className="text-muted-foreground">Classe</dt>
<dd className="flex flex-wrap items-center gap-2">
<TerritorialClassCardReadout municipality={municipality} />
</dd>
</div>
<div>
<dt className="text-muted-foreground">{municipalityColumnLabels.goalCoverage}</dt>
<dd>
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Votos estimados</dt>
<dd>
{isCity ? (
<span className="text-muted-foreground">—</span>
) : (
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="sheet"
/>
)}
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Nível</dt>
<dd>
{isCity ? (
<span className="text-muted-foreground">—</span>
) : canMoveEngagementLevel ? (
<MunicipalityListLevelControl
municipalityID={municipality.id}
municipalityName={municipality.name}
level={municipality.engagementLevel}
levelNote={municipality.levelNote}
levelChangedAt={municipality.levelChangedAt}
variant="sheet"
/>
) : (
<MunicipalityLevelBadge
level={municipality.engagementLevel}
note={municipality.levelNote}
layout="card"
/>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Tendência</dt>
<dd>
{isCity ? (
<Badge variant="outline">Não registrada</Badge>
) : (
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalityName={municipality.name}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
variant="sheet"
/>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Última atualização</dt>
<dd>
{isCity ? (
// The city never has a signal (nothing is recorded on the
// aggregate) — a plain dash, not the cold-signal alert the
// readout would paint for a null timestamp.
<span className="text-muted-foreground">—</span>
) : (
<MunicipalityListUpdateControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="sheet"
formAction={signalFormAction}
isStaff={isCampaignUnrestricted}
>
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
</MunicipalityListUpdateControl>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Assessores</dt>
<dd>
{isCity ? (
<span className="text-muted-foreground">—</span>
) : isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={isPriority}
advisorNamesById={advisorNamesById}
options={advisorOptions}
variant="sheet"
/>
) : names.length ? (
names.join(', ')
) : (
<MissingAdvisorBadge isPriority={isPriority} />
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Lideranças</dt>
<dd>
{isCity ? (
<span className="text-muted-foreground">—</span>
) : (
<MunicipalityListLeadershipsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentLeadershipIDs={municipality.leadershipIDs}
leadershipNamesById={leadershipNamesById}
options={leadershipOptions}
variant="sheet"
/>
)}
</dd>
</div>
{/* B176 — staff-wide since 2026-08-09; the write stays scoped to
the actor's administered municípios (B37/B157). */}
{isStaffView ? (
<div className="col-span-2">
<dt className="text-muted-foreground">Dobradinhas</dt>
<dd>
{isCity ? (
<span className="text-muted-foreground">—</span>
) : (
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
stateDeputyIDs={municipality.stateDeputyIDs}
options={stateDeputyOptions}
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="sheet"
/>
)}
</dd>
</div>
) : null}
</dl>
) : null}
</article>
)
})}
{municipalities.map((municipality) => (
<MunicipalityMobileCard
key={municipality.id}
municipality={municipality}
advisorNamesById={advisorNamesById}
leadershipNamesById={leadershipNamesById}
isStaffView={isStaffView}
isCoordinator={isCoordinator}
canMoveEngagementLevel={canMoveEngagementLevel}
advisorOptions={advisorOptions}
leadershipOptions={leadershipOptions}
stateDeputyOptions={stateDeputyOptions}
stateDeputyCommitAction={stateDeputyCommitAction}
stateDeputyCreateAction={stateDeputyCreateAction}
signalFormAction={signalFormAction}
isCampaignUnrestricted={isCampaignUnrestricted}
/>
))}
</div>
)

View file

@ -1,7 +1,7 @@
'use client'
import { CircleHelpIcon, MinusIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import type {
MunicipalityListPoliticalTrendResponse,
@ -47,6 +47,13 @@ type MunicipalityListTrendControlProps = {
trendNote: string | null
variant: CampaignCellEditOverlayVariant
triggerPresentation?: 'full' | 'compact' | 'adaptive'
/**
* B193 — replaces the internal trend badge trigger (e.g. the dense mobile
* card's "Tendência" chip). Receives the LIVE autosave trend.
*/
trigger?: (trend: MunicipalityListSavedPoliticalTrend) => ReactNode
/** B193 — dense card chip styling override (shorter, pill-shaped). */
triggerClassName?: string
}
const politicalTrendIcon = {
@ -62,6 +69,8 @@ export const MunicipalityListTrendControl = ({
trendNote,
variant,
triggerPresentation = 'full',
trigger,
triggerClassName,
}: MunicipalityListTrendControlProps) => {
const { open, onOpenChange, value, change, flush, isPending, errorMessage, statusMessage } =
useCampaignCellAutosave<
@ -105,17 +114,18 @@ export const MunicipalityListTrendControl = ({
<TrendIcon className="size-4" aria-hidden="true" />
</Badge>
)
const trigger =
triggerPresentation === 'full' ? (
fullTrigger
) : triggerPresentation === 'compact' ? (
compactTrigger
) : (
<>
<span className="@min-[60rem]/municipality-list:hidden">{compactTrigger}</span>
<span className="hidden @min-[60rem]/municipality-list:inline-flex">{fullTrigger}</span>
</>
)
const triggerNode = trigger ? (
trigger(value)
) : triggerPresentation === 'full' ? (
fullTrigger
) : triggerPresentation === 'compact' ? (
compactTrigger
) : (
<>
<span className="@min-[60rem]/municipality-list:hidden">{compactTrigger}</span>
<span className="hidden @min-[60rem]/municipality-list:inline-flex">{fullTrigger}</span>
</>
)
return (
<CampaignCellEditOverlay
@ -130,12 +140,14 @@ export const MunicipalityListTrendControl = ({
// `MunicipalityListUpdateControl`'s.
triggerLabel={`Editar tendência política em ${municipalityName} — ${trendLabel}`}
triggerBusy={isPending}
triggerClassName={triggerPresentation === 'full' ? undefined : 'min-w-11'}
triggerClassName={
triggerClassName ?? (triggerPresentation === 'full' ? undefined : 'min-w-11')
}
statusMessage={statusMessage}
tooltipContent={hasNote ? <p className="whitespace-pre-wrap">{value.note}</p> : null}
contentClassName="w-72 p-3"
preventPopoverAutoFocus
trigger={trigger}
trigger={triggerNode}
>
<div className="relative flex flex-col gap-3">
{isPending ? (

View file

@ -0,0 +1,418 @@
'use client'
import { ChevronDownIcon } from 'lucide-react'
import Link from 'next/link'
import { useState, type ReactNode } from 'react'
import {
advisorEntriesFromIds,
MunicipalityAdvisorAvatarStack,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListLeadershipsControl } from '@/components/campaign/municipality/MunicipalityListLeadershipsControl'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import { TerritorialClassCardReadout } from '@/components/campaign/municipality/MunicipalityListRowReadouts'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalityListUpdateControl } from '@/components/campaign/municipality/MunicipalityListUpdateControl'
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
import {
MunicipalityRelationAvatarStack,
type MunicipalityRelationEntry,
} from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
import {
MunicipalityStateDeputyRelationCell,
type MunicipalityStateDeputyCreateAction,
} from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
import { VoteEstimateScenarioStrip } from '@/components/campaign/votePledge/VoteEstimateScenarioStrip'
import { Badge } from '@/components/ui/Badge'
import { SALVADOR_CITY_AGGREGATE_LABEL } from '@/lib/salvadorCity'
import {
municipalityUpdatePolarityBadgeVariant,
municipalityUpdatePolarityLabels,
} from '@/lib/schemas/municipalityUpdate'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
import {
municipalityGeographyParts,
politicalTrendBadgeVariant,
politicalTrendLabels,
} from '@/utilities/municipality/municipalityLabels'
import {
formatMunicipalitySignalAgeLabel,
isMunicipalitySignalCold,
municipalitySignalAgeInDays,
} from '@/utilities/municipality/municipalitySignal'
import type {
EligibleAdvisorOption,
EligibleLeadershipOption,
MunicipalityAdvisorSummary,
MunicipalityLeadershipSummary,
MunicipalityListViewModel,
} from '@/utilities/municipality/municipalityViewModels'
import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
timeStyle: 'short',
})
type MunicipalityStaffFormAction = (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
type MunicipalityMobileCardProps = {
municipality: MunicipalityListViewModel
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
isStaffView: boolean
isCoordinator: boolean
isCampaignUnrestricted: boolean
canMoveEngagementLevel: boolean
advisorOptions: EligibleAdvisorOption[]
leadershipOptions: EligibleLeadershipOption[]
stateDeputyOptions: StateDeputyRelationOption[]
stateDeputyCommitAction: MunicipalityStaffFormAction
stateDeputyCreateAction: MunicipalityStateDeputyCreateAction
signalFormAction: MunicipalityStaffFormAction
}
/** Dense chip trigger styling shared by the Tendência/Nível chips (B193). */
const chipTriggerClassName = 'min-h-8 rounded-full px-2.5'
/** Ghost trigger styling for the read-only relation groups (no hover pill). */
const relationTriggerClassName = 'min-h-0 rounded-md bg-transparent px-0 hover:bg-transparent'
const chipLabel = 'text-xs font-medium text-muted-foreground'
const RelationGroupTrigger = ({
label,
entries,
emptyState,
}: {
label: string
entries: MunicipalityRelationEntry[]
emptyState: ReactNode
}) => (
<div className="flex w-full min-w-0 flex-col gap-1.5">
<span className={chipLabel}>{label}</span>
<MunicipalityRelationAvatarStack entries={entries} emptyState={emptyState} wrap />
</div>
)
/**
* B193 — the dense mobile municipality card: header with the 2022 vote
* position on the right, the estimate scenario bar with the active marker,
* classe/tendência/nível chips, three labelled avatar groups and the
* expandable "Última atualização" footer. Every data point is its own edit
* target (bottom sheet) — anything else on the card falls through to the
* stretched município link and opens the detail page.
*/
export const MunicipalityMobileCard = ({
municipality,
advisorNamesById,
leadershipNamesById,
isStaffView,
isCoordinator,
isCampaignUnrestricted,
canMoveEngagementLevel,
advisorOptions,
leadershipOptions,
stateDeputyOptions,
stateDeputyCommitAction,
stateDeputyCreateAction,
signalFormAction,
}: MunicipalityMobileCardProps) => {
const [expanded, setExpanded] = useState(false)
const isCity = municipality.isCity
const isPriority = municipality.priority === 'alta' && isStaffView
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
const position = municipality.votePosition2022
const advisors = advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById)
const update = municipality.lastUpdate
const updateAge = municipalitySignalAgeInDays(update?.createdAt ?? null)
const updateIsCold = isMunicipalitySignalCold(updateAge)
const registerCta = (
<span className="flex min-h-11 w-full items-center justify-center rounded-md border border-dashed border-border px-3 text-sm font-medium">
Registrar atualização
</span>
)
const updateControl = (trigger: ReactNode) => (
<MunicipalityListUpdateControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="sheet"
formAction={signalFormAction}
isStaff={isCampaignUnrestricted}
>
{trigger}
</MunicipalityListUpdateControl>
)
return (
<article
className={cn(
'relative flex flex-col gap-3 rounded-none border-b p-4 last:border-b-0 md:rounded-xl md:border md:last:border-b',
isPriority && !isCity && 'border-r-[6px] border-r-primary',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-0.5">
<h3 className="flex items-center gap-1.5 font-medium">
<Link
href={`/campanha/municipios/${municipality.slug}`}
className="rounded-md after:absolute after:inset-0 after:rounded-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:after:rounded-xl"
>
{municipality.name}
</Link>
{isCity ? <Badge variant="secondary">Cidade</Badge> : null}
</h3>
<p className="text-sm text-muted-foreground">
{isCity ? (
<>
{SALVADOR_CITY_AGGREGATE_LABEL} · {region}
</>
) : (
<>
<span className="relative">
<TerritoryLink region={region} />
</span>
{zoneSuffix ? ` ${zoneSuffix}` : null}
</>
)}
</p>
</div>
{position ? (
<div className="shrink-0">
<MunicipalityVotePositionReadout
position={position}
layout="card"
className="items-end text-right"
/>
</div>
) : null}
</div>
{isStaffView ? (
<>
{!isCity ? (
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="sheet"
trigger={(values, activeScenario) => (
<VoteEstimateScenarioStrip
values={values}
activeScenario={activeScenario}
markerMode="active-only"
labelMode="all"
className="w-full"
stretch
/>
)}
/>
) : null}
<div className="flex flex-wrap items-center gap-1.5">
<TerritorialClassCardReadout municipality={municipality} />
{isCity ? (
<Badge variant="outline">Não registrada</Badge>
) : (
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalityName={municipality.name}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
variant="sheet"
triggerClassName={chipTriggerClassName}
trigger={(trend) => (
<>
<span className={chipLabel}>Tendência</span>
<Badge
variant={trend.status ? politicalTrendBadgeVariant[trend.status] : 'outline'}
>
{trend.status ? politicalTrendLabels[trend.status] : 'Não registrada'}
</Badge>
</>
)}
/>
)}
{isCity ? (
<span className="text-muted-foreground">—</span>
) : canMoveEngagementLevel ? (
<MunicipalityListLevelControl
municipalityID={municipality.id}
municipalityName={municipality.name}
level={municipality.engagementLevel}
levelNote={municipality.levelNote}
levelChangedAt={municipality.levelChangedAt}
variant="sheet"
triggerClassName={chipTriggerClassName}
trigger={(level, note) => (
<>
<span className={chipLabel}>Nível</span>
<MunicipalityLevelBadge level={level} note={note} layout="table" />
</>
)}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<span className={chipLabel}>Nível</span>
<MunicipalityLevelBadge
level={municipality.engagementLevel}
note={municipality.levelNote}
layout="table"
/>
</span>
)}
</div>
<div className="flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1">
{isCity ? (
<span className="text-muted-foreground">—</span>
) : isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={isPriority}
advisorNamesById={advisorNamesById}
options={advisorOptions}
variant="sheet"
trigger={(entries, emptyState) => (
<RelationGroupTrigger
label="Assessores"
entries={entries}
emptyState={emptyState}
/>
)}
triggerClassName={relationTriggerClassName}
/>
) : (
<div className="flex w-full flex-col gap-1.5">
<span className={chipLabel}>Assessores</span>
<MunicipalityAdvisorAvatarStack
advisors={advisors}
isPriority={isPriority}
wrap
/>
</div>
)}
</div>
<div className="min-w-0 flex-1">
{isCity ? (
<span className="text-muted-foreground">—</span>
) : (
<MunicipalityListLeadershipsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentLeadershipIDs={municipality.leadershipIDs}
leadershipNamesById={leadershipNamesById}
options={leadershipOptions}
variant="sheet"
trigger={(entries, emptyState) => (
<RelationGroupTrigger
label="Lideranças"
entries={entries}
emptyState={emptyState}
/>
)}
triggerClassName={relationTriggerClassName}
/>
)}
</div>
<div className="min-w-0 flex-1">
{isCity ? (
<span className="text-muted-foreground">—</span>
) : (
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
stateDeputyIDs={municipality.stateDeputyIDs}
options={stateDeputyOptions}
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="sheet"
trigger={(entries, emptyState) => (
<RelationGroupTrigger
label="Dobradinhas"
entries={entries}
emptyState={emptyState}
/>
)}
triggerClassName={relationTriggerClassName}
/>
)}
</div>
</div>
{isCity ? (
<span className="text-sm text-muted-foreground">—</span>
) : update ? (
<>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
className="relative inline-flex min-h-11 items-center justify-end gap-1.5 rounded-md text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={cn(
'tabular-nums',
updateIsCold
? 'font-medium text-estimate-pending-foreground'
: 'text-muted-foreground',
)}
>
Última atualização {formatMunicipalitySignalAgeLabel(updateAge)}
</span>
<ChevronDownIcon
aria-hidden="true"
className={cn(
'size-4 text-muted-foreground transition-transform',
expanded && 'rotate-180',
)}
/>
</button>
{expanded ? (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={municipalityUpdatePolarityBadgeVariant[update.polarity]}>
{municipalityUpdatePolarityLabels[update.polarity]}
</Badge>
{update.urgent ? <Badge variant="destructive">Urgente</Badge> : null}
{update.adversarySignal ? <Badge variant="outline">Adversário</Badge> : null}
</div>
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{update.body ?? 'Sem texto.'}
</p>
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{update.authorName}</span>
<span aria-hidden="true"> · </span>
{dateTimeFormatter.format(new Date(update.createdAt))}
</p>
</div>
{updateControl(registerCta)}
</div>
) : null}
</>
) : (
updateControl(registerCta)
)}
</>
) : null}
</article>
)
}

View file

@ -11,9 +11,12 @@ export type MunicipalityVotePositionReadoutLayout = 'table' | 'card' | 'search'
export const MunicipalityVotePositionReadout = ({
position,
layout,
className,
}: {
position: MunicipalityVoteRankEntry
layout: MunicipalityVotePositionReadoutLayout
/** B193 — the dense mobile card header right-aligns the card layout. */
className?: string
}) => {
const share = formatVoteSharePercent(position.share)
const rank = formatPlacementOrdinal(position.rank)
@ -28,6 +31,7 @@ export const MunicipalityVotePositionReadout = ({
className={cn(
'flex flex-col gap-0.5 tabular-nums',
layout === 'table' || layout === 'search' ? 'items-end text-right' : 'text-sm',
className,
)}
aria-label={ariaLabel}
>

View file

@ -109,6 +109,8 @@ export const CampaignCellEditOverlay = ({
}: CampaignCellEditOverlayProps) => {
const sharedSheet = useCampaignListSheet()
const wasOpenRef = useRef(open)
/** C109 — the shared drawer session this control opened (see the host). */
const sessionIdRef = useRef<number | null>(null)
// Miss #52: without the provider every opened cell keeps its own Drawer
// root. The fallback below stays for prod resilience, but in dev the
@ -179,23 +181,45 @@ export const CampaignCellEditOverlay = ({
wasOpenRef.current = open
if (!open) {
if (wasOpen) sharedSheet.dismissSheet(onOpenChange)
if (wasOpen && sessionIdRef.current !== null) {
sharedSheet.dismissSheet(sessionIdRef.current, onOpenChange)
sessionIdRef.current = null
}
return
}
sharedSheet.openSheet({
title,
description,
footer,
sheetBodyClassName,
onOpenChange,
})
// Only the false→true transition opens a session: the controls' callbacks
// are recreated per render (autosave `onOpenChange`, level `handleOpenChange`),
// and calling `openSheet` on every re-render made the host dismiss the
// session it just started (C109 — "sheet quebra ao abrir").
if (!wasOpen) {
sessionIdRef.current = sharedSheet.openSheet({
title,
description,
hasCustomFooter: Boolean(footer),
sheetBodyClassName,
onOpenChange,
})
}
}, [variant, sharedSheet, open, title, description, footer, sheetBodyClassName, onOpenChange])
// C109 — the custom footer is REGISTERED here and rendered by the host
// INSIDE the Drawer's React tree; a portal would keep the Dialog context
// away from its `DialogClose`. Re-registers on every change (spinner,
// label) while the session stays active; a stale session's call is a no-op
// in the host.
useLayoutEffect(() => {
if (variant !== 'sheet' || !sharedSheet) return
const sessionId = sessionIdRef.current
if (sessionId === null) return
sharedSheet.setFooterContent(sessionId, open ? (footer ?? null) : null)
}, [variant, sharedSheet, open, footer])
if (variant === 'sheet' && sharedSheet) {
const showPortals = open && sharedSheet.isActiveSheet(onOpenChange)
const showPortals =
open && sessionIdRef.current !== null && sharedSheet.isActiveSheet(sessionIdRef.current)
const bodyTarget = showPortals ? sharedSheet.bodyPortalRef.current : null
// portalRevision keeps this render in sync after the drawer mounts its target.
// portalRevision keeps this render in sync after the drawer mounts its targets.
void sharedSheet.portalRevision
return (

View file

@ -25,18 +25,7 @@ import { cn } from '@/lib/utils'
export type CampaignListSheetChrome = {
title: string
description?: string
/**
* Custom footer for a sheet whose commit is an explicit submit (E14's
* "Registrar movimento", B42's "Registrar atualização"). Rendered INLINE in
* the host's DrawerFooter so its controls resolve the Drawer's context —
* the cell's own tree has no `Drawer.Root`, and a portaled footer keeps the
* source context and crashes base-ui's `DialogClose` (C109). The element is
* still CREATED by the cell (form state, `form` attribute association live
* there); only its render location moves — so it can rely on props/state
* captured by the cell and on providers ABOVE the sheet provider, never on
* providers local to the cell.
*/
footer?: ReactNode
hasCustomFooter: boolean
sheetBodyClassName?: string
onOpenChange: (open: boolean) => void
}
@ -45,9 +34,19 @@ type CampaignListSheetContextValue = {
bodyPortalRef: RefObject<HTMLDivElement | null>
/** Bumps when the drawer body mount target attaches — portals read this. */
portalRevision: number
openSheet: (chrome: CampaignListSheetChrome) => void
dismissSheet: (onOpenChange: (open: boolean) => void) => void
isActiveSheet: (onOpenChange: (open: boolean) => void) => boolean
/** Opens the shared drawer; returns the session id the caller must keep. */
openSheet: (chrome: CampaignListSheetChrome) => number
/** Closes the drawer only when `sessionId` is the active session. */
dismissSheet: (sessionId: number, onOpenChange: (open: boolean) => void) => void
isActiveSheet: (sessionId: number) => boolean
/**
* C109 — the caller's custom footer (submit + `DrawerCloseButton`) must
* render INSIDE the drawer's React tree: the overlay lives outside the
* `Drawer`, so a portal keeps the Dialog context away from `DialogClose`.
* The host renders the registered node for the ACTIVE session only — a
* stale session's close render can never wipe the open drawer's footer.
*/
setFooterContent: (sessionId: number, footer: ReactNode | null) => void
}
const CampaignListSheetContext = createContext<CampaignListSheetContextValue | null>(null)
@ -57,9 +56,7 @@ export const useCampaignListSheet = () => useContext(CampaignListSheetContext)
/**
* One bottom Drawer for an entire list surface (B42 mobile cards). Cell bodies
* portal into it so we never mount hundreds of Drawer roots and never sync form
* trees through provider state (which tripped React #185 in prod). Custom
* footers travel in the chrome instead of portaling: a portaled footer keeps
* the cell's context, which has no `Drawer.Root`, and crashes (C109).
* trees through provider state (which tripped React #185 in prod).
*/
export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) => {
const titleRef = useRef<HTMLHeadingElement | null>(null)
@ -67,27 +64,45 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode })
const [open, setOpen] = useState(false)
const [chrome, setChrome] = useState<CampaignListSheetChrome | null>(null)
const [portalRevision, setPortalRevision] = useState(0)
const [footerContent, setFooterContent] = useState<ReactNode | null>(null)
const activeOnOpenChangeRef = useRef<((open: boolean) => void) | null>(null)
/**
* C109 (2026-08-10, B193) — sheet sessions are keyed by a monotonically
* increasing id instead of the control's `onOpenChange` identity: the
* autosave/level callbacks are recreated per render, so ANY re-render of an
* open control (a keystroke, `isPending`) used to look like "a different
* control opened" — the host dismissed the old session, the drawer stayed
* up with its body portal torn down, and the next `DialogClose` crashed
* outside its root. A session id survives re-renders; only an explicit
* open/close moves it.
*/
const sessionCounterRef = useRef(0)
const activeSessionRef = useRef<number | null>(null)
const attachBodyPortal = useCallback((node: HTMLDivElement | null) => {
bodyPortalRef.current = node
if (node) setPortalRevision((value) => value + 1)
}, [])
const dismissSheet = useCallback((onOpenChange: (open: boolean) => void) => {
if (activeOnOpenChangeRef.current !== onOpenChange) return
const dismissSheet = useCallback((sessionId: number, onOpenChange: (open: boolean) => void) => {
if (activeSessionRef.current !== sessionId) return
setOpen(false)
activeSessionRef.current = null
activeOnOpenChangeRef.current = null
setChrome(null)
setFooterContent(null)
onOpenChange(false)
}, [])
const openSheet = useCallback((next: CampaignListSheetChrome) => {
const openSheet = useCallback((next: CampaignListSheetChrome): number => {
const session = ++sessionCounterRef.current
const previousOnOpenChange = activeOnOpenChangeRef.current
if (previousOnOpenChange && previousOnOpenChange !== next.onOpenChange) {
previousOnOpenChange(false)
}
activeOnOpenChangeRef.current = next.onOpenChange
activeSessionRef.current = session
setFooterContent(null)
setChrome((current) => {
if (
current &&
@ -95,35 +110,37 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode })
current.title === next.title &&
current.description === next.description &&
current.sheetBodyClassName === next.sheetBodyClassName &&
// Deliberately NOT `hasCustomFooter`: a custom footer is a fresh JSX
// element on every cell render, and this comparison is what forces the
// host to adopt it — `isPending`/spinner freshness depends on the
// footer being re-rendered here (the cell no longer renders it itself).
current.footer === next.footer
current.hasCustomFooter === next.hasCustomFooter
) {
return current
}
return next
})
setOpen(true)
return session
}, [])
const isActiveSheet = useCallback(
(onOpenChange: (open: boolean) => void) =>
open && activeOnOpenChangeRef.current === onOpenChange,
(sessionId: number) => open && activeSessionRef.current === sessionId,
[open],
)
const registerFooter = useCallback((sessionId: number, footer: ReactNode | null) => {
if (activeSessionRef.current !== sessionId) return
setFooterContent(footer)
}, [])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) return
const sessionId = activeSessionRef.current
const onOpenChange = activeOnOpenChangeRef.current
if (!onOpenChange) {
if (sessionId === null || !onOpenChange) {
setOpen(false)
setChrome(null)
return
}
dismissSheet(onOpenChange)
dismissSheet(sessionId, onOpenChange)
},
[dismissSheet],
)
@ -135,8 +152,9 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode })
openSheet,
dismissSheet,
isActiveSheet,
setFooterContent: registerFooter,
}),
[portalRevision, openSheet, dismissSheet, isActiveSheet],
[portalRevision, openSheet, dismissSheet, isActiveSheet, registerFooter],
)
return (
@ -161,7 +179,13 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode })
)}
/>
<DrawerFooter>
{chrome.footer ?? <DrawerCloseButton>Fechar</DrawerCloseButton>}
{/* C109 — the custom footer renders HERE, inside the Drawer's
React tree, so its `DialogClose` has the root context; the
body keeps the portal (its content never needs Dialog). */}
{footerContent ? (
<div className="flex w-full flex-col gap-2">{footerContent}</div>
) : null}
{!chrome.hasCustomFooter ? <DrawerCloseButton>Fechar</DrawerCloseButton> : null}
</DrawerFooter>
</DrawerContent>
) : null}

View file

@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { cn } from '@/lib/utils'
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
export type MunicipalityRelationEntry = {
@ -16,18 +17,35 @@ export const MunicipalityRelationAvatarStack = ({
entries,
emptyState,
maxVisible = 3,
wrap = false,
avatarClassName,
}: {
entries: MunicipalityRelationEntry[]
emptyState: ReactNode
maxVisible?: number
/**
* B193 — dense card mode: every entry gets its own avatar (no cap) and the
* row wraps to the available width instead of overlapping in a fixed stack.
*/
wrap?: boolean
avatarClassName?: string
}) => {
if (entries.length === 0) return emptyState
const visible = wrap ? entries : entries.slice(0, maxVisible)
return (
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
{entries.slice(0, maxVisible).map((entry) => (
<Avatar key={entry.id} className="size-8 border-2 border-background">
<div className={cn('flex items-center gap-2', wrap && 'w-full min-w-0 flex-wrap gap-1.5')}>
<div className={cn('flex', wrap ? 'flex-wrap gap-1.5' : '-space-x-2')}>
{visible.map((entry) => (
<Avatar
key={entry.id}
className={cn(
'size-8 border-2 border-background',
wrap && 'size-7 border-0',
avatarClassName,
)}
>
<AvatarFallback>
{campaignUserInitials(entry.initialsLabel ?? entry.label)}
</AvatarFallback>

View file

@ -44,6 +44,16 @@ type MunicipalityRelationEditorProps = {
createErrorMessage?: string
triggerLabel: (entries: MunicipalityRelationEntry[]) => string
emptyState: ReactNode
/**
* B193 — replaces the default avatar-stack trigger (e.g. the dense mobile
* card's labelled, wrapping stack). Same pattern as `RelationChipCell`'s
* `trigger` render prop: the overlay machinery stays, only the closed
* display changes. Receives the relation's own `emptyState` so the custom
* trigger can render the same absence affordance the default stack would.
*/
trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode
/** B193 — dense card trigger styling override (no min-height/hover pill). */
triggerClassName?: string
createLabel: (name: string) => string
createMaxLength?: number
sortSelected?: boolean
@ -76,6 +86,8 @@ export const MunicipalityRelationEditor = ({
onToggle,
onCreate,
onCreated,
trigger,
triggerClassName,
}: MunicipalityRelationEditorProps) => {
const [open, setOpen] = useState(false)
const [selectedIDs, setSelectedIDs] = useState(currentIDs)
@ -270,10 +282,15 @@ export const MunicipalityRelationEditor = ({
triggerBusy={isPending}
statusMessage={statusMessage}
tooltipContent={tooltipContent}
triggerClassName={triggerClassName}
contentClassName="w-80 p-0"
sheetBodyClassName="px-0 pt-2"
trigger={
<MunicipalityRelationAvatarStack entries={selectedEntries} emptyState={emptyState} />
trigger ? (
trigger(selectedEntries, emptyState)
) : (
<MunicipalityRelationAvatarStack entries={selectedEntries} emptyState={emptyState} />
)
}
>
<div className={cn('relative flex shrink-0 flex-col gap-2 px-4 pb-0', !isSheet && 'pt-3')}>

View file

@ -11,6 +11,7 @@ import { stateDeputyDisplayName } from '@/lib/stateDeputyNameParty'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
import type { ReactNode } from 'react'
type MunicipalityStateDeputyRelationCellProps = {
municipalityId: number
@ -23,6 +24,10 @@ type MunicipalityStateDeputyRelationCellProps = {
) => Promise<CampaignFormActionState>
createAction: MunicipalityStateDeputyCreateAction
editorVariant: CampaignCellEditOverlayVariant
/** B193 — dense mobile card trigger (labelled wrapping stack). */
trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode
/** B193 — dense card trigger styling override. */
triggerClassName?: string
}
export type MunicipalityStateDeputyCreateAction = (
@ -64,6 +69,8 @@ export const MunicipalityStateDeputyRelationCell = ({
commitAction,
createAction,
editorVariant,
trigger,
triggerClassName,
}: MunicipalityStateDeputyRelationCellProps) => {
const toggle = async (
stateDeputyID: number,
@ -122,6 +129,8 @@ export const MunicipalityStateDeputyRelationCell = ({
}`
}
emptyState={<span className="text-sm text-muted-foreground">—</span>}
trigger={trigger}
triggerClassName={triggerClassName}
createLabel={(name) => `Criar dobradinha “${name}”`}
createMaxLength={120}
sortSelected={false}

View file

@ -58,6 +58,7 @@ import {
territorialClassSortWeight,
type MunicipalityTerritorialClassification,
} from '@/utilities/municipality/municipalityTerritorialClass'
import { loadMunicipalityLastUpdates } from '@/utilities/municipality/municipalityUpdatePageData'
import {
loadMunicipalityLeadershipSummaries,
municipalityListSelect,
@ -603,13 +604,22 @@ export const loadMunicipalityListPageBundle = async (
// B155 — the leaderships of the visible page only (staff surfaces; the
// leader view already returned above). One reverse batch: ids per município
// for the rows, contact names for the chips.
const leadershipBundle = isStaff
? await loadMunicipalityLeadershipSummaries(
payload,
user,
pageDocs.map((municipality) => municipality.id),
)
: null
const [leadershipBundle, lastUpdatesByMunicipalityID] = await Promise.all([
isStaff
? loadMunicipalityLeadershipSummaries(
payload,
user,
pageDocs.map((municipality) => municipality.id),
)
: null,
isStaff
? loadMunicipalityLastUpdates(
payload,
user,
pageDocs.map((d) => d.id),
)
: new Map(),
])
return {
municipalities: pageDocs.map((municipality) => {
@ -622,6 +632,7 @@ export const loadMunicipalityListPageBundle = async (
leadershipBundle?.leadershipIDsByMunicipality.get(municipality.id) ?? [],
isCity,
isCity ? classBySlug?.get(SALVADOR_CITY_SLUG) : undefined,
lastUpdatesByMunicipalityID.get(municipality.id) ?? null,
)
}),
totalDocs,

View file

@ -102,3 +102,70 @@ export const loadMunicipalityUpdatesPreview = async (
0,
3,
)
/**
* B193 — the LAST update per listed municipality, for the mobile card's
* expandable footer. One batch over the visible page ids: docs arrive sorted
* by `-createdAt`, so the first row per municipality is the latest. The read
* runs with `overrideAccess: false`, so `canReadMunicipalityUpdate` scopes it
* to the actor's portfolio exactly like the C89 feed loader.
*/
export const loadMunicipalityLastUpdates = async (
payload: Payload,
user: CampaignUser,
municipalityIDs: readonly number[],
): Promise<ReadonlyMap<number, MunicipalityUpdateViewModel>> => {
const ids = [...new Set(municipalityIDs)]
if (ids.length === 0) return new Map()
const result = await payload.find({
collection: 'municipalityUpdate',
where: { municipality: { in: ids } },
depth: 0,
limit: 0,
pagination: false,
sort: '-createdAt',
select: {
municipality: true,
author: true,
polarity: true,
urgent: true,
activeVolunteers: true,
newSupports: true,
adversarySignal: true,
body: true,
createdAt: true,
},
user,
overrideAccess: false,
})
const authorIDs = [
...new Set(
result.docs
.map((update) => relationshipId((update as MunicipalityUpdate).author))
.filter((id): id is number => id !== null),
),
]
const authorNameById = await loadCampaignUserNamesByIds(payload, authorIDs)
const lastByMunicipality = new Map<number, MunicipalityUpdateViewModel>()
for (const update of result.docs as MunicipalityUpdate[]) {
const municipalityID = relationshipId(update.municipality)
if (municipalityID === null || lastByMunicipality.has(municipalityID)) continue
const polarity = update.polarity as MunicipalityUpdatePolarity | undefined
lastByMunicipality.set(municipalityID, {
id: update.id,
authorName: authorNameById.get(relationshipId(update.author) ?? -1) ?? 'Usuário',
createdAt: update.createdAt,
body: update.body ?? null,
polarity: polarity && municipalityUpdatePolarities.includes(polarity) ? polarity : 'neutra',
urgent: Boolean(update.urgent),
activeVolunteers: update.activeVolunteers ?? null,
newSupports: update.newSupports ?? null,
adversarySignal: Boolean(update.adversarySignal),
})
}
return lastByMunicipality
}

View file

@ -24,6 +24,7 @@ import {
type MunicipalityTerritorialClassification,
type TerritorialFactor,
} from '@/utilities/municipality/municipalityTerritorialClass'
import type { MunicipalityUpdateViewModel } from '@/utilities/municipality/municipalityUpdatePageData'
import type { StateDeputySummary } from '@/utilities/stateDeputyData'
import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
@ -81,6 +82,13 @@ export type MunicipalityListViewModel = {
* ordering.
*/
lastSignalAt: string | null
/**
* B193 — the LAST municipality update (body, polarity, author), for the
* mobile card's expandable footer; `null` when nothing was ever recorded
* (the footer becomes a direct register CTA). Staff-only — the leader never
* reaches the list page.
*/
lastUpdate: MunicipalityUpdateViewModel | null
expectedVotes: VoteEstimateScenarioViewModel
politicalTrendStatus: PoliticalTrendStatus | null
politicalTrendNote: string | null
@ -109,6 +117,8 @@ export const toMunicipalityListViewModel = (
isCity = false,
/** B178 — override for the city's AGGREGATE class (the artifact lookup by slug would miss). */
territorialClassOverride?: MunicipalityTerritorialClassification,
/** B193 — the last municipality update for the mobile card footer (staff-only). */
lastUpdate: MunicipalityUpdateViewModel | null = null,
): MunicipalityListViewModel => {
const territorialClass =
territorialClassOverride ?? computeMunicipalityTerritorialClass(municipality.slug)
@ -136,6 +146,7 @@ export const toMunicipalityListViewModel = (
municipality.lastUpdateAt ?? null,
pledges?.lastPledgeAt ?? null,
),
lastUpdate,
expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes),
politicalTrendStatus: municipality.politicalTrend?.status ?? null,
politicalTrendNote: municipality.politicalTrend?.note ?? null,

View file

@ -786,15 +786,13 @@ test.describe('Municípios — cards no celular (B42)', () => {
// Anywhere outside a control is the município's own link: the click lands on
// the stretched `after:inset-0` overlay, which belongs to the card heading.
// The tap goes on a field LABEL inside the metrics grid on purpose — the
// first version of this card positioned each grid cell instead of each
// control, which lifted the labels and their padding above the overlay and
// made half the card a tap that neither edited nor navigated.
// B193 recomposed the card and every label became a trigger, so the tap goes
// on the card's top-left padding corner — the one area no control covers.
// `force`: Playwright's hit-target check retries forever here precisely
// BECAUSE the stretched overlay intercepts the pointer — which is the
// behavior under test. Force dispatches the tap at the label's point, the
// overlay receives it (as a user's finger would), and the card navigates.
await card.getByText('Tendência', { exact: true }).click({ force: true })
// behavior under test. Force dispatches the tap at that point, the overlay
// receives it (as a user's finger would), and the card navigates.
await card.click({ position: { x: 6, y: 6 }, force: true })
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`)
await expect(campaignPageChrome(page, municipality.name)).toBeVisible()
})
@ -955,6 +953,160 @@ test.describe('Municípios — filtro e cards sem moldura no celular (B184)', ()
})
})
/**
* B193 (2026-08-10): the mobile card is a dense composition — scenario bar
* with the active marker, chips (classe/tendência/nível), three labelled
* avatar groups — where every data point is its own bottom-sheet trigger
* (edit-where-you-see), the priority município gets a right accent border,
* and the "Última atualização" footer expands to reveal the last update card
* (or becomes a direct register CTA when nothing was ever recorded).
*/
test.describe('Municípios — card denso mobile (B193)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('scenario bar opens the estimate sheet and reflects saved values', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B193'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
const card = page.locator('[data-view="mobile-cards"] article').first()
await expect(card).toBeVisible()
await card.getByRole('button', { name: /Editar votos estimados/ }).click()
const drawer = page.getByRole('dialog', { name: 'Editar votos estimados' })
await expect(drawer).toBeVisible()
await drawer.getByLabel('Pessimista', { exact: true }).fill('7500')
await drawer.getByLabel('Média', { exact: true }).fill('8000')
await drawer.getByLabel('Otimista', { exact: true }).fill('10000')
// The autosave announces "Salvando…" through the trigger's own live region
// (next to the button, outside the drawer); closing flushes the save.
await drawer.getByRole('button', { name: 'Fechar' }).click()
await expect(drawer).toBeHidden()
// The strip is the live autosave value: the card shows all three
// scenarios after the save echoed back.
await expect(card.getByText('7.500')).toBeVisible()
await expect(card.getByText('8.000')).toBeVisible()
await expect(card.getByText('10.000')).toBeVisible()
await expect(page).toHaveURL(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
})
test('the Nível chip (label included) opens the level sheet', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B193 Nível'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
const card = page.locator('[data-view="mobile-cards"] article').first()
await expect(card).toBeVisible()
await card.getByText('Nível', { exact: true }).click()
const drawer = page.getByRole('dialog', { name: 'Registrar nível de envolvimento' })
await expect(drawer).toBeVisible()
await expect(drawer.getByText(municipality.name, { exact: true })).toBeVisible()
await expect(page).toHaveURL(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
})
test('priority município gets the right accent border (B193)', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B193 Prio'),
})
const municipality = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
id: municipality.id,
data: { priority: 'alta' },
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
const card = page.locator('[data-view="mobile-cards"] article').first()
await expect(card).toBeVisible()
await expect(card).toHaveCSS('border-right-width', '6px')
await expect(card).toHaveCSS('border-right-color', 'rgb(197, 20, 20)')
})
test('footer expands to the last update card and offers register; empty footer is a direct CTA', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B193 Upd'),
})
const withUpdate = await fixtures.claimMunicipality()
await fixtures.payload.create({
collection: 'municipalityUpdate',
data: {
municipality: withUpdate.id,
author: coordinator.id,
polarity: 'boa',
body: `Fato de campo B193 em ${withUpdate.name}`,
},
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(withUpdate.name)}`,
)
const card = page.locator('[data-view="mobile-cards"] article').first()
await expect(card).toBeVisible()
// The footer button expands the card bottom and reveals the last update
// card (no surface change) plus the register CTA.
const footer = card.getByRole('button', { name: /Última atualização/ })
await expect(footer).toHaveAttribute('aria-expanded', 'false')
await footer.click()
await expect(footer).toHaveAttribute('aria-expanded', 'true')
await expect(card.getByText(`Fato de campo B193 em ${withUpdate.name}`)).toBeVisible()
await expect(card.getByText('Boa', { exact: true })).toBeVisible()
const registerCta = card.getByRole('button', { name: /Registrar atualização em/ })
await registerCta.click()
const drawer = page.getByRole('dialog', { name: 'Registrar atualização' })
await expect(drawer).toBeVisible()
await expect(drawer.getByText(withUpdate.name, { exact: true })).toBeVisible()
await drawer.getByRole('button', { name: 'Cancelar' }).click()
await expect(drawer).toBeHidden()
// A município with no update at all has no expandable footer: the footer
// IS the register CTA, without chevron or expansion.
const empty = await fixtures.claimMunicipality()
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(empty.name)}`)
const emptyCard = page.locator('[data-view="mobile-cards"] article').first()
await expect(emptyCard).toBeVisible()
await expect(emptyCard.getByRole('button', { name: /Última atualização/ })).toHaveCount(0)
const emptyCta = emptyCard.getByRole('button', { name: /Registrar atualização em/ })
await emptyCta.click()
await expect(page.getByRole('dialog', { name: 'Registrar atualização' })).toBeVisible()
})
})
/**
* B126: FAB quick-actions overlay — no persistent drawer; opens on demand.
*/

View file

@ -80,6 +80,7 @@ const responsiveMunicipality = stub<MunicipalityListViewModel>({
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@ -221,6 +222,7 @@ describe('campaign visual foundation', () => {
priority: 'alta',
lastUpdateAt: null,
lastSignalAt: null,
lastUpdate: null,
expectedVotes: { pessimistic: null, central: 1500, optimistic: null },
politicalTrendStatus: 'favoravel',
politicalTrendNote: null,
@ -267,6 +269,7 @@ describe('campaign visual foundation', () => {
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@ -361,6 +364,7 @@ describe('campaign visual foundation', () => {
leadershipIDs: [],
lastUpdateAt: null,
lastSignalAt: null,
lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@ -421,6 +425,7 @@ describe('campaign visual foundation', () => {
stateDeputyIDs: [],
lastUpdateAt: null,
lastSignalAt: null,
lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@ -553,6 +558,21 @@ describe('campaign visual foundation', () => {
priority: 'alta',
lastUpdateAt: staleSignal,
lastSignalAt: staleSignal,
// B193 — the mobile card footer's "Última atualização" age comes
// from the LAST UPDATE (not the pledge-merged frescor): a stale
// update paints the footer cold, and the register CTA lives inside
// the collapsed expansion.
lastUpdate: {
id: 99,
authorName: 'Maria',
createdAt: staleSignal,
body: 'Visita ao comitê da região.',
polarity: 'boa',
urgent: false,
activeVolunteers: null,
newSupports: null,
adversarySignal: false,
},
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@ -575,9 +595,12 @@ describe('campaign visual foundation', () => {
// "Sem assessor" still appears in the Assessores column (the avatar stack's
// empty state); the status badge is what escalates to "Sem responsável".
expect(html).toContain('Sem responsável')
expect(html).toContain(`há ${staleDays} dias`)
// The mobile footer's age is the last UPDATE's age (B193): the update is
// 40 days old, so the footer reads "há 40 dias" and paints cold — and the
// expansion stays collapsed (the update body is not in the DOM).
expect(html).toContain('Última atualização há 40 dias')
expect(html).not.toContain('Visita ao comitê da região.')
expect(html).toContain('data-signal="cold"')
expect(html).toContain('Última atualização')
})
it('reads "Sem sinal" when nothing was ever recorded for the município', () => {
@ -600,6 +623,7 @@ describe('campaign visual foundation', () => {
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@ -644,6 +668,7 @@ describe('campaign visual foundation', () => {
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@ -694,6 +719,7 @@ describe('campaign visual foundation', () => {
priority: 'alta',
lastUpdateAt: null,
lastSignalAt: null,
lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@ -892,6 +918,7 @@ describe('campaign visual foundation', () => {
priority: 'normal' as const,
lastUpdateAt: null,
lastSignalAt: null,
lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,

View file

@ -552,6 +552,10 @@ describe('advisor scope fragment comes from access/shared.ts', () => {
// B155 — the municipality list surface reads `leadership.municipalities`
// by reverse batch; same distinct form as `leadershipData.ts` above.
'src/utilities/municipality/municipalityViewModels.ts',
// B193 — the mobile card footer batches the LAST update per listed
// municipality (a domain filter over the visible page ids); the actor
// scope still comes from `overrideAccess: false` + `user`.
'src/utilities/municipality/municipalityUpdatePageData.ts',
'src/utilities/visit/visitPlannerData.ts',
'src/utilities/votePledgeData.ts',
// B178 — the city overview batches activities across the 19 Salvador zone