Merge pull request #367 from fsolla/agent/359-liderancas-coluna-municipios

B155 — Coluna de lideranças na tabela de municípios (espelho de Assessores)
This commit is contained in:
Francisco Solla 2026-08-04 02:23:11 -03:00 committed by GitHub
commit 4cccc4608c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1309 additions and 36 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-04):** **B155** (Issue #359) — coluna de lideranças na lista de municípios (espelho de Assessores): coluna "Lideranças" após "Assessores" (staff; leader fora), chips com nome do contato, popover/sheet com Command + delta otimista (seq/pending/rollback) e criação inline (mini-form Nome + Celular). **Sem migration**: leitura por query reversa de `leadership.municipalities` (batch no bundle, `loadMunicipalityLeadershipSummaries`) e escrita reusando B34 (`setLeadershipMunicipalitiesMembershipRecord`) + wizard create (`createLeadershipWizardRecord`) — fonte única de verdade, consistência automática com `/campanha/liderancas`. Rota nova `POST /campanha/municipios/leaderships` (`campaignJsonMutationRoute`, union toggle|create, safe-messages allowlist); provider bridge espelho do B154. Regra travada com o humano: **célula → rota, form → server action** (débito de migração das 3 anomalias — sinal da lista e colunas B31/B34 de `/campanha/liderancas`). Intenção [`liderancas-coluna-municipios.md`](docs/plans/liderancas-coluna-municipios.md); impl [`liderancas-coluna-municipios-impl.md`](docs/plans/liderancas-coluna-municipios-impl.md).
**Recently resolved (2026-08-03):** **B154** (Issue #352) — criar assessor inline no popover da lista de municípios: opção "+ Criar assessor '<nome>'" quando a busca não casa ninguém; cria `campaignUser` advisor (e-mail stub `<slug>@criado.invalid`, senha aleatória) e atribui ao município na MESMA transação (cap 10 com rollback, lock `municipality-advisors:{id}`); provider client compartilha o assessor criado entre linhas; chip otimista com id temporário negativo; `advisorNameSchema` unificado em `primitives.ts`; predicado de placeholder estende a `@criado.invalid`. Bônus pré-existentes destravando o e2e do spec municipios: key warning do fragment `trailing` de `MunicipalityFilters` (array com keys) e settle-wait da animação do Drawer no teste B136. Intenção [`criar-assessor-inline-popover-municipios.md`](docs/plans/criar-assessor-inline-popover-municipios.md); impl [`criar-assessor-inline-popover-municipios-impl.md`](docs/plans/criar-assessor-inline-popover-municipios-impl.md).
**Recently resolved (2026-08-03):** **B86** (Issue #22) — ações rápidas de apoiadores (staff), último catálogo do lote B80–B90: módulo `supporterQuickActions.ts` (espelha B87) com `register-supporter` → `/apoiadores/novo` (todo staff) e `import-supporters` → `/apoiadores/importar` (coordinator-only, espelha `isCampaignCoordinator`); superfície lista+detalhe numérico; `novo`/`importar` só-busca; leader fora. Sem migration/Consent. Impl [`acoes-rapidas-apoiadores-impl.md`](docs/plans/acoes-rapidas-apoiadores-impl.md); débito S1 (normalização de pathname de lista, 6+ call sites) → #353 [`escala-dry-pos-acoes-rapidas.md`](docs/plans/escala-dry-pos-acoes-rapidas.md).

View file

@ -0,0 +1,88 @@
# Impl: Coluna de lideranças na tabela de municípios
Status: aprovado
Atualizado em: 2026-08-04
Issue: #359
Intenção: docs/plans/liderancas-coluna-municipios.md
Appetite restante: herdado (~0,5–1 dia eng)
## Leitura da intenção
- **Outcome:** staff vê e edita, direto na lista `/campanha/municipios`, as lideranças vinculadas a cada município — chips com nome do contato, popover com busca, adição/remoção com auto-save otimista, e criação inline (nome + telefone) sem sair da tabela. Coluna ao lado de "Assessores".
- **O que NÃO negociar:** líder (`leader`) não vê a coluna (nem acessa a página — `gate: noLeader` já cobre); assessor só vê/edita lideranças dos municípios que administra (`canReadLeadership` + `assertMunicipalitiesWithinScope`); sem collection nova; sem migration fora do padrão; `Consent` permanece fail-closed (a criação inline reusa `createValidatedLeadershipRecord`, que não introduz chave nova — mesmo comportamento do form completo).
- **O que reavaliar:** a hipótese da intenção ("novo field `leadership` no `Municipality`, mirror de `advisors`", com migração de reconciliação) é **rejeitada** neste impl plan. A relação canônica já existe (`leadership.municipalities`) e o B34 já escreve esse lado. Ler por query reversa + reusar as actions B34/B154 elimina migration, join table nova e sync bidirecional.
## Abordagem recomendada
```mermaid
flowchart LR
A["page.tsx /campanha/municipios"] --> B["loadMunicipalityListPageBundle"]
B --> C["query reversa (staff): leaderships<br/>municipalities in pageIDs, depth 1"]
C --> D["rows ganham leadershipIDs<br/>+ retorna leadershipNamesById"]
A --> E["getEligibleLeadershipOptions (staff)"]
D --> F["MunicipalityList — coluna Lideranças"]
E --> F
F --> G["MunicipalityListLeadershipsControl<br/>(popover desktop / sheet mobile)"]
G --> I["POST /campanha/municipios/leaderships"]
I --> J["toggle → setLeadershipMunicipalitiesMembership (B34, reuso)"]
I --> K["create → createMunicipalityLeadership (wrapper fino<br/>→ createLeadershipWizardRecord existente)"]
```
**Opções consideradas:** A | B | C
**Recomendação:** **B** — leitura reversa de `leadership.municipalities` + reuso das actions B34/B154/wizard. Zero migration, fonte única de verdade (uma ponta canônica), consistência automática com a coluna "Municípios" de `/campanha/liderancas` (B34) e com a ficha da liderança — sem hooks de sync que possam divergir. Depth check: reusa actions, schemas, `CampaignCellEditOverlay`, padrão de provider do B154.
**Rejeitadas:**
- **A** — novo campo `municipality.leaderships` (hasMany) + migration + migração de reconciliação + sync bidirecional com `leadership.municipalities` por hooks nas duas collections. Custo de reverter alto (join table nova numa collection "system" de 435 rows + hooks de sync que divergem silenciosamente — o classic footgun do Payload); não traz aceite adicional.
- **C** — coluna read-only para assessor (só coordenação edita). Viola o aceite: "Assessor só vê e edita lideranças dos municípios que administra".
### Componentes / mudanças
- **`MunicipalityListColumnId`** (`src/utilities/municipality/municipalityLabels.ts`): ganha `'leaderships'` (closed union). `municipalityColumnDescriptions` ganha entrada. **`municipalityColumnLabels`** (`src/utilities/municipality/municipalityListUrl.ts`): `leaderships: 'Lideranças'`.
- **`MunicipalityListViewModel`** (`src/utilities/municipality/municipalityViewModels.ts`): ganha `leadershipIDs: number[]` (default `[]`, populado pelo bundle só na staff view). Novos tipos `MunicipalityLeadershipSummary` (`{ id, name }`) e `EligibleLeadershipOption` (`{ id, name }`), mais os helpers `loadMunicipalityLeadershipSummaries(payload, user, municipalityIDs)` (uma query reversa batch) e `getEligibleLeadershipOptions(payload, user)` (espelho de `getEligibleAdvisorOptions`; escopo via `overrideAccess: false` + user).
- **`loadMunicipalityListPageBundle`** (`src/utilities/municipality/municipalityPageData.ts`): para staff, uma query `leadership where { municipalities: { in: pageIDs } }` com `depth: 1` (`contact`), `user`/`overrideAccess: false` → injeta `leadershipIDs` nos rows e devolve `leadershipNamesById` no bundle.
- **`page.tsx`** (`(app)/municipios`): staff carrega `getEligibleLeadershipOptions` e repassa `leadershipNamesById` + `leadershipOptions` ao `MunicipalityList`.
- **`MunicipalityList.tsx`** + **`MunicipalityListMobileCards.tsx`** (+ pass-through de props em `MunicipalityListMobileSection`): coluna `'leaderships'` no bloco staff **após `advisors`** (sem `MunicipalitySortableHead` — header default via `column.label`; não ordena nem filtra). Picker: entrada em `municipalityListPickerColumns`. Célula = controle novo para **todo** staff (advisors também gerenciam); card mobile ganha a linha "Lideranças" com `variant="sheet"`. Envolve a lista no novo provider.
- **`MunicipalityListLeadershipsControl.tsx`** (novo, client): espelho de `MunicipalityListAdvisorsControl` — chips de nome (sort pt-BR), trigger com `aria-label` por extenso, `CampaignCellEditOverlay` (popover/sheet), `Command` com busca `matchesAtWordStart`, delta otimista (seq/pending/revert/`latestConfirmedRef`), `Alert` de erro. **Branch de criação** (diferente do B154 que cria em um clique só porque `name` bastava): `CommandItem` "+ Criar liderança" → mini-form inline (Nome pré-preenchido + Telefone `type="tel"`) → POST → chip otimista com temp id negativo → swap pelo id real. Chips de temp-create sem × (mesmo contrato do B154).
- **`MunicipalityLeadershipCreateProvider.tsx`** (novo, client): bridge de opções criadas inline para todo o surface (espelho de `MunicipalityAdvisorCreateProvider`; 2 call sites → mirror, não abstrair).
- **`src/app/(campaign)/campanha/(app)/municipios/leaderships/route.ts`** (+ `types.ts`, novos): `POST` via `campaignJsonMutationRoute`, body `z.union` (toggle `{ municipalityId, leadershipId, assigned }` | create `{ municipalityId, name, phone }`). Resposta `{ status: 'success', leadershipIDs, createdLeadership?: { id, name } }` — `leadershipIDs` = ids das lideranças do município após o write (query reversa id-only, `overrideAccess: true` — o write acabou de ser access-checkado; precedente B34).
- **`src/app/(campaign)/campanha/actions/leadership.ts`**: **toggle** reusa `setLeadershipMunicipalitiesMembership` tal qual (o B34 já revalida ficha da liderança + detalhe do município; a lista onde estamos NÃO é revalidada — o chip é otimista, mesmo contrato do B34). **create**: wrapper fino `createMunicipalityLeadership({ municipalityId, name, phone })` → `createLeadershipWizardRecord` + slug lookup + revalidate (`/campanha/liderancas` lista + `/campanha/municipios/<slug>`), espelhando `createMunicipalityAdvisor`.
- **Schemas / safe messages:** union parseado no route (padrão advisors, zod inline); allowlist = `LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE`, `LEADERSHIP_MUNICIPALITY_CAP_MESSAGE`, `LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE`, `LEADERSHIP_STAFF_MESSAGE`, `LEADERSHIP_DUPLICATE_MESSAGE`, `CONTACT_PHONE_AMBIGUOUS_MESSAGE`, `BRAZILIAN_PHONE_INVALID_MESSAGE`, `POSTGRES_DEDUP_LOCK_MESSAGE` — todas lançadas pelas actions reusadas.
- **Migration:** **nenhuma**.
### Dados → forma
- Forma escolhida: **chips de nome do contato** na célula (espelho de assessores; sem avatar — liderança não tem `user` necessariamente). Popover: chips + Command. Vazio: "Nenhuma" (texto muted) — o `MissingAdvisorBadge` é sobre responsabilidade e não se aplica. Rejeitadas: avatar stack (presume conta); contagem/ranking (intenção é qualitativa — `Dados: N/A`).
## Fases verificáveis
1. **Schema/server** — view model + bundle + helpers + route + wrapper `createMunicipalityLeadership` + int tests (toda a lógica de domínio é reuso: B34 toggle, wizard create; o que é novo é a superfície município + reconcile).
2. **UI** — coluna + picker + `MunicipalityListLeadershipsControl` (shape → craft → critique → polish, Impeccable B) + provider + cards mobile.
3. **Gates** — unit tests (controle + colunas), `pnpm gate:fast` na iteração, `pnpm push` no fechamento; entrada curta em `docs/CHANGELOG-AGENTS.md`.
## Rabbit holes / Não escopo (engenharia)
- **Generalizar** `MunicipalityAdvisorCreateProvider` em um bridge genérico — 2 call sites, não abstrair (mirror).
- **Sync bidirecional / campo novo** — cortado pela Opção B (sem migration).
- **Filtro/ordenação por liderança no header** — fora de escopo (a intenção não pede; o gatilho análogo B29 é da dobradinha).
- **Server-side search das opções** (catálogo grande) — v1 carrega o conjunto visível como `getEligibleAdvisorOptions`; se ficar pesado, trocar para busca server-side tipo `searchActivityLeadershipOptions` (gatilho de revisitação, não fazer agora).
- **e2e do fluxo completo** — opcional na fase 3 (o e2e de municípios existe; decidir na execução se o custo cabe no appetite).
## Riscos e mitigação
- **Assessor adiciona liderança fora da carteira** → `assertMunicipalitiesWithinScope` (B34/create) rejeita com `LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE` (allowlisted) + rollback otimista.
- **Remover o último município de uma liderança** → floor de 1 do `nextMunicipalityIdsAfterLeadershipMembership` → `LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE` (allowlisted); chip volta (rollback).
- **Cap `MAX_LEADERSHIP_MUNICIPALITIES` (30)** → `LEADERSHIP_MUNICIPALITY_CAP_MESSAGE` (allowlisted).
- **Concorrência no toggle** → lock advisory `leadership-municipalities:{id}` já presente no B34.
- **Criação de pessoa duplicada** → `Contact` dedup por telefone + `LEADERSHIP_DUPLICATE_MESSAGE` (allowlisted); toast no popover.
- **`MunicipalityListViewModel` consumido por outros surfaces** → campo novo opcional com default `[]`; nenhum consumidor quebra.
- **Branch/worktree**: o worktree está na branch `plan/b157-…` com um edit não commitado do plano B157. Ao executar, criar `agent/359-liderancas-coluna-municipios` a partir de `origin/main`; o edit do B157 fica preservado (não commitar) — confirmar com o humano como tratar.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (coluna staff, chips, popover, criação inline, scopes)
- [x] Invariantes AGENTS/engineering-standards: sem migration, sem collection nova, access reusado (`overrideAccess: false` onde o ator importa), transação nas escritas multi-collection (já nas actions reusadas), copy pt-BR / identificadores em inglês
- [x] Testes de domínio: int spec novo (`campaignMunicipalityLeadershipList.int.spec.ts` — toggle e create pela superfície município, scopes advisor/coordinator/leader, floor/cap, duplicata) + unit do controle
## Pós-revisão (/simplify, 3 revisores paralelos — read-only)
Aplicados: reconcile **scoped** reusando `loadMunicipalityLeadershipSummaries` nas duas wrappers (elimina o único bypass novo e o gêmeo da query reversa; o create devolve o **nome real do contato** — `contactReused` — para o chip otimista); toggle passa a revalidar `/campanha/liderancas` (a rationale do B34 não transfere: o chamador NÃO está nessa lista); validação cliente espelha o schema (nome `maxLength 120`, telefone via `normalizeBrazilianPhone`); copy "staff" → "a equipe"; ids do mini-form via `useId` (tabela + cards mobile são árvores irmãs); refoco no CommandInput ao fechar o form (foco não cai no body). Deferidos com gatilho: query reversa do bundle fora do `Promise.all` (1 RTT serial no caminho crítico — otimizar se a lista ficar lenta); slug lookup defensivo pós-commit do create.

View file

@ -0,0 +1,92 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import {
createMunicipalityLeadership,
setMunicipalityLeadershipMembership,
} from '@/app/(campaign)/campanha/actions/leadership'
import { BRAZILIAN_PHONE_INVALID_MESSAGE } from '@/lib/phone'
import {
LEADERSHIP_DUPLICATE_MESSAGE,
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
LEADERSHIP_STAFF_MESSAGE,
municipalityLeadershipCreateSchema,
} from '@/lib/schemas/leadership'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import { CONTACT_PHONE_AMBIGUOUS_MESSAGE } from '@/utilities/contactPhoneInvariant'
import { POSTGRES_DEDUP_LOCK_MESSAGE } from '@/utilities/postgresTransactionLocks'
import type { MunicipalityListLeadershipsResponse } from './types'
export type { MunicipalityListLeadershipsResponse } from './types'
export const dynamic = 'force-dynamic'
/**
* Mutually exclusive shapes (B155): the membership delta (`leadershipId` +
* `assigned`) and the name+phone inline create can never combine — `name` is a
* write affordance over `leadership.municipalities`, `leadershipId` a delta.
*/
const bodySchema = z.union([
z.strictObject({
municipalityId: positiveRelationshipId,
leadershipId: positiveRelationshipId,
assigned: z.boolean(),
}),
municipalityLeadershipCreateSchema,
])
export const POST = campaignJsonMutationRoute(
{
bodySchema,
safeMessages: [
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
LEADERSHIP_STAFF_MESSAGE,
LEADERSHIP_DUPLICATE_MESSAGE,
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
BRAZILIAN_PHONE_INVALID_MESSAGE,
POSTGRES_DEDUP_LOCK_MESSAGE,
],
genericMessage:
'Não foi possível atualizar as lideranças. Verifique seu acesso e tente novamente.',
},
async (body) => {
if ('name' in body) {
const { leadership, leadershipIDs, createdLeadershipName } =
await createMunicipalityLeadership({
municipalityId: body.municipalityId,
name: body.name,
phone: body.phone,
})
return NextResponse.json<MunicipalityListLeadershipsResponse>({
status: 'success',
message: 'Liderança criada e vinculada.',
leadershipIDs,
// The real contact name, not the typed one: a phone-matched create
// (`contactReused`) keeps the stored Contact's name.
createdLeadership: {
id: leadership.id,
name: createdLeadershipName,
},
})
}
const { leadershipIDs } = await setMunicipalityLeadershipMembership({
municipalityId: body.municipalityId,
leadershipId: body.leadershipId,
assigned: body.assigned,
})
return NextResponse.json<MunicipalityListLeadershipsResponse>({
status: 'success',
message: body.assigned ? 'Liderança vinculada.' : 'Liderança removida.',
leadershipIDs,
})
},
)

View file

@ -0,0 +1,13 @@
export type MunicipalityListLeadershipsResponse =
| {
status: 'success'
message: string
/** The município's resulting leadership id set, for the optimistic reconcile. */
leadershipIDs: number[]
/** B155 — present only on the inline-create path; the client registers it locally. */
createdLeadership?: { id: number; name: string }
}
| {
status: 'error'
message: string
}

View file

@ -36,6 +36,7 @@ import {
import { loadMunicipalityListPageBundle } from '@/utilities/municipality/municipalityPageData'
import {
getEligibleAdvisorOptions,
getEligibleLeadershipOptions,
loadAdvisorSummaries,
} from '@/utilities/municipality/municipalityViewModels'
import { createMunicipalityListSignalFormAction } from './municipalityStaffFormActions'
@ -60,7 +61,13 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
const canMoveEngagementLevel = isCampaignUnrestricted(user)
const pageBundle = await loadMunicipalityListPageBundle(payload, user, rawSearchParams)
const { municipalities: listMunicipalities, totalDocs, totalPages, filterFacets } = pageBundle
const {
municipalities: listMunicipalities,
totalDocs,
totalPages,
filterFacets,
leadershipNamesById,
} = pageBundle
const resolvedUrl = resolveMunicipalityListUrl(rawSearchParams, totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
@ -75,10 +82,13 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
...filterFacets.advisorIDs,
]),
]
const [advisorSummaries, advisorOptions] = await Promise.all([
const [advisorSummaries, advisorOptions, leadershipOptions] = await Promise.all([
isStaffView ? loadAdvisorSummaries(payload, user, advisorIDs) : [],
// Only the coordinator-only assign-advisors control consumes these.
isCoordinator ? getEligibleAdvisorOptions(payload, user) : [],
// All staff edit the Lideranças column (scoped server-side); the leader
// never reaches this page (`gate: noLeader`).
isStaffView ? getEligibleLeadershipOptions(payload, user) : [],
])
const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor]))
@ -121,10 +131,12 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
<MunicipalityList
municipalities={listMunicipalities}
advisorNamesById={advisorNamesById}
leadershipNamesById={leadershipNamesById}
isStaffView={isStaffView}
isCoordinator={isCoordinator}
canMoveEngagementLevel={canMoveEngagementLevel}
advisorOptions={advisorOptions}
leadershipOptions={leadershipOptions}
columnFilterOptions={columnFilterOptions}
signalFormAction={createMunicipalityListSignalFormAction}
state={state}

View file

@ -24,6 +24,7 @@ import {
type LeadershipStateDeputyMembershipInput,
type LeadershipWizardCreateInput,
type LeadershipWizardUpdateInput,
type MunicipalityLeadershipCreateInput,
} from '@/lib/schemas/leadership'
import type { CampaignUser, Contact } from '@/payload-types'
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
@ -33,6 +34,7 @@ import {
assertContactPhoneAvailable,
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
} from '@/utilities/contactPhoneInvariant'
import { loadMunicipalityLeadershipSummaries } from '@/utilities/municipality/municipalityViewModels'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import {
@ -450,6 +452,89 @@ export const createLeadershipWizard = async (
return leadership
}
/**
* B155 — inline create from the "Lideranças" column of `/campanha/municipios`.
* Thin municipality-surface entry point over the wizard create (Contact dedup
* by phone, transaction, locks). The list we are ON is deliberately not
* revalidated — the chip already shows optimistically (B34 contract); the
* leadership list and the município detail are, so the new row appears there
* without waiting for a refresh.
*/
export const createMunicipalityLeadership = async (input: MunicipalityLeadershipCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
const leadership = await createLeadershipWizardRecord(payload, actor, {
name: input.name,
phone: input.phone,
municipalityId: input.municipalityId,
})
// Intentional admin bypass: existence is enforced by the write's own
// relationship validation; this read only resolves the revalidate target.
const municipality = await payload.findByID({
collection: 'municipality',
id: input.municipalityId,
depth: 0,
select: { slug: true },
overrideAccess: true,
})
revalidateLeadershipWizardPaths(leadership.id, municipality.slug)
// Scoped reverse read doubles as the reconcile payload and the chip name:
// a phone-matched create (`contactReused`) keeps the Contact's own name,
// which the optimistic chip must show from the start.
const { leadershipIDsByMunicipality, summariesById } = await loadMunicipalityLeadershipSummaries(
payload,
actor,
[input.municipalityId],
)
return {
leadership,
leadershipIDs: leadershipIDsByMunicipality.get(input.municipalityId) ?? [],
createdLeadershipName: summariesById.get(leadership.id)?.name ?? input.name,
}
}
/**
* B155 — one-chip toggle from the "Lideranças" column of `/campanha/municipios`:
* the same B34 membership delta (lock, scope-check, floor/cap) keyed by the
* município side, plus the resulting municipality-side id set. The list we are
* on is skipped (optimistic chip); the leadership ficha, the município detail
* AND `/campanha/liderancas` are busted — unlike B34, the caller is NOT on that
* list, so its stale router cache must not survive.
*/
export const setMunicipalityLeadershipMembership = async (input: {
municipalityId: number
leadershipId: number
assigned: boolean
}) => {
const { payload, actor } = await getCampaignActionContext()
const { leadership, municipalitySlugs } = await setLeadershipMunicipalitiesMembershipRecord(
payload,
actor,
{
leadershipId: input.leadershipId,
municipalityIds: [input.municipalityId],
assigned: input.assigned,
},
)
// No-op writes nothing, so there is nothing to revalidate.
if (municipalitySlugs.length > 0) {
revalidateLeadershipMunicipalityPaths(input.leadershipId, municipalitySlugs)
revalidatePath('/campanha/liderancas', 'page')
}
const { leadershipIDsByMunicipality } = await loadMunicipalityLeadershipSummaries(
payload,
actor,
[input.municipalityId],
)
return {
leadership,
leadershipIDs: leadershipIDsByMunicipality.get(input.municipalityId) ?? [],
}
}
/**
* `/campanha/liderancas` — the list the chip was toggled ON — is deliberately
* NOT here: see `revalidateLeadershipMunicipalityPaths` below for the reason.

View file

@ -0,0 +1,49 @@
'use client'
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
import type { EligibleLeadershipOption } from '@/utilities/municipality/municipalityViewModels'
type MunicipalityLeadershipCreateContextValue = {
/** Leaderships created inline (B155) since the page last rendered server-side. */
createdOptions: EligibleLeadershipOption[]
registerCreatedLeadership: (option: EligibleLeadershipOption) => void
}
const MunicipalityLeadershipCreateContext =
createContext<MunicipalityLeadershipCreateContextValue | null>(null)
export const useMunicipalityLeadershipCreate = () => useContext(MunicipalityLeadershipCreateContext)
/**
* B155 — one shared bridge of inline-created leadership options for the whole
* `/campanha/municipios` list surface (desktop table + mobile cards): a create
* in one row's popover makes the new leadership selectable in every other row's
* popover on the same page load, without waiting for an RSC refresh. The bridge
* is short-lived by design — the page is dynamic, so the next navigation's
* `getEligibleLeadershipOptions` already returns the record from the DB.
*
* Deliberately a mirror of `MunicipalityAdvisorCreateProvider` (B154), not a
* generalized bridge: two call sites, and the option types differ (`isCurrent`
* exists only for advisors).
*/
export const MunicipalityLeadershipCreateProvider = ({ children }: { children: ReactNode }) => {
const [createdOptions, setCreatedOptions] = useState<EligibleLeadershipOption[]>([])
const registerCreatedLeadership = useCallback((option: EligibleLeadershipOption) => {
setCreatedOptions((current) =>
current.some((existing) => existing.id === option.id) ? current : [...current, option],
)
}, [])
const contextValue = useMemo(
() => ({ createdOptions, registerCreatedLeadership }),
[createdOptions, registerCreatedLeadership],
)
return (
<MunicipalityLeadershipCreateContext.Provider value={contextValue}>
{children}
</MunicipalityLeadershipCreateContext.Provider>
)
}

View file

@ -7,10 +7,12 @@ import {
MunicipalityAdvisorAvatarStack,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { MunicipalityAdvisorCreateProvider } from '@/components/campaign/municipality/MunicipalityAdvisorCreateProvider'
import { MunicipalityLeadershipCreateProvider } from '@/components/campaign/municipality/MunicipalityLeadershipCreateProvider'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
import { MunicipalityListLeadershipsControl } from '@/components/campaign/municipality/MunicipalityListLeadershipsControl'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import { MunicipalityListMobileSection } from '@/components/campaign/municipality/MunicipalityListMobileSection'
import {
@ -24,7 +26,11 @@ import { MunicipalitySortableHead } from '@/components/campaign/municipality/Mun
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import { buttonVariants } from '@/components/ui/button'
import {
Empty,
@ -60,7 +66,9 @@ import {
} from '@/utilities/municipality/municipalityListUrl'
import type {
EligibleAdvisorOption,
EligibleLeadershipOption,
MunicipalityAdvisorSummary,
MunicipalityLeadershipSummary,
MunicipalityListViewModel,
} from '@/utilities/municipality/municipalityViewModels'
import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
@ -82,6 +90,8 @@ type MunicipalityColumnFilterOptions = {
export type MunicipalityListProps = {
municipalities: MunicipalityListViewModel[]
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
/** B155 — contact-name lookup for the Lideranças column chips. */
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
isStaffView: boolean
isCoordinator: boolean
/**
@ -91,6 +101,8 @@ export type MunicipalityListProps = {
*/
canMoveEngagementLevel: boolean
advisorOptions: EligibleAdvisorOption[]
/** B155 — every leadership the actor may add, for the Lideranças popover. */
leadershipOptions: EligibleLeadershipOption[]
columnFilterOptions: MunicipalityColumnFilterOptions
signalFormAction: MunicipalityStaffFormAction
state: MunicipalityListState
@ -144,6 +156,8 @@ const municipalityListColumns = ({
columnFilterOptions,
advisorNamesById,
advisorOptions,
leadershipNamesById,
leadershipOptions,
signalFormAction,
}: MunicipalityListProps): Array<MunicipalityColumn> => [
{
@ -306,6 +320,29 @@ const municipalityListColumns = ({
? null
: formatAdvisorNamesTooltip(advisorEntries(municipality, advisorNamesById)),
},
{
// Right after "Assessores": the two relations of "who" in the
// município sit together, and both are editable by all staff (the
// advisor only within their portfolio, enforced server-side).
id: 'leaderships',
label: municipalityColumnLabels.leaderships,
// No sort, no filter — the B22 explanation rides on the plain head.
head: (
<CampaignTableHead description={municipalityColumnDescriptions.leaderships}>
{municipalityColumnLabels.leaderships}
</CampaignTableHead>
),
cell: (municipality) => (
<MunicipalityListLeadershipsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentLeadershipIDs={municipality.leadershipIDs}
leadershipNamesById={leadershipNamesById}
options={leadershipOptions}
variant="popover"
/>
),
},
{
id: 'trend',
label: municipalityColumnLabels.trend,
@ -429,6 +466,7 @@ export const municipalityListPickerColumns = ({
{ id: 'classe', label: municipalityColumnLabels.classe },
{ id: 'level', label: municipalityColumnLabels.level },
{ id: 'advisors', label: municipalityColumnLabels.advisors },
{ id: 'leaderships', label: municipalityColumnLabels.leaderships },
{ id: 'trend', label: municipalityColumnLabels.trend },
{ id: 'expectedVotes', label: municipalityColumnLabels.expectedVotes },
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
@ -438,8 +476,16 @@ export const municipalityListPickerColumns = ({
}
export const MunicipalityList = (props: MunicipalityListProps) => {
const { municipalities, advisorNamesById, isStaffView, isCoordinator, advisorOptions, state } =
props
const {
municipalities,
advisorNamesById,
leadershipNamesById,
isStaffView,
isCoordinator,
advisorOptions,
leadershipOptions,
state,
} = props
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir)
const columns = municipalityListColumns(props)
@ -452,34 +498,38 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
return (
<MunicipalityAdvisorCreateProvider>
<MunicipalityListMobileSection
municipalities={municipalities}
advisorNamesById={advisorNamesById}
isStaffView={isStaffView}
isCoordinator={isCoordinator}
canMoveEngagementLevel={props.canMoveEngagementLevel}
advisorOptions={advisorOptions}
signalFormAction={props.signalFormAction}
emptySlot={<MunicipalityListEmptyState state={state} />}
/>
<MunicipalityLeadershipCreateProvider>
<MunicipalityListMobileSection
municipalities={municipalities}
advisorNamesById={advisorNamesById}
leadershipNamesById={leadershipNamesById}
isStaffView={isStaffView}
isCoordinator={isCoordinator}
canMoveEngagementLevel={props.canMoveEngagementLevel}
advisorOptions={advisorOptions}
leadershipOptions={leadershipOptions}
signalFormAction={props.signalFormAction}
emptySlot={<MunicipalityListEmptyState state={state} />}
/>
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
<CampaignTable
className="hidden overflow-visible md:block"
containerClassName="overflow-x-auto"
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
caption={
<>
{sortSummary}
{showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null}
</>
}
columns={columns}
columnVisibility={props.columnVisibility}
rows={municipalities}
rowKey={(municipality) => municipality.id}
empty={<MunicipalityListEmptyState state={state} />}
/>
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
<CampaignTable
className="hidden overflow-visible md:block"
containerClassName="overflow-x-auto"
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
caption={
<>
{sortSummary}
{showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null}
</>
}
columns={columns}
columnVisibility={props.columnVisibility}
rows={municipalities}
rowKey={(municipality) => municipality.id}
empty={<MunicipalityListEmptyState state={state} />}
/>
</MunicipalityLeadershipCreateProvider>
</MunicipalityAdvisorCreateProvider>
)
}

View file

@ -0,0 +1,530 @@
'use client'
import { UserPlusIcon, XIcon } from 'lucide-react'
import { useEffect, useId, useMemo, useRef, useState, type FormEvent } from 'react'
import type { MunicipalityListLeadershipsResponse } from '@/app/(campaign)/campanha/(app)/municipios/leaderships/types'
import { useMunicipalityLeadershipCreate } from '@/components/campaign/municipality/MunicipalityLeadershipCreateProvider'
import {
CampaignCellEditOverlay,
type CampaignCellEditOverlayVariant,
} from '@/components/campaign/shared/CampaignCellEditOverlay'
import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useCampaignCellFailureChannel'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Spinner } from '@/components/ui/Spinner'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import {
formatBrazilianPhoneInput,
normalizeBrazilianPhone,
sanitizeBrazilianPhoneInput,
} from '@/lib/phone'
import { sameIdSet } from '@/lib/sameIdSet'
import { cn } from '@/lib/utils'
import { matchesAtWordStart } from '@/lib/wordStartFilter'
import type {
EligibleLeadershipOption,
MunicipalityLeadershipSummary,
} from '@/utilities/municipality/municipalityViewModels'
const LEADERSHIPS_ENDPOINT = '/campanha/municipios/leaderships'
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar as lideranças. Tente novamente.'
/** B155 — sentinel temp id for an in-flight inline create; negative = never a real leadership. */
const isPendingCreateId = (id: number): boolean => id < 0
// Shared empty values so the memos stay referentially stable when the bridge
// is absent (standalone renders in unit tests) or nothing is pending.
const EMPTY_CREATED_OPTIONS: EligibleLeadershipOption[] = []
const EMPTY_PENDING_CREATES: ReadonlyMap<number, { name: string; phone: string }> = new Map()
/** Resolves leadership ids to summaries, dropping any without a name (deleted/unknown). */
const leadershipEntriesFromIds = (
leadershipIDs: readonly number[],
namesById: ReadonlyMap<number, MunicipalityLeadershipSummary>,
): MunicipalityLeadershipSummary[] =>
leadershipIDs.flatMap((id) => {
const leadership = namesById.get(id)
return leadership ? [{ id: leadership.id, name: leadership.name }] : []
})
type MunicipalityListLeadershipsControlProps = {
municipalityID: number
municipalityName: string
currentLeadershipIDs: number[]
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
options: EligibleLeadershipOption[]
variant: CampaignCellEditOverlayVariant
}
export const MunicipalityListLeadershipsControl = ({
municipalityID,
municipalityName,
currentLeadershipIDs,
leadershipNamesById,
options,
variant,
}: MunicipalityListLeadershipsControlProps) => {
const [open, setOpen] = useState(false)
const [selectedIDs, setSelectedIDs] = useState<number[]>(currentLeadershipIDs)
const [query, setQuery] = useState('')
const { errorMessage, setErrorMessage, reportFailure, noteOpenChange } =
useCampaignCellFailureChannel()
const [isPending, setIsPending] = useState(false)
const pendingCountRef = useRef(0)
const lastPropsIDsRef = useRef(currentLeadershipIDs)
// Requests can settle out of send order (network jitter), so the send order
// is tracked explicitly: only the response with the highest sequence number
// seen so far is kept, and it is adopted — win or fail — once every
// in-flight delta has settled (`pendingCountRef` back to 0). No response
// ever writes `lastPropsIDsRef`: that ref exists solely to tell a genuine
// external prop change (nav/refresh) apart from a no-op re-render carrying
// the same pre-edit content.
const requestSeqRef = useRef(0)
const latestConfirmedRef = useRef<{ seq: number; leadershipIDs: number[] } | null>(null)
// B155 — the inline-create mini-form, hidden until the user picks
// "+ Criar liderança" in the no-results branch.
const [createFormOpen, setCreateFormOpen] = useState(false)
const [createName, setCreateName] = useState('')
const [createPhone, setCreatePhone] = useState('')
// Unique per mounted instance: the desktop table and the mobile cards are
// sibling trees, so the same município renders two controls at once.
const createFormId = useId()
// Search box to return focus to once the create form closes (the form
// unmounts, so focus would otherwise fall to the body).
const commandInputRef = useRef<HTMLInputElement>(null)
const handleOpenChange = (nextOpen: boolean) => {
noteOpenChange(nextOpen)
setOpen(nextOpen)
}
// 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(() => {
if (sameIdSet(currentLeadershipIDs, lastPropsIDsRef.current)) return
lastPropsIDsRef.current = currentLeadershipIDs
setSelectedIDs(currentLeadershipIDs)
}, [currentLeadershipIDs])
// B155 — leaderships created inline since the page last rendered server-side,
// shared across every row's popover through `MunicipalityLeadershipCreateProvider`
// (null when the control renders standalone, e.g. in unit tests).
const createBridge = useMunicipalityLeadershipCreate()
const createdOptions = createBridge?.createdOptions ?? EMPTY_CREATED_OPTIONS
const registerCreatedLeadership = createBridge?.registerCreatedLeadership
// tempId → {name, phone} for in-flight creates: the chip renders optimistically
// while the request is out, and the id is not known until the response arrives.
const [pendingCreates, setPendingCreates] =
useState<ReadonlyMap<number, { name: string; phone: string }>>(EMPTY_PENDING_CREATES)
// Every leadership the actor may add is listed here regardless of current
// assignment, so it doubles as the name lookup for an id the popover just
// optimistically added — no round trip needed to label it.
// `leadershipNamesById` is the fallback for an id that fell out of
// eligibility but is still assigned; `leadershipEntriesFromIds` drops the
// rare id found in neither, same as every other relation cell in the list.
const effectiveOptions = useMemo(() => {
// The provider bridge can outlive a server re-render (sort/filter
// navigation keeps client state, and `getEligibleLeadershipOptions` then
// returns the created record again), so the same leadership can arrive
// twice — dedupe by id to keep combobox entries and React keys unique.
const byId = new Map<number, EligibleLeadershipOption>()
for (const option of options) byId.set(option.id, option)
for (const option of createdOptions) byId.set(option.id, option)
return [...byId.values()]
}, [options, createdOptions])
const leadershipLookup = useMemo(() => {
const lookup = new Map<number, MunicipalityLeadershipSummary>(leadershipNamesById)
for (const option of effectiveOptions)
lookup.set(option.id, { id: option.id, name: option.name })
for (const [tempId, pending] of pendingCreates)
lookup.set(tempId, { id: tempId, name: pending.name })
return lookup
}, [leadershipNamesById, effectiveOptions, pendingCreates])
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs])
const chips = useMemo(
() =>
[...leadershipEntriesFromIds(selectedIDs, leadershipLookup)].sort((left, right) =>
left.name.localeCompare(right.name, 'pt-BR'),
),
[selectedIDs, leadershipLookup],
)
// The trigger already reads the names by extenso — no hover tooltip needed.
const assignedLabel = chips.length ? chips.map((chip) => chip.name).join(', ') : 'nenhuma'
const filteredOptions = useMemo(
() => effectiveOptions.filter((option) => matchesAtWordStart(option.name, query)),
[effectiveOptions, query],
)
// Single settle point shared by the toggle and the inline create (success,
// mapped error, network failure): decrements the pending count and, once it
// reaches 0, stops the spinner and reconciles to the latest confirmed server
// set — even on a request's own failure, an earlier delta in the same batch
// may have already confirmed one.
const finishRequest = () => {
pendingCountRef.current = Math.max(0, pendingCountRef.current - 1)
if (pendingCountRef.current > 0) return
setIsPending(false)
if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current.leadershipIDs)
}
const toggle = (leadershipId: number, assigned: boolean) => {
// A negative id is a B155 pending-create sentinel, never a real record —
// the temp chip has no remove affordance, so this only guards the path.
if (isPendingCreateId(leadershipId)) return
setErrorMessage(null)
setSelectedIDs((current) => {
const next = new Set(current)
if (assigned) next.add(leadershipId)
else next.delete(leadershipId)
return [...next]
})
setQuery('')
pendingCountRef.current += 1
setIsPending(true)
const requestSeq = (requestSeqRef.current += 1)
void (async () => {
const revertDelta = () => {
setSelectedIDs((current) => {
const next = new Set(current)
if (assigned) next.delete(leadershipId)
else next.add(leadershipId)
return [...next]
})
}
try {
const { ok, payload } = await postCampaignJson<MunicipalityListLeadershipsResponse>(
LEADERSHIPS_ENDPOINT,
{ municipalityId: municipalityID, leadershipId, assigned },
)
if (!ok || payload.status !== 'success') {
revertDelta()
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
finishRequest()
return
}
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
latestConfirmedRef.current = { seq: requestSeq, leadershipIDs: payload.leadershipIDs }
}
finishRequest()
} catch {
revertDelta()
reportFailure(SAVE_ERROR_MESSAGE)
finishRequest()
}
})()
}
/**
* B155 — name+phone inline create, assigned to this município. The chip goes
* up immediately under a sentinel temp id (the real id only exists once the
* response lands); the request rides the same seq/pending machinery as the
* toggle, so out-of-order settles and the floor/cap failure reconcile exactly
* like a toggle — a failed create reverts the temp chip and shows the Alert.
*/
const createLeadership = (name: string, phone: string) => {
// A create is not an idempotent delta: re-activating the same person while
// a create is in flight would mint a second record. The temp chip already
// signals the pending one — skip.
if (
[...pendingCreates.values()].some(
(pending) => pending.name === name && pending.phone === phone,
)
) {
return
}
setErrorMessage(null)
setCreateFormOpen(false)
setCreateName('')
setCreatePhone('')
setQuery('')
pendingCountRef.current += 1
setIsPending(true)
const requestSeq = (requestSeqRef.current += 1)
const tempId = -requestSeq
setSelectedIDs((current) => [...current, tempId])
setPendingCreates((current) => new Map(current).set(tempId, { name, phone }))
void (async () => {
const revertCreate = () => {
setSelectedIDs((current) => current.filter((id) => id !== tempId))
setPendingCreates((current) => {
const next = new Map(current)
next.delete(tempId)
return next
})
}
try {
const { ok, payload } = await postCampaignJson<MunicipalityListLeadershipsResponse>(
LEADERSHIPS_ENDPOINT,
{ municipalityId: municipalityID, name, phone },
)
if (!ok || payload.status !== 'success') {
revertCreate()
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
finishRequest()
return
}
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
latestConfirmedRef.current = { seq: requestSeq, leadershipIDs: payload.leadershipIDs }
}
const createdLeadership = payload.createdLeadership
if (createdLeadership) {
// Swap the sentinel for the real id right away: if another delta is
// still in flight the reconcile is deferred, and the chip must not
// vanish in the gap (the lookup drops a temp id the moment its
// pending entry is removed).
setSelectedIDs((current) => [
...current.filter((id) => id !== tempId),
createdLeadership.id,
])
setPendingCreates((current) => {
const next = new Map(current)
next.delete(tempId)
return next
})
// Make the record selectable in every other row's popover on this
// page load — `createdOptions` is the client-side bridge until the
// next navigation's `getEligibleLeadershipOptions` picks it up.
registerCreatedLeadership?.({ id: createdLeadership.id, name: createdLeadership.name })
}
finishRequest()
} catch {
revertCreate()
reportFailure(SAVE_ERROR_MESSAGE)
finishRequest()
}
})()
}
const closeCreateForm = () => {
setCreateFormOpen(false)
// The form unmounts with it; return focus to the search box once the
// Command remounts (otherwise keyboard/SR focus falls to the body).
requestAnimationFrame(() => commandInputRef.current?.focus())
}
const handleCreateSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const name = createName.trim()
const phone = normalizeBrazilianPhone(createPhone)
if (name.length < 2 || !phone) return
createLeadership(name, phone)
}
const handlePhoneChange = (value: string) => {
setCreatePhone(formatBrazilianPhoneInput(sanitizeBrazilianPhoneInput(value)))
}
// Mirrors the zod schema (`name 2–120`, `brazilianMobile`) so a malformed
// submit never reaches the server's generic error.
const canCreate =
createName.trim().length >= 2 && createName.trim().length <= 120
? normalizeBrazilianPhone(createPhone) !== null
: false
const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando lideranças.' : ''
// The body pads its own sections (`px-4`, aligned with the Drawer header) so
// the `Command` can stay full-bleed in both containers — hence `px-0` on the
// sheet body and `p-0` on the popover content.
const isSheet = variant === 'sheet'
const trimmedQuery = query.trim()
return (
<CampaignCellEditOverlay
variant={variant}
open={open}
onOpenChange={handleOpenChange}
title="Gerenciar lideranças"
description={municipalityName}
triggerLabel={`Editar lideranças em ${municipalityName} — ${assignedLabel}`}
triggerBusy={isPending}
statusMessage={statusMessage}
contentClassName="w-80 p-0"
sheetBodyClassName="px-0 pt-2"
trigger={
chips.length === 0 ? (
<span className="text-sm text-muted-foreground">Nenhuma</span>
) : (
<span className="flex flex-wrap gap-1">
{chips.map((chip) => (
<Badge key={chip.id} variant="secondary" className="max-w-full font-normal">
<span className="truncate">{chip.name}</span>
</Badge>
))}
</span>
)
}
>
<div className={cn('relative flex shrink-0 flex-col gap-2 px-4 pb-0', !isSheet && 'pt-3')}>
{/* The Drawer's own header already titles it. */}
{isSheet ? null : <p className="text-sm font-medium">Gerenciar lideranças</p>}
<p className={cn('text-xs text-muted-foreground', isSheet && 'pr-6')}>
A liderança passa a aparecer também na ficha dela, na área Lideranças.
</p>
{isPending ? (
<Spinner
className={cn(
'absolute size-3.5 text-muted-foreground',
isSheet ? 'top-0 right-4' : 'top-3 right-3',
)}
aria-label="Salvando lideranças"
/>
) : null}
</div>
{chips.length > 0 ? (
<div className="flex shrink-0 flex-wrap gap-1.5 px-4 pt-2">
{chips.map((chip) =>
// A pending-create chip has no remove affordance: the server-side
// create cannot be cancelled mid-flight, so removal waits for the
// real record (and the real toggle) once the response lands.
isPendingCreateId(chip.id) ? (
<Badge
key={chip.id}
variant="secondary"
className={cn('max-w-full font-normal', isSheet && 'min-h-11')}
>
<span className="truncate">{chip.name}</span>
</Badge>
) : (
<button
key={chip.id}
type="button"
// A 20px pill is a fine mouse target and a bad thumb one: the
// Drawer pads it to the 44px minimum without growing the pill.
className={cn('inline-flex items-center', isSheet && 'min-h-11')}
aria-label={`Remover ${chip.name}`}
onClick={() => toggle(chip.id, false)}
>
<Badge
variant="secondary"
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
>
<span className="truncate">{chip.name}</span>
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
</Badge>
</button>
),
)}
</div>
) : null}
{createFormOpen ? (
<form className="flex shrink-0 flex-col gap-3 px-4 pb-3 pt-3" onSubmit={handleCreateSubmit}>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`leadership-create-name-${createFormId}`}>Nome</Label>
<Input
id={`leadership-create-name-${createFormId}`}
value={createName}
onChange={(event) => setCreateName(event.currentTarget.value)}
placeholder="Nome completo"
autoComplete="name"
maxLength={120}
autoFocus
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`leadership-create-phone-${createFormId}`}>Celular (com DDD)</Label>
<Input
id={`leadership-create-phone-${createFormId}`}
type="tel"
inputMode="tel"
autoComplete="tel"
value={createPhone}
onChange={(event) => handlePhoneChange(event.currentTarget.value)}
placeholder="(71) 99999-9999"
/>
</div>
<div className="flex gap-2 pt-1">
<Button type="submit" size="sm" disabled={!canCreate || isPending}>
Criar liderança
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending}
onClick={closeCreateForm}
>
Cancelar
</Button>
</div>
</form>
) : (
<Command shouldFilter={false} className="mt-1">
<CommandInput
ref={commandInputRef}
value={query}
onValueChange={setQuery}
placeholder="Buscar liderança…"
aria-label="Buscar liderança"
/>
<CommandList>
{filteredOptions.length === 0 ? (
trimmedQuery.length >= 2 && trimmedQuery.length <= 120 ? (
<CommandItem
value={`create-${trimmedQuery}`}
className={isSheet ? 'min-h-11' : undefined}
onSelect={() => {
setCreateFormOpen(true)
setCreateName(trimmedQuery)
}}
>
<UserPlusIcon className="size-4 shrink-0" aria-hidden="true" />
<span className="truncate">Criar liderança “{trimmedQuery}”</span>
</CommandItem>
) : (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Nenhum resultado.
</p>
)
) : (
filteredOptions.map((option) => {
const assigned = selectedSet.has(option.id)
return (
<CommandItem
key={option.id}
value={`leadership-${option.id}`}
data-checked={assigned}
className={isSheet ? 'min-h-11' : undefined}
onSelect={() => toggle(option.id, !assigned)}
>
<span className="truncate">{option.name}</span>
</CommandItem>
)
})
)}
</CommandList>
</Command>
)}
{errorMessage ? (
<div className="shrink-0 px-4 pt-2 pb-3">
<Alert variant="destructive">
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
</div>
) : null}
</CampaignCellEditOverlay>
)
}

View file

@ -11,6 +11,7 @@ import { MunicipalityLevelBadge } from '@/components/campaign/municipality/Munic
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
import { MunicipalityListLeadershipsControl } from '@/components/campaign/municipality/MunicipalityListLeadershipsControl'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import {
SignalAgeReadout,
@ -26,7 +27,9 @@ import { municipalityGeographyParts } from '@/utilities/municipality/municipalit
import { municipalityColumnLabels } from '@/utilities/municipality/municipalityListUrl'
import type {
EligibleAdvisorOption,
EligibleLeadershipOption,
MunicipalityAdvisorSummary,
MunicipalityLeadershipSummary,
MunicipalityListViewModel,
} from '@/utilities/municipality/municipalityViewModels'
import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
@ -39,10 +42,14 @@ type MunicipalityStaffFormAction = (
export type MunicipalityListMobileCardsProps = {
municipalities: MunicipalityListViewModel[]
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
/** B155 — contact-name lookup for the Lideranças sheet chips. */
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
isStaffView: boolean
isCoordinator: boolean
canMoveEngagementLevel: boolean
advisorOptions: EligibleAdvisorOption[]
/** B155 — every leadership the actor may add, for the Lideranças sheet. */
leadershipOptions: EligibleLeadershipOption[]
signalFormAction: MunicipalityStaffFormAction
emptySlot: ReactNode
}
@ -54,10 +61,12 @@ export type MunicipalityListMobileCardsProps = {
export const MunicipalityListMobileCards = ({
municipalities,
advisorNamesById,
leadershipNamesById,
isStaffView,
isCoordinator,
canMoveEngagementLevel,
advisorOptions,
leadershipOptions,
signalFormAction,
emptySlot,
}: MunicipalityListMobileCardsProps) => (
@ -195,6 +204,19 @@ export const MunicipalityListMobileCards = ({
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Lideranças</dt>
<dd>
<MunicipalityListLeadershipsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentLeadershipIDs={municipality.leadershipIDs}
leadershipNamesById={leadershipNamesById}
options={leadershipOptions}
variant="sheet"
/>
</dd>
</div>
</dl>
) : null}
</article>

View file

@ -102,6 +102,19 @@ export const leadershipWizardCreateSchema = leadershipWizardFieldsSchema.extend(
municipalityId: positiveRelationshipId,
})
/**
* B155 — inline create from the "Lideranças" column of `/campanha/municipios`:
* name + phone only, linked to the município whose popover created it. Strict
* so the toggle shape (`leadershipId`/`assigned`) can never slip through.
*/
export const municipalityLeadershipCreateSchema = z.strictObject({
municipalityId: positiveRelationshipId,
name: z.string().trim().min(2).max(120),
phone: brazilianMobile,
})
export type MunicipalityLeadershipCreateInput = z.input<typeof municipalityLeadershipCreateSchema>
export const leadershipWizardUpdateSchema = leadershipWizardFieldsSchema.extend({
id: positiveRelationshipId,
})

View file

@ -189,6 +189,7 @@ export type MunicipalityListColumnId =
// for the same column is `nivel`, and both URL spellings are frozen (B18).
| 'level'
| 'advisors'
| 'leaderships'
| 'trend'
| 'expectedVotes'
| 'lastSignal'
@ -218,6 +219,7 @@ export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, st
classe: CLASS_COLUMN_DESCRIPTION,
level: campaignConceptOneLiner('nivel-de-envolvimento'),
advisors: 'Assessor(es) responsável(is) pelo município — o coordenador atribui clicando aqui.',
leaderships: 'Lideranças vinculadas ao município — a equipe adiciona e remove clicando aqui.',
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
expectedVotes: campaignConceptOneLiner('meta'),
lastSignal: `Última atualização da equipe ou declaração de liderança, o que for mais recente. Fica destacado a partir de ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias sem registro.`,

View file

@ -107,6 +107,8 @@ export const municipalityColumnLabels: Record<MunicipalityListColumnId, string>
classe: municipalityListSortLabels.classe,
level: municipalityListSortLabels.nivel,
advisors: municipalityListSortLabels.coverage,
/** B155 — the column has no sort/filter; the label stands alone in the picker. */
leaderships: 'Lideranças',
trend: municipalityListSortLabels.trend,
expectedVotes: municipalityListSortLabels.expectedVotes,
/** The column shows the signal; `frescor` sorts by how old it is. */

View file

@ -44,10 +44,12 @@ import {
territorialClassSortWeight,
} from '@/utilities/municipality/municipalityTerritorialClass'
import {
loadMunicipalityLeadershipSummaries,
municipalityListSelect,
toMunicipalityDetailViewModel,
toMunicipalityListViewModel,
type MunicipalityDetailViewModel,
type MunicipalityLeadershipSummary,
type MunicipalityListViewModel,
} from '@/utilities/municipality/municipalityViewModels'
import { loadStateDeputySummaries } from '@/utilities/stateDeputyData'
@ -72,8 +74,12 @@ export type MunicipalityListPageBundle = {
totalPages: number
scopeTotal: number
filterFacets: MunicipalityListFilterFacets
/** B155 — contact-name lookup for the chips of the município list rows. */
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
}
const EMPTY_LEADERSHIP_NAMES: ReadonlyMap<number, MunicipalityLeadershipSummary> = new Map()
const emptyMunicipalityListFilterFacets: MunicipalityListFilterFacets = {
slugs: [],
regions: [],
@ -299,6 +305,7 @@ export const loadMunicipalityListPageBundle = async (
totalPages: 0,
scopeTotal: 0,
filterFacets: emptyMunicipalityListFilterFacets,
leadershipNamesById: EMPTY_LEADERSHIP_NAMES,
}
}
@ -412,6 +419,17 @@ export const loadMunicipalityListPageBundle = async (
pageDocs = allDocs.slice(start, start + municipalityPageSize)
}
// B155 — the leaderships of the visible page only (staff surfaces; the
// leader view already returned above). One reverse batch: ids per município
// for the rows, contact names for the chips.
const leadershipBundle = isStaff
? await loadMunicipalityLeadershipSummaries(
payload,
user,
pageDocs.map((municipality) => municipality.id),
)
: null
return {
municipalities: pageDocs.map((municipality) =>
toMunicipalityListViewModel(
@ -419,12 +437,14 @@ export const loadMunicipalityListPageBundle = async (
isStaff ? pledgeAggregates.get(municipality.id) : undefined,
ranks.get(municipality.slug) ?? null,
isStaff ? goalCoverageByMunicipalityID.get(municipality.id) : undefined,
leadershipBundle?.leadershipIDsByMunicipality.get(municipality.id) ?? [],
),
),
totalDocs,
totalPages,
scopeTotal: scopeCount.totalDocs,
filterFacets,
leadershipNamesById: leadershipBundle?.summariesById ?? EMPTY_LEADERSHIP_NAMES,
}
}

View file

@ -4,13 +4,13 @@ import type { Payload } from 'payload'
import type { EngagementLevel } from '@/lib/engagementLevel'
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
import { relationshipId } from '@/lib/relationship'
import { populatedContactName, relationshipId } from '@/lib/relationship'
import {
toVoteEstimateScenarioViewModel,
type VoteEstimateScenario,
type VoteEstimateScenarioViewModel,
} from '@/lib/voteEstimate'
import type { CampaignUser, Municipality } from '@/payload-types'
import type { CampaignUser, Leadership, Municipality } from '@/payload-types'
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
import {
createEmptyGoalCoverageByScenario,
@ -65,6 +65,8 @@ export type MunicipalityListViewModel = {
ibgeCode: string
zoneNumber: number | null
advisorIDs: number[]
/** B155 — leadership ids linked to this município (reverse read of `leadership.municipalities`). */
leadershipIDs: number[]
priority: 'alta' | 'normal'
lastUpdateAt: string | null
/**
@ -95,6 +97,8 @@ export const toMunicipalityListViewModel = (
pledges: MunicipalityPledgeAggregate | undefined,
votePosition2022: MunicipalityListViewModel['votePosition2022'],
goalCoverageByScenario?: Record<VoteEstimateScenario, MunicipalityGoalCoverage>,
/** B155 — empty for non-staff views; the leader never renders this column. */
leadershipIDs: number[] = [],
): MunicipalityListViewModel => {
const territorialClass = computeMunicipalityTerritorialClass(municipality.slug)
@ -110,6 +114,7 @@ export const toMunicipalityListViewModel = (
advisorIDs: (municipality.advisors ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),
leadershipIDs,
priority: municipality.priority === 'alta' ? 'alta' : 'normal',
lastUpdateAt: municipality.lastUpdateAt ?? null,
lastSignalAt: resolveMunicipalityLastSignalAt(
@ -278,3 +283,103 @@ export const getEligibleAdvisorOptions = async (
}))
.sort((left, right) => Number(right.isCurrent) - Number(left.isCurrent))
}
// ---------------------------------------------------------------------------
// B155 — Lideranças na lista de municípios
// ---------------------------------------------------------------------------
export type MunicipalityLeadershipSummary = {
id: number
name: string
}
export type EligibleLeadershipOption = {
id: number
name: string
}
const emptyLeadershipSummaries = {
leadershipIDsByMunicipality: new Map<number, number[]>(),
summariesById: new Map<number, MunicipalityLeadershipSummary>(),
} as const
/**
* B155 — one reverse batch over `leadership.municipalities` for the município
* list page: which leaderships each listed município links to (ids) plus the
* contact-name lookup for their chips. Names ship in the cell, so the read
* honours `canReadLeadership` (`user` + `overrideAccess: false`) — an advisor
* only ever sees the leaderships of the municípios they administer.
*/
export const loadMunicipalityLeadershipSummaries = async (
payload: Payload,
user: CampaignUser,
municipalityIDs: readonly number[],
): Promise<{
leadershipIDsByMunicipality: ReadonlyMap<number, number[]>
summariesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
}> => {
if (municipalityIDs.length === 0) return emptyLeadershipSummaries
const result = await payload.find({
collection: 'leadership',
where: { municipalities: { in: municipalityIDs } },
depth: 1,
limit: 0,
pagination: false,
select: { municipalities: true, contact: true },
user,
overrideAccess: false,
})
const leadershipIDsByMunicipality = new Map<number, number[]>()
const summariesById = new Map<number, MunicipalityLeadershipSummary>()
for (const doc of result.docs as Leadership[]) {
const leadershipID = relationshipId(doc.id)
if (leadershipID === null) continue
summariesById.set(leadershipID, { id: leadershipID, name: populatedContactName(doc.contact) })
for (const municipality of doc.municipalities ?? []) {
const municipalityID = relationshipId(municipality)
if (municipalityID === null) continue
const list = leadershipIDsByMunicipality.get(municipalityID) ?? []
list.push(leadershipID)
leadershipIDsByMunicipality.set(municipalityID, list)
}
}
return { leadershipIDsByMunicipality, summariesById }
}
/**
* B155 — every leadership the actor may add to a município from the popover:
* all of them for unrestricted staff, portfolio-scoped for an advisor (the
* same `canReadLeadership` the list cells trust). Sorted by contact name for
* the Command; `createdAt` keeps the underlying fetch deterministic.
*/
export const getEligibleLeadershipOptions = async (
payload: Payload,
user: CampaignUser,
): Promise<EligibleLeadershipOption[]> => {
const result = await payload.find({
collection: 'leadership',
depth: 1,
limit: 0,
pagination: false,
sort: 'createdAt',
select: { contact: true },
user,
overrideAccess: false,
})
return result.docs
.map((doc) => {
const leadership = doc as Leadership
const id = relationshipId(leadership.id)
if (id === null) return null
return { id, name: populatedContactName(leadership.contact) }
})
.filter((option): option is EligibleLeadershipOption => option !== null)
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
}

View file

@ -32,6 +32,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/municipios/advisors',
'/campanha/municipios/engagement-level',
'/campanha/municipios/expected-votes',
'/campanha/municipios/leaderships',
'/campanha/municipios/pledge-declared-votes',
'/campanha/municipios/pledge-estimated-votes',
'/campanha/municipios/political-trend',

View file

@ -0,0 +1,153 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { createLeadershipRecord } from '@/app/(campaign)/campanha/actions/leadership'
import config from '@/payload.config'
import { loadMunicipalityListPageBundle } from '@/utilities/municipality/municipalityPageData'
import {
getEligibleLeadershipOptions,
loadMunicipalityLeadershipSummaries,
} from '@/utilities/municipality/municipalityViewModels'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('B155 — lideranças na lista de municípios (leitura reversa + opções)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('summarizes the leaderships of the given municípios with contact names', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const first = await fixtures.getMunicipality()
const second = await fixtures.getMunicipality()
const contactA = await fixtures.createContact({ name: 'Maria de Jesus' })
const leadershipA = await fixtures.createLeadership({
contact: contactA,
municipalities: [first],
})
const contactB = await fixtures.createContact({ name: 'João do Brejo' })
const leadershipB = await fixtures.createLeadership({
contact: contactB,
municipalities: [first, second],
})
const { leadershipIDsByMunicipality, summariesById } =
await loadMunicipalityLeadershipSummaries(payload, coordinator, [first.id, second.id])
expect([...summariesById.keys()].sort()).toEqual([leadershipA.id, leadershipB.id].sort())
expect(summariesById.get(leadershipA.id)?.name).toBe('Maria de Jesus')
expect(summariesById.get(leadershipB.id)?.name).toBe('João do Brejo')
expect(leadershipIDsByMunicipality.get(first.id)?.sort()).toEqual(
[leadershipA.id, leadershipB.id].sort(),
)
expect(leadershipIDsByMunicipality.get(second.id)).toEqual([leadershipB.id])
})
it('returns empty maps for an empty id list', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const { leadershipIDsByMunicipality, summariesById } =
await loadMunicipalityLeadershipSummaries(payload, coordinator, [])
expect(leadershipIDsByMunicipality.size).toBe(0)
expect(summariesById.size).toBe(0)
})
it('scopes the summaries and options to an advisor portfolio', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getMunicipality()
const stranger = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered, [advisor])
const ownContact = await fixtures.createContact({ name: 'Própria' })
const own = await fixtures.createLeadership({
contact: ownContact,
municipalities: [administered],
})
const alienContact = await fixtures.createContact({ name: 'Alheia' })
const alien = await fixtures.createLeadership({
contact: alienContact,
municipalities: [stranger],
})
const { summariesById } = await loadMunicipalityLeadershipSummaries(payload, advisor, [
administered.id,
stranger.id,
])
expect(summariesById.has(own.id)).toBe(true)
expect(summariesById.has(alien.id)).toBe(false)
const options = await getEligibleLeadershipOptions(payload, advisor)
expect(options.map((option) => option.id)).toContain(own.id)
expect(options.map((option) => option.id)).not.toContain(alien.id)
})
it('lists every leadership for unrestricted staff and names them by contact', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact({ name: 'Fulana dos Anjos' })
const leadership = await fixtures.createLeadership({ contact, municipalities: [municipality] })
const options = await getEligibleLeadershipOptions(payload, coordinator)
const found = options.find((option) => option.id === leadership.id)
expect(found).toMatchObject({ id: leadership.id, name: 'Fulana dos Anjos' })
})
it('carries leadershipIDs and names in the list page bundle for staff only', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact({ name: 'Chica da Roça' })
const leadership = await fixtures.createLeadership({ contact, municipalities: [municipality] })
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
slug: municipality.slug,
})
const row = bundle.municipalities.find((item) => item.id === municipality.id)
expect(row?.leadershipIDs).toContain(leadership.id)
expect(bundle.leadershipNamesById.get(leadership.id)?.name).toBe('Chica da Roça')
const leader = await fixtures.createCampaignUser('leader')
const leaderBundle = await loadMunicipalityListPageBundle(payload, leader, {})
expect(leaderBundle.municipalities).toEqual([])
expect(leaderBundle.leadershipNamesById.size).toBe(0)
})
it('sees an inline-created leadership through the column read (create → summarize)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
// The route's create branch calls `createLeadershipWizardRecord`, which is
// the same validated path as `createLeadershipRecord` with one município —
// this pins the create → column-read integration.
const created = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Criada na Reunião',
phone: fixtures.phone(),
})
const { leadershipIDsByMunicipality, summariesById } =
await loadMunicipalityLeadershipSummaries(payload, coordinator, [municipality.id])
expect(leadershipIDsByMunicipality.get(municipality.id)).toContain(created.id)
expect(summariesById.get(created.id)?.name).toBe('Criada na Reunião')
})
})

View file

@ -4,6 +4,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListLeadershipsControl } from '@/components/campaign/municipality/MunicipalityListLeadershipsControl'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
@ -71,6 +72,20 @@ const overlayCases: OverlayCase[] = [
variant,
}),
},
{
name: 'lideranças',
triggerLabel: `Editar lideranças em ${MUNICIPALITY_NAME} — Maria de Jesus`,
drawerTitle: 'Gerenciar lideranças',
element: (variant) =>
createElement(MunicipalityListLeadershipsControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
currentLeadershipIDs: [11],
leadershipNamesById: new Map([[11, { id: 11, name: 'Maria de Jesus' }]]),
options: [{ id: 11, name: 'Maria de Jesus' }],
variant,
}),
},
{
name: 'votos estimados',
triggerLabel: `Editar votos estimados em ${MUNICIPALITY_NAME} — Média: 1.200`,

View file

@ -50,6 +50,8 @@ const municipalityListDefaultProps = {
} satisfies CampaignColumnVisibility,
canMoveEngagementLevel: false,
advisorOptions: [],
leadershipNamesById: new Map(),
leadershipOptions: [],
columnFilterOptions: { name: [], region: [], advisor: [] },
signalFormAction: noopListFormAction,
state: { page: 1 },
@ -177,6 +179,7 @@ describe('campaign visual foundation', () => {
ibgeCode: '2929800',
zoneNumber: null,
advisorIDs: [advisor.id],
leadershipIDs: [11],
priority: 'alta',
lastUpdateAt: null,
lastSignalAt: null,
@ -220,6 +223,7 @@ describe('campaign visual foundation', () => {
ibgeCode: '2927408',
zoneNumber: 3,
advisorIDs: [],
leadershipIDs: [],
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
@ -243,6 +247,7 @@ describe('campaign visual foundation', () => {
advisorNamesById: new Map([[advisor.id, advisor]]),
isStaffView: true,
...municipalityListDefaultProps,
leadershipNamesById: new Map([[11, { id: 11, name: 'Maria de Jesus' }]]),
}),
)
@ -264,12 +269,15 @@ describe('campaign visual foundation', () => {
expect(html).not.toContain('Cobertura da meta')
expect(html).toContain('80%')
expect(html).toContain('Faltam 300 votos para a meta')
// B155: the Lideranças column chips the linked leaderships by name.
expect(html).toContain('Lideranças')
expect(html).toContain('Maria de Jesus')
// B22: every one of the 11 staff columns carries a header explanation —
// pinned inside `<thead>` itself, since `cellTooltip` (e.g. "Classe") and
// the mobile card's own class tooltip (B42) add triggers of their own.
const [, tbodyHtml = ''] = html.split('<tbody')
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10)
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
// B41: horizontal scroll table with a pinned Município column header.
expect(html).toContain('data-slot="table-container"')
@ -297,6 +305,7 @@ describe('campaign visual foundation', () => {
city: 'Seabra',
region: 'Chapada Diamantina',
advisorIDs: [],
leadershipIDs: [],
priority: 'normal',
expectedVotes: toVoteEstimateScenarioViewModel(null),
pledges: createEmptyMunicipalityPledgeAggregate(),
@ -316,6 +325,7 @@ describe('campaign visual foundation', () => {
city: 'Sem série',
region: 'Chapada Diamantina',
advisorIDs: [],
leadershipIDs: [],
priority: 'normal',
expectedVotes: toVoteEstimateScenarioViewModel(null),
pledges: createEmptyMunicipalityPledgeAggregate(),
@ -360,6 +370,7 @@ describe('campaign visual foundation', () => {
ibgeCode: '2903201',
zoneNumber: null,
advisorIDs: [],
leadershipIDs: [],
priority: 'alta',
lastUpdateAt: staleSignal,
lastSignalAt: staleSignal,
@ -404,6 +415,7 @@ describe('campaign visual foundation', () => {
ibgeCode: '2932002',
zoneNumber: null,
advisorIDs: [],
leadershipIDs: [],
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
@ -445,6 +457,7 @@ describe('campaign visual foundation', () => {
ibgeCode: '2914703',
zoneNumber: null,
advisorIDs: [],
leadershipIDs: [],
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
@ -492,6 +505,7 @@ describe('campaign visual foundation', () => {
ibgeCode: '2929800',
zoneNumber: null,
advisorIDs: [],
leadershipIDs: [],
priority: 'alta',
lastUpdateAt: null,
lastSignalAt: null,
@ -555,7 +569,7 @@ describe('campaign visual foundation', () => {
// The staff `<thead>` already carries 11 header-explanation tooltips
// (B22), regardless of row data. This baseline isolates the advisors
// CELL tooltip, the one this test actually exercises.
const HEADER_TOOLTIP_COUNT = 10
const HEADER_TOOLTIP_COUNT = 11
const baseMunicipality = {
id: 1,
name: 'Seabra',
@ -565,6 +579,8 @@ describe('campaign visual foundation', () => {
region: 'Chapada Diamantina',
ibgeCode: '2929800',
zoneNumber: null,
advisorIDs: [],
leadershipIDs: [],
priority: 'normal' as const,
lastUpdateAt: null,
lastSignalAt: null,

View file

@ -533,6 +533,9 @@ describe('advisor scope fragment comes from access/shared.ts', () => {
'src/utilities/leadership/leadershipData.ts',
'src/utilities/leadership/leadershipListUrl.ts',
'src/utilities/municipality/municipalityTriggers.ts',
// B155 — the municipality list surface reads `leadership.municipalities`
// by reverse batch; same distinct form as `leadershipData.ts` above.
'src/utilities/municipality/municipalityViewModels.ts',
'src/utilities/visit/visitPlannerData.ts',
'src/utilities/votePledgeData.ts',
])