fix(campanha): B147 simplify — sinal empty body + cold select

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 <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 23:33:46 +00:00
parent c66ffd598c
commit 6b8aa8b6cf
No known key found for this signature in database
8 changed files with 37 additions and 40 deletions

View file

@ -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`.

View file

@ -41,7 +41,8 @@ export const E2E_AFFECTED_MANIFEST = [
'campaignSavedFilters',
'campaignColumnPicker',
],
}, {
},
{
prefixes: [
`${CAMPAIGN_APP}/atividades`,
'src/components/campaign/activity',

View file

@ -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<CampaignFormActionState> =>
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<CampaignFormActionState> => createMunicipalityListSignalFormAction({}, formData)

View file

@ -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)
}

View file

@ -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
}
/**

View file

@ -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
}

View file

@ -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],

View file

@ -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(
{