B36: chips de lideranças na lista de dobradinhas

Espelha a aresta leadership.stateDeputies do B31 do lado do deputado, com célula compartilhada bidirecional, loadLeadershipOptions e casca própria de formActions.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 21:14:38 -03:00
parent 8b8a242073
commit 06ea1ff295
15 changed files with 624 additions and 280 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B36 entregue em código (2026-07-26):** coluna "Lideranças" em `/campanha/dobradinhas` — chips nomeados (link `/campanha/liderancas/[id]`) + Popover+Command + auto-save por delta, reusando **sem alteração** a mutação/schema/lock do **B31 ✓** (`setLeadershipStateDeputyMembership`, `leadership-state-deputies:{id}`, `nextStateDeputyIdsAfterMembership`). **Auditoria do plano** (escrito antes do B31 as-built) corrigiu duas defasagens: (1) sem `RelationOptionsProvider`/Context — B31 já tinha trocado Context por columns factory no `/simplify`; (2) casca `dobradinhas/formActions.ts` própria (não import cross-rota de `liderancas/formActions`). **Extração paga aqui:** `LeadershipStateDeputiesCell` (domínio) → `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction: 'fromLeadership' | 'fromStateDeputy'`, `measureOverflow` default true — clamp 3 linhas + "Ver mais…" no lado do deputado; `liderancas` passa `false`). `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Gate: tsc/lint/format/knip/cycles/unit 530 + int (flakes de timeout re-rodados verdes)/build; Aikido 0 findings. Plano: [`chips-liderancas-lista-dobradinhas.md`](../../../docs/plans/chips-liderancas-lista-dobradinhas.md).
- **B30 entregue em código (2026-07-26):** convite de completar cadastro por WhatsApp na lista `/campanha/liderancas` — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` existente (B28 ✓), ao lado do `MessageCircleIcon` de contato; Popover two-step (`Gerar convite` → Alert com âncora `wa.me` + copiar link) reusa `createCampaignInvite`/`kind: 'autopreenchimento'`; disable alinhado ao `whatsAppHrefForPhone` da mesma célula (`hasValidPhone`); sem migration/action/collection nova. Plano revisado pós-B28 (encaixe em `actions`, não coluna `inviteAction`). Gate: tsc/lint/format/cycles; 532 unit (+2) / 406 int; build não rodou sem `PAYLOAD_SECRET`; knip P3 pré-existente; Aikido 0 findings. Plano: [`convite-whatsapp-lista-liderancas.md`](../../../docs/plans/convite-whatsapp-lista-liderancas.md).
- **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).

View file

@ -1,12 +1,23 @@
# Chips de lideranças na lista de dobradinhas (espelho da coluna Municípios de Assessores)
Status: rascunho
Atualizado em: 2026-07-25
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B36**)
Impeccable: B — encaixe na coluna "Lideranças" de `/campanha/dobradinhas`, reusando o padrão visual já em produção em `/campanha/assessores` (**B19 ✓**) e a mutação desenhada pelo **B31** para o lado inverso da mesma relação
Appetite: ~0,5–0,75 dia se **B31** já tiver aterrissado (só a direção nova + o loader da lista); ~1–1,5 dia se **B36** entrar primeiro (paga a extração do componente/ação compartilhados que o B31 passaria a consumir)
Status: entregue (2026-07-26)
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B36** ✓)
Impeccable: B — encaixe na coluna "Lideranças" de `/campanha/dobradinhas`, reusando o padrão visual já em produção em `/campanha/assessores` (**B19 ✓**) e a mutação entregue pelo **B31 ✓** para o lado inverso da mesma relação
Appetite: ~0,75–1d (B31 já aterrissado; paga a extração da célula compartilhada que o B31 deixou pendente)
Responsável: —
## Revisão na entrega (2026-07-26)
Auditoria pré-implementação contra o B31 as-built:
1. **Context → columns factory.** O plano pedia `RelationOptionsProvider`; o B31 `/simplify` rodada 2 já tinha deletado o Context em favor de `leadershipColumns(options)`. Entrega seguiu o precedente: `stateDeputyColumns(..., leadershipOptions)` + `toLeadershipRelationOptions`.
2. **Casca por rota.** `setLeadershipStateDeputyMembershipFormAction` ganhou cópia fina em `dobradinhas/formActions.ts` (não importa de `liderancas/` — `formActions` são cascas por rota no codebase-map). Mutação/schema/lock inalterados.
3. **Célula compartilhada.** `LeadershipStateDeputiesCell` deletado; nasce `shared/LeadershipStateDeputyRelationCell` com `direction` + `measureOverflow` (default true; lado liderança passa `false`). Tipos de UI: `RelationCellItem` / `RelationCellOption`.
4. **Loader.** `leadershipCount` → `leaderships: {id,name}[]` em `StateDeputyRowViewModel`; `loadLeadershipOptions` em `campaignRelationOptions.ts`.
As-built bate com as Decisões travadas de mutação/reuso; só o packaging de UI/catalogo divergiu do rascunho de 25/07, alinhado ao B31 real.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípios 3 **Edit where you see**, 4 **Auto-save, no Save button**, 8 Feel the action; anti-goals "spreadsheet mode" e "always-mounted inputs on every row") / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · precedentes de interação [`AdvisorMunicipalityCell.tsx`](../../src/components/campaign/advisor/AdvisorMunicipalityCell.tsx) (B19 ✓ — chips removíveis + typeahead + otimista + `ResizeObserver`/"Ver mais…") e o desenho ainda não implementado do **B31** ([`dobradinhas-lista-liderancas.md`](dobradinhas-lista-liderancas.md) — chips + Popover + `Command`, sem "Ver mais…", para a mesma relação `leadership.stateDeputies` na direção oposta) · shells `CampaignTable` / `CampaignPageShell` · regras `.cursor/rules/campanha-edit-where-you-see.mdc` e `campanha-action-feedback.mdc`.

File diff suppressed because one or more lines are too long

View file

@ -7,7 +7,12 @@ import {
setAdvisorMunicipalityMembership,
updateAdvisorProfile,
} from '@/app/(campaign)/campanha/actions/advisor'
import { nullableFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import {
nullableFormText,
requiredFormBoolean,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { ADVISOR_ACTION_SAFE_MESSAGES } from '@/lib/schemas/advisor'
import {
runCampaignFormAction,
@ -65,11 +70,10 @@ export const setAdvisorMunicipalityMembershipFormAction = async (
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const assignedRaw = requiredFormText(formData, 'assigned')
await setAdvisorMunicipalityMembership({
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
municipalityId: requiredRelationshipFormValue(formData, 'municipalityId'),
assigned: assignedRaw === 'true',
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Carteira atualizada.' }
},
@ -87,11 +91,10 @@ export const setAdvisorMunicipalitiesBatchFormAction = async (
if (municipalityIds.length === 0) {
throw new Error(EMPTY_BATCH_MESSAGE)
}
const assignedRaw = requiredFormText(formData, 'assigned')
await setAdvisorMunicipalitiesBatch({
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
municipalityIds,
assigned: assignedRaw === 'true',
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Carteira atualizada.' }
},

View file

@ -0,0 +1,32 @@
'use server'
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
import { requiredFormBoolean, 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 "Lideranças" column of `/campanha/dobradinhas` (B36). */
export const setLeadershipStateDeputyMembershipFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setLeadershipStateDeputyMembership({
leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'),
stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Lideranças atualizadas.' }
},
safeMessages,
genericMessage: 'Não foi possível atualizar as lideranças. Tente novamente.',
})

View file

@ -15,6 +15,10 @@ import {
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import {
LeadershipStateDeputyRelationCell,
type RelationCellOption,
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters'
import { StateDeputySortableHead } from '@/components/campaign/stateDeputy/StateDeputySortableHead'
@ -30,6 +34,7 @@ import {
import { cn } from '@/lib/utils'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadLeadershipOptions } from '@/utilities/campaignRelationOptions'
import {
loadStateDeputyListPageData,
type StateDeputyRowViewModel,
@ -46,6 +51,8 @@ import {
type StateDeputyListState,
} from '@/utilities/stateDeputyListUrl'
import { setLeadershipStateDeputyMembershipFormAction } from './formActions'
type StateDeputiesPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
@ -79,6 +86,7 @@ const stateDeputyColumns = (
state: StateDeputyListState,
partyFilterOptions: StateDeputyFilterOption[],
hasNoPartyOption: boolean,
leadershipOptions: RelationCellOption[],
): Array<CampaignTableColumn<StateDeputyRowViewModel>> => [
{
id: 'name',
@ -121,8 +129,20 @@ const stateDeputyColumns = (
{
id: 'leaderships',
head: <CampaignTableHead>Lideranças</CampaignTableHead>,
cellClassName: 'tabular-nums',
cell: (row) => row.leadershipCount,
cellClassName: 'max-w-72 whitespace-normal',
cell: (row) => (
<LeadershipStateDeputyRelationCell
direction="fromStateDeputy"
fixedId={row.id}
items={row.leaderships.map((leadership) => ({
id: leadership.id,
label: leadership.name,
href: `/campanha/liderancas/${leadership.id}`,
}))}
options={leadershipOptions}
membershipAction={setLeadershipStateDeputyMembershipFormAction}
/>
),
},
]
@ -135,11 +155,10 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
const { rows, totalDocs, totalPages, filterFacets } = await loadStateDeputyListPageData(
payload,
user,
canonicalUrl.state,
)
const [{ rows, totalDocs, totalPages, filterFacets }, leadershipOptions] = await Promise.all([
loadStateDeputyListPageData(payload, user, canonicalUrl.state),
loadLeadershipOptions(payload, user),
])
const resolvedUrl = resolveStateDeputyListUrl(rawSearchParams, totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
@ -147,6 +166,20 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
const { sort, dir } = resolveStateDeputyListSort(state)
const sortSummary = formatStateDeputyListSortSummary(sort, dir)
const partyFilterOptions = filterFacets.parties.map((party) => ({ value: party, label: party }))
const columns = stateDeputyColumns(
state,
partyFilterOptions,
filterFacets.hasNoParty,
leadershipOptions.map((option) => ({
id: option.id,
searchLabel: option.name,
item: {
id: option.id,
label: option.name,
href: `/campanha/liderancas/${option.id}`,
},
})),
)
return (
<CampaignPageShell>
@ -154,8 +187,8 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Dobradinhas</h1>
<p className="text-muted-foreground">
Deputados estaduais com quem a campanha dobra — vincule a municípios e lideranças nas
fichas correspondentes.
Deputados estaduais com quem a campanha dobra — vincule lideranças na própria lista e
municípios nas fichas correspondentes.
</p>
</div>
<Button asChild className="min-h-11">
@ -179,7 +212,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
</p>
<CampaignTable
caption={`${sortSummary}. Deputados estaduais com quem a campanha dobra.`}
columns={stateDeputyColumns(state, partyFilterOptions, filterFacets.hasNoParty)}
columns={columns}
rows={rows}
rowKey={(row) => row.id}
empty={<StateDeputyListEmptyState state={state} />}

View file

@ -1,7 +1,7 @@
'use server'
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import { requiredFormBoolean, requiredRelationshipFormValue } from '@/lib/formData'
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
import {
runCampaignFormAction,
@ -20,11 +20,10 @@ export const setLeadershipStateDeputyMembershipFormAction = async (
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const assignedRaw = requiredFormText(formData, 'assigned')
await setLeadershipStateDeputyMembership({
leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'),
stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
assigned: assignedRaw === 'true',
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Dobradinhas atualizadas.' }
},

View file

@ -5,7 +5,6 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
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'
@ -20,16 +19,17 @@ import {
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import {
LeadershipStateDeputyRelationCell,
type RelationCellOption,
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
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 { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
import {
buildLeadershipListHref,
loadLeadershipListPageData,
@ -44,7 +44,7 @@ type LeadershipsPageProps = {
}
const leadershipColumns = (
stateDeputyOptions: StateDeputyRelationOption[],
stateDeputyOptions: RelationCellOption[],
): Array<CampaignTableColumn<LeadershipRowViewModel>> => [
{
id: 'name',
@ -99,11 +99,18 @@ const leadershipColumns = (
head: <CampaignTableHead>Dobradinhas</CampaignTableHead>,
cellClassName: 'max-w-56 whitespace-normal',
cell: (row) => (
<LeadershipStateDeputiesCell
leadershipId={row.id}
stateDeputies={row.stateDeputies}
<LeadershipStateDeputyRelationCell
direction="fromLeadership"
fixedId={row.id}
items={row.stateDeputies.map((deputy) => ({
id: deputy.id,
label: deputy.name,
href: `/campanha/dobradinhas/${deputy.slug}`,
...(deputy.party ? { party: deputy.party } : {}),
}))}
options={stateDeputyOptions}
membershipAction={setLeadershipStateDeputyMembershipFormAction}
measureOverflow={false}
/>
),
},
@ -173,7 +180,18 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
loadLeadershipListPageData(payload, user, state),
loadStateDeputyOptions(payload, user),
])
const columns = leadershipColumns(stateDeputyOptions)
const columns = leadershipColumns(
stateDeputyOptions.map((option) => ({
id: option.id,
searchLabel: option.name,
item: {
id: option.id,
label: option.plainName,
href: `/campanha/dobradinhas/${option.slug}`,
...(option.party ? { party: option.party } : {}),
},
})),
)
return (
<CampaignPageShell>

View file

@ -1,222 +0,0 @@
'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

@ -0,0 +1,385 @@
'use client'
import { PencilIcon, XIcon } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { toast } from 'sonner'
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'
export type RelationCellItem = {
id: number
label: string
href: string
party?: string
}
export type RelationCellOption = {
id: number
searchLabel: string
item: RelationCellItem
}
type LeadershipStateDeputyRelationDirection = 'fromLeadership' | 'fromStateDeputy'
type LeadershipStateDeputyRelationCellProps = {
direction: LeadershipStateDeputyRelationDirection
fixedId: number
items: RelationCellItem[]
options: RelationCellOption[]
membershipAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
/** Clamp rest chips to 3 rows + "Ver mais…". Default true; pass false when few chips are expected. */
measureOverflow?: boolean
}
const COLLAPSED_CHIP_ROWS = 3
/** Matches `gap-1` on the rest chip row. */
const CHIP_GAP_PX = 4
const COPY = {
fromLeadership: {
editLabel: 'Editar dobradinhas',
title: 'Atribuir dobradinhas',
description: 'Deputados estaduais em dobradinha com esta liderança.',
searchPlaceholder: 'Buscar deputado estadual…',
searchAriaLabel: 'Buscar deputado estadual',
saving: 'Salvando dobradinhas.',
savingAria: 'Salvando dobradinhas',
saveError: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
},
fromStateDeputy: {
editLabel: 'Editar lideranças',
title: 'Atribuir lideranças',
description: 'Lideranças em dobradinha com este deputado estadual.',
searchPlaceholder: 'Buscar liderança…',
searchAriaLabel: 'Buscar liderança',
saving: 'Salvando lideranças.',
savingAria: 'Salvando lideranças',
saveError: 'Não foi possível atualizar as lideranças. Tente novamente.',
},
} as const
const itemLabel = (item: RelationCellItem): string =>
item.party ? `${item.label} (${item.party})` : item.label
/**
* Bidirectional cell for the `leadership.stateDeputies` edge.
* Both directions write `leadershipId`/`stateDeputyId`/`assigned` through the
* same membership action.
*/
export const LeadershipStateDeputyRelationCell = ({
direction,
fixedId,
items,
options,
membershipAction,
measureOverflow = true,
}: LeadershipStateDeputyRelationCellProps) => {
const copy = COPY[direction]
const [open, setOpen] = useState(false)
const [current, setCurrent] = useState<RelationCellItem[]>(items)
const [query, setQuery] = useState('')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const lastPropsRef = useRef(items)
const [expanded, setExpanded] = useState(false)
const [collapsedHeight, setCollapsedHeight] = useState<number | null>(null)
/** `null` means "render every chip so the next effect can measure them". */
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
const [measureToken, setMeasureToken] = useState(0)
const lastRowWidth = useRef(0)
const chipRowRef = useRef<HTMLDivElement>(null)
const editRef = useRef<HTMLButtonElement>(null)
// 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 = items.map((item) => item.id)
if (
sameIdSet(
propIds,
lastPropsRef.current.map((item) => item.id),
)
)
return
lastPropsRef.current = items
setCurrent(items)
}, [items])
const currentIds = useMemo(() => new Set(current.map((item) => item.id)), [current])
const optionById = useMemo(() => new Map(options.map((option) => [option.id, option])), [options])
const filteredOptions = useMemo(() => {
if (!open) return []
return options.filter(
(option) => !currentIds.has(option.id) && matchesAtWordStart(option.searchLabel, query),
)
}, [open, options, currentIds, query])
const invalidateMeasurement = useCallback(() => {
setVisibleChipCount(null)
setMeasureToken((token) => token + 1)
}, [])
const chipsKey = current.map((item) => item.id).join('|')
useEffect(() => {
if (!measureOverflow) return
invalidateMeasurement()
}, [chipsKey, measureOverflow, invalidateMeasurement])
useEffect(() => {
if (!measureOverflow) return
const row = chipRowRef.current
if (!row) return
lastRowWidth.current = row.clientWidth
const observer = new ResizeObserver(() => {
if (row.clientWidth === lastRowWidth.current) return
lastRowWidth.current = row.clientWidth
invalidateMeasurement()
})
observer.observe(row)
return () => observer.disconnect()
}, [measureOverflow, invalidateMeasurement])
useEffect(() => {
if (!measureOverflow || visibleChipCount !== null) return
const row = chipRowRef.current
if (!row) return
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-relation-chip]')]
if (chipElements.length === 0) {
setVisibleChipCount(0)
return
}
const rowRect = row.getBoundingClientRect()
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
const firstChip = chipRects[0]
if (!firstChip) {
setVisibleChipCount(0)
return
}
const chipHeight = firstChip.height
setCollapsedHeight(chipHeight * COLLAPSED_CHIP_ROWS + CHIP_GAP_PX * (COLLAPSED_CHIP_ROWS - 1))
const lastVisibleTop = firstChip.top + (chipHeight + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
// Reserve room on the last visible line for "Ver mais…" (and the sibling pencil).
const toggleWidth =
row.querySelector<HTMLElement>('[data-relation-toggle]')?.getBoundingClientRect().width ?? 0
const pencilWidth = editRef.current?.getBoundingClientRect().width ?? 0
const trailingWidth = toggleWidth + CHIP_GAP_PX + pencilWidth
while (fitting > 0 && fitting < chipElements.length) {
const trailing = chipRects[fitting - 1]
if (!trailing || trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
fitting -= 1
}
setVisibleChipCount(fitting)
}, [measureOverflow, measureToken, visibleChipCount])
const toggle = (itemId: 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 an item that has
// since dropped out of `options` — same asymmetry as the `optionById`
// lookup below, which only needs to resolve *additions*.
const removedItem = assigned ? undefined : current.find((item) => item.id === itemId)
if (assigned && !optionById.has(itemId)) return
setErrorMessage(null)
setCurrent((previous) => {
if (assigned) {
if (previous.some((item) => item.id === itemId)) return previous
const option = optionById.get(itemId)
if (!option) return previous
return [...previous, option.item]
}
return previous.filter((item) => item.id !== itemId)
})
setQuery('')
const formData = new FormData()
if (direction === 'fromLeadership') {
formData.set('leadershipId', String(fixedId))
formData.set('stateDeputyId', String(itemId))
} else {
formData.set('leadershipId', String(itemId))
formData.set('stateDeputyId', String(fixedId))
}
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((item) => item.id !== itemId)
: removedItem
? [...previous, removedItem]
: previous,
)
const message = result.message ?? copy.saveError
setErrorMessage(message)
toast.error(message)
})
}
const statusMessage = errorMessage ?? (isPending ? copy.saving : '')
const showAllChips = !measureOverflow || expanded
const measuring = measureOverflow && visibleChipCount === null
const hasHiddenChips =
measureOverflow && visibleChipCount !== null && visibleChipCount < current.length
const visibleItems = (() => {
if (!measureOverflow || showAllChips || measuring || visibleChipCount === null) return current
return current.slice(0, visibleChipCount)
})()
/**
* Only the pre-measurement render needs a height cap; once the chip list is
* trimmed the row is three lines tall on its own.
*/
const clamping = measuring && !showAllChips
const chipRowStyle =
clamping && collapsedHeight !== null ? { maxHeight: collapsedHeight } : undefined
// Mounted while measuring so width is reserved; hidden until overflow is known.
const expandToggle =
measureOverflow && (measuring || hasHiddenChips) ? (
<button
type="button"
data-relation-toggle
tabIndex={measuring ? -1 : undefined}
aria-hidden={measuring || undefined}
className={cn(
'px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
measuring && 'invisible pointer-events-none',
)}
onClick={(event) => {
event.stopPropagation()
setExpanded((previous) => !previous)
}}
>
{showAllChips ? 'Ver menos' : 'Ver mais…'}
</button>
) : null
return (
<div className="flex flex-wrap items-center gap-1.5">
<div
ref={chipRowRef}
className={cn(
'flex flex-wrap items-center gap-1',
measureOverflow && current.length > 0 ? 'min-w-0 flex-1' : undefined,
clamping && 'overflow-hidden',
)}
style={chipRowStyle}
>
{current.length === 0 ? (
<span className="text-sm text-muted-foreground">—</span>
) : (
visibleItems.map((item) => (
<Badge key={item.id} variant="outline" asChild>
<Link href={item.href} data-relation-chip className="max-w-full truncate">
{itemLabel(item)}
</Link>
</Badge>
))
)}
{expandToggle}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
ref={editRef}
type="button"
aria-expanded={open}
aria-haspopup="dialog"
aria-label={copy.editLabel}
className={cn(
'inline-flex min-h-11 min-w-11 shrink-0 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">{copy.title}</p>
<p className="text-xs text-muted-foreground">{copy.description}</p>
{isPending ? (
<Spinner
className="absolute top-3 right-3 size-3.5 text-muted-foreground"
aria-label={copy.savingAria}
/>
) : null}
</div>
{current.length > 0 ? (
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
{current.map((item) => (
<button
key={item.id}
type="button"
aria-label={`Remover ${item.label}`}
onClick={() => toggle(item.id, false)}
>
<Badge
variant="secondary"
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
>
<span className="truncate">{itemLabel(item)}</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={copy.searchPlaceholder}
aria-label={copy.searchAriaLabel}
/>
<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={`${direction}-${option.id}`}
onSelect={() => toggle(option.id, true)}
>
<span className="truncate">{option.searchLabel}</span>
</CommandItem>
))
)}
</CommandList>
</Command>
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</PopoverContent>
</Popover>
</div>
)
}

View file

@ -32,6 +32,14 @@ export const requiredFormText = (formData: FormData, field: string): string => {
return value
}
/** Membership toggles send `'true'` / `'false'` as form text. */
export const requiredFormBoolean = (formData: FormData, field: string): boolean => {
const value = requiredFormText(formData, field)
if (value === 'true') return true
if (value === 'false') return false
throw new FormDataBoundaryError(field, 'Valor inválido.')
}
export const requiredFormSecret = (formData: FormData, field: string): string => {
const entry = formEntry(formData, field)
const value = entry.present ? entry.value : ''

View file

@ -4,7 +4,7 @@ import type { Payload } from 'payload'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import type { CampaignUser } from '@/payload-types'
import { relationshipId } from '@/utilities/relationship'
import { populatedContactName, relationshipId } from '@/utilities/relationship'
export type ActivityRelationOption = RelationOption & {
municipalityId: number
@ -105,3 +105,30 @@ export const loadStateDeputyOptions = async (
slug: stateDeputy.slug,
}))
}
/**
* Leadership catalog for typeahead cells (B36). Access of `canReadLeadership`
* already scopes an advisor to leaderships of administered municipalities —
* no manual intersection with `getAccessibleLeadershipIds`.
*/
export const loadLeadershipOptions = async (
payload: Payload,
user: CampaignUser,
): Promise<RelationOption[]> => {
const result = await payload.find({
collection: 'leadership',
depth: 1,
limit: 0,
pagination: false,
select: { contact: true },
where: {},
user,
overrideAccess: false,
})
return result.docs
.map((leadership) => ({
id: leadership.id,
name: populatedContactName(leadership.contact),
}))
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
}

View file

@ -13,6 +13,13 @@ export const relationshipId = (value: unknown): number | null => {
return isPopulatedRelationship(value) ? value.id : null
}
/** Display name from a populated `Contact` relation (or `fallback` when unpopulated). */
export const populatedContactName = (contact: unknown, fallback = 'Contato'): string => {
if (typeof contact !== 'object' || contact === null || !('name' in contact)) return fallback
const { name } = contact
return typeof name === 'string' && name.length > 0 ? name : fallback
}
export const requireRelationshipId = (
value: unknown,
message = 'Relacionamento inválido.',

View file

@ -2,8 +2,8 @@ import 'server-only'
import type { Payload } from 'payload'
import type { CampaignUser, Leadership, StateDeputy } from '@/payload-types'
import { relationshipId } from '@/utilities/relationship'
import type { CampaignUser, StateDeputy } from '@/payload-types'
import { populatedContactName, relationshipId } from '@/utilities/relationship'
import {
buildStateDeputyListWhere,
NO_PARTY_FILTER_VALUE,
@ -13,13 +13,18 @@ import {
type StateDeputyListState,
} from '@/utilities/stateDeputyListUrl'
type LeadershipRelationSummary = {
id: number
name: string
}
export type StateDeputyRowViewModel = {
id: number
name: string
slug: string
party: string | null
municipalityCount: number
leadershipCount: number
leaderships: LeadershipRelationSummary[]
}
/** Values still reachable under the OTHER active filters (the Partido popover). */
@ -94,7 +99,7 @@ export const loadStateDeputyListPageData = async (
const stateDeputyIDs = result.docs.map((doc) => doc.id)
const municipalityCounts = new Map<number, number>()
const leadershipCounts = new Map<number, number>()
const leadershipsByDeputy = new Map<number, LeadershipRelationSummary[]>()
if (stateDeputyIDs.length) {
const [municipalities, leaderships] = await Promise.all([
@ -110,11 +115,14 @@ export const loadStateDeputyListPageData = async (
payload.find({
collection: 'leadership',
where: { stateDeputies: { in: stateDeputyIDs } },
depth: 0,
depth: 1,
limit: 0,
pagination: false,
select: { stateDeputies: true },
overrideAccess: true,
select: { stateDeputies: true, contact: true },
// Names ship in the list cell — honour canReadLeadership (unlike
// municipalityCounts above, which stays aggregate-only).
user,
overrideAccess: false,
}),
])
@ -128,13 +136,24 @@ export const loadStateDeputyListPageData = async (
}
for (const leadership of leaderships.docs) {
const summary: LeadershipRelationSummary = {
id: leadership.id,
name: populatedContactName(leadership.contact),
}
for (const deputy of leadership.stateDeputies ?? []) {
const id = relationshipId(deputy)
if (id !== null && stateDeputyIDs.includes(id)) {
leadershipCounts.set(id, (leadershipCounts.get(id) ?? 0) + 1)
const list = leadershipsByDeputy.get(id) ?? []
list.push(summary)
leadershipsByDeputy.set(id, list)
}
}
}
for (const [id, list] of leadershipsByDeputy) {
list.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
leadershipsByDeputy.set(id, list)
}
}
return {
@ -144,7 +163,7 @@ export const loadStateDeputyListPageData = async (
slug: doc.slug,
party: doc.party ?? null,
municipalityCount: municipalityCounts.get(doc.id) ?? 0,
leadershipCount: leadershipCounts.get(doc.id) ?? 0,
leaderships: leadershipsByDeputy.get(doc.id) ?? [],
})),
totalDocs: result.totalDocs,
totalPages: result.totalPages,
@ -217,16 +236,10 @@ export const loadStateDeputyDetail = async (
name: municipality.name,
slug: municipality.slug,
})),
leaderships: leaderships.docs.map((leadership) => {
const contact = leadership.contact as Leadership['contact']
return {
id: leadership.id,
name:
typeof contact === 'object' && contact !== null && 'name' in contact
? String(contact.name)
: 'Contato',
}
}),
leaderships: leaderships.docs.map((leadership) => ({
id: leadership.id,
name: populatedContactName(leadership.contact),
})),
}
}

View file

@ -132,7 +132,36 @@ describe('loadStateDeputyListPageData', () => {
slug: stateDeputy.slug,
party: 'PT',
municipalityCount: 0,
leadershipCount: 0,
leaderships: [],
})
})
it('resolves named leaderships linked to the deputy (not a bare count)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const stateDeputy = await fixtures.createStateDeputy({ party: 'PT' })
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
})
await payload.update({
collection: 'leadership',
id: leadership.id,
data: { stateDeputies: [stateDeputy.id] },
depth: 0,
overrideAccess: true,
})
const result = await loadStateDeputyListPageData(payload, coordinator, {
page: 1,
q: stateDeputy.name,
})
expect(result.rows[0]).toMatchObject({
slug: stateDeputy.slug,
leaderships: [{ id: leadership.id, name: contact.name }],
})
})