Merge branch 'cursor/bfba8789'
This commit is contained in:
commit
fac6792d9f
12 changed files with 377 additions and 16 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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'),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const municipalityListDefaultProps = {
|
|||
columnFilterOptions: { name: [], region: [], advisor: [] },
|
||||
trendFormAction: noopListFormAction,
|
||||
advisorsFormAction: noopListFormAction,
|
||||
signalFormAction: noopListFormAction,
|
||||
state: { page: 1 },
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue