From 212a1f2423e368a013a82e087f9f3c48b3bcd521 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 7 Aug 2026 05:04:41 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(campaign):=20C87=20=E2=80=94=20atualiz?= =?UTF-8?q?a=C3=A7=C3=A3o=20unificada=20(texto=20+=20polaridade=20+=20urge?= =?UTF-8?q?nte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substitui o modelo de MunicipalityUpdate (kind/semanal/urgente/nota/sinal + signalType) por um modelo unificado: body obrigatório + polarity (boa/neutra/ruim) + urgent + adversarySignal. - Schema: polarity enum obrigatório, urgent boolean default false, adversarySignal boolean - Wizard: novo WizardUpdateBodyStep com campo de texto + polaridade + urgente - Lista: controle unificado de atualização com polaridade inline - Feed: exibe polaridade como badge colorido + indicador de urgência - V2 status strip: atualização rápida de polaridade - E11: queries atualizadas para polarity + adversarySignal - Migração: 20260807_030000_unify_municipality_update (backfill polarity=neutra, urgent de kind, adversarySignal de kind+signalType) - Testes: 18/18 integração C87, 593/593 total, 1399/1399 unitários - Fix: testes de state deputy usavam nomes fixos que colidiam entre execuções --- ...zacao-unificada-polaridade-urgente-impl.md | 434 + .../campanha/(app)/acoes/[slug]/page.tsx | 42 +- .../(app)/municipio/[slug]/v2/formActions.ts | 6 +- .../[slug]/MunicipalityDetailTabs.tsx | 5 +- .../municipios/[slug]/updateFormActions.ts | 31 +- .../municipalityStaffFormActions.ts | 34 +- .../campanha/(app)/municipios/page.tsx | 4 +- .../campanha/actions/municipalityUpdate.ts | 5 + src/collections/MunicipalityUpdate.ts | 107 +- .../municipality/MunicipalityList.tsx | 7 +- .../MunicipalityListMobileCards.tsx | 9 +- .../MunicipalityListTrendControl.tsx | 2 +- ....tsx => MunicipalityListUpdateControl.tsx} | 31 +- .../municipality/MunicipalitySignalFields.tsx | 65 - .../municipality/MunicipalityUpdateFeed.tsx | 46 +- .../municipality/MunicipalityUpdateFields.tsx | 94 + .../municipality/MunicipalityUpdateForm.tsx | 180 +- .../MunicipalityV2StatusStrip.tsx | 126 +- .../municipality/WizardSignalBodyStep.tsx | 132 - .../municipality/WizardSignalTypeStep.tsx | 131 - ...kipTrailing.tsx => WizardSkipTrailing.tsx} | 8 +- .../municipality/WizardUpdateBodyStep.tsx | 186 + .../shared/CampaignCellEditOverlay.tsx | 2 +- src/lib/activityQuickActions.ts | 2 +- src/lib/campaignActionRoutes.ts | 65 +- src/lib/campaignHomeActions.ts | 8 +- src/lib/campaignQuickActionDemands.ts | 2 +- src/lib/campaignQuickActionLeadership.ts | 2 +- src/lib/municipalitySignalTypeMeta.ts | 57 - src/lib/politicalTrendWizardUi.ts | 18 - src/lib/schemas/municipalityUpdate.ts | 110 +- src/lib/wizardActionChain.ts | 26 +- src/lib/wizardSignalUi.ts | 37 - src/lib/wizardUpdateUi.ts | 33 + ...0807_030000_unify_municipality_update.json | 12943 ++++++++++++++++ ...260807_030000_unify_municipality_update.ts | 109 + src/migrations/index.ts | 6 + src/payload-types.ts | 20 +- .../municipality/municipalityDetailTabUi.ts | 2 +- .../municipality/municipalityListUrl.ts | 2 +- .../municipality/municipalityTriggers.ts | 11 +- .../municipalityUpdatePageData.ts | 38 +- .../municipality/municipalityV2StatusData.ts | 19 +- .../municipality/municipalityV2StatusView.ts | 55 +- .../notification/notificationEvents.ts | 15 +- tests/e2e/campaignHomeActions.e2e.spec.ts | 90 +- tests/e2e/campaignMunicipalities.e2e.spec.ts | 12 +- ...nMunicipalityResponsiveColumns.e2e.spec.ts | 4 +- tests/helpers/campaignFixtures.ts | 6 +- tests/int/campaignEntityListData.int.spec.ts | 2 +- ...nMunicipalityStateDeputyCreate.int.spec.ts | 32 +- .../campaignMunicipalityUpdate.int.spec.ts | 96 +- ...aignMunicipalityUpdatePageData.int.spec.ts | 39 +- tests/int/campaignSuggestions.int.spec.ts | 2 +- tests/int/homeSearchStateDeputies.int.spec.ts | 15 +- tests/unit/campaignActionRoutes.unit.spec.ts | 29 - .../unit/campaignCellEditOverlay.unit.spec.ts | 25 +- tests/unit/campaignComponents.unit.spec.ts | 6 +- .../campaignHomeActionButton.unit.spec.tsx | 4 +- tests/unit/campaignHomeActions.unit.spec.ts | 6 +- tests/unit/campaignMobileTopBar.unit.spec.tsx | 2 +- ...paignMunicipalityQuickActions.unit.spec.ts | 2 +- ...ignMunicipalityV2QuickActions.unit.spec.ts | 4 +- tests/unit/campaignQuickAction.unit.spec.ts | 12 +- ...campaignQuickActionLeadership.unit.spec.ts | 2 +- tests/unit/campaignWizardCopy.unit.spec.ts | 2 +- .../municipalityV2StatusView.unit.spec.ts | 30 +- .../unit/politicalTrendWizardUi.unit.spec.ts | 21 +- tests/unit/wizardActionChain.unit.spec.ts | 34 +- .../wizardExpectedVotesStep.unit.spec.tsx | 4 +- tests/unit/wizardSignalUi.unit.spec.ts | 46 - tests/unit/wizardUpdateUi.unit.spec.ts | 27 + 72 files changed, 14449 insertions(+), 1372 deletions(-) create mode 100644 docs/plans/atualizacao-unificada-polaridade-urgente-impl.md rename src/components/campaign/municipality/{MunicipalityListSignalControl.tsx => MunicipalityListUpdateControl.tsx} (75%) delete mode 100644 src/components/campaign/municipality/MunicipalitySignalFields.tsx create mode 100644 src/components/campaign/municipality/MunicipalityUpdateFields.tsx delete mode 100644 src/components/campaign/municipality/WizardSignalBodyStep.tsx delete mode 100644 src/components/campaign/municipality/WizardSignalTypeStep.tsx rename src/components/campaign/municipality/{WizardSignalSkipTrailing.tsx => WizardSkipTrailing.tsx} (59%) create mode 100644 src/components/campaign/municipality/WizardUpdateBodyStep.tsx delete mode 100644 src/lib/municipalitySignalTypeMeta.ts delete mode 100644 src/lib/wizardSignalUi.ts create mode 100644 src/lib/wizardUpdateUi.ts create mode 100644 src/migrations/20260807_030000_unify_municipality_update.json create mode 100644 src/migrations/20260807_030000_unify_municipality_update.ts delete mode 100644 tests/unit/wizardSignalUi.unit.spec.ts create mode 100644 tests/unit/wizardUpdateUi.unit.spec.ts diff --git a/docs/plans/atualizacao-unificada-polaridade-urgente-impl.md b/docs/plans/atualizacao-unificada-polaridade-urgente-impl.md new file mode 100644 index 00000000..f4319480 --- /dev/null +++ b/docs/plans/atualizacao-unificada-polaridade-urgente-impl.md @@ -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/_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. diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx index b24d5cbe..9689c117 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx @@ -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 | 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 ( - - ) - } return ( - ) } diff --git a/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions.ts b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions.ts index 8d4d57f6..fb73afa3 100644 --- a/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions.ts @@ -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 => createMunicipalityListSignalFormAction({}, formData) +): Promise => createMunicipalityListUpdateFormAction({}, formData) diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx index 456630a6..dddfa1d1 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx @@ -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 (
- `/campanha/municipios/${municipalitySlug}?tab=updates${feedState.kind ? `&updateKind=${feedState.kind}` : ''}&updatePage=${page}` + `/campanha/municipios/${municipalitySlug}?tab=updates&updatePage=${page}` } />
diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts index 2a255c0a..b32d5971 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions.ts @@ -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.' } diff --git a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts index 856c0303..8cddfde0 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts @@ -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 => 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.', }) /** diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index f4491a74..deafacbf 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -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} /> diff --git a/src/app/(campaign)/campanha/actions/municipalityUpdate.ts b/src/app/(campaign)/campanha/actions/municipalityUpdate.ts index 190252e3..721ad792 100644 --- a/src/app/(campaign)/campanha/actions/municipalityUpdate.ts +++ b/src/app/(campaign)/campanha/actions/municipalityUpdate.ts @@ -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), diff --git a/src/collections/MunicipalityUpdate.ts b/src/collections/MunicipalityUpdate.ts index d9d98b7c..564d4435 100644 --- a/src/collections/MunicipalityUpdate.ts +++ b/src/collections/MunicipalityUpdate.ts @@ -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', - }, - }, ], } diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 32c0b66a..be2a82d9 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -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', ) => ( - - + ) return [ diff --git a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx index dc3e0bb5..aaba1daf 100644 --- a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx +++ b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx @@ -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 = ({
-
Último sinal
+
Última atualização
- - +
diff --git a/src/components/campaign/municipality/MunicipalityListTrendControl.tsx b/src/components/campaign/municipality/MunicipalityListTrendControl.tsx index 7de8a7b5..88d64f52 100644 --- a/src/components/campaign/municipality/MunicipalityListTrendControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListTrendControl.tsx @@ -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'} diff --git a/src/components/campaign/municipality/MunicipalityListSignalControl.tsx b/src/components/campaign/municipality/MunicipalityListUpdateControl.tsx similarity index 75% rename from src/components/campaign/municipality/MunicipalityListSignalControl.tsx rename to src/components/campaign/municipality/MunicipalityListUpdateControl.tsx index a1c2423e..23b4dcb2 100644 --- a/src/components/campaign/municipality/MunicipalityListSignalControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListUpdateControl.tsx @@ -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 -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 `
` 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 `` had to wrap the footer (B32+ F5). ) @@ -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 = ({ > - + {state.status !== 'success' ? : null} {isSheet ? null : submitButton} diff --git a/src/components/campaign/municipality/MunicipalitySignalFields.tsx b/src/components/campaign/municipality/MunicipalitySignalFields.tsx deleted file mode 100644 index 11cdb695..00000000 --- a/src/components/campaign/municipality/MunicipalitySignalFields.tsx +++ /dev/null @@ -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 -} - -export const MunicipalitySignalFields = ({ - idPrefix, - fieldErrors, -}: MunicipalitySignalFieldsProps) => { - const [signalType, setSignalType] = useState('') - const bodyId = `${idPrefix}-body` - const signalTypeId = `${idPrefix}-signal-type` - - return ( -
- - Texto -