Merge branch 'cursor/ab035786'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 10:04:52 -03:00
commit 22135c0a31
18 changed files with 500 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).

View file

@ -0,0 +1,104 @@
# Escala e DRY pós-B32 (editar Status de apoio na lista de lideranças)
Status: rascunho
Atualizado em: 2026-07-27
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B32+**, fill-in de engenharia)
Impeccable: A — N/A (sem superfície UI nova; extrai comportamento já entregue sem mudar contrato de rede nem markup visível)
Appetite: ~1–1,25 dia eng; F1 é uma auditoria com decisão binária (extrair ou registrar rejeição medida, mesmo formato do B33+); F2/F3 são baratas e só valem a pena se F1 concluir "extrair"
Responsável: —
## Dados → decisão → apresentação
- **Vou apresentar dados?** Não (N/A) — lote de manutenção de três controles de auto-save já entregues; nenhuma métrica, série ou escala nova.
- **Anti-goals de dado:** N/A.
## Contexto
O plano do **B32** ([autosave-status-lista-liderancas.md](autosave-status-lista-liderancas.md)) e os dois planos anteriores ([autosave-tendencia-lista-municipios.md](autosave-tendencia-lista-municipios.md) — B24, [combobox-assessores-lista-municipios.md](combobox-assessores-lista-municipios.md) — B27) registraram, cada um, o mesmo "Adiado com gatilho": **extração só no 3º call site**. Com o B32 entregue (2026-07-26), o gatilho disparou — três controles client-side compartilham a mesma máquina de auto-save por debounce:
- `src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx` (B27, cenário de votos)
- `src/components/campaign/municipality/MunicipalityListTrendControl.tsx` (B24, status + justificativa)
- `src/components/campaign/leadership/LeadershipListSupportStatusControl.tsx` (B32, select único)
A comparação linha a linha dos três (feita nesta triage, `capture-review-debts` pós-B32) confirma o achado do `/simplify`/reuse: os três repetem, quase byte a byte —
- Estado: `open`, `isDirty`, `isPending`, `errorMessage`.
- Refs: `saveTimeoutRef` (debounce), `abortRef` (cancela o fetch em voo), `committed<X>Ref` (último valor confirmado pelo servidor), `lastProps<X>Ref` (detecta prop externa vs. eco do próprio save), `saveGenerationRef` (descarta resposta de um save superado por um mais recente).
- Efeito de adoção de props externas (navegação/RSC refresh) que só reage quando o valor muda de fora, comparado por uma função `xEqual`/`===` própria de domínio.
- Efeito de cleanup (`clearTimeout` + `abortRef.current?.abort()`) no unmount.
- `save<X>` assíncrono: gera geração, aborta o anterior, `fetch(ENDPOINT, { method: 'POST', headers, credentials: 'same-origin', signal, body })`, faz `cast` da resposta, reverte para o valor confirmado em erro/exceção, atualiza `committed<X>Ref` em sucesso — sempre guardado por `generation !== saveGenerationRef.current`.
- `scheduleSave`/`handleXChange`: marca `isDirty`, atualiza o rascunho local, reagenda o `setTimeout`.
- `handleOpenChange`: ao abrir, limpa erro/dirty; ao fechar com um save pendente, faz _flush_ (chama o save direto em vez de deixar o cleanup do unmount descartá-lo) — o **B32** só ganhou esse flush no `/simplify` da própria entrega, espelhando o `flushDraft` que o B24 já tinha.
- `statusMessage` calculado igual nos três (`errorMessage` → "Salvando…" → "Alterações serão salvas automaticamente." → vazio), num `<p className="sr-only" aria-live="polite">`.
As diferenças reais, que é o que torna a extração não trivial (achado do `/simplify` reuse review do B32, 2026-07-26): o **shape do valor** diverge (`VoteEstimateScenarioViewModel` objeto com 3 cenários × `MunicipalityListSavedPoliticalTrend` `{status, note}` × `SupportStatus` escalar), **display vs. draft** existe em dois dos três (o B32 colapsou os dois porque só tem um campo — ver "Decisões travadas" do próprio plano B32) e o **B24** tem um guard extra (`inFlightTrendRef`) para não abortar+reenviar o mesmo payload quando blur e fechamento do popover disparam o mesmo flush.
Um quarto controle, `MunicipalityListAdvisorsControl.tsx` (B27, chips + `Command`), **não** compartilha essa máquina — é delta multi-seleção com `requestSeqRef`/`latestConfirmedRef` (fora de escopo, mecânica genuinamente diferente) — mas compartilha com os quatro (incluindo ele) o **preâmbulo do `fetch`** (`credentials: 'same-origin'`, `Content-Type`/`Accept` JSON, `cast` da resposta) e, do lado do servidor, os quatro `route.ts` (`liderancas/support-status`, `municipios/political-trend`, `municipios/expected-votes`, `municipios/advisors`) repetem o mesmo preâmbulo de rota: `isSameOriginRequest(request)` → 403 → `parseCampaignJsonRequestBody` → `bodySchema.parse` → `try/catch` com `campaignJsonMutationErrorResponse`.
## Objetivos
- **F1 (dominante):** decidir, com auditoria de código (não de intenção), se um hook `useCampaignListAutosave<TValue>` genérico compensa nos três call sites do debounce — e, se sim, extraí-lo; se não, registrar a rejeição medida (precedente B33+: a auditoria pode derrubar a premissa do plano).
- **F2 (condicional a F1 ter concluído "vale a pena mexer aqui"):** avaliar um helper de preâmbulo de rota (`guardCampaignJsonMutationRequest` ou nome equivalente) que combine `isSameOriginRequest` + `parseCampaignJsonRequestBody`, reduzindo o boilerplate dos 4 `route.ts`.
- **F3 (condicional, menor prioridade que F2):** avaliar um wrapper fino de `fetch` cliente (`postCampaignJson<TResponse>`) para os 4 controles, cobrindo só o preâmbulo comum (`credentials`/headers/`cast`), não a máquina de estado.
- Guardrail comum às três fases: **zero mudança de contrato de URL/rede e zero mudança de comportamento visível** — é refactor puro, testável pelos specs/e2e já existentes (`campaignLeaderships.e2e.spec.ts`, `campaignMunicipalities.e2e.spec.ts`).
## Decisões travadas
- **Nenhuma travada ainda — este item começa pela auditoria, não pela extração.** O precedente imediato (**B33+**, `escala-dry-pos-b33.md`) mostrou a mesma armadilha duas vezes na família de hooks de lista: o plano supôs "os domínios divergem" e a auditoria achou que dois terços da divergência eram cópia acidental, não necessidade. Este plano recusa-se a pré-decidir a forma do hook antes de olhar o código (que já foi feito no "Contexto" acima, mas a decisão de **assinatura** do hook — genérico por `TValue` vs. par de callbacks `equals`/`buildRequest`/`parseResponse` — fica para a implementação, com o mesmo filtro que o B33+ aplicou ao `toHref`).
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`useCampaignListAutosave` é o nome de trabalho; pode mudar na auditoria), sem strings visíveis novas.
## Questões em aberto
- **O hook deve cobrir também o `inFlightTrendRef` do B24 (dedup de blur+close) ou deixar esse guard como responsabilidade do chamador?** **Opções:** (a) o hook expõe um `flush()` idempotente que já resolve isso internamente (um único caminho de save, chamado por blur ou por close); (b) o hook fica "burro" sobre isso e cada chamador decide se precisa do guard. **Recomendação:** (a) — o guard existe porque hoje há _dois_ caminhos de código para chegar ao mesmo save (o `onBlur` do textarea e o `handleOpenChange`); um hook com `flush()` único elimina a duplicidade na raiz em vez de generalizar o sintoma.
- **Vale a pena F2/F3 se F1 concluir "não compensa"?** **Opções:** (a) fazer F2/F3 de qualquer forma, são baratas e independentes de F1; (b) não fazer nenhuma se F1 rejeitar, porque a motivação de fundo (essa família de controles é jovem e ainda pode crescer/divergir) provavelmente também derruba F2/F3. **Recomendação:** (b) com uma ressalva — reavaliar F2/F3 isoladamente **só** se um 5º `route.ts`/controle chegar antes de alguém revisitar este plano (mesma lógica de "3º call site" aplicada à sub-dívida).
## Abordagem proposta
```mermaid
flowchart LR
Hook["useCampaignListAutosave&lt;TValue&gt;<br/>(shared/, se F1 = extrair)"] --> EV["MunicipalityListExpectedVotesControl"]
Hook --> TC["MunicipalityListTrendControl"]
Hook --> SS["LeadershipListSupportStatusControl"]
Guard["guardCampaignJsonMutationRequest<br/>(campaignJsonMutationRoute.ts, se F2 = extrair)"] --> R1["liderancas/support-status/route.ts"]
Guard --> R2["municipios/political-trend/route.ts"]
Guard --> R3["municipios/expected-votes/route.ts"]
Guard --> R4["municipios/advisors/route.ts"]
```
Componentes (condicionais à decisão de cada fase):
- **F1 — auditoria primeiro, código depois.** Antes de escrever o hook, mapear célula a célula (como o "Contexto" já fez) quais das ~12 peças de estado/ref são **idênticas**, quais são **paramétricas** (mudam só de tipo, não de lógica) e quais são **genuinamente divergentes** (o `inFlightTrendRef`, o par display/draft). Só extrair as duas primeiras categorias; a terceira fica exposta como parâmetro do hook (`equals(a, b)`, `buildRequestBody(value)`, `parseResponse(json)`) ou como responsabilidade do chamador, seguindo a "Questão em aberto" acima. **Depth check:** o hook não deve importar nada de `@/app/(campaign)/campanha/(app)/**/types` (os três tipos de resposta são por domínio) — ele recebe `parseResponse` como callback, mesmo padrão de `useCampaignListFilterNavigation` (recebe `toHref`, nunca importa os módulos de URL por domínio).
- **F2 — `guardCampaignJsonMutationRequest(request, schema)`** (se aprovado): retorna `{ ok: true, body } | { ok: false, response }`, combinando `isSameOriginRequest` + `parseCampaignJsonRequestBody` + `bodySchema.parse` num só call site por rota. Vive ao lado de `campaignJsonMutationErrorResponse` em `src/utilities/campaignJsonMutationRoute.ts`.
- **F3 — `postCampaignJson<TResponse>(url, body, signal)`** (se aprovado): `fetch` com o preâmbulo comum + `cast`, sem decidir revert/retry (isso fica no hook do F1 ou no chamador).
- **Migration**: sem migration, sem collection, sem server action — puro refactor client + route handler.
## Dependências
- Nenhuma de outro plano aberto. Depende só do **B32** já entregue (é o item que fecha o gatilho de 3 call sites).
## Não escopo
- **`MunicipalityListAdvisorsControl.tsx`** — mecânica de delta multi-seleção com `requestSeqRef`/`latestConfirmedRef`, genuinamente diferente do debounce single-value; participa só de F2/F3 (preâmbulo de rota/fetch), nunca de F1.
- **Migrar `SupporterFilters`/`ActivityFilters` ou qualquer filtro de lista** — família de hook diferente (`useCampaignListFilterNavigation`, débito do **B33+**), não deste plano.
- **Mudar o comportamento de qualquer um dos três controles** (timings de debounce, mensagens, contrato de rede) — puro refactor interno.
## Rabbit holes
- **"Já que estou generalizando, faço o hook aceitar `TValue` totalmente genérico com `deepEqual` embutido."** Um `deepEqual` genérico reabre a discussão que `voteEstimatesEqual`/`trendsEqual`/`===` já resolveram por domínio (cada um sabe o que "igual" significa para o seu shape — ex. `trendsEqual` normaliza a nota antes de comparar). **Mitigação neste item:** `equals` continua um callback do chamador, nunca uma função genérica no hook.
- **"Enquanto mexo nisso, unifico também o botão/Popover/Field ao redor."** O JSX diverge de propósito (número vs. select vs. select+textarea vs. badge de trigger) — um shell visual genérico é o mesmo anti-padrão que `escala-dry-pos-b33.md` já rejeitou para o `<form>` dos filtros. **Mitigação:** só a máquina de estado/efeitos sai do componente; o JSX fica onde está.
## Adiado com gatilho
- **F2/F3 se F1 rejeitar a extração do hook principal.** Ver "Questões em aberto" — reavaliar isoladamente só com um 5º call site (rota ou controle).
- **`MunicipalityListAdvisorsControl` adotar o preâmbulo de fetch do F3** (se F3 for feito) — hoje ele tem uma política de erro diferente (`pendingCountRef`/sequência, sem revert único), então adotar herdaria só o `fetch`+`cast`, não a lógica de estado; barato, mas não é o call site que motivou o item. Gatilho: F3 concluído e aprovado.
## Referências
- `docs/roadmap.md` (Fill-ins abertos — **B32+**)
- [autosave-status-lista-liderancas.md](autosave-status-lista-liderancas.md) — o pai do lote (B32 ✓), onde o gatilho "3º call site" foi fechado
- [autosave-tendencia-lista-municipios.md](autosave-tendencia-lista-municipios.md) (B24) e [combobox-assessores-lista-municipios.md](combobox-assessores-lista-municipios.md) (B27) — os outros dois planos que registraram o mesmo "Adiado com gatilho"
- [escala-dry-pos-b33.md](escala-dry-pos-b33.md) — precedente direto de forma: hook via callbacks (não módulos de domínio), auditoria derrubando a premissa do plano antes de escrever código
- `src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx`, `MunicipalityListTrendControl.tsx`, `src/components/campaign/leadership/LeadershipListSupportStatusControl.tsx` — os três call sites de F1
- `src/app/(campaign)/campanha/(app)/liderancas/support-status/route.ts`, `municipios/{political-trend,expected-votes,advisors}/route.ts` — os quatro call sites de F2
- `src/utilities/campaignJsonMutationRoute.ts`, `src/utilities/sameOriginRequest.ts` — onde F2 pousaria
- AGENTS.md — abstrações precisam de 3+ call sites (aqui há exatamente 3 para F1, 4 para F2/F3)

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