mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
Merge pull request #410 from fsolla/C87-atualizacao-unificada-texto-polaridade-urgente
feat(campaign): C87 — atualização unificada (texto + polaridade + urgente)
This commit is contained in:
commit
c5001b5005
73 changed files with 14460 additions and 1381 deletions
434
docs/plans/atualizacao-unificada-polaridade-urgente-impl.md
Normal file
434
docs/plans/atualizacao-unificada-polaridade-urgente-impl.md
Normal 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.
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.' }
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
186
src/components/campaign/municipality/WizardUpdateBodyStep.tsx
Normal file
186
src/components/campaign/municipality/WizardUpdateBodyStep.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
33
src/lib/wizardUpdateUi.ts
Normal 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
|
||||
12943
src/migrations/20260807_030000_unify_municipality_update.json
Normal file
12943
src/migrations/20260807_030000_unify_municipality_update.json
Normal file
File diff suppressed because it is too large
Load diff
109
src/migrations/20260807_030000_unify_municipality_update.ts
Normal file
109
src/migrations/20260807_030000_unify_municipality_update.ts
Normal 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";
|
||||
`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -47,12 +47,13 @@ test.describe('Atividades — registro-fundação', () => {
|
|||
await expect(page.getByLabel('Município')).toHaveValue(String(municipality.id))
|
||||
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=updates`)
|
||||
await page.getByLabel('Tipo', { exact: true }).selectOption('sinal')
|
||||
await expect(page.getByText('Invasão:', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Adversário ocupando nosso espaço.')).toBeVisible()
|
||||
await expect(page.getByText('Rede esfriou:', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Adversário apareceu:', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Alguém pediu algo:', { exact: true })).toBeVisible()
|
||||
// Unified C87 surface: body + polarity + urgent + adversary signal, no
|
||||
// legacy kind/signal-type navigation.
|
||||
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.getByLabel('Sinalizar adversário')).toBeVisible()
|
||||
await expect(page.getByLabel('Tipo', { exact: true })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 com sucesso.')).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' })
|
||||
|
|
|
|||
|
|
@ -398,7 +398,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
await expect(reopened.getByLabel('Justificativa')).toHaveValue(note)
|
||||
})
|
||||
|
||||
test('coordinator registers a typed signal from the list freshness cell', async ({
|
||||
test('coordinator registers an adversary-signal update from the list freshness cell', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
|
|
@ -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()
|
||||
|
|
@ -434,17 +434,18 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
await signalForm
|
||||
.getByRole('textbox', { name: 'Texto' })
|
||||
.fill('Liderança local reportou visita adversária na feira.')
|
||||
await signalForm.getByLabel('Tipo do sinal').selectOption('visita_adversario')
|
||||
await signalForm.getByLabel('Polaridade').selectOption('ruim')
|
||||
await signalForm.getByLabel('Sinalizar adversário').check()
|
||||
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 +702,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 +761,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 +773,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()
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ const expectedHeadersAt = (width: number) => {
|
|||
if (width >= 66 * REM_IN_PIXELS) headers.push('Liderança')
|
||||
if (width >= 72 * REM_IN_PIXELS) headers.push('Dobradinha')
|
||||
if (width >= 78 * REM_IN_PIXELS) headers.push('Cobertura')
|
||||
headers.push('Sinal')
|
||||
headers.push('Atualização')
|
||||
return headers
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
|
||||
const wizardActionIds = [
|
||||
'update-votes',
|
||||
'register-signal',
|
||||
'register-update',
|
||||
'change-trend',
|
||||
'update-leadership',
|
||||
'register-demand',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,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', () => {
|
||||
|
|
@ -333,9 +335,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`,
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -372,7 +374,7 @@ describe('campaignQuickActionRegistry', () => {
|
|||
})
|
||||
expect(actions.map((action) => action.id)).toEqual([
|
||||
'update-votes',
|
||||
'register-signal',
|
||||
'register-update',
|
||||
'change-trend',
|
||||
'update-leadership',
|
||||
'register-demand',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
27
tests/unit/wizardUpdateUi.unit.spec.ts
Normal file
27
tests/unit/wizardUpdateUi.unit.spec.ts
Normal 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`,
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue