From 70c61ab49ecdafda3ebc92904fa13f653a343547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 21 Jul 2026 11:45:37 -0300 Subject: [PATCH] =?UTF-8?q?Deliver=20B9=20quick=20edit=20on=20Pra=C3=A7as?= =?UTF-8?q?=20list=20and=20register=20simplify=20follow-ups.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline staff edits for advisors, political trend, and expected votes on /campanha/pracas; absorb post-simplify debts into A9+ and C8 plans. --- docs/plans/edicao-rapida-lista-pracas.md | 13 ++ docs/plans/escala-dry-pos-a9.md | 23 +++- docs/plans/escala-dry-pos-c6.md | 23 ++-- docs/roadmap.md | 4 +- .../campanha/(app)/pracas/listFormActions.ts | 100 ++++++++++++++ .../(campaign)/campanha/(app)/pracas/page.tsx | 17 ++- .../campaign/PlazaAdvisorAvatarStack.tsx | 37 ++++++ src/components/campaign/PlazaList.tsx | 122 ++++++++++++------ .../campaign/PlazaListAdvisorsControl.tsx | 104 +++++++++++++++ .../PlazaListExpectedVotesControl.tsx | 90 +++++++++++++ .../campaign/PlazaListTrendControl.tsx | 92 +++++++++++++ src/components/campaign/PlazaStrategyCard.tsx | 10 +- src/components/ui/Popover.tsx | 40 ++++++ src/utilities/plazaUi.ts | 6 + src/utilities/plazaViewModels.ts | 23 +++- tests/int/campaignPlazaAccess.int.spec.ts | 22 ++++ tests/unit/campaignComponents.unit.spec.ts | 23 ++++ 17 files changed, 682 insertions(+), 67 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/pracas/listFormActions.ts create mode 100644 src/components/campaign/PlazaAdvisorAvatarStack.tsx create mode 100644 src/components/campaign/PlazaListAdvisorsControl.tsx create mode 100644 src/components/campaign/PlazaListExpectedVotesControl.tsx create mode 100644 src/components/campaign/PlazaListTrendControl.tsx create mode 100644 src/components/ui/Popover.tsx diff --git a/docs/plans/edicao-rapida-lista-pracas.md b/docs/plans/edicao-rapida-lista-pracas.md index e8d9494f..3d544518 100644 --- a/docs/plans/edicao-rapida-lista-pracas.md +++ b/docs/plans/edicao-rapida-lista-pracas.md @@ -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 diff --git a/docs/plans/escala-dry-pos-a9.md b/docs/plans/escala-dry-pos-a9.md index 71e1e949..3892bcd8 100644 --- a/docs/plans/escala-dry-pos-a9.md +++ b/docs/plans/escala-dry-pos-a9.md @@ -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` (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
1× aggregatePledgesByPlaza"] + F2["Fase 2 — revalidate escopado
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` diff --git a/docs/plans/escala-dry-pos-c6.md b/docs/plans/escala-dry-pos-c6.md index 0b6695f7..0a90d6c8 100644 --- a/docs/plans/escala-dry-pos-c6.md +++ b/docs/plans/escala-dry-pos-c6.md @@ -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). diff --git a/docs/roadmap.md b/docs/roadmap.md index 1dcccd23..751b997b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/src/app/(campaign)/campanha/(app)/pracas/listFormActions.ts b/src/app/(campaign)/campanha/(app)/pracas/listFormActions.ts new file mode 100644 index 00000000..28a7f22b --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/pracas/listFormActions.ts @@ -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 => { + 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 => { + 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 => { + 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.', + }) + } +} diff --git a/src/app/(campaign)/campanha/(app)/pracas/page.tsx b/src/app/(campaign)/campanha/(app)/pracas/page.tsx index 501933ba..224b7e60 100644 --- a/src/app/(campaign)/campanha/(app)/pracas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/pracas/page.tsx @@ -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> @@ -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 ( @@ -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} />

diff --git a/src/components/campaign/PlazaAdvisorAvatarStack.tsx b/src/components/campaign/PlazaAdvisorAvatarStack.tsx new file mode 100644 index 00000000..b75d25af --- /dev/null +++ b/src/components/campaign/PlazaAdvisorAvatarStack.tsx @@ -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 Sem assessor + + return ( +

+
+ {advisors.slice(0, maxVisible).map((advisor) => ( + + {campaignUserInitials(advisor.name)} + + ))} +
+ {advisors.map((advisor) => advisor.name).join(', ')} +
+ ) +} diff --git a/src/components/campaign/PlazaList.tsx b/src/components/campaign/PlazaList.tsx index 4ae28ddc..0ead881a 100644 --- a/src/components/campaign/PlazaList.tsx +++ b/src/components/campaign/PlazaList.tsx @@ -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 Sem assessor - - return ( -
-
- {names.slice(0, 3).map((name) => ( - - {getInitials(name)} - - ))} -
- {names.join(', ')} -
- ) -} +type ListFormAction = ( + state: CampaignFormActionState, + formData: FormData, +) => Promise export type PlazaListProps = { plazas: PlazaListViewModel[] advisorNamesById: ReadonlyMap 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 ( <>
@@ -83,16 +87,42 @@ export const PlazaList = ({ plazas, advisorNamesById, isStaffView }: PlazaListPr
Votos estimados
-
+
Tendência
+
+ +
+
+
Assessoria
-
{names.length ? names.join(', ') : 'Sem assessor'}
+
+ {isCoordinator ? ( + + ) : names.length ? ( + names.join(', ') + ) : ( + 'Sem assessor' + )} +
) : null} @@ -114,6 +144,7 @@ export const PlazaList = ({ plazas, advisorNamesById, isStaffView }: PlazaListPr {isStaffView ? ( <> Assessores + Tendência Votos estimados Última atualização Cobertura @@ -150,13 +181,32 @@ export const PlazaList = ({ plazas, advisorNamesById, isStaffView }: PlazaListPr {isStaffView ? ( <> - + {isCoordinator ? ( + + ) : ( + + )} - + + + diff --git a/src/components/campaign/PlazaListAdvisorsControl.tsx b/src/components/campaign/PlazaListAdvisorsControl.tsx new file mode 100644 index 00000000..22bd430d --- /dev/null +++ b/src/components/campaign/PlazaListAdvisorsControl.tsx @@ -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 + options: EligibleAdvisorOption[] + formAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise +} + +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 ( + + + + + +
+ +

+ O assessor vê e gerencia somente as Praças que administra. +

+
    + {options.map((option) => ( +
  • + +
  • + ))} +
+ {state.message && state.status !== 'success' ? ( + + {state.message} + + ) : null} + +
+
+
+ ) +} diff --git a/src/components/campaign/PlazaListExpectedVotesControl.tsx b/src/components/campaign/PlazaListExpectedVotesControl.tsx new file mode 100644 index 00000000..a26abe70 --- /dev/null +++ b/src/components/campaign/PlazaListExpectedVotesControl.tsx @@ -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 +} + +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 ( + + + + + +
+ + + Total da Praça + { + if (event.key === 'Enter') { + event.preventDefault() + event.currentTarget.form?.requestSubmit() + } + }} + /> + + {state.message && state.status !== 'success' ? ( + + {state.message} + + ) : null} + +
+
+
+ ) +} diff --git a/src/components/campaign/PlazaListTrendControl.tsx b/src/components/campaign/PlazaListTrendControl.tsx new file mode 100644 index 00000000..647b2e2b --- /dev/null +++ b/src/components/campaign/PlazaListTrendControl.tsx @@ -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 +} + +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 ( + + + + + +
+ + + + Tendência + + Não registrada + {(Object.keys(politicalTrendLabels) as Array).map( + (trendStatus) => ( + + {politicalTrendLabels[trendStatus]} + + ), + )} + + + {state.message && state.status !== 'success' ? ( + + {state.message} + + ) : null} + +
+
+
+ ) +} diff --git a/src/components/campaign/PlazaStrategyCard.tsx b/src/components/campaign/PlazaStrategyCard.tsx index 114ba8f9..b64c3026 100644 --- a/src/components/campaign/PlazaStrategyCard.tsx +++ b/src/components/campaign/PlazaStrategyCard.tsx @@ -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 = ({ Prioridade normal )} {trend.status ? ( - + Tendência {politicalTrendLabels[trend.status].toLowerCase()} ) : ( diff --git a/src/components/ui/Popover.tsx b/src/components/ui/Popover.tsx new file mode 100644 index 00000000..447665b6 --- /dev/null +++ b/src/components/ui/Popover.tsx @@ -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) => ( + +) + +const PopoverTrigger = ({ ...props }: React.ComponentProps) => ( + +) + +const PopoverAnchor = ({ ...props }: React.ComponentProps) => ( + +) + +const PopoverContent = ({ + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentProps) => ( + + + +) + +export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } diff --git a/src/utilities/plazaUi.ts b/src/utilities/plazaUi.ts index 3d0530b2..292e5c17 100644 --- a/src/utilities/plazaUi.ts +++ b/src/utilities/plazaUi.ts @@ -32,6 +32,12 @@ export const politicalTrendLabels: Record = { desfavoravel: 'Desfavorável', } +export const politicalTrendBadgeVariant = { + favoravel: 'estimate-confirmed', + neutra: 'secondary', + desfavoravel: 'destructive', +} as const + export type PlazaListState = { page: number q?: string diff --git a/src/utilities/plazaViewModels.ts b/src/utilities/plazaViewModels.ts index 69fe73ad..305ccdfe 100644 --- a/src/utilities/plazaViewModels.ts +++ b/src/utilities/plazaViewModels.ts @@ -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 diff --git a/tests/int/campaignPlazaAccess.int.spec.ts b/tests/int/campaignPlazaAccess.int.spec.ts index a4b04d75..f5869ed4 100644 --- a/tests/int/campaignPlazaAccess.int.spec.ts +++ b/tests/int/campaignPlazaAccess.int.spec.ts @@ -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) + }) }) diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index b453d6a0..e8623a5d 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -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 => ({}) + +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(), isStaffView: false, + ...plazaListDefaultProps, }), )