mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge branch 'cursor/ab035786'
This commit is contained in:
commit
22135c0a31
18 changed files with 500 additions and 48 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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).
|
||||
|
|
|
|||
104
docs/plans/escala-dry-pos-b32.md
Normal file
104
docs/plans/escala-dry-pos-b32.md
Normal 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<TValue><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
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { SupportStatus } from '@/lib/schemas/leadership'
|
||||
|
||||
export type LeadershipListSupportStatusResponse =
|
||||
| { status: 'success'; message: string; savedSupportStatus: SupportStatus }
|
||||
| { status: 'error'; message: string }
|
||||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
12
src/utilities/leadershipLabels.ts
Normal file
12
src/utilities/leadershipLabels.ts
Normal 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',
|
||||
}
|
||||
80
tests/e2e/campaignLeaderships.e2e.spec.ts
Normal file
80
tests/e2e/campaignLeaderships.e2e.spec.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue