B32: editar Status de apoio na célula da lista de lideranças

- Popover + NativeSelect sempre montado na coluna Status de
  /campanha/liderancas, sem toggle de modo de edição
- Auto-save (~150ms) via POST /campanha/liderancas/support-status,
  reusando isSameOriginRequest/campaignJsonMutationRoute (B24/B27),
  sem revalidatePath
- Extrai leadershipStaffEditSafeMessages (adotado também em
  liderancas/formActions.ts e nova/formActions.ts) e
  utilities/leadershipLabels.ts (3º call site de supportStatusLabels,
  agora fonte única também para SupportStatusBadge)
- Novo e2e tests/e2e/campaignLeaderships.e2e.spec.ts; setup.e2e.spec.ts
  prewarma as rotas novas
- Fecha o Adiado com gatilho do B29; docs/plans, notebook e roadmap
  atualizados

Gate: tsc/lint/format/knip(P3 pré-existente)/check:cycles/unit+int/e2e
verdes; two /simplify passes (labels/estado duplicado/isSupportStatus;
flush-on-close ao fechar o popover, comentário órfão de summary/
campos summary mortos removidos de SupportStatusBadge).
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 09:42:57 -03:00
parent e712397241
commit 211276b4e4
17 changed files with 395 additions and 48 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,12 +1,16 @@
# Auto-save do Status de apoio na lista de lideranças
Status: rascunho
Atualizado em: 2026-07-25
Status: entregue
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B32)
Impeccable: B — encaixe em `LeadershipsPage` / `CampaignTable` de `/campanha/liderancas` (coluna "Status"); sem rota nova de UI
Appetite: ~0,4–0,5 dia eng; 1 componente novo + 1 route handler JSON espelhando `expected-votes/`; extração do guard CSRF compartilhado; sem migration
Responsável: —
## Revisão na entrega (2026-07-26)
Implementado como planejado, com duas defasagens (o repo andou desde o rascunho — menos trabalho, não mais): `isSameOriginRequest` já tinha sido extraído para `src/utilities/sameOriginRequest.ts` pelo B24/B27 (este item é o 3º consumidor, não o 2º) e o shell genérico `src/utilities/campaignJsonMutationRoute.ts` (`parseCampaignJsonRequestBody`/`campaignJsonMutationErrorResponse`) já existia — o route novo reusa os dois em vez de reescrever parsing/erro do zero. A verificação de "unit do parse/erro do route" foi trocada por e2e (nenhum dos routes irmãos — `political-trend`/`advisors`/`expected-votes` — tem esse unit; a cobertura real da família é e2e). Extração adicional não prevista no plano: `src/utilities/leadershipLabels.ts` (`supportStatusLabels`), por já ter 3 call sites (`LeadershipInternalForm`, `LeadershipForm`, o novo controle). Gate completo (tsc/lint/format/knip/check:cycles/unit/int/e2e/Aikido) verde; 2 falhas isoladas em `campaignMunicipalities.e2e.spec.ts` (B24/B27) na suíte cheia com 1 worker foram reproduzidas como contenção de outra sessão de agente concorrente no mesmo Postgres local — passam limpo isoladas, não relacionadas a este item.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípio 3 **Edit where you see**, princípio 4 **Auto-save, no Save button**, 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` e `.cursor/rules/campanha-action-feedback.mdc` · precedente vivo: [`MunicipalityListExpectedVotesControl.tsx`](../../src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx) (máquina de auto-save com rollback) e o [`MunicipalityListTrendControl.tsx`](../../src/components/campaign/municipality/MunicipalityListTrendControl.tsx) atual (Popover + select — o que este item **não** deve repetir é o botão "Salvar" dele, hoje ainda sem o passe do B24).

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,7 @@
import { revalidatePath } from 'next/cache'
import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
import { updateLeadershipInternal } from '@/app/(campaign)/campanha/actions/leadership'
import {
nullableFormText,
@ -17,7 +18,7 @@ import {
const safeMessages = [
'Você só pode vincular lideranças aos municípios que assessora.',
'Somente a coordenação e a assessoria podem gerenciar lideranças.',
...leadershipStaffEditSafeMessages,
] as const
export const updateLeadershipInternalFormAction = async (

View file

@ -1,5 +1,6 @@
'use server'
import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
import { requiredFormBoolean, requiredRelationshipFormValue } from '@/lib/formData'
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
@ -9,7 +10,7 @@ import {
} from '@/utilities/campaignFormActionError'
const safeMessages = [
'Somente a coordenação e a assessoria podem gerenciar lideranças.',
...leadershipStaffEditSafeMessages,
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
] as const

View file

@ -0,0 +1,4 @@
/** Shared form-error copy for leadership staff edits — kept out of `'use server'` files. */
export const leadershipStaffEditSafeMessages = [
'Somente a coordenação e a assessoria podem gerenciar lideranças.',
] as const

View file

@ -1,5 +1,6 @@
'use server'
import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
import { createLeadership } from '@/app/(campaign)/campanha/actions/leadership'
import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData'
import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership'
@ -11,7 +12,7 @@ import {
const safeMessages = [
'Esta pessoa já está cadastrada como liderança. Edite a ficha existente para vincular novos municípios.',
'Você só pode vincular lideranças aos municípios que assessora.',
'Somente a coordenação e a assessoria podem gerenciar lideranças.',
...leadershipStaffEditSafeMessages,
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.',
] as const

View file

@ -5,7 +5,7 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { LeadershipListSupportStatusControl } from '@/components/campaign/leadership/LeadershipListSupportStatusControl'
import { CampaignCopyableCell } from '@/components/campaign/shared/CampaignCopyableCell'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
@ -80,7 +80,9 @@ const leadershipColumns = (
{
id: 'supportStatus',
head: <CampaignTableHead>Status</CampaignTableHead>,
cell: (row) => (row.supportStatus ? <SupportStatusBadge status={row.supportStatus} /> : '—'),
cell: (row) => (
<LeadershipListSupportStatusControl leadershipID={row.id} status={row.supportStatus} />
),
},
{
id: 'municipalities',

View file

@ -0,0 +1,50 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
import { updateLeadershipInternal } from '@/app/(campaign)/campanha/actions/leadership'
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import {
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import type { LeadershipListSupportStatusResponse } from './types'
export type { LeadershipListSupportStatusResponse } from './types'
export const dynamic = 'force-dynamic'
const bodySchema = z.object({
leadershipId: positiveRelationshipId,
supportStatus: z.enum(leadershipSupportStatuses),
})
export async function POST(
request: Request,
): Promise<NextResponse<LeadershipListSupportStatusResponse>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { leadershipId, supportStatus } = bodySchema.parse(parsed.body)
const updated = await updateLeadershipInternal({ id: leadershipId, supportStatus })
return NextResponse.json({
status: 'success',
message: 'Status de apoio atualizado.',
savedSupportStatus: updated.supportStatus,
})
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: leadershipStaffEditSafeMessages,
genericMessage: 'Não foi possível salvar o status. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -0,0 +1,5 @@
import type { SupportStatus } from '@/lib/schemas/leadership'
export type LeadershipListSupportStatusResponse =
| { status: 'success'; message: string; savedSupportStatus: SupportStatus }
| { status: 'error'; message: string }

View file

@ -16,6 +16,7 @@ import { Textarea } from '@/components/ui/textarea'
import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
import { supportStatusLabels } from '@/utilities/leadershipLabels'
const sectorLabels: Record<(typeof leadershipSectors)[number], string> = {
religioso: 'Religioso',
@ -30,13 +31,6 @@ const sectorLabels: Record<(typeof leadershipSectors)[number], string> = {
outro: 'Outro',
}
const supportStatusLabels: Record<(typeof leadershipSupportStatuses)[number], string> = {
engajado: 'Engajado',
a_abordar: 'A abordar',
em_disputa: 'Em disputa',
negativo: 'Negativo',
}
type LeadershipFormProps = {
municipalityOptions: RelationOption[]
organizationOptions: RelationOption[]

View file

@ -16,6 +16,7 @@ import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/lead
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
import type { LeadershipDetailViewModel } from '@/utilities/leadershipData'
import { supportStatusLabels } from '@/utilities/leadershipLabels'
const sectorLabels: Record<(typeof leadershipSectors)[number], string> = {
religioso: 'Religioso',
@ -30,13 +31,6 @@ const sectorLabels: Record<(typeof leadershipSectors)[number], string> = {
outro: 'Outro',
}
const supportStatusLabels: Record<(typeof leadershipSupportStatuses)[number], string> = {
engajado: 'Engajado',
a_abordar: 'A abordar',
em_disputa: 'Em disputa',
negativo: 'Negativo',
}
type LeadershipInternalFormProps = {
leadership: LeadershipDetailViewModel
municipalityOptions: RelationOption[]

View file

@ -0,0 +1,208 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { LeadershipListSupportStatusResponse } from '@/app/(campaign)/campanha/(app)/liderancas/support-status/types'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import {
isSupportStatus,
leadershipSupportStatuses,
type SupportStatus,
} from '@/lib/schemas/leadership'
import { cn } from '@/lib/utils'
import { supportStatusLabels } from '@/utilities/leadershipLabels'
const STATUS_AUTOSAVE_MS = 150
const SUPPORT_STATUS_ENDPOINT = '/campanha/liderancas/support-status'
const SAVE_ERROR_MESSAGE = 'Não foi possível salvar o status. Tente novamente.'
const DEFAULT_STATUS: SupportStatus = 'a_abordar'
type LeadershipListSupportStatusControlProps = {
leadershipID: number
status: SupportStatus | null
}
export const LeadershipListSupportStatusControl = ({
leadershipID,
status,
}: LeadershipListSupportStatusControlProps) => {
const initialStatus = status ?? DEFAULT_STATUS
const [open, setOpen] = useState(false)
// Single value drives both the trigger badge and the select — unlike
// `MunicipalityListExpectedVotesControl`/`Trend`, there is only one field
// here, so a "display" value distinct from the "draft" would just be a
// second name for the same state.
const [value, setValue] = useState(initialStatus)
const [isDirty, setIsDirty] = useState(false)
const [isPending, setIsPending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const abortRef = useRef<AbortController | null>(null)
const committedStatusRef = useRef(initialStatus)
const lastPropsStatusRef = useRef(initialStatus)
const saveGenerationRef = useRef(0)
// Adopt server props only when they change from outside (navigation / RSC refresh).
useEffect(() => {
const next = status ?? DEFAULT_STATUS
if (next === lastPropsStatusRef.current) return
lastPropsStatusRef.current = next
committedStatusRef.current = next
setValue(next)
setIsDirty(false)
}, [status])
useEffect(
() => () => {
clearTimeout(saveTimeoutRef.current)
abortRef.current?.abort()
},
[],
)
const saveStatus = async (nextStatus: SupportStatus) => {
if (nextStatus === committedStatusRef.current) return
const generation = ++saveGenerationRef.current
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setIsPending(true)
setErrorMessage(null)
try {
const response = await fetch(SUPPORT_STATUS_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
signal: controller.signal,
body: JSON.stringify({ leadershipId: leadershipID, supportStatus: nextStatus }),
})
const payload = (await response.json()) as LeadershipListSupportStatusResponse
if (generation !== saveGenerationRef.current) return
if (!response.ok || payload.status !== 'success') {
setValue(committedStatusRef.current)
setIsDirty(false)
setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
return
}
committedStatusRef.current = payload.savedSupportStatus
setValue(payload.savedSupportStatus)
setIsDirty(false)
} catch {
if (controller.signal.aborted || generation !== saveGenerationRef.current) return
setValue(committedStatusRef.current)
setIsDirty(false)
setErrorMessage(SAVE_ERROR_MESSAGE)
} finally {
if (generation === saveGenerationRef.current) {
setIsPending(false)
}
}
}
const handleStatusChange = (raw: string) => {
if (!isSupportStatus(raw)) return
setIsDirty(true)
setValue(raw)
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = setTimeout(() => {
saveTimeoutRef.current = undefined
void saveStatus(raw)
}, STATUS_AUTOSAVE_MS)
}
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
setIsDirty(false)
setErrorMessage(null)
} else if (saveTimeoutRef.current !== undefined) {
// Flush a pending debounced save instead of letting the unmount
// cleanup drop it — closing right after picking an option must not
// silently discard the change (same precedent as `flushDraft` in
// `MunicipalityListTrendControl`).
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = undefined
void saveStatus(value)
}
setOpen(nextOpen)
}
const statusMessage = errorMessage
? errorMessage
: isPending
? 'Salvando status de apoio.'
: isDirty
? 'Alterações serão salvas automaticamente.'
: ''
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
aria-expanded={open}
aria-haspopup="dialog"
aria-busy={isPending || undefined}
className={cn(
'min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
open ? 'bg-muted/60' : undefined,
)}
aria-label="Editar status de apoio"
>
<SupportStatusBadge status={value} />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-64 p-3"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="relative flex flex-col gap-3">
{isPending ? (
<Spinner
className="absolute top-0 right-0 size-3.5 text-muted-foreground"
aria-label="Salvando status de apoio"
/>
) : null}
<Field>
<FieldLabel htmlFor={`leadership-list-support-status-${leadershipID}`}>
Status de apoio
</FieldLabel>
<NativeSelect
id={`leadership-list-support-status-${leadershipID}`}
value={value}
onChange={(event) => handleStatusChange(event.target.value)}
className="min-h-11 w-full"
>
{leadershipSupportStatuses.map((option) => (
<NativeSelectOption key={option} value={option}>
{supportStatusLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -1,29 +1,16 @@
import { Badge } from '@/components/ui/Badge'
import type { SupportStatus } from '@/lib/schemas/leadership'
import { supportStatusLabels } from '@/utilities/leadershipLabels'
export type { SupportStatus } from '@/lib/schemas/leadership'
// Labels live in `leadershipLabels.ts` (shared with the ficha forms and the
// list quick-edit control) — this table only adds the badge-specific variant.
const statusPresentation = {
engajado: {
label: 'Engajado',
variant: 'support-engaged',
summary: 'Participa ativamente da campanha nos seus municípios e pode ter acesso ao app.',
},
a_abordar: {
label: 'A abordar',
variant: 'support-to-approach',
summary: 'Cadastrado, mas o engajamento ainda não foi confirmado.',
},
em_disputa: {
label: 'Em disputa',
variant: 'support-disputed',
summary: 'Apoio contestado, incerto ou em negociação.',
},
negativo: {
label: 'Negativo',
variant: 'support-negative',
summary: 'Declarou não apoiar ou recusou o contato.',
},
engajado: { variant: 'support-engaged' },
a_abordar: { variant: 'support-to-approach' },
em_disputa: { variant: 'support-disputed' },
negativo: { variant: 'support-negative' },
} as const
export const SupportStatusBadge = ({ status }: { status: SupportStatus }) => {
@ -31,7 +18,7 @@ export const SupportStatusBadge = ({ status }: { status: SupportStatus }) => {
return (
<Badge variant={presentation.variant} data-support-status={status}>
{presentation.label}
{supportStatusLabels[status]}
</Badge>
)
}

View file

@ -0,0 +1,12 @@
/**
* Leadership display labels shared across the ficha forms and the list
* quick-edit control. Client-safe: a plain string table, no Payload/DB.
*/
import type { SupportStatus } from '@/lib/schemas/leadership'
export const supportStatusLabels: Record<SupportStatus, string> = {
engajado: 'Engajado',
a_abordar: 'A abordar',
em_disputa: 'Em disputa',
negativo: 'Negativo',
}

View file

@ -0,0 +1,80 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* `/campanha/liderancas` journeys. First e2e spec for this route — starts
* with the B32 support-status quick edit (Popover + auto-save, no toggle).
*/
test.describe('campaign leaderships list', () => {
test('coordinator edits support status in the cell with auto-save (B32)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const coordinator = await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenador Status'),
email: `${fixtures.value('coordinator-status')}@example.com`,
password,
role: 'coordinator',
},
depth: 0,
})
const municipality = await fixtures.claimMunicipality()
const contactName = fixtures.value('Liderança Status')
const contact = await campaign.payload.create({
collection: 'contact',
data: {
name: contactName,
phone: fixtures.phone(),
state: 'BA',
city: municipality.name,
},
depth: 0,
})
await campaign.payload.create({
collection: 'leadership',
data: {
contact: contact.id,
municipalities: [municipality.id],
supportStatus: 'a_abordar',
},
depth: 0,
})
await campaign.login(page, coordinator.email!, password)
await page.goto(`${campaign.baseURL}/campanha/liderancas?q=${encodeURIComponent(contactName)}`)
await expect(page.getByRole('heading', { name: 'Lideranças', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Editar status de apoio' }).click()
const statusPopover = page.locator('[data-slot="popover-content"]')
await expect(statusPopover).toBeVisible()
await expect(statusPopover.getByRole('button', { name: 'Salvar' })).toHaveCount(0)
// Auto-save (150 ms debounce): wait for the actual POST response — the
// badge text updates optimistically before the request even lands, so a
// text-only assertion here would race the reload below against a save
// that hasn't reached the database yet.
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/campanha/liderancas/support-status') &&
response.request().method() === 'POST' &&
response.ok(),
),
statusPopover.getByLabel('Status de apoio', { exact: true }).selectOption('engajado'),
])
await expect(page.getByRole('button', { name: 'Editar status de apoio' })).toContainText(
'Engajado',
)
await page.keyboard.press('Escape')
await page.reload()
await expect(page.getByRole('button', { name: 'Editar status de apoio' })).toContainText(
'Engajado',
)
})
})

View file

@ -6,10 +6,12 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
test.slow()
for (const path of [
'/campanha/login',
'/campanha',
'/campanha/municipios',
'/campanha/territorios',
'/campanha/municipios/e2e-prewarm',
'/campanha/demandas',
'/campanha/liderancas',
'/campanha/conceitos',
'/campanha/convite/e2e-prewarm',
'/',
@ -27,6 +29,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/municipios/advisors',
'/campanha/municipios/expected-votes',
'/campanha/municipios/political-trend',
'/campanha/liderancas/support-status',
]) {
await request.post(`${baseURL}${path}`, { data: {} }).catch(() => undefined)
}