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:
parent
936323c7ee
commit
70c61ab49e
17 changed files with 682 additions and 67 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
100
src/app/(campaign)/campanha/(app)/pracas/listFormActions.ts
Normal file
100
src/app/(campaign)/campanha/(app)/pracas/listFormActions.ts
Normal 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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
37
src/components/campaign/PlazaAdvisorAvatarStack.tsx
Normal file
37
src/components/campaign/PlazaAdvisorAvatarStack.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
104
src/components/campaign/PlazaListAdvisorsControl.tsx
Normal file
104
src/components/campaign/PlazaListAdvisorsControl.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
src/components/campaign/PlazaListExpectedVotesControl.tsx
Normal file
90
src/components/campaign/PlazaListExpectedVotesControl.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
src/components/campaign/PlazaListTrendControl.tsx
Normal file
92
src/components/campaign/PlazaListTrendControl.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
40
src/components/ui/Popover.tsx
Normal file
40
src/components/ui/Popover.tsx
Normal 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 }
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue