Deliver B9 quick edit on Praças list and register simplify follow-ups.

Inline staff edits for advisors, political trend, and expected votes on /campanha/pracas; absorb post-simplify debts into A9+ and C8 plans.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-21 11:45:37 -03:00
parent 936323c7ee
commit 70c61ab49e
17 changed files with 682 additions and 67 deletions

View file

@ -102,6 +102,19 @@ Componentes:
- **Nota de tendência no popover.** Feedback de uso.
- **Bulk assign de assessor a N Praças.** Volume real na fila “Sem assessor”.
- **Atalho “ajustar lideranças” a partir da cobertura.** Se assessores pedirem sem abrir a Praça.
- **Hook/shell popover compartilhado (`usePlazaListPopoverForm`).** Gatilho: 4º inline editor na campanha.
- **`PlazaAdvisorCheckboxList` extraído de `PlazaAdvisorsForm`.** Gatilho: 3º uso ou refactor do form `/editar`.
- **Layout responsivo único (uma árvore React vs mobile+desktop duplicados).** Gatilho: page size >25 ou profiling de hydration.
- **~75 hooks `useActionState` por página.** Gatilho: reclamação de perf ou **R6**.
- **E2E save inline na lista.** Gatilho: smoke pós-merge B9.
## Simplify (2026-07-21)
Limpezas aplicadas no cleanup da sessão (não reabrir): `politicalTrend.note` via hidden field (sem `findByID` extra); `politicalTrendBadgeVariant` em `plazaUi.ts`; `PlazaAdvisorAvatarStack`; tipos `PoliticalTrendStatus`; `max={1_000_000}` no input; higiene de testes.
Débitos absorvidos noutros planos: twin `listFormActions` ↔ `/editar` → **C8 F4** ([escala-dry-pos-c6.md](escala-dry-pos-c6.md)); `revalidatePath` full-page pós-save → **A9+ F2** ([escala-dry-pos-a9.md](escala-dry-pos-a9.md)).
**Explicitamente fora (triage):** lazy-load `getEligibleAdvisorOptions`; `PopoverAnchor` não usado; helper `parsePoliticalTrendStatus`; unit tests dos `PlazaList*Control`.
## Referências

View file

@ -1,10 +1,10 @@
# Escala e DRY pós-A9 (loader da lista de Praças)
Status: registrado no roadmap (Fase 1 pendente; **B7 entregue 2026-07-21** — prioridade sobe)
Atualizado em: 2026-07-21 (`capture-review-debts` pós-B7 + `/simplify`)
Atualizado em: 2026-07-21 (`capture-review-debts` pós-B7 + pós-B9 `/simplify`)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, fill-in **A9+** pós-A9)
Impeccable: A — N/A (sem superfície UI; otimização de loader)
Appetite: ~0,5–1 dia eng (1 fase; PR único)
Appetite: ~1–1,5 dia eng (F1 loader + F2 revalidate pós-B9; PR único ou dois commits)
Responsável: —
## Contexto
@ -19,16 +19,21 @@ O revisor de **performance** marcou como **maior que simplify** o follow-up abai
2. **Dois `find` de Praças no mesmo filtro (overview + mapa).** Ambos usam `buildPlazaListWhere` com `pagination: false` e `select` distinto — duplicação de round-trip por request quando o mapa está visível (staff, conjunto não vazio).
3. **Revalidação full-page após edição inline (B9).** Cada save em `listFormActions.ts` chama `revalidatePath('/campanha/pracas', 'page')` (+ detalhe), o que reroda overview unpaginado, mapa e lista — custo amplificado quando o coordenador edita várias células seguidas (pós-B9).
**Já resolvido no simplify/critique (não reabrir):** parsing duplo do form `expectedVotes`; `hasStaffVoteData` derivado de `staffVoteTotal > 0`; `rollupPlazaStaffVotes` + `sumStaffPledgeEffectiveTotal`; `StaffPlazaVotesDisplay`; assinatura estreita de `resolvePlazaStaffVoteTotal`; testes unit do rollup. **Pós-B7 simplify:** `PlazaListSearchParams` exportado de `plazaUi.ts`; parse único no map loader (`loadScopedPlazas` recebe `PlazaListState`).
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-A9):** semântica lista (só `expectedVotes` manual) vs mapa/overview (fallback) — decisão de produto A9; merge de forms Estratégia + Votos no `/editar`; grid cosmético do `PlazaStrategyCard`; `loadPlazaPledges` em abas distintas do detalhe (uma aba por request); débitos adiados/não escopo do plano A9 (nota/autor, chip auto-sugerir, histórico, filtro `?expectedVotes=`); critique Impeccable formal por superfície → **R6**.
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B7):** `Promise.all` nos loads TSE por ano em `plazaMapData` (micro-opt pré-existente; gatilho abaixo); testes int extras em `plazaMapData` (`region`/`leader`/`compare`) — cobertura opcional no próximo toque no loader; split do teste advisor por `kind` (legibilidade).
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B9):** ~75 hooks `useActionState` por página (3 controles × 25 linhas × 2 views) — gatilho: reclamação de perf ou **R6**; merge `listFormActions` ↔ `/editar` → **C8 F4**; hook popover compartilhado / layout responsivo único / lazy advisor options → gatilhos no plano B9.
## Objetivos
- Abrir `/campanha/pracas` com filtro amplo não executa **três** `aggregatePledgesByPlaza` independentes quando um mapa de agregados compartilhado basta.
- Overview, mapa 2026 e coluna da página reutilizam o mesmo `Map<plazaId, PlazaPledgeAggregate>` (e, onde os filtros coincidirem, o mesmo `find` de Praças).
- Saves inline da lista (B9) não disparam rerender full-page desnecessário quando um refresh parcial (segmento de lista, tag de cache, ou `revalidatePath` estreito) preserva paridade de KPIs/mapa.
- Paridade funcional: KPIs, métrica 2026 e sublinha “Nas lideranças” permanecem idênticos ao comportamento pós-A9.
- Guardrails: sem migration, sem collection, sem Consent, sem server action nova; access inalterado (`overrideAccess: false` nos reads de Praça; agregado de pledges continua admin-bypass intencional com ids já access-checked).
@ -50,11 +55,14 @@ O revisor de **performance** marcou como **maior que simplify** o follow-up abai
```mermaid
flowchart TD
A9["A9 expectedVotes ✓"] --> F1
B9["B9 edição inline lista"] --> F2
F1["Fase 1 — loadPlazaListPageBundle<br/>1× aggregatePledgesByPlaza"]
F2["Fase 2 — revalidate escopado<br/>pós-save inline"]
F1 --> List["PlazaList página"]
F1 --> Overview["PlazaListOverview KPIs"]
F1 --> Map["PlazaMapBundle 2026"]
B7["B7 mapa filtrado ✓"] -.mesmo bundle.-> F1
F2 -.menos rerender.-> List
```
### Fase 1 — Loader compartilhado da lista de Praças
@ -67,19 +75,27 @@ flowchart TD
- Atualizar `src/app/(campaign)/campanha/(app)/pracas/page.tsx` para consumir o bundle.
- Testes: int ou unit do bundle — paridade de `staffVoteTotal` e métrica 2026 antes/depois; advisor scope inalterado.
### Fase 2 — Revalidação escopada pós-B9
- Avaliar `revalidatePath` estreito (só segmento necessário) ou tag de cache dedicada à lista vs full-page `revalidatePath('/campanha/pracas', 'page')` em `listFormActions.ts`.
- Critério de aceite: após N saves inline seguidos, overview/mapa/lista permanecem corretos sem rerodar loaders caros quando o dado alterado não afeta KPI agregado (ex.: tendência não muda métrica 2026 — validar caso a caso).
- Coordenar com **C8 F4** se extrair `revalidatePlazaListPaths` compartilhado entre lista e `/editar`.
- Testes: int leve ou e2e smoke de save inline + paridade visual de KPIs (manual ok se appetite apertar).
**Migration:** nenhuma.
## Dependências
- **Dura:** A9 [estimativa-votos-praca.md](estimativa-votos-praca.md) — campo, agregadores e superfícies (merge em `main`).
- **Suave:** B7 [mapa-pracas-filtrado.md](mapa-pracas-filtrado.md) — entregue 2026-07-21; mesmo URL de filtro; F1 deduplica pledges e pode compartilhar parse/`find` onde os escopos coincidem.
- **Suave:** B9 [edicao-rapida-lista-pracas.md](edicao-rapida-lista-pracas.md) — F2 só faz sentido com saves inline na lista; não reabre escopo de UI B9.
- Reusa: `aggregatePledgesByPlaza`, `rollupPlazaStaffVotes`, `resolvePlazaStaffVoteTotal`, precedente E6 F1 em [escala-dry-pos-e1.md](escala-dry-pos-e1.md).
## Não escopo
- SQL aggregate de pledges (`COUNT(*) FILTER`) — volume ainda baixo; reavaliar no 3º hot path ou com evidência de latency.
- Unificar `loadPlazaPledges` entre abas Overview/Lideranças no detalhe — defer (uma aba por request).
- Edição inline na lista → **B9** ([edicao-rapida-lista-pracas.md](edicao-rapida-lista-pracas.md)).
- Implementar os controles inline da lista → **B9** (entregue em código; polish Impeccable → **R6**).
- Merge de forms no `/editar` — fora do triage.
- Impeccable critique das superfícies A9 → **R6**.
@ -100,6 +116,7 @@ flowchart TD
- `docs/plans/estimativa-votos-praca.md` — plano pai A9
- `docs/plans/escala-dry-pos-e1.md` — precedente E6 F1 (lista+overview núcleos)
- `src/app/(campaign)/campanha/(app)/pracas/page.tsx` — `Promise.all` dos três loaders
- `src/app/(campaign)/campanha/(app)/pracas/listFormActions.ts` — `revalidatePlazaListPaths` pós-B9
- `src/utilities/plazaPageData.ts` — `loadPlazaListPageData`, `loadPlazaListOverviewData`
- `src/utilities/plazaMapData.ts` — `loadPlazaMapBundle`, métrica 2026
- `src/utilities/votePledgeData.ts` — `aggregatePledgesByPlaza`, `rollupPlazaStaffVotes`

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-C6 (apoiadores / import / listas)
Status: implementado (pendente merge em `main`)
Atualizado em: 2026-07-19
Atualizado em: 2026-07-21 (`capture-review-debts` pós-B9 `/simplify`)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C8)
Responsável: —
@ -77,16 +77,15 @@ flowchart TD
### Fase 4 — DRY de forms
- Migrar os `formActions.ts` restantes para `mapCampaignFormActionError` + `campaignFormFields` (hoje o ladder `FormDataBoundaryError` → `ZodError` → `validationFieldErrors` → safe-message está inlined em ~6 arquivos):
- `src/app/(campaign)/campanha/(app)/nucleos/formActions.ts`
- `src/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions.ts`
- `src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions.ts`
- `src/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions.ts`
- `src/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions.ts`
- Migrar os `formActions.ts` restantes para `mapCampaignFormActionError` + `campaignFormFields` (hoje o ladder `FormDataBoundaryError` → `ZodError` → `validationFieldErrors` → safe-message está inlined em vários arquivos):
- `src/app/(campaign)/campanha/(app)/pracas/listFormActions.ts` + `pracas/[slug]/editar/formActions.ts` — **twin paths pós-B9** (mesmo parsing `formData`, mesmas actions de domínio, `revalidatePlazaListPaths` duplicado vs `revalidatePath` por action no `/editar`; extrair helper compartilhado de revalidate + wrappers finos por superfície)
- `src/app/(campaign)/campanha/(app)/pracas/[slug]/updateFormActions.ts`, `pledgeFormActions.ts` (se ainda não no mapper)
- `src/app/(campaign)/campanha/convite/[token]/formActions.ts`
- `src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts`
- `src/app/(campaign)/campanha/(app)/planos/formActions.ts` e `planos/[slug]/updateFormActions.ts`
- Migrar `fieldError`/`firstError` local em `src/components/campaign/NucleusUpdateForm.tsx` e `CampaignInviteForm.tsx` para importar de `campaignFormFields.ts`.
- `src/app/(campaign)/campanha/(app)/planos/formActions.ts` e `planos/[slug]/updateFormActions.ts`, `resultFormActions.ts`, `lifecycleFormActions.ts`
- Demais `formActions` de `/campanha` (lideranças, demandas, organizações, apoiadores/novo) conforme grep no PR
- Migrar `fieldError`/`firstError` local em forms legados (ex.: `CampaignInviteForm.tsx`) para importar de `campaignFormFields.ts`.
- **Nota pós-remodel:** paths `nucleos/*` foram superseded; não reabrir.
**Migration:** Fases 1, 3, 4 sem schema. Fase 2: `pnpm migrate:create add_contact_trgm_index` (extensão + 2 GIN indexes). Sem Consent novo.
@ -132,3 +131,9 @@ Os débitos que os revisores marcaram como importantes e maiores que cleanup for
5. **Componentes de form com `fieldError` local** — `NucleusForm`, `ActionPlanForm`, `NucleusTerritoryFields`, `VoteEstimateDialog`, etc.
**Explicitamente fora (tradeoffs aceitos no C8):** waterfall lista→overview (precisa de `totalDocs`); unificar lista+aggregate numa query; widen de `PostgresTransactionDatabase`.
## Simplify (2026-07-21 pós-B9)
`/simplify` da entrega **B9** ([edicao-rapida-lista-pracas.md](edicao-rapida-lista-pracas.md)) marcou como **maior que cleanup** o twin `listFormActions.ts` ↔ `editar/formActions.ts` — absorvido na **Fase 4** acima. B9 já adota `mapCampaignFormActionError`; o débito é DRY de parsing/revalidate entre lista e `/editar`, não introdução do mapper.
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B9):** hook `usePlazaListPopoverForm` (gatilho: 4º inline editor); `PlazaAdvisorCheckboxList` (gatilho: 3º uso ou refactor do form `/editar`); layout responsivo único mobile+desktop na lista (gatilho: page size >25 ou profiling de hydration); lazy-load de `getEligibleAdvisorOptions` (staff set pequeno); `PopoverAnchor` export não usado (scaffold shadcn); helper `parsePoliticalTrendStatus`; unit tests dos `PlazaList*Control` (int access cobre domínio); E2E save inline (gatilho: smoke pós-merge B9).

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-21 (B7 entregue; A9+ loader lista; B9 desbloqueado; Pixel Meta)
Atualizado em: 2026-07-21 (B7 entregue; A9+ loader + revalidate pós-B9; débitos B9 `/simplify` → C8 F4 + A9+; Pixel Meta)
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
@ -134,7 +134,7 @@ Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2):
- **C5** operação dia D / GOTV _(validar com produto)_ · design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) · depende de C2 dados reais
- **D2** push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md)
- **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · gatilho: antes de 16/08 · (opcional: mover filtros acima do mapa)
- **Fill-ins:** filtros auto na lista de Praças (debounce 1s no `q`, selects já imediatos; remove Buscar) · [plano](plans/filtros-auto-pracas.md) · **A9+** loader compartilhado lista/mapa ([plano](plans/escala-dry-pos-a9.md)) · O0+ ([plano](plans/escala-dry-pos-onda0.md)) · RS+ auth read leve + shells de senha ([plano](plans/escala-dry-pos-reset-senha-perfil.md)) · higiene PascalCase
- **Fill-ins:** filtros auto na lista de Praças (debounce 1s no `q`, selects já imediatos; remove Buscar) · [plano](plans/filtros-auto-pracas.md) · **A9+** loader compartilhado lista/mapa + revalidate escopada pós-B9 ([plano](plans/escala-dry-pos-a9.md)) · **C8** F4 DRY `listFormActions`↔`/editar` ([plano](plans/escala-dry-pos-c6.md)) · O0+ ([plano](plans/escala-dry-pos-onda0.md)) · RS+ auth read leve + shells de senha ([plano](plans/escala-dry-pos-reset-senha-perfil.md)) · higiene PascalCase
## Bloqueadores atuais

View file

@ -0,0 +1,100 @@
'use server'
import { revalidatePath } from 'next/cache'
import {
assignPlazaAdvisors,
setPlazaExpectedVotes,
setPlazaPoliticalTrend,
} from '@/app/(campaign)/campanha/actions/plaza'
import {
nullableFormText,
optionalFormText,
optionalIntegerFormValue,
repeatedRelationshipFormValues,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { politicalTrendStatuses, type PoliticalTrendStatusValue } from '@/lib/schemas/plaza'
import {
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
const revalidatePlazaListPaths = () => {
revalidatePath('/campanha/pracas', 'page')
revalidatePath('/campanha/pracas/[slug]', 'page')
}
export const setPlazaExpectedVotesListFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const plaza = requiredRelationshipFormValue(formData, 'plazaId')
const expectedVotes =
optionalIntegerFormValue(formData, 'expectedVotes', {
minimum: 0,
maximum: 1_000_000,
}) ?? null
await setPlazaExpectedVotes({ plaza, expectedVotes })
revalidatePlazaListPaths()
return { status: 'success', message: 'Votos estimados atualizados.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria podem editar a Praça.'],
genericMessage:
'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.',
})
}
}
export const setPlazaPoliticalTrendListFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const plaza = requiredRelationshipFormValue(formData, 'plazaId')
const rawStatus = optionalFormText(formData, 'trendStatus')
const status = politicalTrendStatuses.includes(rawStatus as PoliticalTrendStatusValue)
? (rawStatus as PoliticalTrendStatusValue)
: null
await setPlazaPoliticalTrend({
plaza,
status,
note: nullableFormText(formData, 'trendNote'),
})
revalidatePlazaListPaths()
return { status: 'success', message: 'Tendência política registrada.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria podem editar a Praça.'],
genericMessage:
'Não foi possível registrar a tendência. Verifique seu acesso e tente novamente.',
})
}
}
export const assignPlazaAdvisorsListFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const plaza = requiredRelationshipFormValue(formData, 'plazaId')
const advisors = repeatedRelationshipFormValues(formData, 'advisors')
await assignPlazaAdvisors({ plaza, advisors })
revalidatePlazaListPaths()
return { status: 'success', message: 'Assessores atualizados.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente o Coordenador Geral designa assessores.'],
genericMessage:
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -21,7 +21,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { isCampaignCoordinator, isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
import { loadPlazaMapBundle } from '@/utilities/plazaMapData'
@ -34,7 +34,12 @@ import {
getCampaignScopeLabel,
resolvePlazaListUrl,
} from '@/utilities/plazaUi'
import { loadAdvisorSummaries } from '@/utilities/plazaViewModels'
import { loadAdvisorSummaries, getEligibleAdvisorOptions } from '@/utilities/plazaViewModels'
import {
assignPlazaAdvisorsListFormAction,
setPlazaExpectedVotesListFormAction,
setPlazaPoliticalTrendListFormAction,
} from './listFormActions'
type PlazasPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
@ -49,6 +54,7 @@ export default async function PlazasPage({ searchParams }: PlazasPageProps) {
if (!user) return null
const isStaffView = isCampaignStaff(user)
const isCoordinator = isCampaignCoordinator(user)
const [listData, overview, mapBundle] = await Promise.all([
loadPlazaListPageData(payload, user, rawSearchParams),
@ -64,6 +70,8 @@ export default async function PlazasPage({ searchParams }: PlazasPageProps) {
const advisorIDs = [...new Set(listData.plazas.flatMap((plaza) => plaza.advisorIDs))]
const advisorSummaries = isStaffView ? await loadAdvisorSummaries(payload, user, advisorIDs) : []
const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor]))
const advisorOptions =
isCoordinator ? await getEligibleAdvisorOptions(payload, user) : []
return (
<CampaignPageShell>
@ -95,6 +103,11 @@ export default async function PlazasPage({ searchParams }: PlazasPageProps) {
plazas={listData.plazas}
advisorNamesById={advisorNamesById}
isStaffView={isStaffView}
isCoordinator={isCoordinator}
advisorOptions={advisorOptions}
expectedVotesFormAction={setPlazaExpectedVotesListFormAction}
trendFormAction={setPlazaPoliticalTrendListFormAction}
advisorsFormAction={assignPlazaAdvisorsListFormAction}
/>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">

View file

@ -0,0 +1,37 @@
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
export const campaignUserInitials = (name: string): string =>
name
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
export type PlazaAdvisorAvatarEntry = {
id: number
name: string
}
export const PlazaAdvisorAvatarStack = ({
advisors,
maxVisible = 3,
}: {
advisors: PlazaAdvisorAvatarEntry[]
maxVisible?: number
}) => {
if (!advisors.length) return <span className="text-muted-foreground">Sem assessor</span>
return (
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
{advisors.slice(0, maxVisible).map((advisor) => (
<Avatar key={advisor.id} className="size-8 border-2 border-background">
<AvatarFallback>{campaignUserInitials(advisor.name)}</AvatarFallback>
</Avatar>
))}
</div>
<span className="sr-only">{advisors.map((advisor) => advisor.name).join(', ')}</span>
</div>
)
}

View file

@ -1,10 +1,12 @@
import { CircleAlertIcon, CircleCheckIcon } from 'lucide-react'
import Link from 'next/link'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { PlazaListAdvisorsControl } from '@/components/campaign/PlazaListAdvisorsControl'
import { PlazaAdvisorAvatarStack } from '@/components/campaign/PlazaAdvisorAvatarStack'
import { PlazaListExpectedVotesControl } from '@/components/campaign/PlazaListExpectedVotesControl'
import { PlazaListTrendControl } from '@/components/campaign/PlazaListTrendControl'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { StaffPlazaVotesDisplay } from '@/components/campaign/StaffPlazaVotesDisplay'
import {
Table,
TableBody,
@ -13,53 +15,55 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/Table'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
formatPlazaGeographyLabel,
plazaKindLabels,
plazaPriorityLabels,
} from '@/utilities/plazaUi'
import type { PlazaAdvisorSummary, PlazaListViewModel } from '@/utilities/plazaViewModels'
import type {
EligibleAdvisorOption,
PlazaAdvisorSummary,
PlazaListViewModel,
} from '@/utilities/plazaViewModels'
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
const getInitials = (name: string): string =>
name
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
const AdvisorAvatars = ({ names }: { names: string[] }) => {
if (!names.length) return <span className="text-muted-foreground">Sem assessor</span>
return (
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
{names.slice(0, 3).map((name) => (
<Avatar key={name} className="size-8 border-2 border-background">
<AvatarFallback>{getInitials(name)}</AvatarFallback>
</Avatar>
))}
</div>
<span className="sr-only">{names.join(', ')}</span>
</div>
)
}
type ListFormAction = (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
export type PlazaListProps = {
plazas: PlazaListViewModel[]
advisorNamesById: ReadonlyMap<number, PlazaAdvisorSummary>
isStaffView: boolean
isCoordinator: boolean
advisorOptions: EligibleAdvisorOption[]
expectedVotesFormAction: ListFormAction
trendFormAction: ListFormAction
advisorsFormAction: ListFormAction
}
export const PlazaList = ({ plazas, advisorNamesById, isStaffView }: PlazaListProps) => {
const advisorNames = (plaza: PlazaListViewModel): string[] =>
export const PlazaList = ({
plazas,
advisorNamesById,
isStaffView,
isCoordinator,
advisorOptions,
expectedVotesFormAction,
trendFormAction,
advisorsFormAction,
}: PlazaListProps) => {
const advisorEntries = (plaza: PlazaListViewModel) =>
plaza.advisorIDs.flatMap((id) => {
const advisor = advisorNamesById.get(id)
return advisor ? [advisor.name] : []
return advisor ? [{ id: advisor.id, name: advisor.name }] : []
})
const advisorNames = (plaza: PlazaListViewModel): string[] =>
advisorEntries(plaza).map((advisor) => advisor.name)
return (
<>
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
@ -83,16 +87,42 @@ export const PlazaList = ({ plazas, advisorNamesById, isStaffView }: PlazaListPr
<div>
<dt className="text-muted-foreground">Votos estimados</dt>
<dd>
<StaffPlazaVotesDisplay
<PlazaListExpectedVotesControl
plazaID={plaza.id}
expectedVotes={plaza.expectedVotes}
leadershipEffectiveTotal={plaza.pledges.effectiveTotal}
valueClassName="font-medium tabular-nums"
formAction={expectedVotesFormAction}
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Tendência</dt>
<dd>
<PlazaListTrendControl
plazaID={plaza.id}
status={plaza.politicalTrendStatus}
trendNote={plaza.politicalTrendNote}
formAction={trendFormAction}
/>
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Assessoria</dt>
<dd>{names.length ? names.join(', ') : 'Sem assessor'}</dd>
<dd>
{isCoordinator ? (
<PlazaListAdvisorsControl
plazaID={plaza.id}
currentAdvisorIDs={plaza.advisorIDs}
advisorNamesById={advisorNamesById}
options={advisorOptions}
formAction={advisorsFormAction}
/>
) : names.length ? (
names.join(', ')
) : (
'Sem assessor'
)}
</dd>
</div>
</dl>
) : null}
@ -114,6 +144,7 @@ export const PlazaList = ({ plazas, advisorNamesById, isStaffView }: PlazaListPr
{isStaffView ? (
<>
<TableHead>Assessores</TableHead>
<TableHead>Tendência</TableHead>
<TableHead>Votos estimados</TableHead>
<TableHead>Última atualização</TableHead>
<TableHead>Cobertura</TableHead>
@ -150,13 +181,32 @@ export const PlazaList = ({ plazas, advisorNamesById, isStaffView }: PlazaListPr
{isStaffView ? (
<>
<TableCell>
<AdvisorAvatars names={names} />
{isCoordinator ? (
<PlazaListAdvisorsControl
plazaID={plaza.id}
currentAdvisorIDs={plaza.advisorIDs}
advisorNamesById={advisorNamesById}
options={advisorOptions}
formAction={advisorsFormAction}
/>
) : (
<PlazaAdvisorAvatarStack advisors={advisorEntries(plaza)} />
)}
</TableCell>
<TableCell>
<StaffPlazaVotesDisplay
<PlazaListTrendControl
plazaID={plaza.id}
status={plaza.politicalTrendStatus}
trendNote={plaza.politicalTrendNote}
formAction={trendFormAction}
/>
</TableCell>
<TableCell>
<PlazaListExpectedVotesControl
plazaID={plaza.id}
expectedVotes={plaza.expectedVotes}
leadershipEffectiveTotal={plaza.pledges.effectiveTotal}
valueClassName="font-medium tabular-nums"
formAction={expectedVotesFormAction}
/>
</TableCell>
<TableCell>

View file

@ -0,0 +1,104 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import { PlazaAdvisorAvatarStack } from '@/components/campaign/PlazaAdvisorAvatarStack'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import type { EligibleAdvisorOption, PlazaAdvisorSummary } from '@/utilities/plazaViewModels'
type PlazaListAdvisorsControlProps = {
plazaID: number
currentAdvisorIDs: number[]
advisorNamesById: ReadonlyMap<number, PlazaAdvisorSummary>
options: EligibleAdvisorOption[]
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}
export const PlazaListAdvisorsControl = ({
plazaID,
currentAdvisorIDs,
advisorNamesById,
options,
formAction,
}: PlazaListAdvisorsControlProps) => {
const [open, setOpen] = useState(false)
const [state, submitAction, isPending] = useActionState(formAction, {})
const currentSet = new Set(currentAdvisorIDs)
const names = currentAdvisorIDs.flatMap((id) => {
const advisor = advisorNamesById.get(id)
return advisor ? [{ id: advisor.id, name: advisor.name }] : []
})
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
setOpen(false)
}, [state.message, state.status])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Editar assessores"
>
{names.length ? (
<PlazaAdvisorAvatarStack advisors={names} />
) : (
<span className="text-muted-foreground">Sem assessor</span>
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80">
<form action={submitAction} className="flex flex-col gap-3">
<input type="hidden" name="plazaId" value={plazaID} />
<p className="text-sm text-muted-foreground">
O assessor vê e gerencia somente as Praças que administra.
</p>
<ul className="flex max-h-60 flex-col gap-1 overflow-y-auto">
{options.map((option) => (
<li key={option.id}>
<Label
htmlFor={`plaza-list-advisor-${plazaID}-${option.id}`}
className="flex min-h-11 items-center gap-3 rounded-md px-2 hover:bg-accent"
>
<Checkbox
id={`plaza-list-advisor-${plazaID}-${option.id}`}
name="advisors"
value={String(option.id)}
defaultChecked={currentSet.has(option.id)}
/>
<span>
{option.name}
{option.isCurrent ? ' (você)' : ''}
</span>
</Label>
</li>
))}
</ul>
{state.message && state.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<Button type="submit" disabled={isPending} className="min-h-11 w-full">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar assessores
</Button>
</form>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,90 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import { StaffPlazaVotesDisplay } from '@/components/campaign/StaffPlazaVotesDisplay'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
type PlazaListExpectedVotesControlProps = {
plazaID: number
expectedVotes: number | null
leadershipEffectiveTotal: number
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}
export const PlazaListExpectedVotesControl = ({
plazaID,
expectedVotes,
leadershipEffectiveTotal,
formAction,
}: PlazaListExpectedVotesControlProps) => {
const [open, setOpen] = useState(false)
const [state, submitAction, isPending] = useActionState(formAction, {})
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
setOpen(false)
}, [state.message, state.status])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="min-h-11 rounded-md px-1 text-left hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Editar votos estimados"
>
<StaffPlazaVotesDisplay
expectedVotes={expectedVotes}
leadershipEffectiveTotal={leadershipEffectiveTotal}
valueClassName="font-medium tabular-nums"
/>
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72">
<form action={submitAction} className="flex flex-col gap-3">
<input type="hidden" name="plazaId" value={plazaID} />
<Field>
<FieldLabel htmlFor={`plaza-list-expected-votes-${plazaID}`}>Total da Praça</FieldLabel>
<Input
id={`plaza-list-expected-votes-${plazaID}`}
name="expectedVotes"
type="number"
min={0}
max={1_000_000}
inputMode="numeric"
defaultValue={expectedVotes ?? undefined}
className="min-h-11"
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
event.currentTarget.form?.requestSubmit()
}
}}
/>
</Field>
{state.message && state.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<Button type="submit" disabled={isPending} className="min-h-11 w-full">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar
</Button>
</form>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,92 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
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 type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { politicalTrendBadgeVariant, politicalTrendLabels } from '@/utilities/plazaUi'
import type { PlazaPoliticalTrendViewModel } from '@/utilities/plazaViewModels'
type PlazaListTrendControlProps = {
plazaID: number
status: PlazaPoliticalTrendViewModel['status']
trendNote: string | null
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}
export const PlazaListTrendControl = ({
plazaID,
status,
trendNote,
formAction,
}: PlazaListTrendControlProps) => {
const [open, setOpen] = useState(false)
const [state, submitAction, isPending] = useActionState(formAction, {})
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
setOpen(false)
}, [state.message, state.status])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Editar tendência política"
>
{status ? (
<Badge variant={politicalTrendBadgeVariant[status]}>{politicalTrendLabels[status]}</Badge>
) : (
<Badge variant="outline">Não registrada</Badge>
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72">
<form action={submitAction} className="flex flex-col gap-3">
<input type="hidden" name="plazaId" value={plazaID} />
<input type="hidden" name="trendNote" value={trendNote ?? ''} />
<Field>
<FieldLabel htmlFor={`plaza-list-trend-${plazaID}`}>Tendência</FieldLabel>
<NativeSelect
id={`plaza-list-trend-${plazaID}`}
name="trendStatus"
defaultValue={status ?? ''}
className="min-h-11 w-full"
>
<NativeSelectOption value="">Não registrada</NativeSelectOption>
{(Object.keys(politicalTrendLabels) as Array<keyof typeof politicalTrendLabels>).map(
(trendStatus) => (
<NativeSelectOption key={trendStatus} value={trendStatus}>
{politicalTrendLabels[trendStatus]}
</NativeSelectOption>
),
)}
</NativeSelect>
</Field>
{state.message && state.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<Button type="submit" disabled={isPending} className="min-h-11 w-full">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar
</Button>
</form>
</PopoverContent>
</Popover>
)
}

View file

@ -4,18 +4,12 @@ import Link from 'next/link'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { StaffPlazaVotesDisplay } from '@/components/campaign/StaffPlazaVotesDisplay'
import { plazaPriorityLabels, politicalTrendLabels } from '@/utilities/plazaUi'
import { plazaPriorityLabels, politicalTrendBadgeVariant, politicalTrendLabels } from '@/utilities/plazaUi'
import type { PlazaDetailViewModel } from '@/utilities/plazaViewModels'
const voteFormatter = new Intl.NumberFormat('pt-BR')
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
const trendVariant = {
favoravel: 'estimate-confirmed',
neutra: 'secondary',
desfavoravel: 'destructive',
} as const
export const PlazaStrategyCard = ({
strategy,
plazaSlug,
@ -47,7 +41,7 @@ export const PlazaStrategyCard = ({
<Badge variant="secondary">Prioridade normal</Badge>
)}
{trend.status ? (
<Badge variant={trendVariant[trend.status]}>
<Badge variant={politicalTrendBadgeVariant[trend.status]}>
Tendência {politicalTrendLabels[trend.status].toLowerCase()}
</Badge>
) : (

View file

@ -0,0 +1,40 @@
'use client'
import * as React from 'react'
import { Popover as PopoverPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
const Popover = ({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) => (
<PopoverPrimitive.Root data-slot="popover" {...props} />
)
const PopoverTrigger = ({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) => (
<PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
)
const PopoverAnchor = ({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) => (
<PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
)
const PopoverContent = ({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg bg-popover p-4 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }

View file

@ -32,6 +32,12 @@ export const politicalTrendLabels: Record<PoliticalTrendStatus, string> = {
desfavoravel: 'Desfavorável',
}
export const politicalTrendBadgeVariant = {
favoravel: 'estimate-confirmed',
neutra: 'secondary',
desfavoravel: 'destructive',
} as const
export type PlazaListState = {
page: number
q?: string

View file

@ -1,6 +1,7 @@
import type { Payload } from 'payload'
import type { CampaignUser, Plaza } from '@/payload-types'
import type { PoliticalTrendStatus } from '@/utilities/plazaUi'
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
import { toVoteGoalsViewModel, type VoteGoalsViewModel } from '@/utilities/voteGoals'
@ -19,8 +20,19 @@ export const plazaListSelect = {
priority: true,
lastUpdateAt: true,
expectedVotes: true,
politicalTrend: {
status: true,
note: true,
},
} as const
export type PlazaPoliticalTrendViewModel = {
status: PoliticalTrendStatus | null
note: string | null
recordedByName: string | null
recordedAt: string | null
}
export type PlazaListViewModel = {
id: number
name: string
@ -34,6 +46,8 @@ export type PlazaListViewModel = {
priority: 'alta' | 'normal'
lastUpdateAt: string | null
expectedVotes: number | null
politicalTrendStatus: PoliticalTrendStatus | null
politicalTrendNote: string | null
pledges: PlazaPledgeAggregate
}
@ -53,16 +67,11 @@ export const toPlazaListViewModel = (
priority: plaza.priority === 'alta' ? 'alta' : 'normal',
lastUpdateAt: plaza.lastUpdateAt ?? null,
expectedVotes: plaza.expectedVotes ?? null,
politicalTrendStatus: plaza.politicalTrend?.status ?? null,
politicalTrendNote: plaza.politicalTrend?.note ?? null,
pledges: pledges ?? { ...emptyPlazaPledgeAggregate },
})
export type PlazaPoliticalTrendViewModel = {
status: 'favoravel' | 'neutra' | 'desfavoravel' | null
note: string | null
recordedByName: string | null
recordedAt: string | null
}
export type PlazaAdvisorSummary = {
id: number
name: string

View file

@ -280,4 +280,26 @@ describe('plaza catalog seed and per-role access', () => {
expect(cleared.expectedVotes).toBeNull()
fixtures.touchPlaza(administered.id)
})
it('lets staff set and clear political trend status', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const plaza = await fixtures.getPlaza()
const updated = await setPlazaPoliticalTrendRecord(payload, coordinator, {
plaza: plaza.id,
status: 'favoravel',
note: null,
})
expect(updated.politicalTrend?.status).toBe('favoravel')
fixtures.touchPlaza(plaza.id)
const cleared = await setPlazaPoliticalTrendRecord(payload, coordinator, {
plaza: plaza.id,
status: null,
note: null,
})
expect(cleared.politicalTrend?.status).toBeNull()
fixtures.touchPlaza(plaza.id)
})
})

View file

@ -12,8 +12,22 @@ import { Progress } from '@/components/ui/Progress'
import { Toggle } from '@/components/ui/Toggle'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import type { PlazaAdvisorSummary, PlazaListViewModel } from '@/utilities/plazaViewModels'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { emptyPlazaPledgeAggregate } from '@/utilities/votePledgeData'
const noopListFormAction = async (
_state: CampaignFormActionState,
_formData: FormData,
): Promise<CampaignFormActionState> => ({})
const plazaListDefaultProps = {
isCoordinator: false,
advisorOptions: [],
expectedVotesFormAction: noopListFormAction,
trendFormAction: noopListFormAction,
advisorsFormAction: noopListFormAction,
}
describe('campaign visual foundation', () => {
it('exposes the current progress value to assistive technology', () => {
const html = renderToStaticMarkup(createElement(Progress, { value: 42 }))
@ -120,6 +134,8 @@ describe('campaign visual foundation', () => {
priority: 'alta',
lastUpdateAt: null,
expectedVotes: 1500,
politicalTrendStatus: 'favoravel',
politicalTrendNote: null,
pledges: {
declaredTotal: 1200,
effectiveTotal: 1200,
@ -140,6 +156,8 @@ describe('campaign visual foundation', () => {
priority: 'normal',
lastUpdateAt: null,
expectedVotes: null,
politicalTrendStatus: null,
politicalTrendNote: null,
pledges: { ...emptyPlazaPledgeAggregate },
},
]
@ -149,6 +167,7 @@ describe('campaign visual foundation', () => {
plazas,
advisorNamesById: new Map([[advisor.id, advisor]]),
isStaffView: true,
...plazaListDefaultProps,
}),
)
@ -160,6 +179,7 @@ describe('campaign visual foundation', () => {
expect(html).toContain('Prioritária')
expect(html).toContain('Coberta')
expect(html).toContain('Sem assessor')
expect(html).toContain('Tendência')
})
it('hides staff-only pledge and coverage columns from the leader view', () => {
@ -179,11 +199,14 @@ describe('campaign visual foundation', () => {
priority: 'alta',
lastUpdateAt: null,
expectedVotes: null,
politicalTrendStatus: null,
politicalTrendNote: null,
pledges: { ...emptyPlazaPledgeAggregate },
},
],
advisorNamesById: new Map<number, PlazaAdvisorSummary>(),
isStaffView: false,
...plazaListDefaultProps,
}),
)