mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
dc2f76e1a1
commit
3d8dbae61f
22 changed files with 1061 additions and 325 deletions
|
|
@ -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).
|
||||
|
|
|
|||
99
docs/plans/municipios-card-mobile-edit-where-you-see-impl.md
Normal file
99
docs/plans/municipios-card-mobile-edit-where-you-see-impl.md
Normal 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
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
418
src/components/campaign/municipality/MunicipalityMobileCard.tsx
Normal file
418
src/components/campaign/municipality/MunicipalityMobileCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue