Merge branch 'cursor/bfba8789'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 20:42:57 -03:00
commit fac6792d9f
12 changed files with 377 additions and 16 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B26 entregue em código (2026-07-25):** registrar sinal a partir da célula "Último sinal" de `/campanha/municipios` — Popover (desktop) / Drawer bottom sheet (mobile) cria `municipalityUpdate` `kind: 'sinal'` (C12) sem sair da fila; submit explícito "Registrar sinal" (exceção do princípio 4); `revalidateMunicipalityListPaths`; sem migration. Plano: [`registrar-sinal-lista-municipios.md`](../../../docs/plans/registrar-sinal-lista-municipios.md).
- **B20 entregue (2026-07-25):** Municípios prioritários no Início — `pickDashboardPriorityMunicipalities` (`src/utilities/dashboardPriorityMunicipalities.ts`) ordena `priority=alta` por déficit E9/`central`, amostra 8; `CampaignDashboard` exibe Badge com `highPriorityCount`, CTA “Ver todas” → `?priority=alta`, chips com `list-none`. Unit `dashboardPriorityMunicipalities.unit.spec.ts`. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.md).
- **B28 registrado (2026-07-25):** e-mail + celular na lista `/campanha/liderancas` — colunas com copy-on-click + WhatsApp (`wa.me`) no padrão de `/campanha/assessores` (B19 ✓); `email` no view model da lista; extrair primitivo compartilhado (2º call site); **sem** toggle Editar in-list (Contact update adiado). Appetite ~0,5–0,75d; Janela 1; cortável; Impeccable B. Plano: [`email-celular-lista-liderancas.md`](../../../docs/plans/email-celular-lista-liderancas.md).
- **B27 registrado (2026-07-25):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (pedido de produto). Hoje: lista de checkboxes de todas as contas elegíveis + botão "Salvar assessores" (`MunicipalityListAdvisorsControl`, B9) → replace do array + `revalidateMunicipalityListPaths`. Plano: chips removíveis acima de um `Command` com busca acento-insensível; gravação **por delta** em endpoint JSON espelho do `expected-votes/` (sem revalidar 435 linhas), reusando o lock `municipality-advisors:{id}` e o cálculo puro do B19 (`nextAdvisorIdsAfterMembership` sobe para `src/lib/`) — mas **não** `setAdvisorMunicipalityMembership`, cujo `assertTargetAdvisor` recusa coordenador/candidato e o próprio ator (legais nesta tela). Gatilho da célula intacto (contrato de tooltip do B23); UI segue coordenador-only (candidato = adiado com gatilho, servidor já permite desde B19 ✓). Appetite ~0,75d; Janela 1–2; cortável; Impeccable B. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).

View file

@ -1,12 +1,16 @@
# Registrar sinal a partir da célula "Último sinal" da lista de municípios
Status: rascunho
Status: entregue (2026-07-25)
Atualizado em: 2026-07-25
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B26)
Impeccable: B — encaixe na célula `lastSignal` de `/campanha/municipios` (tabela desktop + card mobile); sem rota nova de UI
Appetite: ~0,75 dia eng; 1 controle novo + 1 form action + fields compartilhados; sem migration, sem collection, sem endpoint JSON
Responsável: —
## Revisão de entrega (2026-07-25)
Entregue: `MunicipalitySignalFields` + `MunicipalityListSignalControl` (Popover desktop / Drawer mobile) na coluna "Último sinal"; `createMunicipalityListSignalFormAction` em `municipalityStaffFormActions.ts`; wiring em `MunicipalityList` + `municipios/page.tsx`; e2e dedicado. Correções da auditoria aplicadas: (1) form action no padrão try/catch das irmãs do arquivo — **não** `runCampaignFormAction` (esse arquivo ainda é hand-rolled); (2) sem novo teste int (access já coberto em `campaignMunicipalityUpdate.int.spec.ts`); (3) Drawer controlado sem `DrawerTrigger`. Gate: tsc/lint/format/knip/cycles/unit+int verdes; e2e do fluxo B26 verde; build verde. Aikido: 0 findings nos arquivos novos/editados.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípio 2 **Clarity under pressure**, princípio 3 **Edit where you see**, princípio 4 **Auto-save, no Save button** — e a **exceção** nomeada nele para escrita atômica multi-campo, princípio 8 **Feel the action**) / `DESIGN.md` (register `product` — Field Desk) · tema `data-theme='campaign'` · regras `.cursor/rules/campanha-edit-where-you-see.mdc` (item 4: submit explícito continua válido para fluxo multi-campo) e `.cursor/rules/campanha-action-feedback.mdc` · precedentes vivos: [`MunicipalityListTrendControl.tsx`](../../src/components/campaign/municipality/MunicipalityListTrendControl.tsx) (Popover na célula) e [`MunicipalityUpdateForm.tsx`](../../src/components/campaign/municipality/MunicipalityUpdateForm.tsx) (campos do sinal tipado do C12).
@ -82,7 +86,7 @@ Componentes:
- **`MunicipalitySignalFields`** (novo, `src/components/campaign/municipality/MunicipalitySignalFields.tsx`): só os campos do sinal (`body` textarea 2 linhas, `signalType` `NativeSelect` + descrição do tipo escolhido, `signalSource` `Input`, `triangulated` checkbox) mais os `fieldErrors` do estado da action. Reusa `municipalitySignalTypes` / `municipalitySignalTypeLabels` / `municipalitySignalTypeDescriptions` de `src/lib/schemas/municipalityUpdate.ts` e os shells `Field`/`FieldLabel`/`FieldError`. Consumido pelas duas variantes (Popover e Drawer) — e é a peça que o `MunicipalityUpdateForm` do detalhe pode passar a reusar depois (ver Adiado com gatilho).
- **`MunicipalityListSignalControl`** (novo, `src/components/campaign/municipality/MunicipalityListSignalControl.tsx`): client component com prop `variant: 'popover' | 'sheet'`, `useActionState` sobre a form action, `Spinner` + `disabled` no submit, `Alert` de erro, live region `sr-only`, `setOpen(false)` + toast só no sucesso. O gatilho recebe o `SignalAgeReadout` como children — a leitura E9 não é reimplementada.
- **`MunicipalityList`** (`src/components/campaign/municipality/MunicipalityList.tsx`): a célula `lastSignal` (coluna staff) passa a envolver `SignalAgeReadout` no controle com `variant="popover"`; o card mobile usa `variant="sheet"`. Nova prop `signalFormAction` no mesmo formato das existentes (`MunicipalityStaffFormAction`).
- **`createMunicipalityListSignalFormAction`** (`src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts`): casca sobre `runCampaignFormAction` — lê `municipalityId`, `body`, `signalType`, `signalSource`, `triangulated` e o `municipalitySlug` do `FormData` (helpers `requiredRelationshipFormValue` / `nullableFormText` / `optionalMunicipalitySlugFromForm` já usados no arquivo), chama `createMunicipalityUpdate({ kind: 'sinal', … })` e `revalidateMunicipalityListPaths({ slug })`. **Depth check:** ao contrário do `createMunicipalityUpdateFormAction` do detalhe (try/catch à mão), esta nasce dentro de `runCampaignFormAction` — o padrão fechado no Pass 2 W4d; a do detalhe fica como está (troca é fill-in de higiene, não deste item).
- **`createMunicipalityListSignalFormAction`** (`src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts`): casca no padrão try/catch + `mapCampaignFormActionError` das irmãs do arquivo — **não** `runCampaignFormAction` (esse arquivo ainda é hand-rolled; a troca do detalhe/`runCampaignFormAction` continua fill-in de higiene). Lê `municipalityId`, `body`, `signalType`, `signalSource`, `triangulated` e o `municipalitySlug` do `FormData` (helpers `requiredRelationshipFormValue` / `optionalFormText` / `checkboxFormValue` / `optionalMunicipalitySlugFromForm` + `parseMunicipalitySignalType`), chama `createMunicipalityUpdate({ kind: 'sinal', … })` e `revalidateMunicipalityListPaths({ slug })`.
- **`municipios/page.tsx`**: passa a nova form action para `MunicipalityList`, ao lado de `trendFormAction`/`advisorsFormAction`.
- **Migration:** nenhuma. `municipalityUpdate` e todos os campos do sinal existem desde o C12; `lastUpdateAt` já é derivado por hook.
- **Testes:** int cobrindo o access da nova form action (assessor fora da carteira recebe erro e nada é criado; assessor da carteira cria e `lastUpdateAt` muda) em `tests/int/campaignMunicipalityUpdate.int.spec.ts`; e2e em `tests/e2e/campaignMunicipalities.e2e.spec.ts` — abrir a célula na tabela, preencher, submeter, ver a célula virar "hoje".
@ -113,7 +117,7 @@ Componentes:
- **Shell responsivo compartilhado (Popover ↔ Drawer) para controles de célula.** Revisitar quando: existir o **3º** controle de lista que precise das duas variantes.
- **Reuso de `MunicipalitySignalFields` dentro do `MunicipalityUpdateForm` do detalhe.** Revisitar quando: este item estiver em produção e os dois blocos de campos tiverem divergido em algum detalhe visível (hoje seriam dois consumidores no mesmo dia — extrair é barato, unificar copy/erros no mesmo passe é que estoura o appetite).
- **Contexto do último sinal dentro do popover.** Revisitar quando: alguém pedir em uso real "registrei duplicado porque não vi o que já tinha".
- **Lazy-mount de `Popover`/`Drawer` até o primeiro open** (custo idle do ramo CSS-oculto). Revisitar quando: perfilar a lista com vários controles dual-branch montados e o idle cost aparecer no trace — aplicar em lote nos controles da linha, não só neste.
## Referências

File diff suppressed because one or more lines are too long

View file

@ -11,9 +11,8 @@ import {
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
municipalitySignalTypes,
municipalityUpdateKinds,
type MunicipalitySignalType,
parseMunicipalitySignalType,
type MunicipalityUpdateKind,
} from '@/lib/schemas/municipalityUpdate'
import {
@ -32,11 +31,6 @@ export const createMunicipalityUpdateFormAction = async (
? (rawKind as MunicipalityUpdateKind)
: 'semanal'
const rawSignalType = optionalFormText(formData, 'signalType')
const signalType = municipalitySignalTypes.includes(rawSignalType as MunicipalitySignalType)
? (rawSignalType as MunicipalitySignalType)
: undefined
await createMunicipalityUpdate({
municipality,
kind,
@ -46,7 +40,7 @@ export const createMunicipalityUpdateFormAction = async (
body: optionalFormText(formData, 'body'),
activeVolunteers: optionalIntegerFormValue(formData, 'activeVolunteers'),
newSupports: optionalIntegerFormValue(formData, 'newSupports'),
signalType,
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
signalSource: optionalFormText(formData, 'signalSource'),
triangulated: checkboxFormValue(formData, 'triangulated'),
})

View file

@ -5,7 +5,9 @@ import {
setMunicipalityExpectedVotes,
setMunicipalityPoliticalTrend,
} from '@/app/(campaign)/campanha/actions/municipality'
import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
import {
checkboxFormValue,
nullableFormText,
optionalFormText,
optionalMunicipalitySlugFromForm,
@ -14,6 +16,7 @@ import {
voteEstimateScenarioFromForm,
} from '@/lib/formData'
import { parsePoliticalTrendStatusFormValue } from '@/lib/schemas/municipality'
import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
@ -88,3 +91,28 @@ export const assignMunicipalityAdvisorsFormAction = async (
})
}
}
export const createMunicipalityListSignalFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const municipality = requiredRelationshipFormValue(formData, 'municipalityId')
await createMunicipalityUpdate({
municipality,
kind: 'sinal',
body: optionalFormText(formData, 'body'),
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
signalSource: optionalFormText(formData, 'signalSource'),
triangulated: checkboxFormValue(formData, 'triangulated'),
})
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { status: 'success', message: 'Sinal registrado.' }
} catch (error) {
return mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -34,6 +34,7 @@ import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData
import { getEligibleAdvisorOptions, loadAdvisorSummaries } from '@/utilities/municipalityViewModels'
import {
assignMunicipalityAdvisorsFormAction,
createMunicipalityListSignalFormAction,
setMunicipalityPoliticalTrendFormAction,
} from './municipalityStaffFormActions'
@ -131,6 +132,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
columnFilterOptions={columnFilterOptions}
trendFormAction={setMunicipalityPoliticalTrendFormAction}
advisorsFormAction={assignMunicipalityAdvisorsFormAction}
signalFormAction={createMunicipalityListSignalFormAction}
state={state}
/>
<CampaignListFooter

View file

@ -8,6 +8,7 @@ import {
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalitySortableHead } from '@/components/campaign/municipality/MunicipalitySortableHead'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
@ -79,6 +80,7 @@ export type MunicipalityListProps = {
columnFilterOptions: MunicipalityColumnFilterOptions
trendFormAction: MunicipalityStaffFormAction
advisorsFormAction: MunicipalityStaffFormAction
signalFormAction: MunicipalityStaffFormAction
state: MunicipalityListState
}
@ -207,6 +209,7 @@ const municipalityListColumns = ({
advisorOptions,
trendFormAction,
advisorsFormAction,
signalFormAction,
concentrationHint,
signalHint,
deficitHint,
@ -361,7 +364,16 @@ const municipalityListColumns = ({
</MunicipalitySortableHead>
),
cell: (municipality) => (
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="table" />
<MunicipalityListSignalControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="popover"
formAction={signalFormAction}
>
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="table" />
</MunicipalityListSignalControl>
),
},
{
@ -476,7 +488,16 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
<div>
<dt className="text-muted-foreground">Último sinal</dt>
<dd>
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
<MunicipalityListSignalControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="sheet"
formAction={props.signalFormAction}
>
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
</MunicipalityListSignalControl>
</dd>
</div>
<div>

View file

@ -0,0 +1,164 @@
'use client'
import { useActionState, useEffect, useId, useState, type ReactNode } from 'react'
import { toast } from 'sonner'
import { MunicipalitySignalFields } from '@/components/campaign/municipality/MunicipalitySignalFields'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
formatMunicipalitySignalAgeLabel,
municipalitySignalAgeInDays,
} from '@/utilities/municipalitySignal'
type MunicipalityStaffFormAction = (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
type MunicipalityListSignalControlProps = {
municipalityID: number
municipalitySlug: string
municipalityName: string
lastSignalAt: string | null
variant: 'popover' | 'sheet'
formAction: MunicipalityStaffFormAction
children: ReactNode
}
const SignalFormFields = ({
municipalityID,
municipalitySlug,
idPrefix,
state,
isPending,
}: {
municipalityID: number
municipalitySlug: string
idPrefix: string
state: CampaignFormActionState
isPending: boolean
}) => (
<>
<input type="hidden" name="municipalityId" value={municipalityID} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
<MunicipalitySignalFields idPrefix={idPrefix} fieldErrors={state.fieldErrors} />
{state.message && state.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<span className="sr-only" aria-live="polite">
{isPending ? 'Registrando sinal…' : ''}
</span>
</>
)
const SubmitButton = ({ isPending }: { isPending: boolean }) => (
<Button type="submit" disabled={isPending} className="min-h-11 w-full">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Registrar sinal
</Button>
)
export const MunicipalityListSignalControl = ({
municipalityID,
municipalitySlug,
municipalityName,
lastSignalAt,
variant,
formAction,
children,
}: MunicipalityListSignalControlProps) => {
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 frescorLabel = formatMunicipalitySignalAgeLabel(municipalitySignalAgeInDays(lastSignalAt))
const triggerLabel = `Registrar sinal em ${municipalityName} — ${frescorLabel}`
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
setOpen(false)
setFormKey((key) => key + 1)
}, [state.message, state.status])
const triggerClassName =
'min-h-11 rounded-md px-1 text-left hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
const fields = (
<SignalFormFields
municipalityID={municipalityID}
municipalitySlug={municipalitySlug}
idPrefix={idPrefix}
state={state}
isPending={isPending}
/>
)
if (variant === 'sheet') {
return (
<>
<button
type="button"
className={triggerClassName}
aria-label={triggerLabel}
onClick={() => setOpen(true)}
>
{children}
</button>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent>
<form key={formKey} action={submitAction} className="flex min-h-0 flex-1 flex-col">
<DrawerHeader>
<DrawerTitle>Registrar sinal</DrawerTitle>
<DrawerDescription>{municipalityName}</DrawerDescription>
</DrawerHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 pb-2">
{fields}
</div>
<DrawerFooter>
<SubmitButton isPending={isPending} />
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Cancelar
</DrawerClose>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
</>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" className={triggerClassName} aria-label={triggerLabel}>
{children}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80">
<form key={formKey} action={submitAction} className="flex flex-col gap-3">
{fields}
<SubmitButton isPending={isPending} />
</form>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,87 @@
'use client'
import { useState } from 'react'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
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`
const signalSourceId = `${idPrefix}-signal-source`
const triangulatedId = `${idPrefix}-triangulated`
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>
<Field>
<FieldLabel htmlFor={signalSourceId}>Fonte</FieldLabel>
<Input
id={signalSourceId}
name="signalSource"
maxLength={160}
required
className="min-h-11"
/>
{fieldError(fieldErrors, 'signalSource') ? (
<FieldError>{fieldError(fieldErrors, 'signalSource')}</FieldError>
) : null}
</Field>
<Label htmlFor={triangulatedId} className="flex min-h-11 items-center gap-3 font-normal">
<Checkbox id={triangulatedId} name="triangulated" value="on" />
<span>Triangulado — confirmado por mais de uma fonte independente</span>
</Label>
</div>
)
}

View file

@ -37,6 +37,15 @@ export const municipalitySignalTypeDescriptions: Record<MunicipalitySignalType,
outro: 'Fato político relevante que não se encaixa nos tipos anteriores.',
}
export const parseMunicipalitySignalType = (
raw: string | undefined,
): MunicipalitySignalType | undefined => {
if (!raw) return undefined
return municipalitySignalTypes.includes(raw as MunicipalitySignalType)
? (raw as MunicipalitySignalType)
: undefined
}
const optionalCount = z.number().int().min(0).max(1_000_000).optional()
export const municipalityUpdateCreateSchema = z

View file

@ -122,6 +122,55 @@ test.describe('Municípios — jornadas por papel', () => {
await expect(page.getByText(`Assessoria: ${advisor.name}`)).toBeVisible()
})
test('coordinator registers a typed signal from the list freshness cell', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const coordinator = await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenador Sinal'),
email: `${fixtures.value('coordinator-signal')}@example.com`,
password,
role: 'coordinator',
},
depth: 0,
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(page.getByRole('heading', { name: 'Municípios', exact: true })).toBeVisible()
const signalTrigger = page.getByRole('button', {
name: new RegExp(`Registrar sinal em ${municipality.name}`),
})
await expect(signalTrigger).toBeVisible()
await signalTrigger.click()
const signalForm = page.locator('[data-slot="popover-content"]')
await expect(signalForm).toBeVisible()
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
.getByRole('textbox', { name: 'Fonte', exact: true })
.fill('Liderança local / WhatsApp')
await signalForm.getByRole('button', { name: 'Registrar sinal', exact: true }).click()
await expect(page.getByText('Sinal registrado.', { exact: true })).toBeVisible()
await expect(
page.getByRole('button', {
name: `Registrar sinal em ${municipality.name} — hoje`,
}),
).toBeVisible()
})
test('advisor scopes municipalities; staff declare and estimate; leader is locked to contacts', async ({
campaign,
page,

View file

@ -42,6 +42,7 @@ const municipalityListDefaultProps = {
columnFilterOptions: { name: [], region: [], advisor: [] },
trendFormAction: noopListFormAction,
advisorsFormAction: noopListFormAction,
signalFormAction: noopListFormAction,
state: { page: 1 },
}