B31: coluna de dobradinhas na lista de lideranças

Coluna "Dobradinhas" em /campanha/liderancas com chips clicáveis
para /campanha/dobradinhas/<slug> e edição via Popover+Command
(padrão B27, escrita via server action delta + lock, como B19).

- LeadershipRowViewModel.stateDeputies (StateDeputySummary[]) via
  loadStateDeputySummaries, substituindo stateDeputyNames.
- setLeadershipStateDeputyMembership(Record): delta com
  withPayloadTransaction + acquireTextAdvisoryLocks + revalidatePath.
- nextStateDeputyIdsAfterMembership (lib/leadershipStateDeputyMembership.ts),
  espelha nextAdvisorIdsAfterMembership.
- loadStateDeputyOptions estendido com plainName/party/slug.
- Catálogo entregue uma vez por tabela via leadershipColumns(options)
  (columns factory, mesmo precedente de municipalityListColumns).

/simplify (1ª rodada): corrige revert otimista por delta (não pelo
snapshot inteiro), remove fallback silencioso na adição, extrai
sameIdSet compartilhado com MunicipalityListAdvisorsControl (B27), e
evita leitura+revalidação supérfluas no caminho no-op da action.

/simplify (2ª rodada, pós-rebase): troca o StateDeputyOptionsProvider
(Context) inicial pela columns factory acima — a 1ª rodada tinha
validado o Context por falta da evidência de que MunicipalityList.tsx
já resolve o mesmo problema sem Context; corrige revalidatePath para
o id concreto em vez do template dinâmico '/campanha/liderancas/[id]'
(que invalidava toda ficha a cada toggle); remove non-null assertion
movendo a guarda para dentro do setCurrent updater.

Gate verde: tsc, lint --max-warnings=0, format:check, unit 530/530,
int 406/406 (incl. os 6 de campaignLeadershipStateDeputyMembership),
build. Aikido: 0 achados nos arquivos novos/editados.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 19:30:26 -03:00
parent 2ee9a4fe23
commit ef3d5b6c22
15 changed files with 727 additions and 35 deletions

View file

@ -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.

View file

@ -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: um revisor sugeriu trocar por uma columns factory; outro validou o Context como decisão correta (só um consumidor hoje, mesmo padrão de `MunicipalityEstimateScenarioProvider`). Mantido como está — não há um 2º catálogo consumindo o Context ainda para justificar a escolha de forma ou outra.
- 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

View 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.',
})

View file

@ -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"

View file

@ -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
}

View file

@ -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>
)
}

View file

@ -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[]

View 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
View 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))
}

View file

@ -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
>

View file

@ -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,
}))
}

View file

@ -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,

View 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)
})
})

View 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.`,
)
})
})