mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge branch 'cursor/b37ff931'
This commit is contained in:
commit
3d2cd4e163
15 changed files with 727 additions and 35 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **B31 entregue em código (2026-07-26):** coluna "Dobradinhas" em `/campanha/liderancas` — chips clicáveis (`StateDeputyChips`, link para `/campanha/dobradinhas/<slug>`) com edição por Popover+Command (adicionar/remover sem "Salvar"), gravação **por delta** com lock (`leadership-state-deputies:{id}`), nunca replace do array inteiro (que `updateLeadershipInternalRecord` continua fazendo, inalterado, para o form completo da ficha). **Auditoria do plano antes de codar** achou 5 defasagens (ordem de colunas mudou com o B28 ✓; B27 ✓ já tinha entregue a mecânica Popover+Command+chips, então B31 é o 2º call site, não o 1º; o precedente de UI correto é duplo — mecânica de `MunicipalityListAdvisorsControl` (B27) + escrita por server-action-direta-do-cliente de `AdvisorMunicipalityCell`/`setAdvisorMunicipalityMembership` (B19), nenhum dos dois sozinho; `MAX_LEADERSHIP_STATE_DEPUTIES` era privado; `loadLeadershipDetail` fazia uma 2ª chamada redundante a `loadStateDeputySummaries`), nenhuma delas mudou a abordagem central do plano. **Fase 1:** `LeadershipRowViewModel.stateDeputyNames: string[]` → `stateDeputies: StateDeputySummary[]` (via `loadStateDeputySummaries`, já existente); `loadLeadershipDetail` parou de duplicar a chamada — deriva `stateDeputyIDs` de `row.stateDeputies`; `MAX_LEADERSHIP_STATE_DEPUTIES` exportado; novo `leadershipStateDeputyMembershipSchema`; novo helper puro `nextStateDeputyIdsAfterMembership` (`src/lib/leadershipStateDeputyMembership.ts`, espelha `nextAdvisorIdsAfterMembership` linha a linha); `loadStateDeputyOptions` ganhou `plainName`/`party`/`slug` no retorno (`StateDeputyRelationOption`) para a célula montar um chip otimista completo sem 2ª busca. **Fase 2:** `setLeadershipStateDeputyMembershipRecord`/`setLeadershipStateDeputyMembership` em `actions/leadership.ts` (transação + `acquireTextAdvisoryLocks` + `revalidatePath` em `/campanha/liderancas`, `/campanha/liderancas/[id]`, `/campanha/dobradinhas` e `/campanha/dobradinhas/<slug>` do deputado tocado); novo `liderancas/formActions.ts` (a rota da lista não tinha um ainda — só `nova/` e `[id]/` tinham). **Fase 3 (Impeccable craft→critique→polish):** catálogo uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory) + `LeadershipStateDeputiesCell` (chips removíveis + `Command`/`matchesAtWordStart`, `useTransition` chamando a form action diretamente — não `fetch` —, reversão otimista + `toast.error` em falha, `aria-live` de status); coluna nova entre Organizações e Acesso ao app. Sem "Ver mais…" (teto de schema 20, uso típico 0–3); sem filtro/sort por dobradinha (gatilho no B29, ainda não entregue). **`/simplify` (1ª rodada):** corrigido revert otimista por delta (não pelo snapshot inteiro — um toggle falho não apaga mais um toggle anterior já salvo), removido fallback silencioso na adição de chip, extraído `sameIdSet` compartilhado com `MunicipalityListAdvisorsControl` (B27) para `src/lib/sameIdSet.ts`, e o caminho no-op da action parou de pagar uma leitura + revalidação supérfluas. **`/simplify` (2ª rodada, pós-rebase):** o catálogo tinha nascido como `StateDeputyOptionsProvider` (Context) — trocado pela columns factory acima ao confirmar que `MunicipalityList.tsx`/`municipalityListColumns` já resolve o mesmo problema sem Context (1ª rodada tinha validado o Context por falta dessa evidência); `revalidatePath('/campanha/liderancas/[id]', 'page')` trocado pelo id concreto (o template dinâmico invalidava toda ficha de liderança a cada toggle); non-null assertion em `optionById.get(...)!` movida para dentro do `setCurrent` updater. Adiado (documentado no plano): passar o `slug` do deputado pelo form em vez do `findByID` extra na action — fica para o 3º call site. Gate verde (tsc, lint --max-warnings=0, format:check, knip — P3 pré-existente, unit 530/530, int 406/406, build); Aikido 0 achados. Plano: [`dobradinhas-lista-liderancas.md`](../../../docs/plans/dobradinhas-lista-liderancas.md).
|
||||
- **B25 entregue em código (2026-07-26):** link do território na lista de `/campanha/municipios` — coluna "Território" e card mobile usam `TerritoryLink` → `/campanha/territorios#ti-<slug>`; helper compartilhado `territoryAnchor.ts`; destino com `id` nas linhas parent de `TerritoryList` via `CampaignTable.rowId`; sem migration/action. Gate: tsc/lint/format/knip/check:cycles; unit+int verdes (1 int flake por timeout em suíte cheia, pré-existente); `pnpm build` não rodou neste worktree (falta `PAYLOAD_SECRET` no ambiente). Plano: [`link-territorio-lista-municipios.md`](../../../docs/plans/link-territorio-lista-municipios.md).
|
||||
- **Fill-in "Limitar mapa à Bahia" entregue em código (2026-07-26):** `BahiaMap` trava pan com `maxBounds: L.latLngBounds(BAHIA_BOUNDS).pad(0.1)` + `maxBoundsViscosity: 1` e zoom-out com `applyBahiaMinZoom` (`getBoundsZoom` + padding 16px alinhado ao `fitBounds`, guarda `Number.isFinite`, recalcula no `ResizeObserver` existente). Único arquivo; props/`MunicipalityMapPanel` intactos. Impeccable B: critique 0 P0/P1; harden/optimize out. Gate: tsc/lint/format/knip (P3 pré-existente)/cycles/test (2 timeouts flaky re-rodados verdes)/build; Aikido 0 findings. Plano: [`limitar-mapa-a-bahia.md`](../../../docs/plans/limitar-mapa-a-bahia.md).
|
||||
- **B38 entregue em código (2026-07-26):** sidebar recolhido em tablet no shell de `/campanha`. `CampaignSidebar` deixou `collapsible="none"` e passou a `collapsible="offcanvas"` (gap `w-0` — listas densas ganham largura no iPad). Layout `(app)` lê cookie `sidebar_state` no SSR para `defaultOpen`; `CampaignSidebarViewportDefault` colapsa **uma vez** em viewport 768–1023 px quando não há cookie (`matchMedia` `min-width: 1024px`); após o primeiro toggle o cookie vence. `SidebarTrigger` no header vermelho mobile (`md:hidden`) inalterado; nova fileira slim `hidden md:flex` no inset com o mesmo label pt-BR. Mobile continua Sheet. Sem migration. Gate: tsc/lint/format/knip/check:cycles/522 unit verdes; int 399/400 (1 timeout pré-existente em `municipalityPageData`); build não rodou neste worktree sem `.env.local`. Plano: [`sidebar-recolhido-tablet.md`](../../../docs/plans/sidebar-recolhido-tablet.md). Soft **B18:** com rail collapsed o submenu de filtros salvos exigirá expandir antes do 2º nível.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Coluna de dobradinhas na lista de lideranças
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Status: entregue
|
||||
Atualizado em: 2026-07-26
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B31**)
|
||||
Impeccable: B — encaixe em `/campanha/liderancas` (coluna + célula editável no `CampaignTable` existente); sem rota nova
|
||||
Appetite: ~1 dia eng (0,75–1,25); coluna + célula de chips com busca e gravação por delta + action nova + provider de opções; sem migration
|
||||
|
|
@ -136,3 +136,48 @@ Depth check: reusa `CampaignTable`, `Popover`/`Command`/`Badge` do kit, `wordSta
|
|||
- [`combobox-assessores-lista-municipios.md`](combobox-assessores-lista-municipios.md) (B27) · [`email-celular-lista-liderancas.md`](email-celular-lista-liderancas.md) (B28) · [`ordenacao-filtros-lista-liderancas.md`](ordenacao-filtros-lista-liderancas.md) (B29) · [`convite-whatsapp-lista-liderancas.md`](convite-whatsapp-lista-liderancas.md) (B30) · [`gerenciar-assessores.md`](gerenciar-assessores.md) (B19 ✓)
|
||||
- AGENTS.md — Campaign auth, naming, `overrideAccess: false`, escrita transacional, `Contact` join
|
||||
- `PRODUCT.md` / `DESIGN.md` — princípios 3, 4 e 8; Field Desk · `.cursor/rules/campanha-edit-where-you-see.mdc`
|
||||
|
||||
## Revisão na entrega (2026-07-26)
|
||||
|
||||
O plano acima executou como escrito, com as 5 defasagens já sinalizadas na auditoria pré-implementação corrigidas no código, não no texto deste plano (preservado como histórico):
|
||||
|
||||
- **Ordem de colunas.** B28 ✓ já tinha inserido E-mail/Celular e a coluna Ações antes deste item chegar; "Dobradinhas" entrou entre Organizações e Acesso ao app como previsto, só a vizinhança mudou (Nome · E-mail · Celular · Status · Municípios · Organizações · **Dobradinhas** · Acesso ao app · Ações).
|
||||
- **B27 já entregue.** A questão em aberto sobre "Popover ou inline" ficou resolvida por precedente direto, não por suposição: `LeadershipStateDeputiesCell` usa a mesma mecânica Popover+`Command`+chips de `MunicipalityListAdvisorsControl` (B27), mas grava por **server action chamada direto do cliente via `useTransition`** (padrão de `AdvisorMunicipalityCell`/B19), não pelo endpoint JSON que o B27 usou — a diferença existe porque esta célula tem derivados a revalidar (`leadershipCount`, a ficha do deputado) e o B27 não tinha.
|
||||
- **`MAX_LEADERSHIP_STATE_DEPUTIES` exportado**, `loadStateDeputyOptions` estendido com `plainName`/`party`/`slug` (não só `slug`) para a célula montar o chip otimista sem 2ª busca, e a chamada duplicada de `loadStateDeputySummaries` em `loadLeadershipDetail` removida — `LeadershipDetailViewModel` deriva `stateDeputyIDs` de `row.stateDeputies`.
|
||||
- **Extração de célula genérica continua adiada.** Este é o **2º call site** do padrão Popover+Command+chips (depois de B27); a extração para `shared/` segue esperando o 3º (candidatos: B34/B36/B37).
|
||||
- Questões em aberto resolvidas: Popover (não inline) confirmado pelo precedente B27; chip só com nome na célula, partido no resultado da busca (A); sem filtro por dobradinha no header (gatilho no B29, ainda não entregue); sem "Ver mais…" (A).
|
||||
|
||||
Gate verde (tsc, lint, format, knip — P3 pré-existente, check:cycles, unit+int, e2e smoke, build); Aikido 0 achados nos 9 arquivos novos/editados.
|
||||
|
||||
### `/simplify` (2026-07-26)
|
||||
|
||||
Três revisores paralelos (qualidade, performance, reuso) sobre o diff completo. Corrigidos:
|
||||
|
||||
- **Bug de revert otimista.** `LeadershipStateDeputiesCell` desfazia a falha de um toggle voltando para `lastPropsRef.current` (o snapshot pré-edição inteiro), o que apagava um toggle anterior já salvo com sucesso na mesma sessão de popover. Agora desfaz só o delta que falhou (mesma garantia que `MunicipalityListAdvisorsControl`/B27 tem via `revertDelta`, sem herdar sua complexidade de `requestSeq`/`latestConfirmed` — a action daqui não devolve a lista "fonte da verdade" do servidor, então não há o que reconciliar).
|
||||
- **Fallback silencioso na adição.** `toggle` construía o chip otimista com `option?.plainName ?? 'Dobradinha'` / `slug: option?.slug ?? ''` — nunca deveria disparar (a opção sempre vem de `filteredOptions`), mas escondia um bug e podia gerar um link `/campanha/dobradinhas/` quebrado. Agora exige a opção e sai sem efeito se ela não existir.
|
||||
- **`sameIdSet` duplicado byte a byte** entre este arquivo e `MunicipalityListAdvisorsControl.tsx` (B27) — extraído para [`src/lib/sameIdSet.ts`](../../src/lib/sameIdSet.ts), importado pelos dois.
|
||||
- **No-op ainda pagava uma leitura + revalidação.** `setLeadershipStateDeputyMembershipRecord` buscava o `slug` do deputado _antes_ de checar `nextStateDeputyIDs === null`, e o wrapper sempre chamava `revalidateLeadershipStateDeputyPaths` mesmo sem escrita. Agora o no-op retorna antes da busca do slug, e o wrapper só revalida quando algo foi de fato escrito.
|
||||
|
||||
Adiados (achados reais, mas fora do apetite deste item — ver também o "2º call site" acima):
|
||||
|
||||
- Unificar `nextStateDeputyIdsAfterMembership`/`nextAdvisorIdsAfterMembership` num helper genérico parametrizado — casa com a extração adiada para o 3º call site (B34/B36/B37), não antes.
|
||||
- ~~`StateDeputyOptionsProvider` como Context vs. props diretas~~ — **revertido na 2ª rodada** (ver abaixo): a evidência que faltava nesta 1ª rodada (o precedente `municipalityListColumns`) apareceu na 2ª.
|
||||
- Query duplicada de `stateDeputy` na página da lista (catálogo completo para o Popover + subconjunto por IDs em `toLeadershipRows`) — `toLeadershipRows` é compartilhado com rotas que não carregam o catálogo, então não pode simplesmente consumi-lo; ficaria como uma otimização condicional só para esta rota.
|
||||
|
||||
Gate verde novamente após os ajustes (tsc, lint, format:check, unit 526/526, int 406/406 incl. os 6 do B31, build); Aikido 0 achados nos 4 arquivos tocados pelo `/simplify`.
|
||||
|
||||
### `/simplify` — segunda rodada, pós-rebase (2026-07-26)
|
||||
|
||||
Mesmos três revisores paralelos sobre o diff (já rebaseado em `origin/main`). Corrigidos:
|
||||
|
||||
- **`StateDeputyOptionsProvider` revertido para prop direta.** A primeira rodada tinha decidido manter o Context; esta rodada trouxe a evidência concreta que faltava — `MunicipalityList.tsx` já resolve exatamente o mesmo problema ("colunas estáticas + uma coluna que precisa de opções por request") com uma **columns factory** (`municipalityListColumns(props)`, chamada dentro do componente, fechando sobre `advisorOptions`), sem Context. Adotar esse precedente em vez de divergir dele: `leadershipColumns` virou `leadershipColumns(stateDeputyOptions)`, `LeadershipStateDeputiesCell` recebe `options` como prop comum, e `StateDeputyOptionsProvider.tsx` foi deletado. Zero comportamento mudado, um arquivo e uma camada de Context a menos.
|
||||
- **`revalidatePath` mais largo que o necessário.** `revalidateLeadershipStateDeputyPaths` chamava `revalidatePath('/campanha/liderancas/[id]', 'page')` — a forma de padrão dinâmico, que invalida a entrada de cache de **toda** ficha de liderança a cada toggle, não só a tocada — mesmo com o `leadershipId` já em escopo. Contrasta com o precedente já estabelecido (`revalidateAdvisorPaths`/`revalidateMunicipalityListPaths`), que sempre usa o id/slug concreto quando ele é conhecido. Corrigido para `revalidatePath(`/campanha/liderancas/${leadershipId}`, 'page')`.
|
||||
- **Non-null assertion evitável.** `optionById.get(stateDeputyId)!` dentro do `setCurrent` updater dependia de uma guarda feita _fora_ do updater (`if (assigned && !optionById.has(...)) return`) — correto hoje, mas as duas partes podiam se separar numa edição futura sem o TypeScript avisar. Movida a checagem para dentro do próprio updater (`if (!option) return previous`), sem asserção.
|
||||
|
||||
Adiados (achados reais desta rodada, registrados para quem tocar este código de novo):
|
||||
|
||||
- **Round-trip de DB só para resolver o `slug` do deputado a revalidar.** `setLeadershipStateDeputyMembershipRecord` faz um `findByID('stateDeputy', ..., select: { slug })` extra dentro da transação, e o cliente já tem esse slug (vem em `StateDeputyRelationOption`/`StateDeputySummary`). Passar o slug pelo `FormData`/schema evitaria a leitura, mas move a fonte de verdade do slug para o cliente só para fins de cache — a escrita em si continua validada pelo Payload (`overrideAccess: false` + FK); pior caso de um slug incorreto é invalidar a página errada, não uma falha de segurança. Ainda assim é uma mudança de superfície de confiança maior que o achado justifica isolado — fica para quando o 3º call site (B34/B36/B37) tocar esta mesma action.
|
||||
- Query duplicada de `stateDeputy` (catálogo completo + subconjunto por IDs em `toLeadershipRows`) — mesmo adiado da primeira rodada, ainda válido.
|
||||
- `optionById` recomputado uma vez por linha (célula) em vez de uma vez por tabela — `useMemo` já evita recomputar em cada render, só recomputa por linha no mount; moveria para o provider/hook compartilhado se algum dia importar na prática (catálogo de dobradinhas é pequeno).
|
||||
|
||||
Gate verde novamente (tsc, lint --max-warnings=0, format:check, unit 530/530, int 406/406 incl. os 6 do B31, knip com o P3 pré-existente do `payload.config.ts`); Aikido 0 achados nos 3 arquivos editados.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
33
src/app/(campaign)/campanha/(app)/liderancas/formActions.ts
Normal file
33
src/app/(campaign)/campanha/(app)/liderancas/formActions.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use server'
|
||||
|
||||
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
|
||||
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
|
||||
const safeMessages = [
|
||||
'Somente a coordenação e a assessoria podem gerenciar lideranças.',
|
||||
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
|
||||
] as const
|
||||
|
||||
/** One chip toggle in the "Dobradinhas" column of `/campanha/liderancas` (B31). */
|
||||
export const setLeadershipStateDeputyMembershipFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const assignedRaw = requiredFormText(formData, 'assigned')
|
||||
await setLeadershipStateDeputyMembership({
|
||||
leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'),
|
||||
stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
|
||||
assigned: assignedRaw === 'true',
|
||||
})
|
||||
return { message: 'Dobradinhas atualizadas.' }
|
||||
},
|
||||
safeMessages,
|
||||
genericMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
|
||||
})
|
||||
|
|
@ -4,6 +4,7 @@ import Link from 'next/link'
|
|||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { LeadershipStateDeputiesCell } from '@/components/campaign/leadership/LeadershipStateDeputiesCell'
|
||||
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
|
||||
import { CampaignCopyableCell } from '@/components/campaign/shared/CampaignCopyableCell'
|
||||
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
|
||||
|
|
@ -24,6 +25,10 @@ import { Button } from '@/components/ui/button'
|
|||
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import {
|
||||
loadStateDeputyOptions,
|
||||
type StateDeputyRelationOption,
|
||||
} from '@/utilities/campaignRelationOptions'
|
||||
import {
|
||||
buildLeadershipListHref,
|
||||
loadLeadershipListPageData,
|
||||
|
|
@ -31,11 +36,15 @@ import {
|
|||
type LeadershipRowViewModel,
|
||||
} from '@/utilities/leadershipData'
|
||||
|
||||
import { setLeadershipStateDeputyMembershipFormAction } from './formActions'
|
||||
|
||||
type LeadershipsPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
const leadershipColumns: Array<CampaignTableColumn<LeadershipRowViewModel>> = [
|
||||
const leadershipColumns = (
|
||||
stateDeputyOptions: StateDeputyRelationOption[],
|
||||
): Array<CampaignTableColumn<LeadershipRowViewModel>> => [
|
||||
{
|
||||
id: 'name',
|
||||
mandatory: true,
|
||||
|
|
@ -84,6 +93,19 @@ const leadershipColumns: Array<CampaignTableColumn<LeadershipRowViewModel>> = [
|
|||
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
|
||||
cell: (row) => row.organizationNames.join(', ') || '—',
|
||||
},
|
||||
{
|
||||
id: 'stateDeputies',
|
||||
head: <CampaignTableHead>Dobradinhas</CampaignTableHead>,
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<LeadershipStateDeputiesCell
|
||||
leadershipId={row.id}
|
||||
stateDeputies={row.stateDeputies}
|
||||
options={stateDeputyOptions}
|
||||
membershipAction={setLeadershipStateDeputyMembershipFormAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'appAccess',
|
||||
head: <CampaignTableHead>Acesso ao app</CampaignTableHead>,
|
||||
|
|
@ -137,7 +159,11 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
if (!isCampaignStaff(user)) redirect('/campanha')
|
||||
|
||||
const state = parseLeadershipListParams(rawSearchParams)
|
||||
const { rows, totalDocs, totalPages } = await loadLeadershipListPageData(payload, user, state)
|
||||
const [{ rows, totalDocs, totalPages }, stateDeputyOptions] = await Promise.all([
|
||||
loadLeadershipListPageData(payload, user, state),
|
||||
loadStateDeputyOptions(payload, user),
|
||||
])
|
||||
const columns = leadershipColumns(stateDeputyOptions)
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
|
|
@ -167,7 +193,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
<CampaignListResults>
|
||||
{rows.length ? (
|
||||
<>
|
||||
<CampaignTable columns={leadershipColumns} rows={rows} rowKey={(row) => row.id} />
|
||||
<CampaignTable columns={columns} rows={rows} rowKey={(row) => row.id} />
|
||||
<CampaignListFooter
|
||||
totalDocs={totalDocs}
|
||||
singular="liderança"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership'
|
||||
import {
|
||||
leadershipCreateSchema,
|
||||
leadershipInternalUpdateSchema,
|
||||
leadershipStateDeputyMembershipSchema,
|
||||
type LeadershipInternalUpdateInput,
|
||||
type LeadershipStateDeputyMembershipInput,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import type { CampaignUser, Contact } from '@/payload-types'
|
||||
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
|
||||
|
|
@ -13,6 +17,8 @@ import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaign
|
|||
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
import { uniqueRelationshipIds } from '@/utilities/relationship'
|
||||
|
||||
const getFreshStaffActor = (
|
||||
payload: Payload,
|
||||
|
|
@ -224,3 +230,103 @@ export const updateLeadershipInternal = async (input: LeadershipInternalUpdateIn
|
|||
const { payload, actor } = await getCampaignActionContext()
|
||||
return updateLeadershipInternalRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
const revalidateLeadershipStateDeputyPaths = (leadershipId: number, stateDeputySlug?: string) => {
|
||||
revalidatePath('/campanha/liderancas', 'page')
|
||||
revalidatePath(`/campanha/liderancas/${leadershipId}`, 'page')
|
||||
revalidatePath('/campanha/dobradinhas', 'page')
|
||||
if (stateDeputySlug) {
|
||||
revalidatePath(`/campanha/dobradinhas/${stateDeputySlug}`, 'page')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta write for one chip in the "Dobradinhas" column of `/campanha/liderancas`
|
||||
* (B31) — the other side of the same `leadership.stateDeputies` relation that
|
||||
* `updateLeadershipInternalRecord` replaces wholesale from the ficha form.
|
||||
* Under auto-save-per-chip a replace would let two actors on the same ficha
|
||||
* clobber each other, so this locks per leadership and writes only the delta.
|
||||
*/
|
||||
export const setLeadershipStateDeputyMembershipRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: LeadershipStateDeputyMembershipInput,
|
||||
) => {
|
||||
const { leadershipId, stateDeputyId, assigned } =
|
||||
leadershipStateDeputyMembershipSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await getFreshStaffActor(payload, actor, req)
|
||||
|
||||
await acquireTextAdvisoryLocks(payload, req, [`leadership-state-deputies:${leadershipId}`])
|
||||
|
||||
// Row access verifies the leadership is in the actor's scope (same
|
||||
// guard `updateLeadershipInternalRecord` relies on).
|
||||
const current = await payload.findByID({
|
||||
collection: 'leadership',
|
||||
id: leadershipId,
|
||||
depth: 0,
|
||||
select: { stateDeputies: true },
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
|
||||
const currentStateDeputyIDs = uniqueRelationshipIds(current.stateDeputies)
|
||||
const nextStateDeputyIDs = nextStateDeputyIdsAfterMembership(
|
||||
currentStateDeputyIDs,
|
||||
stateDeputyId,
|
||||
assigned,
|
||||
)
|
||||
|
||||
// No-op: nothing to write, and nothing for the caller to revalidate —
|
||||
// skip the slug lookup below, which exists only to target that revalidate.
|
||||
if (nextStateDeputyIDs === null) {
|
||||
return { leadership: current, stateDeputySlug: undefined }
|
||||
}
|
||||
|
||||
// Intentional admin bypass: only used to resolve the slug of the
|
||||
// touched deputy for a targeted revalidate; existence is otherwise
|
||||
// enforced by Payload's relationship validation on `update`.
|
||||
const stateDeputySlug = (
|
||||
await payload.findByID({
|
||||
collection: 'stateDeputy',
|
||||
id: stateDeputyId,
|
||||
depth: 0,
|
||||
select: { slug: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
).slug
|
||||
|
||||
const updated = await payload.update({
|
||||
collection: 'leadership',
|
||||
id: leadershipId,
|
||||
data: { stateDeputies: nextStateDeputyIDs },
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
|
||||
return { leadership: updated, stateDeputySlug }
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível atualizar as dobradinhas.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const setLeadershipStateDeputyMembership = async (
|
||||
input: LeadershipStateDeputyMembershipInput,
|
||||
) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const { leadership, stateDeputySlug } = await setLeadershipStateDeputyMembershipRecord(
|
||||
payload,
|
||||
actor,
|
||||
input,
|
||||
)
|
||||
// No-op writes nothing, so there is nothing to revalidate.
|
||||
if (stateDeputySlug) revalidateLeadershipStateDeputyPaths(input.leadershipId, stateDeputySlug)
|
||||
return leadership
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
'use client'
|
||||
|
||||
import { PencilIcon, XIcon } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyChips'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { matchesAtWordStart } from '@/lib/wordStartFilter'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
|
||||
import type { StateDeputySummary } from '@/utilities/stateDeputyData'
|
||||
|
||||
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar as dobradinhas. Tente novamente.'
|
||||
|
||||
type LeadershipStateDeputiesCellProps = {
|
||||
leadershipId: number
|
||||
stateDeputies: StateDeputySummary[]
|
||||
options: StateDeputyRelationOption[]
|
||||
membershipAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
/**
|
||||
* "Dobradinhas" column cell (B31) — read chips reuse `StateDeputyChips`
|
||||
* (same look as the leadership ficha / município strategy card); a separate
|
||||
* pencil affordance opens the Popover+Command editor, mirroring the
|
||||
* mechanics of `MunicipalityListAdvisorsControl` (B27) but writing through a
|
||||
* server action + `useTransition` like `AdvisorMunicipalityCell` (B19)
|
||||
* instead of a JSON endpoint. The catalog arrives as a prop, computed once
|
||||
* by the page's column factory (mirrors `municipalityListColumns`) instead
|
||||
* of a table-scoped context — one static catalog, one place that builds it.
|
||||
*/
|
||||
export const LeadershipStateDeputiesCell = ({
|
||||
leadershipId,
|
||||
stateDeputies,
|
||||
options,
|
||||
membershipAction,
|
||||
}: LeadershipStateDeputiesCellProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [current, setCurrent] = useState<StateDeputySummary[]>(stateDeputies)
|
||||
const [query, setQuery] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const lastPropsRef = useRef(stateDeputies)
|
||||
|
||||
// Adopt server props only when they change from outside (navigation / RSC
|
||||
// refresh) — an in-flight delta's optimistic state must not be clobbered by
|
||||
// a prop identical in content to what we already had.
|
||||
useEffect(() => {
|
||||
const propIds = stateDeputies.map((deputy) => deputy.id)
|
||||
if (
|
||||
sameIdSet(
|
||||
propIds,
|
||||
lastPropsRef.current.map((deputy) => deputy.id),
|
||||
)
|
||||
)
|
||||
return
|
||||
lastPropsRef.current = stateDeputies
|
||||
setCurrent(stateDeputies)
|
||||
}, [stateDeputies])
|
||||
|
||||
const currentIds = useMemo(() => new Set(current.map((deputy) => deputy.id)), [current])
|
||||
const optionById = useMemo(() => new Map(options.map((option) => [option.id, option])), [options])
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
options.filter(
|
||||
(option) => !currentIds.has(option.id) && matchesAtWordStart(option.name, query),
|
||||
),
|
||||
[options, currentIds, query],
|
||||
)
|
||||
|
||||
const toggle = (stateDeputyId: number, assigned: boolean) => {
|
||||
// The removed summary is captured up front (not re-derived from the
|
||||
// catalog) so a failed removal can be undone even for a deputy that has
|
||||
// since dropped out of `options` — same asymmetry as the `optionById`
|
||||
// lookup below, which only needs to resolve *additions*.
|
||||
const removedSummary = assigned
|
||||
? undefined
|
||||
: current.find((deputy) => deputy.id === stateDeputyId)
|
||||
if (assigned && !optionById.has(stateDeputyId)) return
|
||||
|
||||
setErrorMessage(null)
|
||||
setCurrent((previous) => {
|
||||
if (assigned) {
|
||||
if (previous.some((deputy) => deputy.id === stateDeputyId)) return previous
|
||||
const option = optionById.get(stateDeputyId)
|
||||
if (!option) return previous
|
||||
return [
|
||||
...previous,
|
||||
{ id: stateDeputyId, name: option.plainName, slug: option.slug, party: option.party },
|
||||
]
|
||||
}
|
||||
return previous.filter((deputy) => deputy.id !== stateDeputyId)
|
||||
})
|
||||
setQuery('')
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set('leadershipId', String(leadershipId))
|
||||
formData.set('stateDeputyId', String(stateDeputyId))
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await membershipAction({}, formData)
|
||||
if (result.status === 'success') return
|
||||
// Undo only this delta — not the whole cell back to `lastPropsRef` —
|
||||
// so a failed toggle can't wipe an earlier toggle from the same batch
|
||||
// that already saved successfully.
|
||||
setCurrent((previous) =>
|
||||
assigned
|
||||
? previous.filter((deputy) => deputy.id !== stateDeputyId)
|
||||
: removedSummary
|
||||
? [...previous, removedSummary]
|
||||
: previous,
|
||||
)
|
||||
const message = result.message ?? SAVE_ERROR_MESSAGE
|
||||
setErrorMessage(message)
|
||||
toast.error(message)
|
||||
})
|
||||
}
|
||||
|
||||
const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando dobradinhas.' : ''
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{current.length > 0 ? (
|
||||
<StateDeputyChips deputies={current} />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Editar dobradinhas"
|
||||
className={cn(
|
||||
'inline-flex min-h-11 min-w-11 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
open ? 'bg-muted/60 text-foreground' : undefined,
|
||||
)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-0">
|
||||
<div className="relative flex flex-col gap-2 p-3 pb-0">
|
||||
<p className="text-sm font-medium">Atribuir dobradinhas</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deputados estaduais em dobradinha com esta liderança.
|
||||
</p>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className="absolute top-3 right-3 size-3.5 text-muted-foreground"
|
||||
aria-label="Salvando dobradinhas"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{current.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
|
||||
{current.map((deputy) => (
|
||||
<button
|
||||
key={deputy.id}
|
||||
type="button"
|
||||
aria-label={`Remover ${deputy.name}`}
|
||||
onClick={() => toggle(deputy.id, false)}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
|
||||
>
|
||||
<span className="truncate">
|
||||
{deputy.name}
|
||||
{deputy.party ? ` (${deputy.party})` : ''}
|
||||
</span>
|
||||
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Command shouldFilter={false} className="mt-1">
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Buscar deputado estadual…"
|
||||
aria-label="Buscar deputado estadual"
|
||||
/>
|
||||
<CommandList>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado.
|
||||
</p>
|
||||
) : (
|
||||
filteredOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`state-deputy-${option.id}`}
|
||||
onSelect={() => toggle(option.id, true)}
|
||||
>
|
||||
<span className="truncate">{option.name}</span>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{statusMessage}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { Badge } from '@/components/ui/Badge'
|
|||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { matchesAtWordStart } from '@/lib/wordStartFilter'
|
||||
import type {
|
||||
|
|
@ -25,12 +26,6 @@ import type {
|
|||
const ADVISORS_ENDPOINT = '/campanha/municipios/advisors'
|
||||
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar os assessores. Tente novamente.'
|
||||
|
||||
const sameIdSet = (left: readonly number[], right: readonly number[]): boolean => {
|
||||
if (left.length !== right.length) return false
|
||||
const rightSet = new Set(right)
|
||||
return left.every((id) => rightSet.has(id))
|
||||
}
|
||||
|
||||
type MunicipalityListAdvisorsControlProps = {
|
||||
municipalityID: number
|
||||
currentAdvisorIDs: number[]
|
||||
|
|
|
|||
28
src/lib/leadershipStateDeputyMembership.ts
Normal file
28
src/lib/leadershipStateDeputyMembership.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
|
||||
|
||||
/**
|
||||
* Returns the next state-deputy-id list after applying one membership
|
||||
* change, or `null` when the leadership is already in the desired state (no
|
||||
* write needed). Mirrors `nextAdvisorIdsAfterMembership` — the pure delta
|
||||
* calculator for the other side of a relation edited by chip+combobox
|
||||
* (B31's `leadership.stateDeputies`, edited from the "Dobradinhas" column of
|
||||
* `/campanha/liderancas`).
|
||||
*/
|
||||
export const nextStateDeputyIdsAfterMembership = (
|
||||
currentStateDeputyIDs: readonly number[],
|
||||
stateDeputyId: number,
|
||||
assigned: boolean,
|
||||
): number[] | null => {
|
||||
const alreadyAssigned = currentStateDeputyIDs.includes(stateDeputyId)
|
||||
if (assigned === alreadyAssigned) return null
|
||||
|
||||
if (assigned) {
|
||||
if (currentStateDeputyIDs.length >= MAX_LEADERSHIP_STATE_DEPUTIES) {
|
||||
throw new Error(
|
||||
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
|
||||
)
|
||||
}
|
||||
return [...currentStateDeputyIDs, stateDeputyId]
|
||||
}
|
||||
return currentStateDeputyIDs.filter((id) => id !== stateDeputyId)
|
||||
}
|
||||
11
src/lib/sameIdSet.ts
Normal file
11
src/lib/sameIdSet.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Order-independent equality for two id lists — the guard every list-cell
|
||||
* optimistic-state hook uses to tell a genuine external prop change
|
||||
* (navigation/RSC refresh) apart from a re-render carrying the same
|
||||
* pre-edit content (which must not clobber in-flight optimistic state).
|
||||
*/
|
||||
export const sameIdSet = (left: readonly number[], right: readonly number[]): boolean => {
|
||||
if (left.length !== right.length) return false
|
||||
const rightSet = new Set(right)
|
||||
return left.every((id) => rightSet.has(id))
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export const isSupportStatus = (value: unknown): value is SupportStatus =>
|
|||
|
||||
export const MAX_LEADERSHIP_MUNICIPALITIES = 30
|
||||
const MAX_LEADERSHIP_ORGANIZATIONS = 20
|
||||
const MAX_LEADERSHIP_STATE_DEPUTIES = 20
|
||||
export const MAX_LEADERSHIP_STATE_DEPUTIES = 20
|
||||
|
||||
const municipalitiesArraySchema = z
|
||||
.array(positiveRelationshipId)
|
||||
|
|
@ -83,3 +83,14 @@ export const leadershipInternalUpdateSchema = z.object({
|
|||
})
|
||||
|
||||
export type LeadershipInternalUpdateInput = z.input<typeof leadershipInternalUpdateSchema>
|
||||
|
||||
/** Delta write for one chip in the "Dobradinhas" column of `/campanha/liderancas` (B31). */
|
||||
export const leadershipStateDeputyMembershipSchema = z.object({
|
||||
leadershipId: positiveRelationshipId,
|
||||
stateDeputyId: positiveRelationshipId,
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export type LeadershipStateDeputyMembershipInput = z.input<
|
||||
typeof leadershipStateDeputyMembershipSchema
|
||||
>
|
||||
|
|
|
|||
|
|
@ -69,17 +69,30 @@ export const loadActivityOptions = async (
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `name` folds in the party for select/typeahead disambiguation (e.g.
|
||||
* `RelationMultiSelect`); `plainName`/`party`/`slug` ride along so cell-level
|
||||
* consumers (B31's "Dobradinhas" column) can build a `StateDeputySummary`
|
||||
* chip for an optimistic add and link to `/campanha/dobradinhas/<slug>`
|
||||
* without a second catalog fetch.
|
||||
*/
|
||||
export type StateDeputyRelationOption = RelationOption & {
|
||||
plainName: string
|
||||
party: string | null
|
||||
slug: string
|
||||
}
|
||||
|
||||
export const loadStateDeputyOptions = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
): Promise<RelationOption[]> => {
|
||||
): Promise<StateDeputyRelationOption[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'stateDeputy',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'name',
|
||||
select: { name: true, party: true },
|
||||
select: { name: true, party: true, slug: true },
|
||||
where: {},
|
||||
user,
|
||||
overrideAccess: false,
|
||||
|
|
@ -87,5 +100,8 @@ export const loadStateDeputyOptions = async (
|
|||
return result.docs.map((stateDeputy) => ({
|
||||
id: stateDeputy.id,
|
||||
name: stateDeputy.party ? `${stateDeputy.name} (${stateDeputy.party})` : stateDeputy.name,
|
||||
plainName: stateDeputy.name,
|
||||
party: stateDeputy.party ?? null,
|
||||
slug: stateDeputy.slug,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export type LeadershipRowViewModel = {
|
|||
sector: string | null
|
||||
municipalityNames: string[]
|
||||
organizationNames: string[]
|
||||
stateDeputyNames: string[]
|
||||
stateDeputies: StateDeputySummary[]
|
||||
hasAppAccess: boolean
|
||||
/** Last write to the leadership row — the dossier's freshness readout. */
|
||||
updatedAt: string
|
||||
|
|
@ -50,7 +50,7 @@ const contactSummary = (
|
|||
|
||||
const namesForIds = async (
|
||||
payload: Payload,
|
||||
collection: 'municipality' | 'organization' | 'stateDeputy',
|
||||
collection: 'municipality' | 'organization',
|
||||
ids: number[],
|
||||
): Promise<Map<number, string>> => {
|
||||
if (ids.length === 0) return new Map()
|
||||
|
|
@ -90,11 +90,12 @@ const toLeadershipRows = async (
|
|||
}
|
||||
}
|
||||
|
||||
const [municipalityNames, organizationNames, stateDeputyNames] = await Promise.all([
|
||||
const [municipalityNames, organizationNames, stateDeputySummaries] = await Promise.all([
|
||||
namesForIds(payload, 'municipality', [...municipalityIDs]),
|
||||
namesForIds(payload, 'organization', [...organizationIDs]),
|
||||
namesForIds(payload, 'stateDeputy', [...stateDeputyIDs]),
|
||||
loadStateDeputySummaries(payload, [...stateDeputyIDs]),
|
||||
])
|
||||
const stateDeputyById = new Map(stateDeputySummaries.map((summary) => [summary.id, summary]))
|
||||
|
||||
return docs.map((doc) => {
|
||||
const contact = contactSummary(doc.contact)
|
||||
|
|
@ -114,10 +115,11 @@ const toLeadershipRows = async (
|
|||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null)
|
||||
.map((id) => organizationNames.get(id) ?? 'Organização'),
|
||||
stateDeputyNames: (doc.stateDeputies ?? [])
|
||||
stateDeputies: (doc.stateDeputies ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null)
|
||||
.map((id) => stateDeputyNames.get(id) ?? 'Dobradinha'),
|
||||
.map((id) => stateDeputyById.get(id))
|
||||
.filter((summary): summary is StateDeputySummary => summary !== undefined),
|
||||
hasAppAccess: relationshipId(doc.user) !== null,
|
||||
updatedAt: doc.updatedAt,
|
||||
}
|
||||
|
|
@ -239,7 +241,6 @@ export type LeadershipDetailViewModel = LeadershipRowViewModel & {
|
|||
municipalityIDs: number[]
|
||||
organizationIDs: number[]
|
||||
stateDeputyIDs: number[]
|
||||
stateDeputies: StateDeputySummary[]
|
||||
sectorNotes: string | null
|
||||
notes: string | null
|
||||
consentNote: string | null
|
||||
|
|
@ -265,11 +266,6 @@ export const loadLeadershipDetail = async (
|
|||
const [row] = await toLeadershipRows(payload, [doc])
|
||||
if (!row) return null
|
||||
|
||||
const stateDeputyIDs = (doc.stateDeputies ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null)
|
||||
const stateDeputies = await loadStateDeputySummaries(payload, stateDeputyIDs)
|
||||
|
||||
return {
|
||||
...row,
|
||||
municipalityIDs: (doc.municipalities ?? [])
|
||||
|
|
@ -278,8 +274,7 @@ export const loadLeadershipDetail = async (
|
|||
organizationIDs: (doc.organizations ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null),
|
||||
stateDeputyIDs,
|
||||
stateDeputies,
|
||||
stateDeputyIDs: row.stateDeputies.map((summary) => summary.id),
|
||||
sectorNotes: doc.sectorNotes ?? null,
|
||||
notes: doc.notes ?? null,
|
||||
consentNote: doc.consentNote ?? null,
|
||||
|
|
|
|||
177
tests/int/campaignLeadershipStateDeputyMembership.int.spec.ts
Normal file
177
tests/int/campaignLeadershipStateDeputyMembership.int.spec.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { setLeadershipStateDeputyMembershipRecord } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const stateDeputyIds = (leadership: {
|
||||
stateDeputies?: (number | { id: number })[] | null
|
||||
}): number[] =>
|
||||
(leadership.stateDeputies ?? []).map((entry) => (typeof entry === 'number' ? entry : entry.id))
|
||||
|
||||
describe('setLeadershipStateDeputyMembershipRecord (B31)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it.each(['coordinator', 'candidate'] as const)(
|
||||
'lets a %s assign and remove a state deputy by delta',
|
||||
async (role) => {
|
||||
const fixtures = campaignFixtures()
|
||||
const actor = await fixtures.createCampaignUser(role)
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact,
|
||||
municipalities: [municipality],
|
||||
})
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
const { leadership: assigned, stateDeputySlug } =
|
||||
await setLeadershipStateDeputyMembershipRecord(payload, actor, {
|
||||
leadershipId: leadership.id,
|
||||
stateDeputyId: stateDeputy.id,
|
||||
assigned: true,
|
||||
})
|
||||
expect(stateDeputyIds(assigned)).toContain(stateDeputy.id)
|
||||
expect(stateDeputySlug).toBe(stateDeputy.slug)
|
||||
|
||||
const { leadership: removed } = await setLeadershipStateDeputyMembershipRecord(
|
||||
payload,
|
||||
actor,
|
||||
{
|
||||
leadershipId: leadership.id,
|
||||
stateDeputyId: stateDeputy.id,
|
||||
assigned: false,
|
||||
},
|
||||
)
|
||||
expect(stateDeputyIds(removed)).not.toContain(stateDeputy.id)
|
||||
},
|
||||
)
|
||||
|
||||
it('lets an advisor manage a leadership within their administered municipality', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
|
||||
const contact = await fixtures.createContact()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact,
|
||||
municipalities: [municipality],
|
||||
})
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
const { leadership: assigned } = await setLeadershipStateDeputyMembershipRecord(
|
||||
payload,
|
||||
advisor,
|
||||
{
|
||||
leadershipId: leadership.id,
|
||||
stateDeputyId: stateDeputy.id,
|
||||
assigned: true,
|
||||
},
|
||||
)
|
||||
expect(stateDeputyIds(assigned)).toContain(stateDeputy.id)
|
||||
})
|
||||
|
||||
it('denies an advisor outside the leadership scope and a leader actor', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisorActor = await fixtures.createCampaignUser('advisor')
|
||||
const outsideMunicipality = await fixtures.getMunicipality()
|
||||
const otherMunicipality = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(outsideMunicipality, [advisorActor])
|
||||
const leaderActor = await fixtures.createCampaignUser('leader')
|
||||
const contact = await fixtures.createContact()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact,
|
||||
municipalities: [otherMunicipality],
|
||||
})
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
await expect(
|
||||
setLeadershipStateDeputyMembershipRecord(payload, advisorActor, {
|
||||
leadershipId: leadership.id,
|
||||
stateDeputyId: stateDeputy.id,
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
await expect(
|
||||
setLeadershipStateDeputyMembershipRecord(payload, leaderActor, {
|
||||
leadershipId: leadership.id,
|
||||
stateDeputyId: stateDeputy.id,
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow(/coordenação e a assessoria/i)
|
||||
})
|
||||
|
||||
it('is idempotent (no-op returns the same document without re-writing)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact,
|
||||
municipalities: [municipality],
|
||||
})
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
await setLeadershipStateDeputyMembershipRecord(payload, coordinator, {
|
||||
leadershipId: leadership.id,
|
||||
stateDeputyId: stateDeputy.id,
|
||||
assigned: true,
|
||||
})
|
||||
|
||||
const { leadership: again } = await setLeadershipStateDeputyMembershipRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
{
|
||||
leadershipId: leadership.id,
|
||||
stateDeputyId: stateDeputy.id,
|
||||
assigned: true,
|
||||
},
|
||||
)
|
||||
expect(stateDeputyIds(again)).toEqual([stateDeputy.id])
|
||||
})
|
||||
|
||||
it('rejects once the leadership is at the 20-state-deputy cap', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact,
|
||||
municipalities: [municipality],
|
||||
})
|
||||
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
await setLeadershipStateDeputyMembershipRecord(payload, coordinator, {
|
||||
leadershipId: leadership.id,
|
||||
stateDeputyId: stateDeputy.id,
|
||||
assigned: true,
|
||||
})
|
||||
}
|
||||
|
||||
const twentyFirst = await fixtures.createStateDeputy()
|
||||
await expect(
|
||||
setLeadershipStateDeputyMembershipRecord(payload, coordinator, {
|
||||
leadershipId: leadership.id,
|
||||
stateDeputyId: twentyFirst.id,
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow(/no máximo 20 dobradinhas/i)
|
||||
})
|
||||
})
|
||||
26
tests/unit/leadershipStateDeputyMembership.unit.spec.ts
Normal file
26
tests/unit/leadershipStateDeputyMembership.unit.spec.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership'
|
||||
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
|
||||
|
||||
describe('nextStateDeputyIdsAfterMembership', () => {
|
||||
it('adds a state deputy not yet present', () => {
|
||||
expect(nextStateDeputyIdsAfterMembership([1, 2], 3, true)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('removes a state deputy present in the list', () => {
|
||||
expect(nextStateDeputyIdsAfterMembership([1, 2, 3], 2, false)).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('is a no-op (returns null) when already in the desired state', () => {
|
||||
expect(nextStateDeputyIdsAfterMembership([1, 2], 2, true)).toBeNull()
|
||||
expect(nextStateDeputyIdsAfterMembership([1, 2], 3, false)).toBeNull()
|
||||
})
|
||||
|
||||
it('throws once the leadership is at the state-deputy cap', () => {
|
||||
const atCap = Array.from({ length: MAX_LEADERSHIP_STATE_DEPUTIES }, (_, index) => index + 1)
|
||||
expect(() => nextStateDeputyIdsAfterMembership(atCap, 999, true)).toThrow(
|
||||
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue