From 6b8aa8b6cf98185c240eb6ef7d09b0e81ebeea99 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:33:46 +0000 Subject: [PATCH] =?UTF-8?q?fix(campanha):=20B147=20simplify=20=E2=80=94=20?= =?UTF-8?q?sinal=20empty=20body=20+=20cold=20select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow empty motivo on sinal through collection beforeValidate; distinguish warm-typeless vs cold in the v2 signal select; reuse list signal form action. Co-authored-by: Francisco Solla --- docs/plans/municipio-v2-shell-status-impl.md | 6 ++-- scripts/lib/e2e-affected-manifest.mjs | 3 +- .../(app)/municipio/[slug]/v2/formActions.ts | 34 +++---------------- src/collections/MunicipalityUpdate.ts | 2 +- .../MunicipalityV2StatusReasonDialog.tsx | 3 +- .../MunicipalityV2StatusStrip.tsx | 2 +- .../municipality/municipalityV2StatusView.ts | 14 ++++++-- .../municipalityV2StatusView.unit.spec.ts | 13 +++++++ 8 files changed, 37 insertions(+), 40 deletions(-) diff --git a/docs/plans/municipio-v2-shell-status-impl.md b/docs/plans/municipio-v2-shell-status-impl.md index 89bcc5b6..8d29018f 100644 --- a/docs/plans/municipio-v2-shell-status-impl.md +++ b/docs/plans/municipio-v2-shell-status-impl.md @@ -65,11 +65,11 @@ flowchart LR - **`MunicipalityV2Page`** (`src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx`): RSC; `requireCampaignPageActor({ gate: 'noLeader' })`; status strip; empty slots for conta/rede/agora (children). - **`loadMunicipalityV2StatusData`** (`src/utilities/municipality/municipalityV2StatusData.ts`): server-only composer. -- **`buildMunicipalityV2StatusAggregate`** (`src/lib/municipalityV2Status.ts`): pure aggregate text + cold sentinel helpers (unit-tested). +- **`buildMunicipalityV2StatusAggregate`** (`src/utilities/municipality/municipalityV2StatusView.ts`): pure aggregate text + cold sentinel helpers (unit-tested). Client-safe utilities (depends on `municipalitySignal`). - **`MunicipalityV2StatusStrip`** + **`MunicipalityV2StatusReasonDialog`** (`src/components/campaign/municipality/`): client strip + shared Dialog (motivo opcional). -- **Schema/action/list level:** `municipalityEngagementLevelSchema` note → optional; `reversalSignals` optional/default `''`; list control drops reversal field; int tests updated. +- **Schema/action/list level:** `municipalityEngagementLevelSchema` note → optional; drop `reversalSignals` from write; list control drops reversal field; int tests updated. - **Chrome:** path rule `/campanha/municipio/[^/]+/v2`. -- **Form action:** reuse `createMunicipalityListSignalFormAction` (or thin v2 wrapper colocated under the new route). +- **Form action:** thin alias over `createMunicipalityListSignalFormAction`. - **Migration:** nenhuma. - **Access / Consent:** existing municipality access; no Consent; leader blocked by page gate. - **UI:** Impeccable C — shape → craft → critique → polish; tokens `data-theme='campaign'`; `CampaignHoverTooltip` + `campaignConceptHref` / `campaignConceptOneLiner`. diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 855302f0..4ad88700 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -41,7 +41,8 @@ export const E2E_AFFECTED_MANIFEST = [ 'campaignSavedFilters', 'campaignColumnPicker', ], - }, { + }, + { prefixes: [ `${CAMPAIGN_APP}/atividades`, 'src/components/campaign/activity', 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 d41d55d1..8d4d57f6 100644 --- a/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions.ts @@ -1,35 +1,9 @@ 'use server' -import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate' -import { - optionalFormText, - optionalMunicipalitySlugFromForm, - requiredRelationshipFormValue, -} from '@/lib/formData' -import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate' -import { - runCampaignFormAction, - type CampaignFormActionState, -} from '@/utilities/campaignFormActionError' -import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' - -const SIGNAL_SAVED_MESSAGE = 'Sinal registrado.' +import { createMunicipalityListSignalFormAction } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions' +import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' +/** Thin alias — same write path as the list signal control (B147). */ export const createMunicipalityV2SignalFormAction = async ( formData: FormData, -): Promise => - runCampaignFormAction({ - execute: async () => { - const municipality = requiredRelationshipFormValue(formData, 'municipalityId') - - await createMunicipalityUpdate({ - municipality, - kind: 'sinal', - body: optionalFormText(formData, 'body'), - signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')), - }) - revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) }) - return { message: SIGNAL_SAVED_MESSAGE } - }, - genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.', - }) +): Promise => createMunicipalityListSignalFormAction({}, formData) diff --git a/src/collections/MunicipalityUpdate.ts b/src/collections/MunicipalityUpdate.ts index 8cf50667..d9d98b7c 100644 --- a/src/collections/MunicipalityUpdate.ts +++ b/src/collections/MunicipalityUpdate.ts @@ -52,7 +52,7 @@ const validateMunicipalityUpdateKind: CollectionBeforeValidateHook = ({ 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') { - if (!nonEmptyText(nextData.body)) throw new APIError('Informe o texto da atualização.', 400) + // 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) } diff --git a/src/components/campaign/municipality/MunicipalityV2StatusReasonDialog.tsx b/src/components/campaign/municipality/MunicipalityV2StatusReasonDialog.tsx index a1f804e3..9a8275c5 100644 --- a/src/components/campaign/municipality/MunicipalityV2StatusReasonDialog.tsx +++ b/src/components/campaign/municipality/MunicipalityV2StatusReasonDialog.tsx @@ -1,7 +1,7 @@ 'use client' import Link from 'next/link' -import { useId, useState, type ReactNode } from 'react' +import { useId, useState } from 'react' import { Alert, AlertDescription } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' @@ -37,7 +37,6 @@ export type MunicipalityV2StatusReasonDialogProps = { override?: boolean onOverrideChange?: (value: boolean) => void onConfirm: (reason: string) => void - children?: ReactNode } /** diff --git a/src/components/campaign/municipality/MunicipalityV2StatusStrip.tsx b/src/components/campaign/municipality/MunicipalityV2StatusStrip.tsx index 08b23122..30ac18c8 100644 --- a/src/components/campaign/municipality/MunicipalityV2StatusStrip.tsx +++ b/src/components/campaign/municipality/MunicipalityV2StatusStrip.tsx @@ -393,7 +393,7 @@ export const MunicipalityV2StatusStrip = ({ return } const next = parseMunicipalitySignalType(raw) - if (!next || next === signalType) { + if (!next || next === signalSelect.value) { event.target.value = signalSelect.value return } diff --git a/src/utilities/municipality/municipalityV2StatusView.ts b/src/utilities/municipality/municipalityV2StatusView.ts index acaa9960..194a72eb 100644 --- a/src/utilities/municipality/municipalityV2StatusView.ts +++ b/src/utilities/municipality/municipalityV2StatusView.ts @@ -58,7 +58,8 @@ export type MunicipalityV2SignalSelectState = { /** * Product assumption (intention open Q): cold → sentinel “Sem sinal / frio” - * in the strip; age lives in aggregate/tooltip. + * 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 = ( input: { @@ -70,7 +71,7 @@ export const resolveMunicipalityV2SignalSelectState = ( const ageInDays = municipalitySignalAgeInDays(input.lastSignalAt, now) const isCold = isMunicipalitySignalCold(ageInDays) - if (isCold || !input.signalType) { + if (isCold) { const daysLabel = ageInDays === null ? `frio (≥${MUNICIPALITY_COLD_SIGNAL_DAYS} d)` : `frio (${ageInDays} d)` return { @@ -81,6 +82,15 @@ export const resolveMunicipalityV2SignalSelectState = ( } } + if (!input.signalType) { + return { + value: MUNICIPALITY_V2_SIGNAL_COLD_VALUE, + label: 'Sem tipo de sinal', + ageInDays, + isCold: false, + } + } + return { value: input.signalType, label: municipalitySignalTypeLabels[input.signalType], diff --git a/tests/unit/municipalityV2StatusView.unit.spec.ts b/tests/unit/municipalityV2StatusView.unit.spec.ts index 9588f9bc..244e6d8c 100644 --- a/tests/unit/municipalityV2StatusView.unit.spec.ts +++ b/tests/unit/municipalityV2StatusView.unit.spec.ts @@ -17,6 +17,19 @@ describe('municipalityV2StatusView', () => { expect(state.label).toContain('Sem sinal') }) + it('does not claim frio when frescor is warm but typeless', () => { + const state = resolveMunicipalityV2SignalSelectState( + { + signalType: 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') + }) + it('keeps the typed signal when still warm', () => { const state = resolveMunicipalityV2SignalSelectState( {