mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
e712397241
commit
211276b4e4
17 changed files with 395 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).
|
||||
|
|
|
|||
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