feat(campaign): C87 — atualização unificada (texto + polaridade + urgente)

Substitui o modelo de MunicipalityUpdate (kind/semanal/urgente/nota/sinal + signalType)
por um modelo unificado: body obrigatório + polarity (boa/neutra/ruim) + urgent + adversarySignal.

- Schema: polarity enum obrigatório, urgent boolean default false, adversarySignal boolean
- Wizard: novo WizardUpdateBodyStep com campo de texto + polaridade + urgente
- Lista: controle unificado de atualização com polaridade inline
- Feed: exibe polaridade como badge colorido + indicador de urgência
- V2 status strip: atualização rápida de polaridade
- E11: queries atualizadas para polarity + adversarySignal
- Migração: 20260807_030000_unify_municipality_update (backfill polarity=neutra, urgent de kind, adversarySignal de kind+signalType)
- Testes: 18/18 integração C87, 593/593 total, 1399/1399 unitários
- Fix: testes de state deputy usavam nomes fixos que colidiam entre execuções
This commit is contained in:
Francisco Solla 2026-08-07 05:04:41 -03:00
parent 8c2ec2bddc
commit 212a1f2423
72 changed files with 14449 additions and 1372 deletions

View file

@ -0,0 +1,434 @@
# C87 — Impl: Atualização unificada (texto + polaridade + urgente)
**Status:** plano em elaboração · **Intenção:** `docs/plans/atualizacao-unificada-polaridade-urgente.md` (Issue #396)
**Appetite:** ~1–1,5 dia-eng · **Decisões de gate assinadas no plano**
## 1. Resumo
Eliminar a taxa de `kind` (semanal/urgente/nota/sinal) e o `signalType` tipado do
registro de atualização do município, substituindo por um **único formulário**: texto
livre (`body`) + polaridade (`polarity`: boa/neutra/ruim, obrigatória) + toggle Urgente
(`urgent`). O modelo unificado inclui um toggle extra `adversarySignal` que alimenta o
nível 1 do E11 (alerta de adversário).
**Fora de escopo (mesmo que o nome mude):** Tendência política do município (wizard
`change-trend` — B64, imutável); C88 (thread/responsável/resolvido).
---
## 2. Decisões assumidas (do intent — validar no gate)
| # | Pergunta | Decisão | Justificativa |
| --- | ---------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| D1 | E11 adversary trigger? | **Opção B**: toggle `adversarySignal: boolean` no próprio registro | Mais simples que 5 tipos; a coordenação quer fila de risco confirmada — mesmo custo cognitivo que um campo a mais vs. manter 5 tipos |
| D2 | Polaridade obrigatória? | **Sim** (default implícito Neutra não oferecida — força o gesto) | Intent: "A — força o gesto de classificar sem custo real" |
| D3 | Registros antigos? | **Opção A**: backfill `polarity='neutra'`, concatena worked+failed+needs → body | Sem badge "legado"; feed único |
| D4 | Renomear wizard `register-signal`? | **Sim** → `register-update` / `registrar-atualizacao` | Elimina "sinal" do vocabulário de UI; risco acoplamento documentado no intent |
| D5 | Frescor / "último sinal"? | Mantém lógica (`resolveMunicipalityLastSignalAt` já considera qualquer update + pledge) | Nenhum código de frescor filtra por `kind='sinal'` — confirmação abaixo |
### Confirmação de D5 — frescor não filtra por `kind='sinal'`
`resolveMunicipalityLastSignalAt(lastUpdateAt, lastPledgeAt)` em
`municipalitySignal.ts:14` recebe `lastUpdateAt` de `municipality.lastUpdateAt` (qualquer
update), **não** filtra por `kind`. Confirmação cruzada:
- `municipalityTriggers.ts:333` — `lastSignalAt = resolveMunicipalityLastSignalAt(municipality.lastUpdateAt ?? null, ...)`
- `municipalityV2StatusData.ts:55` — idem
- `municipalityViewModels.ts:126` — idem
Apenas **um** ponto filtra por `kind='sinal'` para frescor-adjacente: `municipalityV2StatusData.ts:22`
`loadLatestSignal` (query explícita `kind: 'sinal'`). → **substituir por `loadLatestUpdate`**
sem filtro de kind, retorna o mais recente de qualquer update.
---
## 3. Mudança de dados (modelo)
### Campos removidos do `MunicipalityUpdate`
- `kind` (enum)
- `signalType` (enum, opcional)
- `worked` (text)
- `failed` (text)
- `needs` (text)
### Campos adicionados
- `polarity` — `enum` (`boa` / `neutra` / `ruim`), **obrigatório**
- `urgent` — `boolean`, default `false`
- `adversarySignal` — `boolean`, default `false` (para E11)
- `body` — `text` (5000 chars), **obrigatório** (substitui worked/failed/needs)
### Campos mantidos inalterados
- `municipality` (relação)
- `author` (relação)
- `activeVolunteers`, `newSupports` (inteiros opcionais)
- `createdAt`, `updatedAt`, `lastUpdateAt` (derivado)
### Labels de polaridade
```ts
municipalityUpdatePolarityLabels = {
boa: 'Boa',
neutra: 'Neutra',
ruim: 'Ruim',
}
```
---
## 4. Migração de dados (migration)
Transformar registros existentes — `municipalityUpdate` migration `@payloadcms/db-postgres`:
```sql
-- 1. Adicionar colunas (feito pelo migrate:create do schema)
ALTER TABLE municipality_updates ADD COLUMN polarity TEXT;
ALTER TABLE municipality_updates ADD COLUMN urgent BOOLEAN DEFAULT FALSE;
ALTER TABLE municipality_updates ADD COLUMN adversary_signal BOOLEAN DEFAULT FALSE;
-- 2. Backfill de dados (data migration dentro da migration)
-- urgente
UPDATE municipality_updates SET urgent = TRUE WHERE kind = 'urgente';
-- sinal + adversary types
UPDATE municipality_updates
SET adversary_signal = TRUE
WHERE kind = 'sinal'
AND signal_type IN ('invasao', 'visita_adversario', 'proposta_broker');
-- body: sinal/nota mantém body; semanal concatena worked||failed||needs
UPDATE municipality_updates
SET body = COALESCE(worked, '') || ' | ' || COALESCE(failed, '') || ' | ' || COALESCE(needs, '')
WHERE kind = 'semanal';
-- polaridade (padrão neutra; não há backfill semântico fino)
UPDATE municipality_updates SET polarity = 'neutra' WHERE polarity IS NULL;
-- 3. Tornar NOT NULL
ALTER TABLE municipality_updates ALTER COLUMN polarity SET NOT NULL;
-- 4. (Opcional) remover colunas antigas — manter dropped columns para segurança
-- (Payload migration pode omitir drop para rollback safety)
```
**Arquivo:** `src/migrations/<timestamp>_unify_municipality_update.ts`
---
## 5. Implementação — fases
### Fase 1: Schema + coleção (dados)
**`src/lib/schemas/municipalityUpdate.ts`**
- Remover: `municipalityUpdateKinds`, `municipalityUpdateKindLabels`, `municipalitySignalTypes`,
`municipalitySignalTypeLabels`, `municipalitySignalTypeDescriptions`, `parseMunicipalitySignalType`,
tipo `MunicipalityUpdateKind`, tipo `MunicipalitySignalType`
- Adicionar: `municipalityUpdatePolarities = ['boa', 'neutra', 'ruim']`, `MunicipalityUpdatePolarity`,
`municipalityUpdatePolarityLabels`, `parseMunicipalityUpdatePolarity`
- Reescrever `municipalityUpdateCreateSchema`: `polarity` (required enum), `urgent` (boolean default false),
`adversarySignal` (boolean default false), `body` (required text 5000), `activeVolunteers`,
`newSupports`, `municipality`. Remover kind/signalType/worked/failed/needs.
**`src/collections/MunicipalityUpdate.ts`**
- Remover fields: `kind`, `signalType`, `worked`, `failed`, `needs`
- Adicionar fields: `polarity` (select, required), `urgent` (checkbox), `adversarySignal` (checkbox, admin-only)
- `beforeValidate` hook: validar que quando `urgent === true` → nada extra (urgent é independente); polaridade required
- `lastUpdateAt` derivado: mantém (qualquer update conta como "sinal")
### Fase 2: Server actions
**`src/app/(campaign)/campanha/actions/municipalityUpdate.ts`**
- Reescrever `createMunicipalityUpdate`: zod parse do novo schema, `payload.create` com `polarity`, `urgent`, `adversarySignal`, `body`
**`src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts`**
- Atualizar `createMunicipalityUpdateFormAction`: parse `polarity` (required), `urgent` (checkbox), `adversarySignal` (staff only), `body` (required). Remover parse de `kind`, `signalType`, `worked/failed/needs`
**`src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts`**
- Substituir `createMunicipalityListSignalFormAction` → `createMunicipalityListUpdateFormAction` (ou unificar)
- Parse do novo schema
### Fase 3: Wizard + navegação (rename `register-signal` → `register-update`)
**`src/lib/campaignActionRoutes.ts`**
- `CampaignWizardActionId`: `'register-signal'` → `'register-update'`
- `CAMPAIGN_WIZARD_ACTION_SLUGS`: `'register-signal': 'registrar-sinal'` → `'register-update': 'registrar-atualizacao'`
- Remover `WIZARD_SIGNAL_TYPE_QUERY_KEY`, `WIZARD_SIGNAL_BODY_QUERY_KEY` (não usados no modelo unificado)
- Remover `wizardSignalHref`, `resolveWizardSignalTypeParam` (substituídos por wizardActionHref padrão)
**`src/lib/wizardActionChain.ts`**
- `WIZARD_CHAIN_AFTER`: `'register-signal'` → `'register-update'` (4 entradas)
- `wizardHrefForChainStep`: remover case `register-signal` (usa `wizardActionHref` padrão)
- `wizardPrincipalStepHref`: idem
**`src/lib/wizardSignalUi.ts`**
- **Arquivo inteiro reescrito como `wizardUpdateUi.ts`** (ou rename):
- Remover `WIZARD_SIGNAL_TYPE_STEP_TITLE`, `WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX`, `WIZARD_SIGNAL_SAVE_LABEL`,
`WIZARD_SIGNAL_SAVED_MESSAGE`, `shouldShowWizardSignalSkip`, `resolveWizardSignalSkip`
- Estes conceitos desaparecem: não há mais step de "tipo de sinal", nem skip de sinal.
- O wizard `register-update` passa a ser: select município → formulário único (texto + polaridade + urgente)
**`src/lib/campaignHomeActions.ts`**
- `CampaignHomeActionId`: `'register-signal'` → `'register-update'`
- Action: label `'Registrar atualização'` (era `'Registrar sinal'`), description
`'Anotar o que aconteceu no município — com polaridade e se é urgente.'`, icon `Megaphone` mantido
- `wizardFlowTitleForActionId`: `'register-signal'` → `'register-update'`
**`src/lib/politicalTrendWizardUi.ts`**
- `resolvePoliticalTrendNotePrefillSource`: remover branch `entryAction === 'register-signal'` +
`signalType`/`signalBody`. O `register-update` não mais alimenta prefill de tendência com texto de sinal.
(Se ainda quiser prefill, pode passar `signalBody` como `notePrefill` direto — mas simplifica: remover o ramo)
**`src/lib/campaignWizardCopy.ts`**
- Verificar se tem copy específico de "sinal" — atualizar para "atualização"
### Fase 4: Quick actions (registries que referenciam `register-signal`)
Estes arquivos enumeram `register-signal` em `DETAIL_WIZARD_ACTION_IDS`, `LEADERSHIP_DETAIL_WIZARD_IDS`, etc.:
- `src/lib/campaignQuickActionLeadership.ts:22` — `'register-signal'` → `'register-update'`
- `src/lib/activityQuickActions.ts:20` — idem
- `src/lib/campaignQuickActionDemands.ts:17` — idem
### Fase 5: View Models (server-side)
**`src/utilities/municipality/municipalityUpdatePageData.ts`**
- `MunicipalityUpdateViewModel`: remover `kind`, `signalType`, `worked`, `failed`, `needs`;
adicionar `polarity: MunicipalityUpdatePolarity`, `urgent: boolean`, `adversarySignal: boolean`
- `MunicipalityUpdateFeedState`: remover `kind?` — feed mostra tudo (sem filtro por tipo)
- `parseMunicipalityUpdateFeedParams`: remover `updateKind`; manter `updatePage`
- `loadMunicipalityUpdatesFeed`: remover filtro `kind`; select `polarity`, `urgent`, `adversarySignal`
- `loadMunicipalityUpdatesPreview`: idem (chama feed sem kind)
**`src/utilities/municipality/municipalityV2StatusView.ts`**
- `MunicipalityV2StatusViewModel`: remover `lastSignalType`, `lastSignalBody`; adicionar
`lastUpdatePolarity: MunicipalityUpdatePolarity | null`, `lastUpdateBody: string | null`
- `MunicipalityV2StatusNotes`: remover `signalType`, `signalBody`; adicionar `updatePolarity`, `updateBody`
- `MunicipalityV2SignalSelectState` → `MunicipalityV2UpdateState` (valor = polarity)
- `resolveMunicipalityV2SignalSelectState` → `resolveMunicipalityV2UpdateState(input: { polarity, lastSignalAt })`
— agora o "select" mostra polarity, não tipo de sinal
- `buildMunicipalityV2StatusAggregate`: atualizar para usar polarity/body
**`src/utilities/municipality/municipalityV2StatusData.ts`**
- `loadLatestSignal` → `loadLatestUpdate`: query `municipalityUpdate` sem kind filter, sorted
`-createdAt`, retorna `{ polarity, body }`
- `loadMunicipalityV2StatusData`: atualizar chamada
**`src/utilities/municipality/municipalityViewModels.ts`**
- Verificar uso de `lastSignalType`/`lastSignalBody` no `MunicipalityListViewModel` (line 80:
`lastSignalAt`) — confirma que `lastSignalAt` continua (nome genérico), mas
`lastSignalType` específico deve virar `lastUpdatePolarity` se exposto
**`src/utilities/municipality/municipalityPageData.ts`**
- Verificar se `lastUpdateAt` é populado (confirmação: é campo derivado da coleção, não precisa de mudança)
### Fase 6: Gatilhos (municipalityTriggers.ts)
**`src/utilities/municipality/municipalityTriggers.ts`**
- Remover `ADVERSARY_SIGNAL_TYPES` array (linha 108)
- Query E11 (linha 224): `{ kind: { equals: 'sinal' }, signalType: { in: ADVERSARY_SIGNAL_TYPES } }`
→ `{ adversarySignal: { equals: true } }`
- O resto (`adversarySignalMunicipalityIDs`, `hasAdversarySignal` no input) continua igual
### Fase 7: UI components
**`src/components/campaign/municipality/MunicipalityUpdateForm.tsx`**
- Reescrever: remover kind selector; renderizar sempre `body` (textarea, required),
`polarity` (radio buttons Boa/Neutra/Ruim, required), `urgent` (checkbox),
`adversarySignal` (checkbox, staff only)
- Remover importação de `municipalityUpdateKinds`, `municipalitySignalTypes`, etc.
**`src/components/campaign/municipality/MunicipalitySignalFields.tsx`**
- **Arquivo reescrito como `MunicipalityUpdateFields.tsx`**:
- `body` textarea (required)
- `polarity` radio buttons (Boa/Neutra/Ruim)
- `urgent` checkbox
- `adversarySignal` checkbox (staff only)
- Remover signalType select + descriptions
**Componentes que referenciam `MunicipalitySignalFields`:**
- `MunicipalityListSignalControl.tsx` → **rename para `MunicipalityListUpdateControl.tsx`**
- Import: `MunicipalitySignalFields` → `MunicipalityUpdateFields`
- Label: "Registrar sinal" → "Registrar atualização"
- Trigger text: "Registrar sinal em {name}" → "Registrar atualização em {name}"
**`src/components/campaign/municipality/MunicipalityUpdateFeed.tsx`**
- Remover `kindVariant` (semanal/urgente/nota/sinal badge mapping)
- Badge: `polarity` (cor: boa=green, neutra=gray, ruim=orange)
- `urgent` → badge ou prefixo "Urgente"
- Render: sempre mostra `body` (remover branching `kind === 'semanal'`)
- Remover `signalType` badge
**`src/components/campaign/municipality/MunicipalityV2StatusStrip.tsx`**
- Status strip: substituir signal type select por polarity badge + body do último update
- O "select" do strip vira: mostrar polarity do último update, ou botão "Registrar atualização"
**`src/components/campaign/municipality/MunicipalityListRowReadouts.tsx`**
- Se faz referência a `lastSignal` label → "Última atualização"
**`src/components/campaign/municipality/MunicipalityListMobileCards.tsx`**
- "Último sinal" → "Última atualização"
**`src/components/campaign/municipality/MunicipalityList.tsx`**
- Se usa `MunicipalityListSignalControl` → renomear referência
- Coluna `lastSignal` → `lastUpdate` ou manter `lastSignal` com label "Última atualização"
**`src/components/campaign/municipality/WizardSignalTypeStep.tsx`** → **DELETE** (não existe mais step de tipo)
**`src/components/campaign/municipality/WizardSignalBodyStep.tsx`** → reescrever como `WizardUpdateBodyStep.tsx`
(único step: texto + polaridade + urgente)
### Fase 8: Labels / cópias / URLs
**`src/utilities/municipality/municipalityLabels.ts`**
- `MunicipalityListColumnId`: `'lastSignal'` → `'lastUpdate'` (ou manter `lastSignal` com label
"Última atualização" — verificar uso do id em query params)
- `municipalityColumnDescriptions['lastSignal']` → "Última atualização registrada (staff ou liderança)."
- Sort key `lastSignal` → `lastUpdate` (verificar `municipalityListUrl.ts` — o sort key é `lastUpdateAt`, já está separado do column id)
**`src/lib/municipalitySignalTypeMeta.ts`**
- **DELETE** (metadata por tipo de sinal) — não usado no modelo unificado.
Verificar referências: `municipalitySignalTypeMeta` / `municipalitySignalTypeMetaByType`
### Fase 9: Notificações
**`src/utilities/notification/notificationEvents.ts`**
- Verificar uso de `municipalityUpdateKindLabels` — remover referência a kind.
Substituir por `municipalityUpdatePolarityLabels` se a notificação menciona polaridade.
### Fase 10: Migração + geração de tipos
```bash
pnpm migrate:create unify_municipality_update
pnpm migrate # local
pnpm generate:types # payload-types
```
---
## 6. Arquivos de teste a atualizar (mapeamento)
| Arquivo | Mudança |
| ------------------------------------------------------------ | ------------------------------------------------------------------- |
| `tests/unit/campaignActionRoutes.unit.spec.ts` | `register-signal` → `register-update`, slug `registrar-atualizacao` |
| `tests/unit/wizardActionChain.unit.spec.ts` | idem; remove `register-signal` do chain |
| `tests/unit/wizardSignalUi.unit.spec.ts` | **DELETE** → novo `wizardUpdateUi.unit.spec.ts` |
| `tests/unit/politicalTrendWizardUi.unit.spec.ts` | remove prefill de signal; `register-update` no chain |
| `tests/unit/campaignQuickAction.unit.spec.ts` | `register-signal` → `register-update`, slug |
| `tests/unit/campaignHomeActions.unit.spec.ts` | `register-signal` → `register-update`, label |
| `tests/unit/campaignMunicipalityQuickActions.unit.spec.ts` | `register-signal` → `register-update` |
| `tests/unit/campaignMunicipalityV2QuickActions.unit.spec.ts` | idem |
| `tests/unit/campaignQuickActionLeadership.unit.spec.ts` | `register-signal` → `register-update` |
| `tests/unit/campaignWizardCopy.unit.spec.ts` | slug `registrar-sinal` → `registrar-atualizacao` |
| `tests/int/campaignMunicipalityUpdate.int.spec.ts` | schema: `kind`/`signalType` → `polarity`/`urgent`/`adversarySignal` |
| `tests/unit/municipalityV2StatusView.unit.spec.ts` | `signalType` → `polarity` |
| `tests/e2e/campaignHomeActions.e2e.spec.ts` | `register-signal` → `register-update`, slug |
### Novos testes a adicionar
- Unit: `wizardUpdateUi.unit.spec.ts` (skip logic simplificado — não há mais tipo de sinal)
- Unit: schema `municipalityUpdatePularity` parse
- Int: create/update flow with polarity + urgent
- Int: E11 adversarySignal trigger (polarity ruim + urgent não dispara — só adversarySignal)
---
## 7. Inventário completo de referências a `register-signal` / `sinal`
```
SOURCE FILES:
src/lib/campaignActionRoutes.ts — id, slug, wizardSignalHref, query key constants
src/lib/wizardActionChain.ts — WIZARD_CHAIN_AFTER x4, wizardHrefForChainStep, wizardPrincipalStepHref
src/lib/wizardSignalUi.ts — TODO type step title, skip, saved msg (todo: substituir)
src/lib/campaignHomeActions.ts — id, label "Registrar sinal", description
src/lib/politicalTrendWizardUi.ts — resolveWizardTrendSkip, buildPoliticalTrendNotePrefill(sinal)
src/lib/campaignQuickActionLeadership.ts — register-signal in LEADERSHIP_DETAIL_WIZARD_IDS
src/lib/activityQuickActions.ts — register-signal in WIZARD_ACTION_IDS
src/lib/campaignQuickActionDemands.ts — register-signal in DETAIL_WIZARD_ACTION_IDS
src/components/campaign/municipality/MunicipalityUpdateForm.tsx — kind=signal conditional
src/components/campaign/municipality/MunicipalitySignalFields.tsx — signalType select
src/components/campaign/municipality/MunicipalityListSignalControl.tsx — "Registrar sinal"
src/components/campaign/municipality/WizardSignalTypeStep.tsx — step de tipo
src/components/campaign/municipality/WizardSignalBodyStep.tsx — step de corpo
src/lib/municipalitySignalTypeMeta.ts — metadata por tipo (DELETE)
TEST FILES:
tests/unit/campaignActionRoutes.unit.spec.ts
tests/unit/wizardActionChain.unit.spec.ts
tests/unit/wizardSignalUi.unit.spec.ts (DELETE)
tests/unit/politicalTrendWizardUi.unit.spec.ts
tests/unit/campaignQuickAction.unit.spec.ts
tests/unit/campaignHomeActions.unit.spec.ts
tests/unit/campaignMunicipalityQuickActions.unit.spec.ts
tests/unit/campaignMunicipalityV2QuickActions.unit.spec.ts
tests/unit/campaignQuickActionLeadership.unit.spec.ts
tests/unit/campaignWizardCopy.unit.spec.ts
tests/int/campaignMunicipalityUpdate.int.spec.ts (schema assertions)
tests/unit/municipalityV2StatusView.unit.spec.ts
tests/e2e/campaignHomeActions.e2e.spec.ts
```
---
## 8. Riscos & mitigações
| Risco | Mitigação |
| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Renomear wizard action ID quebra deep links `/campanha/acoes/registrar-sinal` | Documentar redirect legacy slug → `registrar-atualizacao` em route handler (Next redirect) |
| E11 perde alertas de adversário históricos | Migration backfill: `signalType IN (invasao, visita_adversario, proposta_broker)` → `adversarySignal=true` |
| Status strip v2 usava signal type select para abrir wizard — UX perdida | O botão "Registrar atualização" no strip abre o wizard unificado; polarity badge é read-only |
| `polarity` em payload-types gera tipos estendidos | `pnpm generate:types` após migrate + build |
| Notificações mencionam `municipalityUpdateKindLabels` | Grep + atualizar para polarity label |
---
## 9. Verificação (checklist de saída)
- [ ] `pnpm migrate:status` — migration aplicada localmente
- [ ] `pnpm generate:types` — tipos atualizados
- [ ] `pnpm exec tsc --noEmit` — sem erros de tipo
- [ ] `pnpm lint` — zero warnings
- [ ] `pnpm format:check`
- [ ] `pnpm exec knip` — sem arquivos órfãos (WizardSignalTypeStep, municipalitySignalTypeMeta, etc.)
- [ ] `pnpm check:cycles`
- [ ] `pnpm test` — unit + int passando
- [ ] `pnpm test:e2e`
- [ ] `pnpm build` (local DB)
---
## 10. Decisões pendentes no gate
1. **Renomear wizard `register-signal` → `register-update`?** (D4) — Sim, mas valida no gate.
2. **`adversarySignal` como campo explícito (Opção B)?** (D1) — Sim, assume B.
3. **Remover colunas `kind`/`signalType`/`worked`/`failed`/`needs` da migration?** — Recomendado: não dropar (rollback safety); Payload ignora columns não mapeados.
4. **Texto do botão no home: "Registrar atualização" ou "Anotar o quê houve"?** — "Registrar atualização" (menor troca de copy, alinhado com label da coleção em admin).
5. **Coluna `lastSignal` no list — manter id ou renomear para `lastUpdate`?** — Manter id `lastSignal` (é um sort key interno; o label UI vira "Última atualização"). Evita quebrar query params existentes.

View file

@ -1,10 +1,9 @@
import { notFound, redirect } from 'next/navigation'
import { WizardLeadershipStep } from '@/components/campaign/leadership/WizardLeadershipStep'
import { WizardSignalBodyStep } from '@/components/campaign/municipality/WizardSignalBodyStep'
import { WizardSignalTypeStep } from '@/components/campaign/municipality/WizardSignalTypeStep'
import { WizardTrendChoiceStep } from '@/components/campaign/municipality/WizardTrendChoiceStep'
import { WizardTrendNoteStep } from '@/components/campaign/municipality/WizardTrendNoteStep'
import { WizardUpdateBodyStep } from '@/components/campaign/municipality/WizardUpdateBodyStep'
import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep'
import { WizardMunicipalitySearchStep } from '@/components/campaign/shared/WizardMunicipalitySearchStep'
import { WizardMunicipalitySelectedStub } from '@/components/campaign/shared/WizardMunicipalitySelectedStub'
@ -16,7 +15,6 @@ import {
parseWizardLeadershipIdParam,
parseWizardMunicipioParam,
parseWizardReturnPath,
resolveWizardSignalTypeParam,
resolveWizardTrendStatusParam,
WIZARD_ENTRY_ACTION_QUERY_KEY,
WIZARD_LEADERSHIP_ID_QUERY_KEY,
@ -24,15 +22,13 @@ import {
WIZARD_NOTE_PREFILL_QUERY_KEY,
WIZARD_RETURN_PATH_QUERY_KEY,
WIZARD_SCENARIO_QUERY_KEY,
WIZARD_SIGNAL_BODY_QUERY_KEY,
WIZARD_SIGNAL_TYPE_QUERY_KEY,
WIZARD_TREND_STATUS_QUERY_KEY,
WIZARD_VOTE_FROM_QUERY_KEY,
WIZARD_VOTE_TO_QUERY_KEY,
wizardActionHref,
wizardSignalHref,
wizardTrendHref,
} from '@/lib/campaignActionRoutes'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import {
buildPoliticalTrendNotePrefill,
resolvePoliticalTrendNotePrefillSource,
@ -55,8 +51,6 @@ const politicalTrendWizardPrefillParams = (
): Record<string, string> | undefined => {
const keys = [
WIZARD_NOTE_PREFILL_QUERY_KEY,
WIZARD_SIGNAL_BODY_QUERY_KEY,
WIZARD_SIGNAL_TYPE_QUERY_KEY,
WIZARD_VOTE_FROM_QUERY_KEY,
WIZARD_VOTE_TO_QUERY_KEY,
] as const
@ -181,15 +175,10 @@ export default async function CampaignActionWizardPage({
const entryAction = parseWizardEntryActionParam(
resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY],
)
const { signalType } = resolveWizardSignalTypeParam(
resolvedSearchParams[WIZARD_SIGNAL_TYPE_QUERY_KEY],
)
const prefillExtraParams = politicalTrendWizardPrefillParams(resolvedSearchParams)
const prefillSource = resolvePoliticalTrendNotePrefillSource({
entryAction,
notePrefill: resolvedSearchParams[WIZARD_NOTE_PREFILL_QUERY_KEY],
signalType,
signalBody: resolvedSearchParams[WIZARD_SIGNAL_BODY_QUERY_KEY],
voteFrom: resolvedSearchParams[WIZARD_VOTE_FROM_QUERY_KEY],
voteTo: resolvedSearchParams[WIZARD_VOTE_TO_QUERY_KEY],
})
@ -249,39 +238,20 @@ export default async function CampaignActionWizardPage({
)
}
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']) {
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['register-update']) {
const entryAction = parseWizardEntryActionParam(
resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY],
)
const { signalType, invalid } = resolveWizardSignalTypeParam(
resolvedSearchParams[WIZARD_SIGNAL_TYPE_QUERY_KEY],
)
if (invalid) {
redirect(wizardSignalHref(slug, municipalitySlug, undefined, entryAction))
}
if (signalType) {
return (
<WizardSignalBodyStep
actionSlug={slug}
municipalityId={municipality.id}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
signalType={signalType}
entryAction={entryAction}
returnPath={returnPath}
/>
)
}
return (
<WizardSignalTypeStep
<WizardUpdateBodyStep
actionSlug={slug}
municipalityId={municipality.id}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
entryAction={entryAction}
returnPath={returnPath}
isStaff={isStaffCampaignRole(user.role)}
/>
)
}

View file

@ -1,9 +1,9 @@
'use server'
import { createMunicipalityListSignalFormAction } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions'
import { createMunicipalityListUpdateFormAction } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
/** Thin alias — same write path as the list signal control (B147). */
/** Thin alias — same write path as the list update control (B147). */
export const createMunicipalityV2SignalFormAction = async (
formData: FormData,
): Promise<CampaignFormActionState> => createMunicipalityListSignalFormAction({}, formData)
): Promise<CampaignFormActionState> => createMunicipalityListUpdateFormAction({}, formData)

View file

@ -13,6 +13,7 @@ import { MunicipalityVisitEligibilityCard } from '@/components/campaign/municipa
import { MunicipalityZoneNeighborhoodsCard } from '@/components/campaign/municipality/MunicipalityZoneNeighborhoodsCard'
import { CampaignListPagination } from '@/components/campaign/shared/CampaignListPagination'
import { SuggestionsPanel } from '@/components/campaign/suggestion/SuggestionsPanel'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import type { getCampaignUser } from '@/utilities/campaignAuth'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
@ -340,19 +341,21 @@ export const UpdatesTab = async ({
}) => {
const feedState = parseMunicipalityUpdateFeedParams(rawSearchParams)
const feed = await loadMunicipalityUpdatesFeed(payload, user, municipalityID, feedState)
const isStaff = isStaffCampaignRole(user.role)
return (
<div className="flex flex-col gap-6">
<MunicipalityUpdateForm
municipalityID={municipalityID}
formAction={createMunicipalityUpdateFormAction}
isStaff={isStaff}
/>
<MunicipalityUpdateFeed updates={feed.updates} />
<CampaignListPagination
page={feed.page}
totalPages={feed.totalPages}
hrefForPage={(page) =>
`/campanha/municipios/${municipalitySlug}?tab=updates${feedState.kind ? `&updateKind=${feedState.kind}` : ''}&updatePage=${page}`
`/campanha/municipios/${municipalitySlug}?tab=updates&updatePage=${page}`
}
/>
</div>

View file

@ -4,21 +4,26 @@ import { revalidatePath } from 'next/cache'
import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
import {
optionalFormText,
optionalIntegerFormValue,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
municipalityUpdateKinds,
parseMunicipalitySignalType,
type MunicipalityUpdateKind,
MUNICIPALITY_UPDATE_POLARITY_REQUIRED_MESSAGE,
parseMunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
/** Checkbox pairs with hidden "false"; last value wins (same as leadership exclusive). */
const formBoolean = (formData: FormData, field: string): boolean => {
const values = formData.getAll(field)
if (values.length === 0) return false
return values.at(-1) === 'true'
}
export const createMunicipalityUpdateFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
@ -26,21 +31,19 @@ export const createMunicipalityUpdateFormAction = async (
runCampaignFormAction({
execute: async () => {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const rawKind = requiredFormText(formData, 'kind')
const kind = municipalityUpdateKinds.includes(rawKind as MunicipalityUpdateKind)
? (rawKind as MunicipalityUpdateKind)
: 'semanal'
const polarity = parseMunicipalityUpdatePolarity(requiredFormText(formData, 'polarity'))
if (!polarity) {
throw new Error(MUNICIPALITY_UPDATE_POLARITY_REQUIRED_MESSAGE)
}
await createMunicipalityUpdate({
municipality,
kind,
worked: optionalFormText(formData, 'worked'),
failed: optionalFormText(formData, 'failed'),
needs: optionalFormText(formData, 'needs'),
body: optionalFormText(formData, 'body'),
body: requiredFormText(formData, 'body'),
polarity,
urgent: formBoolean(formData, 'urgent'),
activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
newSupports: optionalIntegerFormValue(formData, 'newSupports'),
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
adversarySignal: formBoolean(formData, 'adversarySignal'),
})
revalidatePath('/campanha/municipios/[slug]', 'page')
return { message: 'Atualização registrada com sucesso.' }

View file

@ -14,6 +14,7 @@ import {
import {
nullableFormText,
optionalFormText,
optionalIntegerFormValue,
optionalMunicipalitySlugFromForm,
repeatedRelationshipFormValues,
requiredFormBoolean,
@ -27,14 +28,16 @@ import {
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
parsePoliticalTrendStatusFormValue,
} from '@/lib/schemas/municipality'
import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import {
MUNICIPALITY_UPDATE_POLARITY_REQUIRED_MESSAGE,
parseMunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import {
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
STATE_DEPUTY_NAME_REQUIRED_MESSAGE,
STATE_DEPUTY_STAFF_MESSAGE,
} from '@/lib/schemas/stateDeputy'
import { WIZARD_SIGNAL_SAVED_MESSAGE } from '@/lib/wizardSignalUi'
import {
runCampaignFormAction,
type CampaignFormActionState,
@ -101,24 +104,39 @@ export const assignMunicipalityAdvisorsFormAction = async (
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
})
export const createMunicipalityListSignalFormAction = async (
/** Checkbox pairs with hidden "false"; last value wins (same as leadership exclusive). */
const formBoolean = (formData: FormData, field: string): boolean => {
const values = formData.getAll(field)
if (values.length === 0) return false
return values.at(-1) === 'true'
}
export const createMunicipalityListUpdateFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
const polarity = parseMunicipalityUpdatePolarity(requiredFormText(formData, 'polarity'))
if (!polarity) {
throw new Error(MUNICIPALITY_UPDATE_POLARITY_REQUIRED_MESSAGE)
}
await createMunicipalityUpdate({
municipality,
kind: 'sinal',
body: optionalFormText(formData, 'body'),
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
body: requiredFormText(formData, 'body'),
polarity,
urgent: formBoolean(formData, 'urgent'),
activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
newSupports: optionalIntegerFormValue(formData, 'newSupports'),
adversarySignal: formBoolean(formData, 'adversarySignal'),
})
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { message: WIZARD_SIGNAL_SAVED_MESSAGE }
return { message: 'Atualização registrada.' }
},
genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
genericMessage:
'Não foi possível registrar a atualização. Verifique seu acesso e tente novamente.',
})
/**

View file

@ -41,7 +41,7 @@ import {
loadAdvisorSummaries,
} from '@/utilities/municipality/municipalityViewModels'
import {
createMunicipalityListSignalFormAction,
createMunicipalityListUpdateFormAction,
createMunicipalityStateDeputyFormAction,
setMunicipalityStateDeputiesFormAction,
} from './municipalityStaffFormActions'
@ -154,7 +154,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
stateDeputyCommitAction={setMunicipalityStateDeputiesFormAction}
stateDeputyCreateAction={createMunicipalityStateDeputyFormAction}
columnFilterOptions={columnFilterOptions}
signalFormAction={createMunicipalityListSignalFormAction}
signalFormAction={createMunicipalityListUpdateFormAction}
state={state}
columnVisibility={columnVisibility}
/>

View file

@ -7,6 +7,7 @@ import {
type MunicipalityUpdateCreateInput,
} from '@/lib/schemas/municipalityUpdate'
import type { CampaignUser } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
@ -23,6 +24,10 @@ export const createMunicipalityUpdateRecord = async (
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
if (!isCampaignStaff(currentActor)) {
data.adversarySignal = false
}
return payload.create({
collection: 'municipalityUpdate',
data: hookFilledCreateData<'municipalityUpdate'>(data),

View file

@ -11,10 +11,9 @@ import { APIError } from 'payload'
import { relationshipId } from '@/lib/relationship'
import {
municipalitySignalTypeLabels,
municipalitySignalTypes,
municipalityUpdateKindLabels,
municipalityUpdateKinds,
MUNICIPALITY_UPDATE_BODY_REQUIRED_MESSAGE,
municipalityUpdatePolarities,
municipalityUpdatePolarityLabels,
} from '@/lib/schemas/municipalityUpdate'
import {
canCreateMunicipalityUpdate,
@ -25,19 +24,15 @@ import {
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
const DERIVED_MUNICIPALITY_UPDATE_CONTEXT = 'municipalityUpdateDerivedField'
const MUNICIPALITY_UPDATE_KIND_OPTIONS = municipalityUpdateKinds.map((value) => ({
label: municipalityUpdateKindLabels[value],
value,
}))
const MUNICIPALITY_SIGNAL_TYPE_OPTIONS = municipalitySignalTypes.map((value) => ({
label: municipalitySignalTypeLabels[value],
const MUNICIPALITY_UPDATE_POLARITY_OPTIONS = municipalityUpdatePolarities.map((value) => ({
label: municipalityUpdatePolarityLabels[value],
value,
}))
const nonEmptyText = (value: unknown): boolean =>
typeof value === 'string' && value.trim().length > 0
const validateMunicipalityUpdateKind: CollectionBeforeValidateHook = ({
const validateMunicipalityUpdatePolarity: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
@ -45,23 +40,16 @@ const validateMunicipalityUpdateKind: CollectionBeforeValidateHook = ({
if (!data) return data
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
const kind = nextData.kind ?? 'semanal'
if (kind === 'semanal') {
if (!nonEmptyText(nextData.worked)) throw new APIError('Informe o que funcionou.', 400)
if (!nonEmptyText(nextData.failed)) throw new APIError('Informe o que não funcionou.', 400)
if (!nonEmptyText(nextData.needs)) throw new APIError('Informe o que você precisa.', 400)
} else if (kind === 'sinal') {
// Motivo/body is optional (B147/B134); type is the required discriminator.
if (typeof nextData.signalType !== 'string') {
throw new APIError('Informe o tipo do sinal.', 400)
}
} else if (!nonEmptyText(nextData.body)) {
throw new APIError('Informe o texto da atualização.', 400)
if (!nonEmptyText(nextData.body)) {
throw new APIError(MUNICIPALITY_UPDATE_BODY_REQUIRED_MESSAGE, 400)
}
if (kind !== 'sinal') {
data.signalType = null
if (
nextData.polarity === undefined ||
!municipalityUpdatePolarities.includes(nextData.polarity)
) {
throw new APIError('Informe a polaridade da atualização.', 400)
}
return data
@ -193,8 +181,8 @@ export const MunicipalityUpdate: CollectionConfig = {
},
admin: {
group: 'Campanha',
useAsTitle: 'kind',
defaultColumns: ['municipality', 'author', 'kind', 'createdAt'],
useAsTitle: 'body',
defaultColumns: ['municipality', 'author', 'polarity', 'urgent', 'createdAt'],
},
access: {
create: canCreateMunicipalityUpdate,
@ -203,7 +191,7 @@ export const MunicipalityUpdate: CollectionConfig = {
delete: canMutateMunicipalityUpdate,
},
hooks: {
beforeValidate: [validateMunicipalityUpdateKind],
beforeValidate: [validateMunicipalityUpdatePolarity],
beforeChange: [lockMunicipalitiesBeforeChange],
beforeDelete: [lockMunicipalityBeforeDelete],
afterChange: [recomputeChangedMunicipalities],
@ -234,40 +222,37 @@ export const MunicipalityUpdate: CollectionConfig = {
},
},
{
name: 'kind',
name: 'polarity',
type: 'select',
label: 'Tipo',
label: 'Polaridade',
required: true,
defaultValue: 'semanal',
defaultValue: 'neutra',
index: true,
options: MUNICIPALITY_UPDATE_KIND_OPTIONS,
options: MUNICIPALITY_UPDATE_POLARITY_OPTIONS,
},
{
name: 'worked',
type: 'textarea',
label: 'O que funcionou',
maxLength: 3000,
name: 'urgent',
type: 'checkbox',
label: 'Urgente',
defaultValue: false,
index: true,
},
{
name: 'adversarySignal',
type: 'checkbox',
label: 'Alerta de adversário',
defaultValue: false,
index: true,
admin: {
condition: (_, siblingData) => siblingData.kind === 'semanal',
condition: (req) => req?.user?.collection === 'users',
},
},
{
name: 'failed',
name: 'body',
type: 'textarea',
label: 'O que não funcionou',
maxLength: 3000,
admin: {
condition: (_, siblingData) => siblingData.kind === 'semanal',
},
},
{
name: 'needs',
type: 'textarea',
label: 'O que preciso',
maxLength: 3000,
admin: {
condition: (_, siblingData) => siblingData.kind === 'semanal',
},
label: 'Texto',
maxLength: 5000,
required: true,
},
{
name: 'activeVolunteers',
@ -281,23 +266,5 @@ export const MunicipalityUpdate: CollectionConfig = {
label: 'Novos apoios',
min: 0,
},
{
name: 'body',
type: 'textarea',
label: 'Texto',
maxLength: 5000,
admin: {
condition: (_, siblingData) => siblingData.kind !== 'semanal',
},
},
{
name: 'signalType',
type: 'select',
label: 'Tipo de sinal',
options: MUNICIPALITY_SIGNAL_TYPE_OPTIONS,
admin: {
condition: (_, siblingData) => siblingData.kind === 'sinal',
},
},
],
}

View file

@ -19,8 +19,8 @@ import {
SignalAgeReadout,
TerritorialClassReadout,
} from '@/components/campaign/municipality/MunicipalityListRowReadouts'
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalityListUpdateControl } from '@/components/campaign/municipality/MunicipalityListUpdateControl'
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
import { MunicipalitySortableHead } from '@/components/campaign/municipality/MunicipalitySortableHead'
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
@ -242,19 +242,20 @@ const municipalityListColumns = ({
municipality: MunicipalityListViewModel,
triggerPresentation: 'compact' | 'adaptive',
) => (
<MunicipalityListSignalControl
<MunicipalityListUpdateControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="popover"
formAction={signalFormAction}
isStaff={isCampaignUnrestricted}
>
<MunicipalityListSignalTrigger
lastSignalAt={municipality.lastSignalAt}
presentation={triggerPresentation}
/>
</MunicipalityListSignalControl>
</MunicipalityListUpdateControl>
)
return [

View file

@ -17,8 +17,8 @@ import {
SignalAgeReadout,
TerritorialClassCardReadout,
} from '@/components/campaign/municipality/MunicipalityListRowReadouts'
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
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'
@ -186,18 +186,19 @@ export const MunicipalityListMobileCards = ({
</dd>
</div>
<div>
<dt className="text-muted-foreground">Último sinal</dt>
<dt className="text-muted-foreground">Última atualização</dt>
<dd>
<MunicipalityListSignalControl
<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" />
</MunicipalityListSignalControl>
</MunicipalityListUpdateControl>
</dd>
</div>
<div>

View file

@ -127,7 +127,7 @@ export const MunicipalityListTrendControl = ({
// The label names the current reading: an `aria-label` replaces the
// badge's own text, so without it the trigger would announce the verb and
// swallow the value every sighted user reads off the pill. Same shape as
// `MunicipalityListSignalControl`'s.
// `MunicipalityListUpdateControl`'s.
triggerLabel={`Editar tendência política em ${municipalityName} — ${trendLabel}`}
triggerBusy={isPending}
triggerClassName={triggerPresentation === 'full' ? undefined : 'min-w-11'}

View file

@ -2,7 +2,7 @@
import { useActionState, useId, useState, type ReactNode } from 'react'
import { MunicipalitySignalFields } from '@/components/campaign/municipality/MunicipalitySignalFields'
import { MunicipalityUpdateFields } from '@/components/campaign/municipality/MunicipalityUpdateFields'
import {
CampaignCellEditOverlay,
type CampaignCellEditOverlayVariant,
@ -23,30 +23,32 @@ type MunicipalityStaffFormAction = (
formData: FormData,
) => Promise<CampaignFormActionState>
type MunicipalityListSignalControlProps = {
type MunicipalityListUpdateControlProps = {
municipalityID: number
municipalitySlug: string
municipalityName: string
lastSignalAt: string | null
variant: CampaignCellEditOverlayVariant
formAction: MunicipalityStaffFormAction
isStaff: boolean
children: ReactNode
}
export const MunicipalityListSignalControl = ({
export const MunicipalityListUpdateControl = ({
municipalityID,
municipalitySlug,
municipalityName,
lastSignalAt,
variant,
formAction,
isStaff,
children,
}: MunicipalityListSignalControlProps) => {
}: MunicipalityListUpdateControlProps) => {
const [open, setOpen] = useState(false)
const [formKey, setFormKey] = useState(0)
const [state, submitAction, isPending] = useActionState(formAction, {})
const reactId = useId()
const idPrefix = `municipality-list-signal-${variant}-${municipalityID}-${reactId}`
const idPrefix = `municipality-list-update-${variant}-${municipalityID}-${reactId}`
const formId = `${idPrefix}-form`
const isSheet = variant === 'sheet'
const frescorLabel = formatMunicipalitySignalAgeLabel(municipalitySignalAgeInDays(lastSignalAt))
@ -57,14 +59,9 @@ export const MunicipalityListSignalControl = ({
})
const submitButton = (
// On the sheet this button lives in the footer, outside the `<form>` it
// submits: `form={formId}` is the standard association, and React's action
// still runs because the submit event fires on the form either way. That is
// what let this control drop the Drawer/Popover it used to hand-roll only
// because its `<form>` had to wrap the footer (B32+ F5).
<Button type="submit" form={formId} disabled={isPending} className="min-h-11 w-full">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Registrar sinal
Registrar atualização
</Button>
)
@ -73,11 +70,11 @@ export const MunicipalityListSignalControl = ({
variant={variant}
open={open}
onOpenChange={setOpen}
title="Registrar sinal"
title="Registrar atualização"
description={municipalityName}
triggerLabel={`Registrar sinal em ${municipalityName} — ${frescorLabel}`}
triggerLabel={`Registrar atualização em ${municipalityName} — ${frescorLabel}`}
triggerBusy={isPending}
statusMessage={isPending ? 'Registrando sinal…' : ''}
statusMessage={isPending ? 'Registrando atualização…' : ''}
triggerClassName="min-w-11 text-left"
contentClassName="w-80"
trigger={children}
@ -99,7 +96,11 @@ export const MunicipalityListSignalControl = ({
>
<input type="hidden" name="municipalityId" value={municipalityID} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
<MunicipalitySignalFields idPrefix={idPrefix} fieldErrors={state.fieldErrors} />
<MunicipalityUpdateFields
idPrefix={idPrefix}
fieldErrors={state.fieldErrors}
isStaff={isStaff}
/>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
{isSheet ? null : submitButton}
</form>

View file

@ -1,65 +0,0 @@
'use client'
import { useState } from 'react'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Textarea } from '@/components/ui/textarea'
import {
municipalitySignalTypeDescriptions,
municipalitySignalTypeLabels,
municipalitySignalTypes,
type MunicipalitySignalType,
} from '@/lib/schemas/municipalityUpdate'
import { fieldError } from '@/utilities/campaignFormFields'
type MunicipalitySignalFieldsProps = {
/** Unique prefix so table + card controls for the same row don't collide on ids. */
idPrefix: string
fieldErrors?: Record<string, string[]>
}
export const MunicipalitySignalFields = ({
idPrefix,
fieldErrors,
}: MunicipalitySignalFieldsProps) => {
const [signalType, setSignalType] = useState<MunicipalitySignalType | ''>('')
const bodyId = `${idPrefix}-body`
const signalTypeId = `${idPrefix}-signal-type`
return (
<div className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={bodyId}>Texto</FieldLabel>
<Textarea id={bodyId} name="body" rows={2} maxLength={5000} className="min-h-11" />
{fieldError(fieldErrors, 'body') ? (
<FieldError>{fieldError(fieldErrors, 'body')}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor={signalTypeId}>Tipo do sinal</FieldLabel>
<NativeSelect
id={signalTypeId}
name="signalType"
value={signalType}
onChange={(event) => setSignalType(event.target.value as MunicipalitySignalType | '')}
required
className="min-h-11 w-full"
>
<NativeSelectOption value="">Selecione</NativeSelectOption>
{municipalitySignalTypes.map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{municipalitySignalTypeLabels[entry]}
</NativeSelectOption>
))}
</NativeSelect>
{signalType ? (
<FieldDescription>{municipalitySignalTypeDescriptions[signalType]}</FieldDescription>
) : null}
{fieldError(fieldErrors, 'signalType') ? (
<FieldError>{fieldError(fieldErrors, 'signalType')}</FieldError>
) : null}
</Field>
</div>
)
}

View file

@ -1,8 +1,5 @@
import { Badge } from '@/components/ui/Badge'
import {
municipalitySignalTypeLabels,
municipalityUpdateKindLabels,
} from '@/lib/schemas/municipalityUpdate'
import { municipalityUpdatePolarityLabels } from '@/lib/schemas/municipalityUpdate'
import type { MunicipalityUpdateViewModel } from '@/utilities/municipality/municipalityUpdatePageData'
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
@ -12,11 +9,10 @@ const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
const numberFormatter = new Intl.NumberFormat('pt-BR')
const kindVariant = {
semanal: 'secondary',
urgente: 'destructive',
nota: 'outline',
sinal: 'default',
const polarityVariant = {
boa: 'default',
neutra: 'secondary',
ruim: 'destructive',
} as const
export const MunicipalityUpdateFeed = ({ updates }: { updates: MunicipalityUpdateViewModel[] }) => {
@ -33,37 +29,19 @@ export const MunicipalityUpdateFeed = ({ updates }: { updates: MunicipalityUpdat
{updates.map((update) => (
<li key={update.id} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={kindVariant[update.kind]}>
{municipalityUpdateKindLabels[update.kind]}
<Badge variant={polarityVariant[update.polarity]}>
{municipalityUpdatePolarityLabels[update.polarity]}
</Badge>
{update.urgent ? <Badge variant="destructive">Urgente</Badge> : null}
{update.adversarySignal ? <Badge variant="outline">Adversário</Badge> : null}
<span className="text-sm font-medium">{update.authorName}</span>
<span className="text-sm text-muted-foreground">
{dateTimeFormatter.format(new Date(update.createdAt))}
</span>
</div>
{update.kind === 'semanal' ? (
<dl className="flex flex-col gap-2 text-sm">
<div>
<dt className="font-medium">O que funcionou</dt>
<dd className="whitespace-pre-wrap text-muted-foreground">{update.worked}</dd>
</div>
<div>
<dt className="font-medium">O que não funcionou</dt>
<dd className="whitespace-pre-wrap text-muted-foreground">{update.failed}</dd>
</div>
<div>
<dt className="font-medium">O que preciso</dt>
<dd className="whitespace-pre-wrap text-muted-foreground">{update.needs}</dd>
</div>
</dl>
) : (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{update.body}</p>
)}
{update.kind === 'sinal' && update.signalType ? (
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{municipalitySignalTypeLabels[update.signalType]}</Badge>
</div>
) : null}
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{update.body ?? 'Sem texto.'}
</p>
{update.activeVolunteers != null || update.newSupports != null ? (
<p className="text-sm text-muted-foreground">
{update.activeVolunteers != null

View file

@ -0,0 +1,94 @@
'use client'
import { useState } from 'react'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Textarea } from '@/components/ui/textarea'
import {
municipalityUpdatePolarityLabels,
type MunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import { fieldError } from '@/utilities/campaignFormFields'
type MunicipalityUpdateFieldsProps = {
idPrefix: string
fieldErrors?: Record<string, string[]>
isStaff: boolean
}
export const MunicipalityUpdateFields = ({
idPrefix,
fieldErrors,
isStaff,
}: MunicipalityUpdateFieldsProps) => {
const [polarity, setPolarity] = useState<MunicipalityUpdatePolarity>('neutra')
const bodyId = `${idPrefix}-body`
const polarityId = `${idPrefix}-polarity`
const urgentId = `${idPrefix}-urgent`
const adversaryId = `${idPrefix}-adversary`
return (
<div className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={bodyId}>Texto</FieldLabel>
<FieldDescription>Descreva o que aconteceu de forma clara e objetiva.</FieldDescription>
<Textarea
id={bodyId}
name="body"
rows={3}
maxLength={5000}
required
className="min-h-11"
placeholder="Ex.: Reunião com líderes, nova adesão, perda de apoio..."
/>
{fieldError(fieldErrors, 'body') ? (
<FieldError>{fieldError(fieldErrors, 'body')}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor={polarityId}>Polaridade</FieldLabel>
<NativeSelect
id={polarityId}
name="polarity"
value={polarity}
onChange={(event) => setPolarity(event.target.value as MunicipalityUpdatePolarity)}
required
className="min-h-11 w-full"
>
{(['boa', 'neutra', 'ruim'] as const).map((option) => (
<NativeSelectOption key={option} value={option}>
{municipalityUpdatePolarityLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>Selecione a polaridade do fato observado.</FieldDescription>
{fieldError(fieldErrors, 'polarity') ? (
<FieldError>{fieldError(fieldErrors, 'polarity')}</FieldError>
) : null}
</Field>
<div className="flex items-center gap-3">
<input type="hidden" name="urgent" value="false" />
<Checkbox id={urgentId} name="urgent" value="true" className="mt-0.5" />
<FieldLabel htmlFor={urgentId} className="font-normal">
Urgente
</FieldLabel>
</div>
{isStaff ? (
<div className="flex items-start gap-3">
<input type="hidden" name="adversarySignal" value="false" />
<Checkbox id={adversaryId} name="adversarySignal" value="true" className="mt-0.5" />
<div className="flex flex-col gap-1">
<FieldLabel htmlFor={adversaryId} className="font-normal">
Sinalizar adversário
</FieldLabel>
<FieldDescription>
Marque se este é um fato relacionado a um adversário.
</FieldDescription>
</div>
</div>
) : null}
</div>
)
}

View file

@ -4,21 +4,14 @@ import { useActionState, useState } from 'react'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import {
municipalitySignalTypeDescriptions,
municipalitySignalTypeLabels,
municipalitySignalTypes,
municipalityUpdateKindLabels,
municipalityUpdateKinds,
type MunicipalityUpdateKind,
} from '@/lib/schemas/municipalityUpdate'
import type { MunicipalityUpdatePolarity } from '@/lib/schemas/municipalityUpdate'
import { municipalityUpdatePolarityLabels } from '@/lib/schemas/municipalityUpdate'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
type MunicipalityUpdateFormProps = {
municipalityID: number
@ -26,129 +19,94 @@ type MunicipalityUpdateFormProps = {
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
isStaff: boolean
}
export const MunicipalityUpdateForm = ({
municipalityID,
formAction,
isStaff,
}: MunicipalityUpdateFormProps) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
const [kind, setKind] = useState<MunicipalityUpdateKind>('semanal')
const [polarity, setPolarity] = useState<MunicipalityUpdatePolarity>('neutra')
return (
<form action={submitAction} className="flex flex-col gap-4 rounded-xl border p-4">
<h3 className="text-base font-medium">Registrar atualização</h3>
<input type="hidden" name="municipalityId" value={municipalityID} />
<Field>
<FieldLabel htmlFor="municipality-update-kind">Tipo</FieldLabel>
<FieldLabel htmlFor="municipality-update-body">Texto da atualização</FieldLabel>
<FieldDescription>Descreva o que aconteceu de forma clara e objetiva.</FieldDescription>
<Textarea
id="municipality-update-body"
name="body"
rows={4}
maxLength={5000}
required
disabled={isPending}
/>
{state.fieldErrors?.body ? <FieldError>{state.fieldErrors.body}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="municipality-update-polarity">Polaridade</FieldLabel>
<NativeSelect
id="municipality-update-kind"
name="kind"
value={kind}
onChange={(event) => setKind(event.target.value as MunicipalityUpdateKind)}
className="min-h-11 w-full sm:w-56"
id="municipality-update-polarity"
name="polarity"
value={polarity}
onChange={(event) => setPolarity(event.target.value as MunicipalityUpdatePolarity)}
required
disabled={isPending}
className="w-48"
>
{municipalityUpdateKinds.map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{municipalityUpdateKindLabels[entry]}
{(['boa', 'neutra', 'ruim'] as const).map((option) => (
<NativeSelectOption key={option} value={option}>
{municipalityUpdatePolarityLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>Selecione a polaridade do fato observado.</FieldDescription>
</Field>
{kind === 'semanal' ? (
<>
<Field>
<FieldLabel htmlFor="municipality-update-worked">O que funcionou</FieldLabel>
<Textarea id="municipality-update-worked" name="worked" rows={3} maxLength={3000} />
{fieldError(state.fieldErrors, 'worked') ? (
<FieldError>{fieldError(state.fieldErrors, 'worked')}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor="municipality-update-failed">O que não funcionou</FieldLabel>
<Textarea id="municipality-update-failed" name="failed" rows={3} maxLength={3000} />
{fieldError(state.fieldErrors, 'failed') ? (
<FieldError>{fieldError(state.fieldErrors, 'failed')}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor="municipality-update-needs">O que preciso</FieldLabel>
<Textarea id="municipality-update-needs" name="needs" rows={3} maxLength={3000} />
{fieldError(state.fieldErrors, 'needs') ? (
<FieldError>{fieldError(state.fieldErrors, 'needs')}</FieldError>
) : null}
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="municipality-update-volunteers">Voluntários ativos</FieldLabel>
<Input
id="municipality-update-volunteers"
name="activeVolunteers"
type="number"
min={0}
inputMode="numeric"
className="min-h-11"
/>
</Field>
<Field>
<FieldLabel htmlFor="municipality-update-supports">Novos apoios</FieldLabel>
<Input
id="municipality-update-supports"
name="newSupports"
type="number"
min={0}
inputMode="numeric"
className="min-h-11"
/>
</Field>
<div className="flex items-center gap-3">
<input type="hidden" name="urgent" value="false" />
<Checkbox
id="municipality-update-urgent"
name="urgent"
value="true"
defaultChecked={false}
disabled={isPending}
/>
<FieldLabel htmlFor="municipality-update-urgent" className="font-normal">
Sinalizar como urgente
</FieldLabel>
</div>
{isStaff ? (
<div className="flex items-start gap-3">
<input type="hidden" name="adversarySignal" value="false" />
<Checkbox
id="municipality-update-adversary"
name="adversarySignal"
value="true"
defaultChecked={false}
disabled={isPending}
className="mt-0.5"
/>
<div className="flex flex-col gap-1">
<FieldLabel htmlFor="municipality-update-adversary" className="font-normal">
Sinalizar adversário
</FieldLabel>
<FieldDescription>
Marque se este é um fato relacionado a um adversário político.
</FieldDescription>
</div>
</>
</div>
) : (
<>
<Field>
<FieldLabel htmlFor="municipality-update-body">Texto</FieldLabel>
<Textarea
id="municipality-update-body"
name="body"
rows={kind === 'sinal' ? 2 : 4}
maxLength={5000}
/>
{fieldError(state.fieldErrors, 'body') ? (
<FieldError>{fieldError(state.fieldErrors, 'body')}</FieldError>
) : null}
</Field>
{kind === 'sinal' ? (
<Field>
<FieldLabel htmlFor="municipality-update-signal-type">Tipo do sinal</FieldLabel>
<NativeSelect
id="municipality-update-signal-type"
name="signalType"
defaultValue=""
required
className="sm:w-72"
>
<NativeSelectOption value="">Selecione</NativeSelectOption>
{municipalitySignalTypes.map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{municipalitySignalTypeLabels[entry]}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>Escolha o fato político observado:</FieldDescription>
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
{municipalitySignalTypes.map((entry) => (
<li key={entry}>
<span className="font-medium text-foreground">
{municipalitySignalTypeLabels[entry]}:
</span>{' '}
{municipalitySignalTypeDescriptions[entry]}
</li>
))}
</ul>
</Field>
) : null}
</>
<input type="hidden" name="adversarySignal" value="false" />
)}
<CampaignFormActionMessage state={state} successFallbackMessage="Atualização registrada." />
<Button type="submit" disabled={isPending} className="min-h-11 self-start">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}

View file

@ -30,10 +30,9 @@ import {
politicalTrendStatuses,
} from '@/lib/schemas/municipality'
import {
municipalitySignalTypeLabels,
municipalitySignalTypes,
parseMunicipalitySignalType,
type MunicipalitySignalType,
MUNICIPALITY_UPDATE_BODY_REQUIRED_MESSAGE,
municipalityUpdatePolarityLabels,
type MunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
@ -47,7 +46,7 @@ import { formatMunicipalitySignalAgeLabel } from '@/utilities/municipality/munic
import {
buildMunicipalityV2StatusAggregate,
MUNICIPALITY_V2_SIGNAL_COLD_VALUE,
resolveMunicipalityV2SignalSelectState,
resolveMunicipalityV2UpdateState,
type MunicipalityV2StatusViewModel,
} from '@/utilities/municipality/municipalityV2StatusView'
@ -55,9 +54,9 @@ const ENGAGEMENT_LEVEL_ENDPOINT = '/campanha/municipios/engagement-level'
const POLITICAL_TREND_ENDPOINT = '/campanha/municipios/political-trend'
const LEVEL_ERROR = 'Não foi possível registrar o nível. Tente novamente.'
const TREND_ERROR = 'Não foi possível salvar a tendência. Tente novamente.'
const SIGNAL_ERROR = 'Não foi possível registrar o sinal. Tente novamente.'
const UPDATE_ERROR = 'Não foi possível registrar a atualização. Tente novamente.'
type PendingAxis = 'level' | 'trend' | 'signal' | null
type PendingAxis = 'level' | 'trend' | 'update' | null
type MunicipalityV2StatusStripProps = {
status: MunicipalityV2StatusViewModel
@ -101,6 +100,8 @@ const ConceptTooltip = ({
</CampaignHoverTooltip>
)
const polarityOptions: readonly MunicipalityUpdatePolarity[] = ['boa', 'neutra', 'ruim']
export const MunicipalityV2StatusStrip = ({
status,
signalFormAction,
@ -114,16 +115,14 @@ export const MunicipalityV2StatusStrip = ({
const [levelChangedAt, setLevelChangedAt] = useState(status.levelChangedAt)
const [trendStatus, setTrendStatus] = useState(status.politicalTrendStatus)
const [trendNote, setTrendNote] = useState(status.politicalTrendNote)
const [signalType, setSignalType] = useState(status.lastSignalType)
const [signalBody, setSignalBody] = useState(status.lastSignalBody)
const [polarity, setPolarity] = useState(status.lastUpdatePolarity)
const [updateBody, setUpdateBody] = useState(status.lastUpdateBody)
const [lastSignalAt, setLastSignalAt] = useState(status.lastSignalAt)
const [pendingAxis, setPendingAxis] = useState<PendingAxis>(null)
const [draftLevel, setDraftLevel] = useState<EngagementLevel | null>(null)
const [draftTrend, setDraftTrend] = useState<typeof trendStatus>(null)
const [draftSignal, setDraftSignal] = useState<MunicipalitySignalType | null>(null)
const [triangulatedShock, setTriangulatedShock] = useState(false)
const [override, setOverride] = useState(false)
const [draftPolarity, setDraftPolarity] = useState<MunicipalityUpdatePolarity | null>(null)
const [serverBlock, setServerBlock] = useState<EngagementLevelViolation[] | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isPending, setIsPending] = useState(false)
@ -137,20 +136,20 @@ export const MunicipalityV2StatusStrip = ({
setLevelChangedAt(status.levelChangedAt)
setTrendStatus(status.politicalTrendStatus)
setTrendNote(status.politicalTrendNote)
setSignalType(status.lastSignalType)
setSignalBody(status.lastSignalBody)
setPolarity(status.lastUpdatePolarity)
setUpdateBody(status.lastUpdateBody)
setLastSignalAt(status.lastSignalAt)
}, [status])
const signalSelect = resolveMunicipalityV2SignalSelectState({
signalType,
const updateSelect = resolveMunicipalityV2UpdateState({
polarity,
lastSignalAt,
})
const aggregate = buildMunicipalityV2StatusAggregate({
levelNote,
trendNote,
signalBody,
signalType,
updateBody,
updatePolarity: polarity,
lastSignalAt,
engagementLevel: level,
})
@ -163,7 +162,7 @@ export const MunicipalityV2StatusStrip = ({
to: draftLevel,
levelChangedAt,
now: new Date(),
triangulatedShock,
triangulatedShock: false,
}),
...(serverBlock ?? []),
]
@ -182,9 +181,7 @@ export const MunicipalityV2StatusStrip = ({
setPendingAxis(null)
setDraftLevel(null)
setDraftTrend(null)
setDraftSignal(null)
setTriangulatedShock(false)
setOverride(false)
setDraftPolarity(null)
setServerBlock(null)
setErrorMessage(null)
}
@ -202,9 +199,9 @@ export const MunicipalityV2StatusStrip = ({
{
municipalityId: status.id,
level: draftLevel,
note: reason.trim() ? reason : null,
triangulatedShock,
override,
note: reason.trim() ? reason.trim() : null,
triangulatedShock: false,
override: false,
},
controller.signal,
)
@ -241,7 +238,7 @@ export const MunicipalityV2StatusStrip = ({
{
municipalityId: status.id,
status: draftTrend,
note: reason.trim() ? reason : null,
note: reason.trim() ? reason.trim() : null,
},
controller.signal,
)
@ -261,28 +258,34 @@ export const MunicipalityV2StatusStrip = ({
}
}
const confirmSignal = async (reason: string) => {
if (!draftSignal || isPending) return
const confirmUpdate = async (reason: string) => {
if (!draftPolarity || isPending) return
const body = reason.trim()
if (!body) {
setErrorMessage(MUNICIPALITY_UPDATE_BODY_REQUIRED_MESSAGE)
return
}
setIsPending(true)
setErrorMessage(null)
try {
const formData = new FormData()
formData.set('municipalityId', String(status.id))
formData.set('municipalitySlug', status.slug)
formData.set('signalType', draftSignal)
if (reason.trim()) formData.set('body', reason.trim())
formData.set('polarity', draftPolarity)
formData.set('body', body)
formData.set('urgent', 'false')
formData.set('adversarySignal', 'false')
const result = await signalFormAction(formData)
if (result.status !== 'success') {
setErrorMessage(result.message || SIGNAL_ERROR)
setErrorMessage(result.message || UPDATE_ERROR)
return
}
setSignalType(draftSignal)
setSignalBody(reason.trim() ? reason.trim() : null)
setPolarity(draftPolarity)
setUpdateBody(reason.trim() ? reason.trim() : null)
setLastSignalAt(new Date().toISOString())
closeDialog()
router.refresh()
} catch {
setErrorMessage(SIGNAL_ERROR)
setErrorMessage(UPDATE_ERROR)
} finally {
setIsPending(false)
}
@ -293,8 +296,8 @@ export const MunicipalityV2StatusStrip = ({
? 'Confirmar nível de envolvimento'
: pendingAxis === 'trend'
? 'Confirmar tendência'
: pendingAxis === 'signal'
? 'Confirmar sinal'
: pendingAxis === 'update'
? 'Confirmar atualização'
: ''
const dialogDescription =
@ -304,8 +307,8 @@ export const MunicipalityV2StatusStrip = ({
? draftTrend
? politicalTrendLabels[draftTrend]
: 'Não registrada'
: pendingAxis === 'signal' && draftSignal
? municipalitySignalTypeLabels[draftSignal]
: pendingAxis === 'update' && draftPolarity
? municipalityUpdatePolarityLabels[draftPolarity]
: ''
return (
@ -327,14 +330,11 @@ export const MunicipalityV2StatusStrip = ({
onChange={(event) => {
const next = event.target.value
if (!isEngagementLevel(next) || next === level) {
// Reset select to current when choosing empty / same.
event.target.value = level ?? ''
return
}
setDraftLevel(next)
setPendingAxis('level')
setTriangulatedShock(false)
setOverride(false)
setServerBlock(null)
setErrorMessage(null)
}}
@ -378,38 +378,38 @@ export const MunicipalityV2StatusStrip = ({
</Field>
<Field>
<ConceptTooltip conceptId="pauta-do-silencio" label="Sinal">
<FieldLabel htmlFor={`${formId}-signal`}>Sinal</FieldLabel>
<ConceptTooltip conceptId="pauta-do-silencio" label="Polaridade">
<FieldLabel htmlFor={`${formId}-update`}>Polaridade</FieldLabel>
</ConceptTooltip>
<NativeSelect
id={`${formId}-signal`}
id={`${formId}-update`}
className="min-h-11 w-full"
value={signalSelect.value}
aria-busy={isPending && pendingAxis === 'signal' ? true : undefined}
value={updateSelect.value}
aria-busy={isPending && pendingAxis === 'update' ? true : undefined}
onChange={(event) => {
const raw = event.target.value
if (raw === MUNICIPALITY_V2_SIGNAL_COLD_VALUE) {
event.target.value = signalSelect.value
event.target.value = updateSelect.value
return
}
const next = parseMunicipalitySignalType(raw)
if (!next || next === signalSelect.value) {
event.target.value = signalSelect.value
const next = raw as MunicipalityUpdatePolarity
if (!next || next === polarity) {
event.target.value = updateSelect.value
return
}
setDraftSignal(next)
setPendingAxis('signal')
setDraftPolarity(next)
setPendingAxis('update')
setErrorMessage(null)
}}
>
{signalSelect.isCold ? (
{updateSelect.isCold ? (
<NativeSelectOption value={MUNICIPALITY_V2_SIGNAL_COLD_VALUE}>
{signalSelect.label}
{updateSelect.label}
</NativeSelectOption>
) : null}
{municipalitySignalTypes.map((option) => (
{polarityOptions.map((option) => (
<NativeSelectOption key={option} value={option}>
{municipalitySignalTypeLabels[option]}
{municipalityUpdatePolarityLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
@ -425,7 +425,7 @@ export const MunicipalityV2StatusStrip = ({
</Badge>
<ConceptTooltip conceptId="pauta-do-silencio" label="Frescor">
<span className="text-xs text-muted-foreground">
{formatMunicipalitySignalAgeLabel(signalSelect.ageInDays)}
{formatMunicipalitySignalAgeLabel(updateSelect.ageInDays)}
</span>
</ConceptTooltip>
</div>
@ -451,15 +451,15 @@ export const MunicipalityV2StatusStrip = ({
isPending={isPending}
errorMessage={errorMessage}
showTriangulatedShock={Boolean(isJump)}
triangulatedShock={triangulatedShock}
onTriangulatedShockChange={setTriangulatedShock}
triangulatedShock={false}
onTriangulatedShockChange={() => {}}
violations={uniqueLevelViolations}
override={override}
onOverrideChange={setOverride}
override={false}
onOverrideChange={() => {}}
onConfirm={(reason) => {
if (pendingAxis === 'level') void confirmLevel(reason)
else if (pendingAxis === 'trend') void confirmTrend(reason)
else if (pendingAxis === 'signal') void confirmSignal(reason)
else if (pendingAxis === 'update') void confirmUpdate(reason)
}}
/>
</section>

View file

@ -1,132 +0,0 @@
'use client'
import { useRouter } from 'next/navigation'
import { useActionState } from 'react'
import { createMunicipalityListSignalFormAction } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions'
import { WizardSignalSkipTrailing } from '@/components/campaign/municipality/WizardSignalSkipTrailing'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import { municipalitySignalTypeLabels } from '@/lib/schemas/municipalityUpdate'
import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
wizardPreviousHref,
} from '@/lib/wizardActionChain'
import {
resolveWizardSignalSkip,
WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX,
WIZARD_SIGNAL_SAVE_LABEL,
} from '@/lib/wizardSignalUi'
type WizardSignalBodyStepProps = {
actionSlug: string
municipalityId: number
municipalityName: string
municipalitySlug: string
signalType: MunicipalitySignalType
entryAction?: CampaignWizardActionId
returnPath?: string
}
export const WizardSignalBodyStep = ({
actionSlug,
municipalityId,
municipalityName,
municipalitySlug,
signalType,
entryAction,
returnPath,
}: WizardSignalBodyStepProps) => {
const router = useRouter()
const [state, submitAction, isPending] = useActionState(
createMunicipalityListSignalFormAction,
{},
)
const skip = resolveWizardSignalSkip(entryAction, municipalitySlug, returnPath)
const stepTitle = `${WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX}: ${municipalitySignalTypeLabels[signalType]}`
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
const sessionEntry = resolveWizardChainEntry(entryAction, 'register-signal')
router.push(
wizardChainContinueHref(sessionEntry, 'register-signal', municipalitySlug, returnPath),
)
})
return (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={stepTitle}
previousHref={wizardPreviousHref({
actionSlug,
stepKind: 'signal-body',
municipalitySlug,
entryAction,
returnPath,
})}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardSignalSkipTrailing skip={skip} /> : undefined}
contentFocus="none"
>
<form
action={submitAction}
className="flex flex-col gap-6"
aria-busy={isPending || undefined}
data-pending={isPending ? '' : undefined}
>
<input type="hidden" name="municipalityId" value={municipalityId} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
<input type="hidden" name="signalType" value={signalType} />
<div className="flex flex-col gap-2">
<label htmlFor="wizard-signal-body" className="text-sm font-medium">
O que aconteceu?
</label>
<Textarea
id="wizard-signal-body"
name="body"
rows={5}
maxLength={5000}
required
disabled={isPending}
autoFocus
className="min-h-28"
placeholder="Descreva o fato com o contexto que a equipe precisa lembrar depois."
/>
</div>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="min-h-11 min-w-[7rem]">
{isPending ? (
<>
<Spinner data-icon="inline-start" aria-hidden="true" />
Salvando…
</>
) : (
WIZARD_SIGNAL_SAVE_LABEL
)}
</Button>
</div>
<div aria-live="polite" className="sr-only">
{isPending ? 'Salvando sinal.' : null}
</div>
</form>
</CampaignWizardShell>
)
}

View file

@ -1,131 +0,0 @@
'use client'
import { Info } from 'lucide-react'
import { useState } from 'react'
import { WizardSignalSkipTrailing } from '@/components/campaign/municipality/WizardSignalSkipTrailing'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardSignalHref } from '@/lib/campaignActionRoutes'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import {
municipalitySignalTypeMeta,
municipalitySignalTypeMetaByType,
} from '@/lib/municipalitySignalTypeMeta'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import { cn } from '@/lib/utils'
import { wizardChainEndHref, wizardPreviousHref } from '@/lib/wizardActionChain'
import { resolveWizardSignalSkip, WIZARD_SIGNAL_TYPE_STEP_TITLE } from '@/lib/wizardSignalUi'
type WizardSignalTypeStepProps = {
actionSlug: string
municipalityName: string
municipalitySlug: string
entryAction?: CampaignWizardActionId
returnPath?: string
}
export const WizardSignalTypeStep = ({
actionSlug,
municipalityName,
municipalitySlug,
entryAction,
returnPath,
}: WizardSignalTypeStepProps) => {
const [infoType, setInfoType] = useState<MunicipalitySignalType | null>(null)
const infoEntry = infoType ? municipalitySignalTypeMetaByType[infoType] : null
const skip = resolveWizardSignalSkip(entryAction, municipalitySlug, returnPath)
return (
<>
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={WIZARD_SIGNAL_TYPE_STEP_TITLE}
previousHref={wizardPreviousHref({
actionSlug,
stepKind: 'signal-type',
municipalitySlug,
entryAction,
returnPath,
})}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
contentAlign="end"
skip={skip}
trailingAction={skip ? <WizardSignalSkipTrailing skip={skip} /> : undefined}
>
<ul className="grid list-none grid-cols-2 gap-3 md:grid-cols-3">
{municipalitySignalTypeMeta.map((entry) => {
const Icon = entry.icon
return (
<li key={entry.type} className="relative">
<CampaignWizardNavLink
href={wizardSignalHref(
actionSlug,
municipalitySlug,
entry.type,
entryAction,
returnPath,
)}
className={cn(
'flex aspect-square w-full flex-col justify-between rounded-lg border border-border bg-transparent p-3 pr-10 text-left',
'transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<Icon className="size-5 shrink-0 text-foreground" aria-hidden />
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">{entry.label}</span>
<span className="line-clamp-2 text-xs text-muted-foreground">
{entry.shortDescription}
</span>
</div>
</CampaignWizardNavLink>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1 size-8"
aria-label={`Informações sobre ${entry.label}`}
onClick={() => setInfoType(entry.type)}
>
<Info className="size-4" aria-hidden />
</Button>
</li>
)
})}
</ul>
</CampaignWizardShell>
<Drawer open={infoEntry != null} onOpenChange={(open) => !open && setInfoType(null)}>
<DrawerContent>
{infoEntry ? (
<>
<DrawerHeader>
<DrawerTitle>{infoEntry.label}</DrawerTitle>
<DrawerDescription>{infoEntry.shortDescription}</DrawerDescription>
</DrawerHeader>
<div className="px-4 pb-2">
<p className="text-sm text-muted-foreground">{infoEntry.infoContent}</p>
</div>
<DrawerFooter>
<DrawerCloseButton>Fechar</DrawerCloseButton>
</DrawerFooter>
</>
) : null}
</DrawerContent>
</Drawer>
</>
)
}

View file

@ -1,12 +1,12 @@
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { Button } from '@/components/ui/button'
import type { WizardSignalSkipAction } from '@/lib/wizardSignalUi'
import type { WizardUpdateSkipAction } from '@/lib/wizardUpdateUi'
type WizardSignalSkipTrailingProps = {
skip: WizardSignalSkipAction
type WizardSkipTrailingProps = {
skip: WizardUpdateSkipAction
}
export const WizardSignalSkipTrailing = ({ skip }: WizardSignalSkipTrailingProps) => (
export const WizardSkipTrailing = ({ skip }: WizardSkipTrailingProps) => (
<Button variant="link" size="sm" className="h-auto px-2 py-1 text-xs" asChild>
<CampaignWizardNavLink href={skip.href}>{skip.label}</CampaignWizardNavLink>
</Button>

View file

@ -0,0 +1,186 @@
'use client'
import { useRouter } from 'next/navigation'
import { useActionState } from 'react'
import { createMunicipalityUpdateFormAction } from '@/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions'
import { WizardSkipTrailing } from '@/components/campaign/municipality/WizardSkipTrailing'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import { municipalityUpdatePolarityLabels } from '@/lib/schemas/municipalityUpdate'
import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
wizardPreviousHref,
} from '@/lib/wizardActionChain'
import {
resolveWizardUpdateSkip,
WIZARD_UPDATE_BODY_STEP_TITLE,
WIZARD_UPDATE_SAVE_LABEL,
} from '@/lib/wizardUpdateUi'
type WizardUpdateBodyStepProps = {
actionSlug: string
municipalityId: number
municipalityName: string
municipalitySlug: string
entryAction?: CampaignWizardActionId
returnPath?: string
isStaff: boolean
}
export const WizardUpdateBodyStep = ({
actionSlug,
municipalityId,
municipalityName,
municipalitySlug,
entryAction,
returnPath,
isStaff,
}: WizardUpdateBodyStepProps) => {
const router = useRouter()
const [state, submitAction, isPending] = useActionState(createMunicipalityUpdateFormAction, {})
const skip = resolveWizardUpdateSkip(entryAction, municipalitySlug, returnPath)
const stepTitle = WIZARD_UPDATE_BODY_STEP_TITLE
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
const sessionEntry = resolveWizardChainEntry(entryAction, 'register-update')
router.push(
wizardChainContinueHref(sessionEntry, 'register-update', municipalitySlug, returnPath),
)
})
return (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={stepTitle}
previousHref={wizardPreviousHref({
actionSlug,
stepKind: 'update-body',
municipalitySlug,
entryAction,
returnPath,
})}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardSkipTrailing skip={skip} /> : undefined}
contentFocus="none"
>
<form
action={submitAction}
className="flex flex-col gap-6"
aria-busy={isPending || undefined}
data-pending={isPending ? '' : undefined}
>
<input type="hidden" name="municipalityId" value={municipalityId} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
<Field>
<FieldLabel htmlFor="wizard-update-body">Texto da atualização</FieldLabel>
<FieldDescription>Descreva o que aconteceu de forma clara e objetiva.</FieldDescription>
<Textarea
id="wizard-update-body"
name="body"
rows={5}
maxLength={5000}
required
disabled={isPending}
className="min-h-28"
placeholder="Ex.: Reunião com líderes comunitários, 45 apoios confirmados..."
/>
{state.fieldErrors?.body ? <FieldError>{state.fieldErrors.body}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="wizard-update-polarity">Polaridade</FieldLabel>
<NativeSelect
id="wizard-update-polarity"
name="polarity"
defaultValue="neutra"
required
disabled={isPending}
className="min-h-11 w-full sm:w-56"
>
{(['boa', 'neutra', 'ruim'] as const).map((option) => (
<NativeSelectOption key={option} value={option}>
{municipalityUpdatePolarityLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>Selecione a polaridade do fato observado.</FieldDescription>
</Field>
<div className="flex items-center gap-3">
<input type="hidden" name="urgent" value="false" />
<Checkbox
id="wizard-update-urgent"
name="urgent"
value="true"
defaultChecked={false}
disabled={isPending}
/>
<FieldLabel htmlFor="wizard-update-urgent" className="font-normal">
Sinalizar como urgente
</FieldLabel>
</div>
{isStaff ? (
<div className="flex items-start gap-3">
<input type="hidden" name="adversarySignal" value="false" />
<Checkbox
id="wizard-update-adversary"
name="adversarySignal"
value="true"
defaultChecked={false}
disabled={isPending}
className="mt-0.5"
/>
<div className="flex flex-col gap-1">
<FieldLabel htmlFor="wizard-update-adversary" className="font-normal">
Sinalizar adversário
</FieldLabel>
<FieldDescription>
Marque se este é um fato relacionado a um adversário político.
</FieldDescription>
</div>
</div>
) : (
<input type="hidden" name="adversarySignal" value="false" />
)}
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="min-h-11 min-w-[7rem]">
{isPending ? (
<>
<Spinner data-icon="inline-start" aria-hidden="true" />
Salvando…
</>
) : (
WIZARD_UPDATE_SAVE_LABEL
)}
</Button>
</div>
<div aria-live="polite" className="sr-only">
{isPending ? 'Salvando atualização.' : null}
</div>
</form>
</CampaignWizardShell>
)
}

View file

@ -73,7 +73,7 @@ type CampaignCellEditOverlayProps = {
/**
* Container for the list's in-cell quick-edit surfaces: a Popover on `md+`, a
* bottom Drawer below it, where a Popover competes with the virtual keyboard
* (B42; `MunicipalityListSignalControl` set the precedent in B26).
* (B42; `MunicipalityListUpdateControl` set the precedent in B26).
*
* The variant comes from the call site, not from `matchMedia`: the lists render
* the mobile cards (`md:hidden`) and the table (`hidden md:block`) as sibling

View file

@ -17,7 +17,7 @@ import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
const WIZARD_ACTION_IDS: readonly CampaignWizardActionId[] = [
'update-votes',
'register-signal',
'register-update',
'change-trend',
'update-leadership',
'register-demand',

View file

@ -4,23 +4,19 @@ import {
parsePoliticalTrendStatusFormValue,
type PoliticalTrendStatusValue,
} from '@/lib/schemas/municipality'
import {
parseMunicipalitySignalType,
type MunicipalitySignalType,
} from '@/lib/schemas/municipalityUpdate'
export const CAMPAIGN_ACTIONS_HOME = '/campanha/acoes' as const
export type CampaignWizardActionId =
| 'update-votes'
| 'register-signal'
| 'register-update'
| 'change-trend'
| 'update-leadership'
| 'register-demand'
export const CAMPAIGN_WIZARD_ACTION_SLUGS: Record<CampaignWizardActionId, string> = {
'update-votes': 'atualizar-votos',
'register-signal': 'registrar-sinal',
'register-update': 'registrar-atualizacao',
'change-trend': 'mudar-tendencia',
'update-leadership': 'atualizar-lideranca',
'register-demand': 'registrar-pedido',
@ -51,16 +47,12 @@ export const WIZARD_MUNICIPIO_QUERY_KEY = 'municipio' as const
/** Legacy B61 ritual param — canonical URLs omit it (B77). */
export const WIZARD_SCENARIO_QUERY_KEY = 'cenario' as const
export const WIZARD_SIGNAL_TYPE_QUERY_KEY = 'signalType' as const
export const WIZARD_ENTRY_ACTION_QUERY_KEY = 'entry' as const
export const WIZARD_TREND_STATUS_QUERY_KEY = 'trendStatus' as const
export const WIZARD_NOTE_PREFILL_QUERY_KEY = 'notePrefill' as const
export const WIZARD_SIGNAL_BODY_QUERY_KEY = 'signalBody' as const
export const WIZARD_VOTE_FROM_QUERY_KEY = 'voteFrom' as const
export const WIZARD_VOTE_TO_QUERY_KEY = 'voteTo' as const
@ -167,43 +159,21 @@ export const parseWizardEntryActionParam = (
return trimmed
}
export const wizardSignalHref = (
actionSlug: string,
municipalitySlug?: string,
signalType?: MunicipalitySignalType,
entryAction?: CampaignWizardActionId,
returnPath?: string,
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug) {
return appendWizardReturnPath(base, returnPath)
}
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
if (signalType) {
params.set(WIZARD_SIGNAL_TYPE_QUERY_KEY, signalType)
}
if (entryAction) {
params.set(WIZARD_ENTRY_ACTION_QUERY_KEY, entryAction)
}
return appendWizardReturnPath(`${base}?${params.toString()}`, returnPath)
}
export const resolveWizardSignalTypeParam = (
export const resolveWizardTrendStatusParam = (
value: string | string[] | undefined,
): { signalType?: MunicipalitySignalType; invalid: boolean } => {
): { trendStatus?: PoliticalTrendStatusValue; invalid: boolean } => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed) {
return { signalType: undefined, invalid: false }
return { trendStatus: undefined, invalid: false }
}
const signalType = parseMunicipalitySignalType(trimmed)
if (signalType) {
return { signalType, invalid: false }
const trendStatus = parsePoliticalTrendStatusFormValue(trimmed)
if (trendStatus) {
return { trendStatus, invalid: false }
}
return { signalType: undefined, invalid: true }
return { trendStatus: undefined, invalid: true }
}
export const wizardTrendHref = (
@ -233,20 +203,3 @@ export const wizardTrendHref = (
}
return appendWizardReturnPath(`${base}?${params.toString()}`, returnPath)
}
export const resolveWizardTrendStatusParam = (
value: string | string[] | undefined,
): { trendStatus?: PoliticalTrendStatusValue; invalid: boolean } => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed) {
return { trendStatus: undefined, invalid: false }
}
const trendStatus = parsePoliticalTrendStatusFormValue(trimmed)
if (trendStatus) {
return { trendStatus, invalid: false }
}
return { trendStatus: undefined, invalid: true }
}

View file

@ -23,7 +23,7 @@ import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
type CampaignHomeActionId =
| 'update-votes'
| 'register-signal'
| 'register-update'
| 'change-trend'
| 'update-leadership'
| 'register-demand'
@ -46,10 +46,10 @@ const staffHomeActionsCoordinator: readonly CampaignHomeAction[] = [
description: 'Atualizar a projeção (média, pessimista, otimista) de um município',
},
{
id: 'register-signal',
label: 'Registrar sinal',
id: 'register-update',
label: 'Registrar atualização',
icon: Megaphone,
description: 'Anotar sinal urgente: invasão, perda de apoio, novo apoio, dificuldade',
description: 'Anotar o que aconteceu no município — texto, polaridade e urgência.',
},
{
id: 'change-trend',

View file

@ -14,7 +14,7 @@ export const CAMPAIGN_DEMANDS_CREATE_HREF = `${CAMPAIGN_DEMANDS_HOME}/nova` as c
const DETAIL_WIZARD_ACTION_IDS: readonly CampaignWizardActionId[] = [
'update-votes',
'register-signal',
'register-update',
'change-trend',
'update-leadership',
'register-demand',

View file

@ -19,7 +19,7 @@ const LEADERSHIP_LIST_PATH = '/campanha/liderancas' as const
const LEADERSHIP_DETAIL_WIZARD_IDS: readonly CampaignWizardActionId[] = [
'update-votes',
'register-signal',
'register-update',
'change-trend',
'update-leadership',
'register-demand',

View file

@ -1,57 +0,0 @@
import {
CircleHelp,
Handshake,
ShieldAlert,
Snowflake,
UserRoundX,
type LucideIcon,
} from 'lucide-react'
import {
municipalitySignalTypeDescriptions,
municipalitySignalTypeLabels,
municipalitySignalTypes,
type MunicipalitySignalType,
} from '@/lib/schemas/municipalityUpdate'
export type MunicipalitySignalTypeMetaEntry = {
type: MunicipalitySignalType
label: string
shortDescription: string
infoContent: string
icon: LucideIcon
}
const signalTypeIcons: Record<MunicipalitySignalType, LucideIcon> = {
invasao: ShieldAlert,
esfriamento: Snowflake,
visita_adversario: UserRoundX,
proposta_broker: Handshake,
outro: CircleHelp,
}
const signalTypeInfoContent: Record<MunicipalitySignalType, string> = {
invasao:
'O adversário está ocupando espaço que era nosso: reunião, base, narrativa ou presença no território. Consequência: a rede pode esfriar ou a leitura do município muda. Use quando a equipe perceber invasão real, não só boatos.',
esfriamento:
'Aliados pararam de responder, sumiram das conversas ou a rede perdeu ritmo. Consequência: cobertura e pledges podem estagnar. Use quando o silêncio for novo e relevante para a decisão do município.',
visita_adversario:
'O adversário apareceu no município — visita, agenda pública ou movimento visível. Consequência: pode pressionar lideranças ou mudar a leitura local. Use quando houver fato concreto, não só expectativa.',
proposta_broker:
'Alguém pediu ou ofereceu algo em troca de apoio, voto ou posicionamento. Consequência: pode virar demanda, risco ou oportunidade de alinhamento. Use quando a conversa for relevante para a campanha.',
outro:
'Fato importante que não encaixa nos tipos acima, mas merece registro datado. Consequência: alimenta o histórico do município e pode acionar sugestões depois. Use com parcimônia — prefira um tipo específico quando couber.',
}
export const municipalitySignalTypeMeta: MunicipalitySignalTypeMetaEntry[] =
municipalitySignalTypes.map((type) => ({
type,
label: municipalitySignalTypeLabels[type],
shortDescription: municipalitySignalTypeDescriptions[type],
infoContent: signalTypeInfoContent[type],
icon: signalTypeIcons[type],
}))
export const municipalitySignalTypeMetaByType = Object.fromEntries(
municipalitySignalTypeMeta.map((entry) => [entry.type, entry]),
) as Record<MunicipalitySignalType, MunicipalitySignalTypeMetaEntry>

View file

@ -1,10 +1,6 @@
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { WIZARD_CHAIN_SKIP_LABEL } from '@/lib/campaignWizardCopy'
import { politicalTrendStatuses, type PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import {
municipalitySignalTypeLabels,
type MunicipalitySignalType,
} from '@/lib/schemas/municipalityUpdate'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
const politicalTrendDisplayLabels: Record<PoliticalTrendStatusValue, string> = {
@ -85,7 +81,6 @@ export const resolveWizardTrendSkip = (
export type PoliticalTrendNotePrefillSource =
| { kind: 'none' }
| { kind: 'signal'; signalType: MunicipalitySignalType; description: string }
| { kind: 'voteAdjustment'; previousValue: number; newValue: number }
| { kind: 'custom'; text: string }
@ -93,8 +88,6 @@ export const buildPoliticalTrendNotePrefill = (source: PoliticalTrendNotePrefill
switch (source.kind) {
case 'none':
return ''
case 'signal':
return `Sinal de ${municipalitySignalTypeLabels[source.signalType].toLowerCase()}: ${source.description}`
case 'voteAdjustment':
return `Ajuste de votos: ${source.previousValue} → ${source.newValue}`
case 'custom':
@ -117,15 +110,11 @@ const parseOptionalIntParam = (value: string | string[] | undefined): number | u
export const resolvePoliticalTrendNotePrefillSource = ({
entryAction,
notePrefill,
signalType,
signalBody,
voteFrom,
voteTo,
}: {
entryAction?: CampaignWizardActionId
notePrefill?: string | string[] | undefined
signalType?: MunicipalitySignalType
signalBody?: string | string[] | undefined
voteFrom?: string | string[] | undefined
voteTo?: string | string[] | undefined
}): PoliticalTrendNotePrefillSource => {
@ -134,13 +123,6 @@ export const resolvePoliticalTrendNotePrefillSource = ({
return { kind: 'custom', text: customText.trim() }
}
if (entryAction === 'register-signal' && signalType) {
const body = Array.isArray(signalBody) ? signalBody[0] : signalBody
if (body?.trim()) {
return { kind: 'signal', signalType, description: body.trim() }
}
}
if (entryAction === 'update-votes') {
const previousValue = parseOptionalIntParam(voteFrom)
const newValue = parseOptionalIntParam(voteTo)

View file

@ -1,105 +1,39 @@
import { z } from 'zod'
import {
MAX_VOTE_COUNT,
positiveRelationshipId,
trimmedOptionalText,
} from '@/lib/schemas/primitives'
import { MAX_VOTE_COUNT, positiveRelationshipId } from '@/lib/schemas/primitives'
export const municipalityUpdateKinds = ['semanal', 'urgente', 'nota', 'sinal'] as const
export type MunicipalityUpdateKind = (typeof municipalityUpdateKinds)[number]
export const municipalityUpdatePolarities = ['boa', 'neutra', 'ruim'] as const
export type MunicipalityUpdatePolarity = (typeof municipalityUpdatePolarities)[number]
export const municipalityUpdateKindLabels: Record<MunicipalityUpdateKind, string> = {
semanal: 'Semanal',
urgente: 'Urgente',
nota: 'Nota',
sinal: 'Sinal',
export const municipalityUpdatePolarityLabels: Record<MunicipalityUpdatePolarity, string> = {
boa: 'Boa',
neutra: 'Neutra',
ruim: 'Ruim',
}
export const municipalitySignalTypes = [
'invasao',
'esfriamento',
'visita_adversario',
'proposta_broker',
'outro',
] as const
export type MunicipalitySignalType = (typeof municipalitySignalTypes)[number]
export const MUNICIPALITY_UPDATE_POLARITY_REQUIRED_MESSAGE = 'Informe a polaridade da atualização.'
export const municipalitySignalTypeLabels: Record<MunicipalitySignalType, string> = {
invasao: 'Invasão',
esfriamento: 'Rede esfriou',
visita_adversario: 'Adversário apareceu',
proposta_broker: 'Alguém pediu algo',
outro: 'Outro',
}
export const MUNICIPALITY_UPDATE_BODY_REQUIRED_MESSAGE = 'Informe o texto da atualização.'
export const municipalitySignalTypeDescriptions: Record<MunicipalitySignalType, string> = {
invasao: 'Adversário ocupando nosso espaço.',
esfriamento: 'Aliados pararam de responder ou caíram.',
visita_adversario: 'Visita ou agenda dele no município.',
proposta_broker: 'Liderança ou intermediário pediu ou ofereceu algo.',
outro: 'Fato importante que não encaixa acima.',
}
export const parseMunicipalitySignalType = (
export const parseMunicipalityUpdatePolarity = (
raw: string | undefined,
): MunicipalitySignalType | undefined => {
): MunicipalityUpdatePolarity | undefined => {
if (!raw) return undefined
return municipalitySignalTypes.includes(raw as MunicipalitySignalType)
? (raw as MunicipalitySignalType)
return municipalityUpdatePolarities.includes(raw as MunicipalityUpdatePolarity)
? (raw as MunicipalityUpdatePolarity)
: undefined
}
const optionalCount = z.number().int().min(0).max(MAX_VOTE_COUNT).optional()
export const municipalityUpdateCreateSchema = z
.object({
municipality: positiveRelationshipId,
kind: z.enum(municipalityUpdateKinds).default('semanal'),
worked: trimmedOptionalText(3000),
failed: trimmedOptionalText(3000),
needs: trimmedOptionalText(3000),
body: trimmedOptionalText(5000),
activeVolunteers: optionalCount,
newSupports: optionalCount,
signalType: z.enum(municipalitySignalTypes).optional(),
})
.superRefine((data, context) => {
if (data.kind === 'semanal') {
if (!data.worked) {
context.addIssue({ code: 'custom', message: 'Informe o que funcionou.', path: ['worked'] })
}
if (!data.failed) {
context.addIssue({
code: 'custom',
message: 'Informe o que não funcionou.',
path: ['failed'],
})
}
if (!data.needs) {
context.addIssue({
code: 'custom',
message: 'Informe o que você precisa.',
path: ['needs'],
})
}
} else if (data.kind !== 'sinal' && !data.body) {
// Sinal inherits B147/B134 optional-motivo: type is enough; body/note is optional.
context.addIssue({
code: 'custom',
message: 'Informe o texto da atualização.',
path: ['body'],
})
}
if (data.kind === 'sinal') {
if (!data.signalType) {
context.addIssue({
code: 'custom',
message: 'Informe o tipo do sinal.',
path: ['signalType'],
})
}
}
})
export const municipalityUpdateCreateSchema = z.object({
municipality: positiveRelationshipId,
body: z.string().trim().min(1, MUNICIPALITY_UPDATE_BODY_REQUIRED_MESSAGE).max(5000),
polarity: z.enum(municipalityUpdatePolarities),
urgent: z.boolean().default(false),
activeVolunteers: optionalCount,
newSupports: optionalCount,
adversarySignal: z.boolean().default(false),
})
export type MunicipalityUpdateCreateInput = z.input<typeof municipalityUpdateCreateSchema>

View file

@ -8,7 +8,6 @@ import {
campaignWizardActionIdForSlug,
isWizardReturnPath,
wizardActionHref,
wizardSignalHref,
wizardTrendHref,
type CampaignWizardActionId,
} from '@/lib/campaignActionRoutes'
@ -18,10 +17,10 @@ import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
export type WizardChainActionId = Exclude<CampaignWizardActionId, 'register-demand'>
const WIZARD_CHAIN_AFTER = {
'update-votes': ['change-trend', 'register-signal', 'update-leadership'],
'register-signal': ['change-trend', 'update-votes', 'update-leadership'],
'change-trend': ['register-signal', 'update-votes', 'update-leadership'],
'update-leadership': ['register-signal', 'update-votes', 'change-trend'],
'update-votes': ['change-trend', 'register-update', 'update-leadership'],
'register-update': ['change-trend', 'update-votes', 'update-leadership'],
'change-trend': ['register-update', 'update-votes', 'update-leadership'],
'update-leadership': ['register-update', 'update-votes', 'change-trend'],
} as const satisfies Record<WizardChainActionId, readonly WizardChainActionId[]>
export const isWizardChainActionId = (
@ -62,9 +61,8 @@ const wizardHrefForChainStep = (
switch (action) {
case 'update-votes':
case 'update-leadership':
case 'register-update':
return wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })
case 'register-signal':
return wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction, returnPath)
case 'change-trend':
return wizardTrendHref(
actionSlug,
@ -111,14 +109,15 @@ export const resolveWizardChainEntry = (
currentAction: WizardChainActionId,
): WizardChainActionId => (isWizardChainActionId(entryAction) ? entryAction : currentAction)
/** Discriminates wizard chrome steps for `wizardPreviousHref` (B135). */
/**
* register-update, update-votes, update-leadership all use wizardActionHref.
*/
type WizardStepKind =
| 'municipality-search'
| 'votes'
| 'trend-choice'
| 'trend-note'
| 'signal-type'
| 'signal-body'
| 'update-body'
| 'leadership-grid'
| 'leadership-form'
@ -132,12 +131,11 @@ const wizardPrincipalStepHref = (
switch (action) {
case 'update-votes':
case 'update-leadership':
case 'register-update':
return wizardActionHref(actionSlug, municipalitySlug, {
entryAction: sessionEntry,
returnPath,
})
case 'register-signal':
return wizardSignalHref(actionSlug, municipalitySlug, undefined, sessionEntry, returnPath)
case 'change-trend':
return wizardTrendHref(
actionSlug,
@ -208,10 +206,6 @@ export const wizardPreviousHref = (input: WizardPreviousHrefInput): string => {
)
}
if (stepKind === 'signal-body') {
return wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction, returnPath)
}
if (stepKind === 'leadership-form') {
return wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })
}

View file

@ -1,37 +0,0 @@
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { WIZARD_CHAIN_SKIP_LABEL } from '@/lib/campaignWizardCopy'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
export const WIZARD_SIGNAL_TYPE_STEP_TITLE = 'Que tipo de sinal?' as const
export const WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX = 'Detalhar sinal' as const
export const WIZARD_SIGNAL_SAVE_LABEL = 'Salvar' as const
export const WIZARD_SIGNAL_SAVED_MESSAGE = 'Sinal registrado.' as const
export type WizardSignalSkipAction = {
label: string
href: string
}
export const shouldShowWizardSignalSkip = (
entryAction: CampaignWizardActionId | undefined,
): boolean => entryAction != null && entryAction !== 'register-signal'
export const resolveWizardSignalSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
returnPath?: string,
): WizardSignalSkipAction | undefined =>
shouldShowWizardSignalSkip(entryAction)
? {
label: WIZARD_CHAIN_SKIP_LABEL,
href: wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'register-signal'),
'register-signal',
municipalitySlug,
returnPath,
),
}
: undefined

33
src/lib/wizardUpdateUi.ts Normal file
View file

@ -0,0 +1,33 @@
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { WIZARD_CHAIN_SKIP_LABEL } from '@/lib/campaignWizardCopy'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
export const WIZARD_UPDATE_BODY_STEP_TITLE = 'Registrar atualização' as const
export const WIZARD_UPDATE_SAVE_LABEL = 'Salvar' as const
export type WizardUpdateSkipAction = {
label: string
href: string
}
export const shouldShowWizardUpdateSkip = (
entryAction: CampaignWizardActionId | undefined,
): boolean => entryAction != null && entryAction !== 'register-update'
export const resolveWizardUpdateSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
returnPath?: string,
): WizardUpdateSkipAction | undefined =>
shouldShowWizardUpdateSkip(entryAction)
? {
label: WIZARD_CHAIN_SKIP_LABEL,
href: wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'register-update'),
'register-update',
municipalitySlug,
returnPath,
),
}
: undefined

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,109 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* C87 — Atualização unificada (texto + polaridade + urgente).
*
* Reescreve o modelo de MunicipalityUpdate: elimina kind (semanal/urgente/nota/sinal)
* e signalType, substituindo por polarity (boa/neutra/ruim), urgent (boolean) e
* adversarySignal (boolean, para E11).
*
* Backfill:
* - polarity = 'neutra' (padrão — não há mapeamento semântico fino de old kinds)
*- urgent = TRUE onde kind = 'urgente'
* - adversary_signal = TRUE onde kind = 'sinal' E signal_type IN (invasao, visita_adversario, proposta_broker)
* - body: para kind = 'semanal', concatena worked || ' | ' || failed || ' | ' || needs;
* para outros kinds, mantém o body existente
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
-- 1. Create the new polarity enum type
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_municipality_update_polarity') THEN
CREATE TYPE "enum_municipality_update_polarity" AS ENUM ('boa', 'neutra', 'ruim');
END IF;
END $$;
-- 2. Add new columns
ALTER TABLE "municipality_update" ADD COLUMN IF NOT EXISTS "polarity" "enum_municipality_update_polarity";
ALTER TABLE "municipality_update" ADD COLUMN IF NOT EXISTS "urgent" boolean DEFAULT false;
ALTER TABLE "municipality_update" ADD COLUMN IF NOT EXISTS "adversary_signal" boolean DEFAULT false;
-- 3. Backfill polarity (all default to neutra)
UPDATE "municipality_update" SET "polarity" = 'neutra' WHERE "polarity" IS NULL;
-- 3b. Set NOT NULL now that all rows have a value
ALTER TABLE "municipality_update" ALTER COLUMN "polarity" SET NOT NULL;
-- 4. Backfill urgent from kind
UPDATE "municipality_update" SET "urgent" = true WHERE "kind" = 'urgente';
-- 5. Backfill adversary_signal from kind + signal_type
UPDATE "municipality_update"
SET "adversary_signal" = true
WHERE "kind" = 'sinal'
AND "signal_type" IN ('invasao', 'visita_adversario', 'proposta_broker');
-- 6. Backfill body: for semanal, concatenate worked|failed|needs
UPDATE "municipality_update"
SET "body" = TRIM(COALESCE(NULLIF(CONCAT_WS(' | ', "worked", "failed", "needs"), ' | '), ''))
WHERE "kind" = 'semanal'
AND (COALESCE("worked", '') || COALESCE("failed", '') || COALESCE("needs", '')) != '';
-- 7. Drop old indexes
DROP INDEX IF EXISTS "municipality_update_kind_idx";
-- 8. Drop old columns
ALTER TABLE "municipality_update" DROP COLUMN IF EXISTS "kind";
ALTER TABLE "municipality_update" DROP COLUMN IF EXISTS "signal_type";
ALTER TABLE "municipality_update" DROP COLUMN IF EXISTS "worked";
ALTER TABLE "municipality_update" DROP COLUMN IF EXISTS "failed";
ALTER TABLE "municipality_update" DROP COLUMN IF EXISTS "needs";
-- 9. Drop old enum types (only if no other table uses them)
DROP TYPE IF EXISTS "enum_municipality_update_kind";
DROP TYPE IF EXISTS "enum_municipality_update_signal_type";
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
-- Recreate old enum types
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_municipality_update_kind') THEN
CREATE TYPE "enum_municipality_update_kind" AS ENUM ('semanal', 'urgente', 'nota', 'sinal');
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_municipality_update_signal_type') THEN
CREATE TYPE "enum_municipality_update_signal_type" AS ENUM ('invasao', 'esfriamento', 'visita_adversario', 'proposta_broker', 'outro');
END IF;
END $$;
-- Recreate old columns (data lost on rollback — acceptable for dev)
ALTER TABLE "municipality_update" ADD COLUMN IF NOT EXISTS "kind" "enum_municipality_update_kind" DEFAULT 'semanal' NOT NULL;
ALTER TABLE "municipality_update" ADD COLUMN IF NOT EXISTS "signal_type" "enum_municipality_update_signal_type";
ALTER TABLE "municipality_update" ADD COLUMN IF NOT EXISTS "worked" varchar;
ALTER TABLE "municipality_update" ADD COLUMN IF NOT EXISTS "failed" varchar;
ALTER TABLE "municipality_update" ADD COLUMN IF NOT EXISTS "needs" varchar;
-- Map back urgent → kind
UPDATE "municipality_update" SET "kind" = 'urgente' WHERE "urgent" = true;
UPDATE "municipality_update" SET "kind" = CASE WHEN "kind" IS NULL THEN 'nota' ELSE "kind" END WHERE "kind" IS NULL;
-- Map back adversary_signal → sinal + signal_type
UPDATE "municipality_update"
SET "kind" = 'sinal', "signal_type" = 'invasao'
WHERE "adversary_signal" = true AND "kind" != 'sinal';
-- Drop new columns
ALTER TABLE "municipality_update" DROP COLUMN IF EXISTS "polarity";
ALTER TABLE "municipality_update" DROP COLUMN IF EXISTS "urgent";
ALTER TABLE "municipality_update" DROP COLUMN IF EXISTS "adversary_signal";
DROP TYPE IF EXISTS "enum_municipality_update_polarity";
`)
}

View file

@ -42,6 +42,7 @@ import * as migration_20260802_230000_strip_engagement_reversal_signals from './
import * as migration_20260804_061017_add_state_deputy_advisors from './20260804_061017_add_state_deputy_advisors';
import * as migration_20260806_082110_add_state_deputy_contact from './20260806_082110_add_state_deputy_contact';
import * as migration_20260806_120000_c14_remodel_activity_agenda from './20260806_120000_c14_remodel_activity_agenda';
import * as migration_20260807_030000_unify_municipality_update from './20260807_030000_unify_municipality_update';
export const migrations = [
{
@ -264,4 +265,9 @@ export const migrations = [
down: migration_20260806_120000_c14_remodel_activity_agenda.down,
name: '20260806_120000_c14_remodel_activity_agenda',
},
{
up: migration_20260807_030000_unify_municipality_update.up,
down: migration_20260807_030000_unify_municipality_update.down,
name: '20260807_030000_unify_municipality_update',
},
];

View file

@ -830,14 +830,12 @@ export interface MunicipalityUpdate {
id: number;
municipality: number | Municipality;
author: number | CampaignUser;
kind: 'semanal' | 'urgente' | 'nota' | 'sinal';
worked?: string | null;
failed?: string | null;
needs?: string | null;
polarity: 'boa' | 'neutra' | 'ruim';
urgent?: boolean | null;
adversarySignal?: boolean | null;
body: string;
activeVolunteers?: number | null;
newSupports?: number | null;
body?: string | null;
signalType?: ('invasao' | 'esfriamento' | 'visita_adversario' | 'proposta_broker' | 'outro') | null;
updatedAt: string;
createdAt: string;
}
@ -1733,14 +1731,12 @@ export interface SupporterImportBatchSelect<T extends boolean = true> {
export interface MunicipalityUpdateSelect<T extends boolean = true> {
municipality?: T;
author?: T;
kind?: T;
worked?: T;
failed?: T;
needs?: T;
polarity?: T;
urgent?: T;
adversarySignal?: T;
body?: T;
activeVolunteers?: T;
newSupports?: T;
body?: T;
signalType?: T;
updatedAt?: T;
createdAt?: T;
}

View file

@ -20,7 +20,7 @@ const helpers = createDetailTabHelpers<MunicipalityDetailTab>({
dossie: [],
elections: ['compare'],
leaderships: ['leadershipQ', 'leadershipPage'],
updates: ['updateKind', 'updatePage', 'newUpdate'],
updates: ['updatePage', 'newUpdate'],
demands: ['demandStatus'],
},
basePath: (municipalitySlug) => `/campanha/municipios/${municipalitySlug}`,

View file

@ -112,7 +112,7 @@ export const municipalityColumnLabels: Record<MunicipalityListColumnId, string>
stateDeputies: 'Dobradinha',
goalCoverage: municipalityListSortLabels.deficit,
/** The column shows the signal; `frescor` sorts by how old it is. */
lastSignal: 'Sinal',
lastSignal: 'Atualização',
lastUpdateAt: municipalityListSortLabels.lastUpdateAt,
}

View file

@ -11,7 +11,6 @@ import {
import { ELECTION_YEAR_2014, ELECTION_YEAR_2018, ELECTION_YEAR_2022 } from '@/lib/electionResults'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import {
evaluateMunicipalityTriggers,
isSuggestionSuppressedByDecision,
@ -104,13 +103,6 @@ export type MunicipalitySuggestionsBundle = {
evaluatedCount: number
}
/** P1's threat leg: signal types that read as an adversary move. */
const ADVERSARY_SIGNAL_TYPES: MunicipalitySignalType[] = [
'invasao',
'visita_adversario',
'proposta_broker',
]
/** Agenda legs: drafts are intentions and cancellations never happened. */
const AGENDA_ACTIVITY_STATUSES = ['confirmado', 'realizado'] as const
@ -226,8 +218,7 @@ export const loadMunicipalitySuggestions = async (
where: {
and: [
{ municipality: { in: municipalityIDs } },
{ kind: { equals: 'sinal' } },
{ signalType: { in: ADVERSARY_SIGNAL_TYPES } },
{ adversarySignal: { equals: true } },
{
createdAt: {
greater_than: isoDaysAgo(now, SUGGESTION_INPUT_WINDOWS.adversarySignalDays),

View file

@ -3,11 +3,10 @@ import 'server-only'
import type { Payload } from 'payload'
import { relationshipId } from '@/lib/relationship'
import type {
MunicipalitySignalType,
MunicipalityUpdateKind,
import {
municipalityUpdatePolarities,
type MunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import { municipalityUpdateKinds } from '@/lib/schemas/municipalityUpdate'
import type { CampaignUser, MunicipalityUpdate } from '@/payload-types'
import { loadCampaignUserNamesByIds } from '@/utilities/loadNamesByIds'
@ -15,20 +14,17 @@ export const municipalityUpdatesPageSize = 10
export type MunicipalityUpdateViewModel = {
id: number
kind: MunicipalityUpdateKind
authorName: string
createdAt: string
worked: string | null
failed: string | null
needs: string | null
body: string | null
polarity: MunicipalityUpdatePolarity
urgent: boolean
activeVolunteers: number | null
newSupports: number | null
signalType: MunicipalitySignalType | null
adversarySignal: boolean
}
export type MunicipalityUpdateFeedState = {
kind?: MunicipalityUpdateKind
page: number
}
@ -36,16 +32,10 @@ export const parseMunicipalityUpdateFeedParams = (
searchParams: Record<string, string | string[] | undefined>,
): MunicipalityUpdateFeedState => {
const first = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value)
const rawKind = first(searchParams.updateKind)
const rawPage = first(searchParams.updatePage)
const page = rawPage && /^[1-9]\d*$/.test(rawPage) ? Number(rawPage) : 1
return {
...(municipalityUpdateKinds.includes(rawKind as MunicipalityUpdateKind)
? { kind: rawKind as MunicipalityUpdateKind }
: {}),
page,
}
return { page }
}
export const loadMunicipalityUpdatesFeed = async (
@ -62,10 +52,7 @@ export const loadMunicipalityUpdatesFeed = async (
const result = await payload.find({
collection: 'municipalityUpdate',
where: {
and: [
{ municipality: { equals: municipalityID } },
...(state.kind ? [{ kind: { equals: state.kind } }] : []),
],
municipality: { equals: municipalityID },
},
depth: 0,
limit: municipalityUpdatesPageSize,
@ -87,18 +74,17 @@ export const loadMunicipalityUpdatesFeed = async (
return {
updates: result.docs.map((update) => {
const doc = update as MunicipalityUpdate
const polarity = doc.polarity as MunicipalityUpdatePolarity | undefined
return {
id: doc.id,
kind: doc.kind as MunicipalityUpdateKind,
authorName: authorNameById.get(relationshipId(doc.author) ?? -1) ?? 'Usuário',
createdAt: doc.createdAt,
worked: doc.worked ?? null,
failed: doc.failed ?? null,
needs: doc.needs ?? null,
body: doc.body ?? null,
polarity: polarity && municipalityUpdatePolarities.includes(polarity) ? polarity : 'neutra',
urgent: Boolean(doc.urgent),
activeVolunteers: doc.activeVolunteers ?? null,
newSupports: doc.newSupports ?? null,
signalType: doc.signalType ?? null,
adversarySignal: Boolean(doc.adversarySignal),
}
}),
totalDocs: result.totalDocs,

View file

@ -2,7 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import type { MunicipalityUpdatePolarity } from '@/lib/schemas/municipalityUpdate'
import type { CampaignUser } from '@/payload-types'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import {
@ -19,18 +19,17 @@ import { aggregateMunicipalityPledgesFromRows } from '@/utilities/votePledgeView
export type { MunicipalityV2StatusViewModel }
const loadLatestSignal = async (
const loadLatestUpdate = async (
payload: Payload,
user: CampaignUser,
municipalityID: number,
): Promise<{ type: MunicipalitySignalType | null; body: string | null }> => {
): Promise<{ polarity: MunicipalityUpdatePolarity | null; body: string | null }> => {
const feed = await loadMunicipalityUpdatesFeed(payload, user, municipalityID, {
page: 1,
kind: 'sinal',
})
const latest = feed.updates[0]
if (!latest) return { type: null, body: null }
return { type: latest.signalType, body: latest.body }
if (!latest) return { polarity: null, body: null }
return { polarity: latest.polarity ?? null, body: latest.body ?? null }
}
export const loadMunicipalityV2StatusData = async (
@ -39,10 +38,10 @@ export const loadMunicipalityV2StatusData = async (
municipalitySlug: string,
): Promise<{ context: AccessibleMunicipalityContext; status: MunicipalityV2StatusViewModel }> => {
const context = await resolveAccessibleMunicipalityContext(payload, user, municipalitySlug)
const [view, pledges, latestSignal] = await Promise.all([
const [view, pledges, latestUpdate] = await Promise.all([
getMunicipalityDetailViewModel(payload, context, user),
loadMunicipalityPledges(payload, user, context.id),
loadLatestSignal(payload, user, context.id),
loadLatestUpdate(payload, user, context.id),
])
const strategy = view.strategy
@ -70,8 +69,8 @@ export const loadMunicipalityV2StatusData = async (
politicalTrendStatus: strategy.politicalTrend.status,
politicalTrendNote: strategy.politicalTrend.note,
lastSignalAt,
lastSignalType: latestSignal.type,
lastSignalBody: latestSignal.body,
lastUpdatePolarity: latestUpdate.polarity,
lastUpdateBody: latestUpdate.body,
territorialClass: computeMunicipalityTerritorialClass(view.slug),
},
}

View file

@ -8,8 +8,8 @@ import {
type EngagementLevel,
} from '@/lib/engagementLevel'
import {
municipalitySignalTypeLabels,
type MunicipalitySignalType,
municipalityUpdatePolarityLabels,
type MunicipalityUpdatePolarity,
} from '@/lib/schemas/municipalityUpdate'
import type { PoliticalTrendStatus } from '@/utilities/municipality/municipalityLabels'
import {
@ -30,8 +30,8 @@ export type MunicipalityV2StatusViewModel = {
politicalTrendStatus: PoliticalTrendStatus | null
politicalTrendNote: string | null
lastSignalAt: string | null
lastSignalType: MunicipalitySignalType | null
lastSignalBody: string | null
lastUpdatePolarity: MunicipalityUpdatePolarity | null
lastUpdateBody: string | null
territorialClass: MunicipalityTerritorialClassification
}
@ -41,15 +41,15 @@ export const MUNICIPALITY_V2_SIGNAL_COLD_VALUE = '__cold__'
export type MunicipalityV2StatusNotes = {
levelNote: string | null
trendNote: string | null
signalBody: string | null
signalType: MunicipalitySignalType | null
updateBody: string | null
updatePolarity: MunicipalityUpdatePolarity | null
lastSignalAt: string | null
engagementLevel: EngagementLevel | null
}
export type MunicipalityV2SignalSelectState = {
/** Current `<select>` value — signal type or cold sentinel. */
value: MunicipalitySignalType | typeof MUNICIPALITY_V2_SIGNAL_COLD_VALUE
export type MunicipalityV2UpdateState = {
/** Current `<select>` value — polarity or cold sentinel. */
value: MunicipalityUpdatePolarity | typeof MUNICIPALITY_V2_SIGNAL_COLD_VALUE
/** Short label for the control (and cold readout). */
label: string
ageInDays: number | null
@ -57,17 +57,17 @@ export type MunicipalityV2SignalSelectState = {
}
/**
* Product assumption (intention open Q): cold → sentinel “Sem sinal / frio”
* in the strip; age lives in aggregate/tooltip. Warm but typeless → “Sem tipo”
* Product assumption (intention open Q): cold → sentinel "Sem sinal / frio"
* in the strip; age lives in aggregate/tooltip. Warm but typeless → "Sem tipo"
* without claiming frio (frescor can still be warm from notes/pledges).
*/
export const resolveMunicipalityV2SignalSelectState = (
export const resolveMunicipalityV2UpdateState = (
input: {
signalType: MunicipalitySignalType | null
polarity: MunicipalityUpdatePolarity | null
lastSignalAt: string | null
},
now: Date = new Date(),
): MunicipalityV2SignalSelectState => {
): MunicipalityV2UpdateState => {
const ageInDays = municipalitySignalAgeInDays(input.lastSignalAt, now)
const isCold = isMunicipalitySignalCold(ageInDays)
@ -82,25 +82,25 @@ export const resolveMunicipalityV2SignalSelectState = (
}
}
if (!input.signalType) {
if (!input.polarity) {
return {
value: MUNICIPALITY_V2_SIGNAL_COLD_VALUE,
label: 'Sem tipo de sinal',
label: 'Sem polaridade',
ageInDays,
isCold: false,
}
}
return {
value: input.signalType,
label: municipalitySignalTypeLabels[input.signalType],
value: input.polarity,
label: municipalityUpdatePolarityLabels[input.polarity],
ageInDays,
isCold: false,
}
}
/**
* Aggregate under the status strip: latest notes from level · trend · signal,
* Aggregate under the status strip: latest notes from level · trend · update,
* with absence/age when there is nothing to quote.
*/
export const buildMunicipalityV2StatusAggregate = (
@ -115,12 +115,15 @@ export const buildMunicipalityV2StatusAggregate = (
const trendPart = input.trendNote?.trim() ? input.trendNote.trim() : 'Tendência sem nota'
const signalState = resolveMunicipalityV2SignalSelectState(input, now)
const signalPart = input.signalBody?.trim()
? input.signalBody.trim()
: signalState.isCold
? signalState.label
: `${signalState.label} — sem nota`
const updateState = resolveMunicipalityV2UpdateState(
{ polarity: input.updatePolarity, lastSignalAt: input.lastSignalAt },
now,
)
const updatePart = input.updateBody?.trim()
? input.updateBody.trim()
: updateState.isCold
? updateState.label
: `${updateState.label} — sem nota`
return `${levelPart} · ${trendPart} · ${signalPart}`
return `${levelPart} · ${trendPart} · ${updatePart}`
}

View file

@ -9,7 +9,8 @@ import {
SKIP_CAMPAIGN_NOTIFICATION_CONTEXT_KEY,
} from '@/lib/notificationContract'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { municipalityUpdateKindLabels } from '@/lib/schemas/municipalityUpdate'
import type { MunicipalityUpdatePolarity } from '@/lib/schemas/municipalityUpdate'
import { municipalityUpdatePolarityLabels } from '@/lib/schemas/municipalityUpdate'
import { createCampaignNotifications } from '@/utilities/notification/createCampaignNotification'
import { resolveMunicipalityStaffRecipientIds } from '@/utilities/notification/notificationRecipients'
@ -29,7 +30,7 @@ export const notifyMunicipalityUpdateCreated = async (
id: number
municipality: unknown
author: unknown
kind: string
polarity: MunicipalityUpdatePolarity
},
): Promise<void> => {
if (shouldSkipCampaignNotification(req.context)) return
@ -60,9 +61,11 @@ export const notifyMunicipalityUpdateCreated = async (
authorName = author.name
}
const kindLabel =
doc.kind in municipalityUpdateKindLabels
? municipalityUpdateKindLabels[doc.kind as keyof typeof municipalityUpdateKindLabels]
const polarityLabel =
doc.polarity in municipalityUpdatePolarityLabels
? municipalityUpdatePolarityLabels[
doc.polarity as keyof typeof municipalityUpdatePolarityLabels
]
: 'Atualização'
const recipientIDs = await resolveMunicipalityStaffRecipientIds(
@ -79,7 +82,7 @@ export const notifyMunicipalityUpdateCreated = async (
type: 'municipality_update',
municipalityID,
payload: {
title: `${kindLabel} — ${municipality.name}`,
title: `${polarityLabel} — ${municipality.name}`,
detail: `${authorName} registrou uma atualização`,
href: `/campanha/municipios/${municipality.slug}`,
},

View file

@ -9,7 +9,7 @@ import {
const staffActionLabels = [
'Ajustar votos',
'Registrar sinal',
'Registrar atualização',
'Mudar tendência',
'Atualizar liderança',
'Registrar pedido',
@ -328,15 +328,45 @@ test.describe('Wizard — atualizar liderança (B70)', () => {
await page.getByRole('button', { name: 'Continuar' }).click()
await page.waitForURL(
new RegExp(
`/campanha/acoes/registrar-sinal\\?municipio=${municipality.slug}&entry=update-leadership`,
`/campanha/acoes/registrar-atualizacao\\?municipio=${municipality.slug}&entry=update-leadership`,
),
)
await expect(page.getByRole('heading', { name: 'Que tipo de sinal?' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Registrar atualização' })).toBeVisible()
})
})
test.describe('Wizard — registrar sinal (B63)', () => {
test('standalone flow: type grid, body step, save continues the chain', async ({
test.describe('Wizard — registrar atualização (C87)', () => {
test('standalone flow: unified form, save continues the chain', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/acoes/registrar-atualizacao?municipio=${municipality.slug}`)
await expect(page.getByRole('heading', { name: 'Registrar atualização' })).toBeVisible({
timeout: 15000,
})
await expect(page.getByRole('link', { name: 'Pular' })).toHaveCount(0)
await page
.getByLabel('Texto da atualização')
.fill('Adversário marcou presença no centro do município.')
await page.getByLabel('Polaridade').selectOption('ruim')
await page.getByRole('button', { name: 'Salvar' }).click()
await page.waitForURL(
new RegExp(
`/campanha/acoes/mudar-tendencia\\?municipio=${municipality.slug}&entry=register-update`,
),
)
await expect(page.getByText('Atualização registrada.')).toBeVisible()
})
test('renders the unified fields without legacy signal-type navigation', async ({
campaign,
page,
}) => {
@ -344,55 +374,19 @@ test.describe('Wizard — registrar sinal (B63)', () => {
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/acoes/registrar-sinal?municipio=${municipality.slug}`)
await page.goto(`/campanha/acoes/registrar-atualizacao?municipio=${municipality.slug}`)
await expect(page.getByRole('heading', { name: 'Que tipo de sinal?' })).toBeVisible({
await expect(page.getByRole('heading', { name: 'Registrar atualização' })).toBeVisible({
timeout: 15000,
})
await expect(page.getByRole('link', { name: 'Pular' })).toHaveCount(0)
await page.getByRole('link', { name: /Invasão/i }).click()
await page.waitForURL(/signalType=invasao/)
await expect(page.getByRole('heading', { name: /Detalhar sinal: Invasão/i })).toBeVisible()
const body = page.getByLabel('O que aconteceu?')
await body.fill('Adversário marcou presença no centro do município.')
await page.getByRole('button', { name: 'Salvar' }).click()
await page.waitForURL(
new RegExp(
`/campanha/acoes/mudar-tendencia\\?municipio=${municipality.slug}&entry=register-signal`,
),
)
await expect(page.getByText('Sinal registrado.')).toBeVisible()
})
test('info button opens drawer without advancing to body step', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/acoes/registrar-sinal?municipio=${municipality.slug}`)
await expect(page.getByRole('heading', { name: 'Que tipo de sinal?' })).toBeVisible({
timeout: 15000,
})
await page.getByRole('button', { name: /Informações sobre Invasão/i }).click()
await expect(page.getByRole('dialog')).toBeVisible()
await expect(page.getByText(/O adversário está ocupando espaço/i)).toBeVisible()
await expect(page).not.toHaveURL(/signalType=/)
await page.getByRole('button', { name: 'Fechar' }).click()
await expect(page.getByRole('dialog')).toHaveCount(0)
await expect(page.getByLabel('Texto da atualização')).toBeVisible()
await expect(page.getByLabel('Polaridade')).toBeVisible()
await expect(page.getByLabel('Sinalizar como urgente')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Que tipo de sinal?' })).toHaveCount(0)
})
test('embedded flow shows skip link to the next chain step', async ({ campaign, page }) => {
@ -404,7 +398,7 @@ test.describe('Wizard — registrar sinal (B63)', () => {
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`/campanha/acoes/registrar-sinal?municipio=${municipality.slug}&entry=update-votes`,
`/campanha/acoes/registrar-atualizacao?municipio=${municipality.slug}&entry=update-votes`,
)
const skip = page.getByRole('link', { name: 'Pular' })

View file

@ -423,7 +423,7 @@ test.describe('Municípios — jornadas por papel', () => {
// current signal state ("— Sem sinal").
const signalTrigger = municipalityContainer(page)
.getByRole('button', {
name: new RegExp(`^Registrar sinal em ${municipality.name} —`),
name: new RegExp(`^Registrar atualização em ${municipality.name} —`),
})
.filter({ visible: true })
await expect(signalTrigger).toBeVisible()
@ -437,14 +437,14 @@ test.describe('Municípios — jornadas por papel', () => {
await signalForm.getByLabel('Tipo do sinal').selectOption('visita_adversario')
await Promise.all([
expectPostResponse(page, '/campanha/municipios'),
signalForm.getByRole('button', { name: 'Registrar sinal', exact: true }).click(),
signalForm.getByRole('button', { name: 'Registrar atualização', exact: true }).click(),
])
// The toast is intentionally transient; the persisted freshness label is
// the stable success contract and must arrive after the server response.
await expect(
page.getByRole('button', {
name: `Registrar sinal em ${municipality.name} — hoje`,
name: `Registrar atualização em ${municipality.name} — hoje`,
}),
).toBeVisible()
})
@ -701,7 +701,7 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
const staffActionLabels = [
'Ajustar votos',
'Registrar sinal',
'Registrar atualização',
'Mudar tendência',
'Atualizar liderança',
'Registrar pedido',
@ -760,7 +760,7 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
const overlay = page.locator('#CampaignQuickActionsOverlay')
await expect(overlay).toBeVisible()
const registerSignal = overlay.getByRole('link', { name: 'Registrar sinal' })
const registerSignal = overlay.getByRole('link', { name: 'Registrar atualização' })
await expect(registerSignal).toBeVisible()
const search = overlay.getByLabel('Buscar na campanha')
@ -772,7 +772,7 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
)
await search.focus()
await suggestResponse
await expect(overlay.getByRole('link', { name: 'Registrar sinal' })).toBeHidden()
await expect(overlay.getByRole('link', { name: 'Registrar atualização' })).toBeHidden()
// Overlay scopes search suggest — list page has no E11 SuggestionsPanel.
await expect(overlay.getByRole('region', { name: 'Sugestões' })).toBeVisible()

View file

@ -94,7 +94,7 @@ test.describe('B158 — colunas responsivas por largura do conteúdo', () => {
const cards = container.locator('[data-view="mobile-cards"]')
const table = container.getByRole('table')
const trendTrigger = table.getByRole('button', { name: /^Editar tendência política/ }).first()
const signalTrigger = table.getByRole('button', { name: /^Registrar sinal em/ }).first()
const signalTrigger = table.getByRole('button', { name: /^Registrar atualização em/ }).first()
// Test around (and on) the three binary switches: cards↔table (48rem),
// trend text (60rem), signal text (84rem). Container queries use min-width.
const widths = [
@ -237,7 +237,7 @@ test.describe('B158 — colunas responsivas por largura do conteúdo', () => {
for (const trigger of [
table.getByRole('button', { name: /^Editar tendência política/ }).first(),
table.getByRole('button', { name: /^Registrar sinal em/ }).first(),
table.getByRole('button', { name: /^Registrar atualização em/ }).first(),
]) {
const box = await trigger.boundingBox()
expect(box?.width).toBeGreaterThanOrEqual(44)

View file

@ -82,7 +82,7 @@ type LeadershipInput = Partial<
type MunicipalityUpdateInput = Partial<
Pick<
MunicipalityUpdate,
'kind' | 'worked' | 'failed' | 'needs' | 'activeVolunteers' | 'newSupports' | 'body'
'polarity' | 'urgent' | 'adversarySignal' | 'activeVolunteers' | 'newSupports' | 'body'
>
> &
Pick<MunicipalityUpdate, 'municipality' | 'author'>
@ -807,8 +807,10 @@ export class CampaignFixtures {
const update = await this.rootPayload.create({
collection: 'municipalityUpdate',
data: {
kind: 'nota',
polarity: 'neutra',
body: this.value('Atualização'),
urgent: false,
adversarySignal: false,
...input,
municipality: relationId(input.municipality),
author: relationId(input.author),

View file

@ -444,7 +444,7 @@ describe('loadStateDeputyDetail', () => {
it('resolves an oversized numeric legacy slug without coercing it to an ID', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const name = '12345678901234567890'
const name = `${fixtures.value('12345678901234567890')}`
const stateDeputy = await fixtures.createStateDeputy({ name })
const result = await loadStateDeputyDetail(payload, coordinator, stateDeputy.slug)

View file

@ -193,14 +193,16 @@ describe('StateDeputy contact fields (B163)', () => {
it('updates Contact fields and party without changing the legacy slug', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({ name: 'Nome Original', party: 'PT' })
const originalName = `${fixtures.value('Nome')} Original`
const correctedName = `${fixtures.value('Nome')} Corrigido`
const stateDeputy = await fixtures.createStateDeputy({ name: originalName, party: 'PT' })
const legacySlug = stateDeputy.slug
const phone = fixtures.phone()
await updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'name',
name: 'Nome Corrigido',
name: correctedName,
})
await updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
@ -226,7 +228,7 @@ describe('StateDeputy contact fields (B163)', () => {
expect(updated.slug).toBe(legacySlug)
expect(updated.party).toBe('PSD')
expect(updated.contact).toMatchObject({
name: 'Nome Corrigido',
name: correctedName,
email: 'corrigido@example.com',
phone,
})
@ -235,7 +237,9 @@ describe('StateDeputy contact fields (B163)', () => {
it('rejects a phone already owned by another Contact', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({ name: 'Dobradinha Principal' })
const stateDeputy = await fixtures.createStateDeputy({
name: `${fixtures.value('Dobradinha')} Principal`,
})
const takenPhone = fixtures.phone()
await fixtures.createContact({ phone: takenPhone })
@ -251,14 +255,16 @@ describe('StateDeputy contact fields (B163)', () => {
it('rejects renaming a dobradinha to another dobradinha name', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
await fixtures.createStateDeputy({ name: 'Nome já usado' })
const stateDeputy = await fixtures.createStateDeputy({ name: 'Nome atual' })
const usedName = `${fixtures.value('Nome')} já usado`
const currentName = `${fixtures.value('Nome')} atual`
await fixtures.createStateDeputy({ name: usedName })
const stateDeputy = await fixtures.createStateDeputy({ name: currentName })
await expect(
updateStateDeputyContactRecord(payload, coordinator, {
id: stateDeputy.id,
field: 'name',
name: 'Nome já usado',
name: usedName,
}),
).rejects.toThrow(STATE_DEPUTY_CONFLICT_MESSAGE)
@ -268,20 +274,22 @@ describe('StateDeputy contact fields (B163)', () => {
depth: 1,
overrideAccess: true,
})
expect(unchanged.contact).toMatchObject({ name: 'Nome atual' })
expect(unchanged.contact).toMatchObject({ name: currentName })
})
it('keeps the name invariant when an admin edits the linked Contact directly', async () => {
const fixtures = campaignFixtures()
const first = await fixtures.createStateDeputy({ name: 'Nome já usado' })
const second = await fixtures.createStateDeputy({ name: 'Nome atual' })
const usedName = `${fixtures.value('Nome')} já usado`
const currentName = `${fixtures.value('Nome')} atual`
const first = await fixtures.createStateDeputy({ name: usedName })
const second = await fixtures.createStateDeputy({ name: currentName })
const secondContactID = typeof second.contact === 'object' ? second.contact.id : second.contact
await expect(
payload.update({
collection: 'contact',
id: secondContactID,
data: { name: 'Nome já usado' },
data: { name: usedName },
depth: 0,
overrideAccess: true,
}),
@ -293,7 +301,7 @@ describe('StateDeputy contact fields (B163)', () => {
depth: 0,
overrideAccess: true,
})
expect(unchanged.name).toBe('Nome atual')
expect(unchanged.name).toBe(currentName)
expect(first.id).not.toBe(second.id)
})
})

View file

@ -68,65 +68,62 @@ describe('campaign municipality update domain', () => {
payload = await getPayload({ config: await config })
})
it('requires weekly answers and requires body for urgent notes', () => {
it('requires body and polarity and accepts urgent and adversary flags', () => {
expect(
municipalityUpdateCreateSchema.safeParse({
municipality: 1,
kind: 'semanal',
worked: 'Mobilização na feira',
failed: 'Chuva forte',
needs: 'Material impresso',
polarity: 'neutra',
body: 'Mobilização na feira',
}).success,
).toBe(true)
expect(
municipalityUpdateCreateSchema.safeParse({
municipality: 1,
kind: 'semanal',
worked: 'Mobilização na feira',
body: 'Sem polaridade',
}).success,
).toBe(false)
expect(
municipalityUpdateCreateSchema.safeParse({
municipality: 1,
kind: 'urgente',
polarity: 'ruim',
urgent: true,
body: 'Precisamos responder hoje.',
}).success,
).toBe(true)
expect(
municipalityUpdateCreateSchema.safeParse({
municipality: 1,
kind: 'nota',
polarity: 'neutra',
body: ' ',
}).success,
).toBe(false)
expect(
municipalityUpdateCreateSchema.safeParse({
municipality: 1,
kind: 'sinal',
polarity: 'ruim',
body: 'Ex-prefeito retirou o apoio na feira.',
signalType: 'esfriamento',
}).success,
).toBe(true)
expect(
municipalityUpdateCreateSchema.safeParse({
municipality: 1,
kind: 'sinal',
polarity: 'invalid',
body: 'Visita adversária confirmada.',
}).success,
).toBe(false)
expect(
municipalityUpdateCreateSchema.safeParse({
municipality: 1,
kind: 'sinal',
signalType: 'invasao',
polarity: 'ruim',
adversarySignal: true,
}).success,
).toBe(true)
).toBe(false)
})
it('strips forged author and timestamps from input', () => {
const parsed = municipalityUpdateCreateSchema.parse({
municipality: 1,
kind: 'nota',
polarity: 'neutra',
body: 'Registro de campo',
author: 999,
createdAt: '2000-01-01T00:00:00.000Z',
@ -135,12 +132,14 @@ describe('campaign municipality update domain', () => {
expect(parsed).toEqual({
municipality: 1,
kind: 'nota',
polarity: 'neutra',
body: 'Registro de campo',
urgent: false,
adversarySignal: false,
})
})
it('enforces kind validation through the Local API', async () => {
it('enforces body and polarity validation through the Local API', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
@ -149,19 +148,21 @@ describe('campaign municipality update domain', () => {
collection: 'municipalityUpdate',
data: stub<MunicipalityUpdateCreateData>({
municipality: municipality.id,
kind: 'semanal',
worked: 'Somente uma resposta',
// @ts-expect-error Testing invalid polarity value
polarity: 'invalid',
body: 'Somente uma resposta',
}),
user: coordinator,
overrideAccess: false,
}),
).rejects.toThrow('não funcionou')
).rejects.toThrow('polaridade')
await expect(
payload.create({
collection: 'municipalityUpdate',
data: stub<MunicipalityUpdateCreateData>({
municipality: municipality.id,
kind: 'urgente',
polarity: 'ruim',
urgent: true,
body: ' ',
}),
user: coordinator,
@ -204,10 +205,8 @@ describe('campaign municipality update domain', () => {
// Forged `author` goes through a widened variable: the schema must strip it.
const forgedInput = {
municipality: municipality.id,
kind: 'semanal' as const,
worked: 'Visitas concluídas',
failed: 'Dois endereços fechados',
needs: 'Mais panfletos',
polarity: 'neutra' as const,
body: 'Visitas concluídas',
activeVolunteers: 7,
newSupports: 12,
author: 999,
@ -219,17 +218,19 @@ describe('campaign municipality update domain', () => {
expect(visible.docs.map(({ id }) => id)).toContain(created.id)
})
it('accepts a sinal with type and empty motivo', async () => {
it('accepts an adversary update with body', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const created = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
kind: 'sinal',
signalType: 'invasao',
polarity: 'ruim',
adversarySignal: true,
body: 'Visita adversária confirmada.',
})
expect(created.signalType).toBe('invasao')
expect(created.body).toBeFalsy()
expect(created.adversarySignal).toBe(true)
expect(created.polarity).toBe('ruim')
expect(created.body).toBe('Visita adversária confirmada.')
})
it('limits advisors to administered municipalities for create and read', async () => {
@ -242,7 +243,7 @@ describe('campaign municipality update domain', () => {
const created = await createMunicipalityUpdateRecord(payload, advisor, {
municipality: assigned.id,
kind: 'nota',
polarity: 'neutra',
body: 'Atualização permitida',
})
expect((await listMunicipalityUpdates(advisor, assigned.id)).docs).toEqual(
@ -251,14 +252,14 @@ describe('campaign municipality update domain', () => {
await expect(
createMunicipalityUpdateRecord(payload, advisor, {
municipality: other.id,
kind: 'nota',
polarity: 'neutra',
body: 'Atualização fora do escopo',
}),
).rejects.toThrow()
await createMunicipalityUpdateRecord(payload, otherAdvisor, {
municipality: other.id,
kind: 'nota',
polarity: 'neutra',
body: 'Atualização alheia',
})
const foreign = await listMunicipalityUpdates(advisor, other.id)
@ -274,7 +275,8 @@ describe('campaign municipality update domain', () => {
await expect(
createMunicipalityUpdateRecord(payload, leader, {
municipality: municipality.id,
kind: 'urgente',
polarity: 'ruim',
urgent: true,
body: 'Pedido da própria liderança',
}),
).rejects.toThrow()
@ -288,7 +290,7 @@ describe('campaign municipality update domain', () => {
const created = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
kind: 'nota',
polarity: 'neutra',
body: 'Registro do staff',
})
@ -312,7 +314,7 @@ describe('campaign municipality update domain', () => {
await expect(
createMunicipalityUpdateRecord(payload, unlinked, {
municipality: municipality.id,
kind: 'nota',
polarity: 'neutra',
body: 'Sem vínculo',
}),
).rejects.toThrow()
@ -326,7 +328,7 @@ describe('campaign municipality update domain', () => {
collection: 'municipalityUpdate',
data: stub<MunicipalityUpdateCreateData>({
municipality: municipality.id,
kind: 'nota',
polarity: 'neutra',
body: 'Tentativa anônima',
}),
overrideAccess: false,
@ -350,7 +352,7 @@ describe('campaign municipality update domain', () => {
await createUpdateAccessLeadershipGraph(coordinator, leader, municipality.id, 'engajado')
const created = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
kind: 'nota',
polarity: 'neutra',
body: 'Registro imutável',
})
@ -382,7 +384,7 @@ describe('campaign municipality update domain', () => {
Array.from({ length: 8 }, (_, index) =>
createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
kind: 'nota',
polarity: 'neutra',
body: `Registro concorrente ${index}`,
}),
),
@ -421,7 +423,7 @@ describe('campaign municipality update domain', () => {
})
const pending = createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
kind: 'nota',
polarity: 'neutra',
body: 'Registro bloqueado até a liberação',
})
@ -455,7 +457,7 @@ describe('campaign municipality update domain', () => {
created.push(
await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
kind: 'nota',
polarity: 'neutra',
body: `Registro para exclusão concorrente ${index}`,
}),
)
@ -491,12 +493,12 @@ describe('campaign municipality update domain', () => {
const newMunicipality = await campaignFixtures().getMunicipality()
const oldRemaining = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: oldMunicipality.id,
kind: 'nota',
polarity: 'neutra',
body: 'Permanece no município original',
})
const moved = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: oldMunicipality.id,
kind: 'nota',
polarity: 'neutra',
body: 'Será transferido',
})
@ -528,13 +530,13 @@ describe('campaign municipality update domain', () => {
const municipality = await campaignFixtures().getMunicipality()
const first = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
kind: 'nota',
polarity: 'neutra',
body: 'Primeiro registro',
})
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
kind: 'nota',
polarity: 'neutra',
body: 'Segundo registro',
})
@ -590,7 +592,7 @@ describe('campaign municipality update domain', () => {
await expect(
createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
kind: 'nota',
polarity: 'neutra',
body: 'Este registro deve reverter',
}),
).rejects.toThrow('falha forçada')

View file

@ -69,19 +69,15 @@ describe('campaign municipality update page data queries', () => {
payload = await getPayload({ config: await config })
})
it('parses feed params with a validated kind and strict positive page', () => {
it('parses feed params with a strict positive page', () => {
expect(parseMunicipalityUpdateFeedParams({})).toEqual({ page: 1 })
expect(parseMunicipalityUpdateFeedParams({ updateKind: 'nota', updatePage: '2' })).toEqual({
kind: 'nota',
expect(parseMunicipalityUpdateFeedParams({ updatePage: '2' })).toEqual({
page: 2,
})
expect(parseMunicipalityUpdateFeedParams({ updateKind: 'invalida', updatePage: '0' })).toEqual({
expect(parseMunicipalityUpdateFeedParams({ updatePage: '0' })).toEqual({
page: 1,
})
expect(
parseMunicipalityUpdateFeedParams({ updateKind: ['urgente'], updatePage: ['3'] }),
).toEqual({
kind: 'urgente',
expect(parseMunicipalityUpdateFeedParams({ updatePage: ['3'] })).toEqual({
page: 3,
})
})
@ -99,7 +95,7 @@ describe('campaign municipality update page data queries', () => {
data: {
municipality: municipality.id,
author: user.id,
kind: 'nota',
polarity: 'neutra',
body: `Atualização ${index}`,
},
depth: 0,
@ -107,7 +103,7 @@ describe('campaign municipality update page data queries', () => {
}
const { queries, result } = await capturePoolQueries(() =>
loadMunicipalityUpdatesFeed(payload, user, municipality.id, { kind: 'nota', page: 1 }),
loadMunicipalityUpdatesFeed(payload, user, municipality.id, { page: 1 }),
)
const updateQueries = queries.filter(({ text }) => /\bfrom\s+"municipality_update"/i.test(text))
const updateDataQueries = updateQueries.filter((query) => queryLimit(query) !== undefined)
@ -119,12 +115,12 @@ describe('campaign municipality update page data queries', () => {
expect(result.updates).toHaveLength(4)
expect(result.totalDocs).toBe(4)
expect(result.updates[0]).toMatchObject({
kind: 'nota',
polarity: 'neutra',
authorName: user.name,
})
})
it('filters the feed by kind and paginates', async () => {
it('paginates the unified feed', async () => {
const user = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
for (let index = 0; index < 3; index += 1) {
@ -133,7 +129,7 @@ describe('campaign municipality update page data queries', () => {
data: {
municipality: municipality.id,
author: user.id,
kind: 'nota',
polarity: 'neutra',
body: `Nota ${index}`,
},
depth: 0,
@ -144,21 +140,18 @@ describe('campaign municipality update page data queries', () => {
data: {
municipality: municipality.id,
author: user.id,
kind: 'urgente',
polarity: 'ruim',
urgent: true,
body: 'Urgente único',
},
depth: 0,
})
const urgentOnly = await loadMunicipalityUpdatesFeed(payload, user, municipality.id, {
kind: 'urgente',
page: 1,
})
expect(urgentOnly.totalDocs).toBe(1)
expect(urgentOnly.updates[0]?.body).toBe('Urgente único')
const all = await loadMunicipalityUpdatesFeed(payload, user, municipality.id, { page: 1 })
expect(all.totalDocs).toBe(4)
expect(all.updates.some((update) => update.body === 'Urgente único' && update.urgent)).toBe(
true,
)
})
it('loads only the latest-three preview', async () => {
@ -170,7 +163,7 @@ describe('campaign municipality update page data queries', () => {
data: {
municipality: municipality.id,
author: user.id,
kind: 'nota',
polarity: 'neutra',
body: `Prévia ${index}`,
},
depth: 0,
@ -196,7 +189,7 @@ describe('campaign municipality update page data queries', () => {
await campaignFixtures().createMunicipalityUpdate({
municipality: municipality.id,
author: coordinator.id,
kind: 'nota',
polarity: 'neutra',
body: 'Registro da coordenação',
})

View file

@ -295,7 +295,7 @@ describe('loadMunicipalitySuggestions (E11)', () => {
await fixtures.createMunicipalityUpdate({
municipality: municipality.id,
author: author.id,
kind: 'nota',
polarity: 'neutra',
body: 'Conversa com o presidente do sindicato.',
})

View file

@ -34,20 +34,19 @@ describe('searchHomeStateDeputies (B52)', () => {
it('matches state deputies by word-start on the name for staff', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({ name: 'Zeca Oliveira Dobradinha' })
const uniqueName = `${fixtures.value('Zeca')} Oliveira`
const stateDeputy = await fixtures.createStateDeputy({ name: uniqueName })
const result = await searchHomeStateDeputies(payload, coordinator, 'Zeca')
expect(result.some((hit) => hit.slug === stateDeputy.slug)).toBe(true)
expect(result.find((hit) => hit.slug === stateDeputy.slug)?.name).toBe(
'Zeca Oliveira Dobradinha',
)
expect(result.find((hit) => hit.slug === stateDeputy.slug)?.name).toBe(uniqueName)
})
it('matches state deputies by word-start on the party when the name does not match', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({
name: 'Maria Silva',
name: `${fixtures.value('Maria')} Silva`,
party: 'PSOL',
})
@ -58,7 +57,8 @@ describe('searchHomeStateDeputies (B52)', () => {
it('returns state deputy hits for advisor role when name matches', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const stateDeputy = await fixtures.createStateDeputy({ name: 'Assessoria Dobradinha' })
const uniqueName = `${fixtures.value('Assessoria')} Dobradinha`
const stateDeputy = await fixtures.createStateDeputy({ name: uniqueName })
const result = await searchHomeStateDeputies(payload, advisor, 'Assessoria')
expect(result.some((hit) => hit.slug === stateDeputy.slug)).toBe(true)
@ -67,7 +67,8 @@ describe('searchHomeStateDeputies (B52)', () => {
it('reports municipality count on matched state deputies', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const stateDeputy = await fixtures.createStateDeputy({ name: 'Carteira Dobradinha' })
const uniqueName = `${fixtures.value('Carteira')} Dobradinha`
const stateDeputy = await fixtures.createStateDeputy({ name: uniqueName })
// Allocated, never pinned slugs: parallel specs mutating the same seeded
// municipality deadlocked municipality_rels (miss #73).
const first = await fixtures.getMunicipality()

View file

@ -11,10 +11,8 @@ import {
parseWizardEntryActionParam,
parseWizardLeadershipIdParam,
parseWizardMunicipioParam,
resolveWizardSignalTypeParam,
resolveWizardTrendStatusParam,
wizardActionHref,
wizardSignalHref,
wizardTrendHref,
} from '@/lib/campaignActionRoutes'
@ -83,33 +81,6 @@ describe('campaignActionRoutes', () => {
expect(parseWizardEntryActionParam(undefined)).toBeUndefined()
})
it('builds signal wizard hrefs with optional signalType and entry', () => {
expect(wizardSignalHref('registrar-sinal', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu`,
)
expect(wizardSignalHref('registrar-sinal', 'cairu', 'invasao')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&signalType=invasao`,
)
expect(wizardSignalHref('registrar-sinal', 'cairu', 'esfriamento', 'update-votes')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&signalType=esfriamento&entry=update-votes`,
)
})
it('resolves signalType search param with invalid flag', () => {
expect(resolveWizardSignalTypeParam(undefined)).toEqual({
signalType: undefined,
invalid: false,
})
expect(resolveWizardSignalTypeParam('invasao')).toEqual({
signalType: 'invasao',
invalid: false,
})
expect(resolveWizardSignalTypeParam('invalid')).toEqual({
signalType: undefined,
invalid: true,
})
})
it('builds trend wizard hrefs with optional trendStatus and entry', () => {
expect(wizardTrendHref('mudar-tendencia', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu`,

View file

@ -6,8 +6,8 @@ import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipal
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListLeadershipsControl } from '@/components/campaign/municipality/MunicipalityListLeadershipsControl'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalityListUpdateControl } from '@/components/campaign/municipality/MunicipalityListUpdateControl'
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import { TooltipProvider } from '@/components/ui/tooltip'
@ -116,20 +116,21 @@ const overlayCases: OverlayCase[] = [
},
{
name: 'sinal',
triggerLabel: `Registrar sinal em ${MUNICIPALITY_NAME} — Sem sinal`,
drawerTitle: 'Registrar sinal',
footerLabel: 'Registrar sinal',
triggerLabel: `Registrar atualização em ${MUNICIPALITY_NAME} — Sem sinal`,
drawerTitle: 'Registrar atualização',
footerLabel: 'Registrar atualização',
element: (variant) =>
// `createElement`'s typing puts a required `children` in props, so the
// rest-argument form this rule prefers does not type-check here.
// eslint-disable-next-line react/no-children-prop
createElement(MunicipalityListSignalControl, {
createElement(MunicipalityListUpdateControl, {
municipalityID: 1,
municipalitySlug: 'feira-de-santana',
municipalityName: MUNICIPALITY_NAME,
lastSignalAt: null,
variant,
formAction: async () => ({}),
isStaff: true,
children: createElement('span', null, 'Sem sinal'),
}),
},
@ -298,28 +299,34 @@ describe('campaign cell edit overlay', () => {
// `createElement`'s typing puts a required `children` in props, so the
// rest-argument form this rule prefers does not type-check here.
// eslint-disable-next-line react/no-children-prop
createElement(MunicipalityListSignalControl, {
createElement(MunicipalityListUpdateControl, {
municipalityID: 1,
municipalitySlug: 'feira-de-santana',
municipalityName: MUNICIPALITY_NAME,
lastSignalAt: null,
variant: 'sheet',
formAction,
isStaff: true,
children: createElement('span', null, 'Sem sinal'),
}),
)
fireEvent.click(
screen.getByRole('button', { name: `Registrar sinal em ${MUNICIPALITY_NAME} — Sem sinal` }),
screen.getByRole('button', {
name: `Registrar atualização em ${MUNICIPALITY_NAME} — Sem sinal`,
}),
)
const dialog = await screen.findByRole('dialog')
// The select is `required`: an invalid form never fires submit, and the
// case would pass for the wrong reason.
fireEvent.change(screen.getByLabelText('Tipo do sinal'), { target: { value: 'invasao' } })
fireEvent.change(screen.getByLabelText('Polaridade'), { target: { value: 'boa' } })
fireEvent.change(screen.getByLabelText('Texto'), {
target: { value: 'Atualização registrada no campo.' },
})
const submit = dialog.querySelector<HTMLButtonElement>('[data-slot="drawer-footer"] button')
expect(submit?.textContent).toContain('Registrar sinal')
expect(submit?.textContent).toContain('Registrar atualização')
expect(submit?.getAttribute('form')).toBe(
dialog.querySelector('form')?.getAttribute('id') ?? null,
)

View file

@ -563,7 +563,7 @@ describe('campaign visual foundation', () => {
expect(html).toContain('Sem responsável')
expect(html).toContain(`há ${staleDays} dias`)
expect(html).toContain('data-signal="cold"')
expect(html).toContain('Último sinal')
expect(html).toContain('Última atualização')
})
it('reads "Sem sinal" when nothing was ever recorded for the município', () => {
@ -736,7 +736,7 @@ describe('campaign visual foundation', () => {
'Liderança',
'Dobradinha',
'Cobertura',
'Sinal',
'Atualização',
]
let previousIndex = -1
@ -783,7 +783,7 @@ describe('campaign visual foundation', () => {
const theadHtml = tableHtml.slice(tableHtml.indexOf('<thead'), tableHtml.indexOf('</thead>'))
expect(tableHtml.match(/aria-label="Editar tendência política/g)).toHaveLength(1)
expect(tableHtml.match(/aria-label="Registrar sinal em/g)).toHaveLength(1)
expect(tableHtml.match(/aria-label="Registrar atualização em/g)).toHaveLength(1)
expect(theadHtml.includes('Ações')).toBe(hasActions)
})

View file

@ -61,8 +61,8 @@ const renderActionButton = (props: CampaignHomeActionButtonProps) =>
describe('CampaignHomeActionButton', () => {
it('exposes the label as the accessible name', () => {
renderActionButton({ label: 'Registrar sinal', icon: BarChart3 })
expect(screen.getByRole('button', { name: 'Registrar sinal' })).toBeTruthy()
renderActionButton({ label: 'Registrar atualização', icon: BarChart3 })
expect(screen.getByRole('button', { name: 'Registrar atualização' })).toBeTruthy()
})
it('fires onClick on a short tap when there is no description', () => {

View file

@ -10,7 +10,7 @@ import { buildMunicipalityListHref } from '@/utilities/municipality/municipality
const staffActionIds = [
'update-votes',
'register-signal',
'register-update',
'change-trend',
'update-leadership',
'register-demand',
@ -61,8 +61,8 @@ describe('toHomeActionButtonProps', () => {
expect(uncovered?.href).toBe(UNCOVERED_MUNICIPALITIES_LIST_HREF)
const updateVotes = props.find((action) => action.id === 'update-votes')
expect(updateVotes?.href).toBe('/campanha/acoes/atualizar-votos')
const registerSignal = props.find((action) => action.id === 'register-signal')
expect(registerSignal?.href).toBe('/campanha/acoes/registrar-sinal')
const registerUpdate = props.find((action) => action.id === 'register-update')
expect(registerUpdate?.href).toBe('/campanha/acoes/registrar-atualizacao')
})
it('omits uncovered href when not provided', () => {

View file

@ -138,7 +138,7 @@ describe('CampaignMobileTopBar', () => {
it('renders skip link instead of dismiss when chained flow defines skip', () => {
renderTopBar({
wizardChrome: {
flowTitle: 'Registrar sinal',
flowTitle: 'Registrar atualização',
stepKind: 'continue',
previousHref: '/campanha/acoes/atualizar-votos?municipio=cairu',
dismissHref: '/campanha',

View file

@ -12,7 +12,7 @@ import {
const wizardActionIds = [
'update-votes',
'register-signal',
'register-update',
'change-trend',
'update-leadership',
'register-demand',

View file

@ -47,9 +47,9 @@ describe('resolveMunicipalityV2QuickActions', () => {
)
})
it('omits register-signal and wizard operational actions', () => {
it('omits register-update and wizard operational actions', () => {
const actions = resolveMunicipalityV2QuickActions('advisor', 'cairu', 1)
expect(actions.some((action) => action.id === 'register-signal')).toBe(false)
expect(actions.some((action) => action.id === 'register-update')).toBe(false)
expect(actions.some((action) => action.id === 'update-votes')).toBe(false)
})

View file

@ -68,7 +68,9 @@ describe('campaignQuickActionMount', () => {
expect(shouldMountQuickActionsFab('/campanha/apoiadores', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/apoiadores/42', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha', 'coordinator')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/acoes/registrar-sinal', 'advisor')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/acoes/registrar-atualizacao', 'advisor')).toBe(
false,
)
})
it('mounts for leader only on contacts', () => {
@ -329,9 +331,9 @@ describe('campaignQuickActionRegistry', () => {
municipalitySlug: 'salvador-ze-01',
},
)
const registerSignal = actions.find((action) => action.id === 'register-signal')
expect(registerSignal?.href).toBe(
`/campanha/acoes/${CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']}?municipio=salvador-ze-01&from=%2Fcampanha%2Fatividades%2Fevento-zona-1`,
const registerUpdate = actions.find((action) => action.id === 'register-update')
expect(registerUpdate?.href).toBe(
`/campanha/acoes/${CAMPAIGN_WIZARD_ACTION_SLUGS['register-update']}?municipio=salvador-ze-01&from=%2Fcampanha%2Fatividades%2Fevento-zona-1`,
)
})
@ -368,7 +370,7 @@ describe('campaignQuickActionRegistry', () => {
})
expect(actions.map((action) => action.id)).toEqual([
'update-votes',
'register-signal',
'register-update',
'change-trend',
'update-leadership',
'register-demand',

View file

@ -20,7 +20,7 @@ describe('resolveLeadershipQuickActions', () => {
})
expect(actions?.map((action) => action.id)).toEqual([
'update-votes',
'register-signal',
'register-update',
'change-trend',
'update-leadership',
'register-demand',

View file

@ -13,7 +13,7 @@ import {
describe('campaignWizardCopy', () => {
it('maps wizard slugs to home action flow titles', () => {
expect(wizardFlowTitleForSlug('atualizar-votos')).toBe('Ajustar votos')
expect(wizardFlowTitleForSlug('registrar-sinal')).toBe('Registrar sinal')
expect(wizardFlowTitleForSlug('registrar-atualizacao')).toBe('Registrar atualização')
expect(wizardFlowTitleForSlug('inventado')).toBe('Continuar')
})

View file

@ -3,13 +3,13 @@ import { describe, expect, it } from 'vitest'
import {
buildMunicipalityV2StatusAggregate,
MUNICIPALITY_V2_SIGNAL_COLD_VALUE,
resolveMunicipalityV2SignalSelectState,
resolveMunicipalityV2UpdateState,
} from '@/utilities/municipality/municipalityV2StatusView'
describe('municipalityV2StatusView', () => {
it('uses cold sentinel when there is no recent signal', () => {
const state = resolveMunicipalityV2SignalSelectState(
{ signalType: null, lastSignalAt: null },
const state = resolveMunicipalityV2UpdateState(
{ polarity: null, lastSignalAt: null },
new Date('2026-08-02T12:00:00.000Z'),
)
expect(state.value).toBe(MUNICIPALITY_V2_SIGNAL_COLD_VALUE)
@ -18,29 +18,29 @@ describe('municipalityV2StatusView', () => {
})
it('does not claim frio when frescor is warm but typeless', () => {
const state = resolveMunicipalityV2SignalSelectState(
const state = resolveMunicipalityV2UpdateState(
{
signalType: null,
polarity: null,
lastSignalAt: '2026-08-01T12:00:00.000Z',
},
new Date('2026-08-02T12:00:00.000Z'),
)
expect(state.value).toBe(MUNICIPALITY_V2_SIGNAL_COLD_VALUE)
expect(state.isCold).toBe(false)
expect(state.label).toBe('Sem tipo de sinal')
expect(state.label).toBe('Sem polaridade')
})
it('keeps the typed signal when still warm', () => {
const state = resolveMunicipalityV2SignalSelectState(
it('keeps the typed polarity when still warm', () => {
const state = resolveMunicipalityV2UpdateState(
{
signalType: 'invasao',
polarity: 'ruim',
lastSignalAt: '2026-08-01T12:00:00.000Z',
},
new Date('2026-08-02T12:00:00.000Z'),
)
expect(state.value).toBe('invasao')
expect(state.value).toBe('ruim')
expect(state.isCold).toBe(false)
expect(state.label).toBe('Invasão')
expect(state.label).toBe('Ruim')
})
it('builds the aggregate from the three note axes', () => {
@ -49,8 +49,8 @@ describe('municipalityV2StatusView', () => {
engagementLevel: 'n2',
levelNote: 'Subiu o nível',
trendNote: 'Tendência melhor',
signalType: 'esfriamento',
signalBody: 'Rede parou',
updatePolarity: 'ruim',
updateBody: 'Rede parou',
lastSignalAt: '2026-08-01T12:00:00.000Z',
},
new Date('2026-08-02T12:00:00.000Z'),
@ -64,8 +64,8 @@ describe('municipalityV2StatusView', () => {
engagementLevel: 'n1',
levelNote: null,
trendNote: null,
signalType: null,
signalBody: null,
updatePolarity: null,
updateBody: null,
lastSignalAt: null,
},
new Date('2026-08-02T12:00:00.000Z'),

View file

@ -45,22 +45,15 @@ describe('politicalTrendWizardUi', () => {
it('shows skip to the next chain step when embedded from another wizard', () => {
expect(shouldShowWizardTrendSkip('update-votes')).toBe(true)
expect(shouldShowWizardTrendSkip('register-signal')).toBe(true)
expect(shouldShowWizardTrendSkip('register-update')).toBe(true)
expect(resolveWizardTrendSkip('update-votes', 'cairu')).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: `${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&entry=update-votes`,
href: `${CAMPAIGN_ACTIONS_HOME}/registrar-atualizacao?municipio=cairu&entry=update-votes`,
})
})
it('builds note prefills from embedded wizard sources', () => {
expect(buildPoliticalTrendNotePrefill({ kind: 'none' })).toBe('')
expect(
buildPoliticalTrendNotePrefill({
kind: 'signal',
signalType: 'invasao',
description: 'Reunião do adversário no território',
}),
).toBe('Sinal de invasão: Reunião do adversário no território')
expect(
buildPoliticalTrendNotePrefill({
kind: 'voteAdjustment',
@ -84,15 +77,9 @@ describe('politicalTrendWizardUi', () => {
expect(
resolvePoliticalTrendNotePrefillSource({
entryAction: 'register-signal',
signalType: 'esfriamento',
signalBody: 'Lideranças sumiram',
entryAction: 'register-update',
}),
).toEqual({
kind: 'signal',
signalType: 'esfriamento',
description: 'Lideranças sumiram',
})
).toEqual({ kind: 'none' })
expect(
resolvePoliticalTrendNotePrefillSource({

View file

@ -21,26 +21,26 @@ describe('wizardActionChain', () => {
it('returns the full queue after the principal action', () => {
expect(wizardChainAfter('update-votes')).toEqual([
'change-trend',
'register-signal',
'register-update',
'update-leadership',
])
expect(wizardChainAfter('update-votes', 'update-votes')).toEqual([
'change-trend',
'register-signal',
'register-update',
'update-leadership',
])
expect(wizardChainAfter('register-signal', 'register-signal')).toEqual([
expect(wizardChainAfter('register-update', 'register-update')).toEqual([
'change-trend',
'update-votes',
'update-leadership',
])
expect(wizardChainAfter('change-trend', 'change-trend')).toEqual([
'register-signal',
'register-update',
'update-votes',
'update-leadership',
])
expect(wizardChainAfter('update-leadership', 'update-leadership')).toEqual([
'register-signal',
'register-update',
'update-votes',
'change-trend',
])
@ -48,12 +48,12 @@ describe('wizardActionChain', () => {
it('slices the remaining queue after a chained step', () => {
expect(wizardChainAfter('update-votes', 'change-trend')).toEqual([
'register-signal',
'register-update',
'update-leadership',
])
expect(wizardChainAfter('update-votes', 'register-signal')).toEqual(['update-leadership'])
expect(wizardChainAfter('update-votes', 'register-update')).toEqual(['update-leadership'])
expect(wizardChainAfter('update-votes', 'update-leadership')).toEqual([])
expect(nextWizardChainStep('change-trend', 'register-signal')).toBe('update-votes')
expect(nextWizardChainStep('change-trend', 'register-update')).toBe('update-votes')
expect(nextWizardChainStep('update-leadership', 'change-trend')).toBeUndefined()
})
@ -62,10 +62,10 @@ describe('wizardActionChain', () => {
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&entry=update-votes`,
)
expect(wizardChainContinueHref('update-votes', 'change-trend', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&entry=update-votes`,
`${CAMPAIGN_ACTIONS_HOME}/registrar-atualizacao?municipio=cairu&entry=update-votes`,
)
expect(wizardChainContinueHref('register-signal', 'change-trend', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=register-signal`,
expect(wizardChainContinueHref('register-update', 'change-trend', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=register-update`,
)
expect(wizardChainContinueHref('change-trend', 'update-leadership', 'cairu')).toBe(
CAMPAIGN_HOME,
@ -79,13 +79,13 @@ describe('wizardActionChain', () => {
origin,
)
expect(wizardChainContinueHref('update-votes', 'change-trend', 'cairu', origin)).toBe(
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&entry=update-votes&from=${encodeURIComponent(origin)}`,
`${CAMPAIGN_ACTIONS_HOME}/registrar-atualizacao?municipio=cairu&entry=update-votes&from=${encodeURIComponent(origin)}`,
)
})
it('resolves session entry from query or current principal', () => {
expect(resolveWizardChainEntry(undefined, 'update-votes')).toBe('update-votes')
expect(resolveWizardChainEntry('register-signal', 'update-votes')).toBe('register-signal')
expect(resolveWizardChainEntry('register-update', 'update-votes')).toBe('register-update')
expect(resolveWizardChainEntry('register-demand', 'change-trend')).toBe('change-trend')
})
@ -127,8 +127,8 @@ describe('wizardActionChain', () => {
).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=update-votes`)
expect(
wizardPreviousHref({
actionSlug: 'registrar-sinal',
stepKind: 'signal-type',
actionSlug: 'registrar-atualizacao',
stepKind: 'update-body',
municipalitySlug: 'cairu',
entryAction: 'update-votes',
}),
@ -138,9 +138,9 @@ describe('wizardActionChain', () => {
actionSlug: 'atualizar-lideranca',
stepKind: 'leadership-grid',
municipalitySlug: 'cairu',
entryAction: 'register-signal',
entryAction: 'register-update',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=register-signal`)
).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=register-update`)
})
it('returns internal previous step within a subflow', () => {

View file

@ -127,11 +127,11 @@ describe('WizardExpectedVotesStep', () => {
})
it('shows skip to the next chain step when votes is chained', () => {
renderVotesStep({ ...defaultProps, entryAction: 'register-signal' })
renderVotesStep({ ...defaultProps, entryAction: 'register-update' })
const skip = screen.getByRole('link', { name: /^Pular$/i })
expect(skip.getAttribute('href')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&entry=register-signal`,
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&entry=register-update`,
)
})
})

View file

@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest'
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { WIZARD_CHAIN_SKIP_LABEL } from '@/lib/campaignWizardCopy'
import {
municipalitySignalTypeMeta,
municipalitySignalTypeMetaByType,
} from '@/lib/municipalitySignalTypeMeta'
import { municipalitySignalTypes } from '@/lib/schemas/municipalityUpdate'
import { resolveWizardSignalSkip, shouldShowWizardSignalSkip } from '@/lib/wizardSignalUi'
describe('wizardSignalUi', () => {
it('hides skip for standalone register-signal entry', () => {
expect(shouldShowWizardSignalSkip(undefined)).toBe(false)
expect(shouldShowWizardSignalSkip('register-signal')).toBe(false)
expect(resolveWizardSignalSkip(undefined, 'cairu')).toBeUndefined()
expect(resolveWizardSignalSkip('register-signal', 'cairu')).toBeUndefined()
})
it('shows skip to the next chain step when embedded from another wizard', () => {
expect(shouldShowWizardSignalSkip('update-votes')).toBe(true)
expect(shouldShowWizardSignalSkip('change-trend')).toBe(true)
expect(resolveWizardSignalSkip('update-votes', 'cairu')).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: `${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&entry=update-votes`,
})
expect(resolveWizardSignalSkip('change-trend', 'cairu')).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: `${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=change-trend`,
})
})
})
describe('municipalitySignalTypeMeta', () => {
it('covers every signal type with icon and info copy', () => {
expect(municipalitySignalTypeMeta).toHaveLength(municipalitySignalTypes.length)
for (const type of municipalitySignalTypes) {
const entry = municipalitySignalTypeMetaByType[type]
expect(entry?.label.length).toBeGreaterThan(0)
expect(entry?.shortDescription.length).toBeGreaterThan(0)
expect(entry?.infoContent.length).toBeGreaterThan(20)
expect(entry?.icon).toBeTruthy()
}
})
})

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { WIZARD_CHAIN_SKIP_LABEL } from '@/lib/campaignWizardCopy'
import { resolveWizardUpdateSkip, shouldShowWizardUpdateSkip } from '@/lib/wizardUpdateUi'
describe('wizardUpdateUi', () => {
it('hides skip for standalone register-update entry', () => {
expect(shouldShowWizardUpdateSkip(undefined)).toBe(false)
expect(shouldShowWizardUpdateSkip('register-update')).toBe(false)
expect(resolveWizardUpdateSkip(undefined, 'cairu')).toBeUndefined()
expect(resolveWizardUpdateSkip('register-update', 'cairu')).toBeUndefined()
})
it('shows skip to the next chain step when embedded from another wizard', () => {
expect(shouldShowWizardUpdateSkip('update-votes')).toBe(true)
expect(shouldShowWizardUpdateSkip('change-trend')).toBe(true)
expect(resolveWizardUpdateSkip('update-votes', 'cairu')).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: `${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&entry=update-votes`,
})
expect(resolveWizardUpdateSkip('change-trend', 'cairu')).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: `${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=change-trend`,
})
})
})