feat(campaign): unify municipality relation editors

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-08-05 02:01:40 -03:00
parent 58e35ad0c6
commit 9fa701633e
17 changed files with 1321 additions and 1176 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-05):** **B159** (Issue #374) — as colunas Assessores, Lideranças e Dobradinhas de `/campanha/municipios` agora compartilham `MunicipalityRelationEditor` (`CampaignCellEditOverlay` + Command + chips + optimistic reconcile) e `MunicipalityRelationAvatarStack`; Dobradinhas deixa `RelationChipCell` só nesta superfície, preserva busca por partido e links para a ficha, e Lideranças cria inline somente com nome (`Contact.phone = null`, sem stub; telefone real depois na ficha). Sem migration, rota nova ou mudança de access. Intenção [`generalizar-colunas-relacao-municipios.md`](docs/plans/generalizar-colunas-relacao-municipios.md); impl [`generalizar-colunas-relacao-municipios-impl.md`](docs/plans/generalizar-colunas-relacao-municipios-impl.md).
**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).

View file

@ -0,0 +1,108 @@
# Impl: Generalizar editor e display das colunas de relação na lista de municípios
Status: em execução
Atualizado em: 2026-08-05
Issue: #374
Intenção: docs/plans/generalizar-colunas-relacao-municipios.md
Appetite restante: ~0,75 dia eng; manter sem migration, rota nova ou mudança de access
## Leitura da intenção
- **Outcome:** Assessores, Lideranças e Dobradinhas devem ter a mesma linguagem de interação na lista de municípios: avatar stack fechado e `CampaignCellEditOverlay` com busca, chips removíveis e criação inline por nome.
- **O que NÃO negociar:** Assessores continua sendo a referência visual; o contrato de props de `MunicipalityList` e as regras de visibilidade por papel não mudam; busca com acento, optimistic UI, reconciliação de respostas fora de ordem, erros e bridges de opções recém-criadas continuam funcionando; `RelationChipCell` permanece dono dos editores inline fora desta lista.
- **O que reavaliar:** a extração não pode presumir que os três writes usam o mesmo transporte. Assessores e Lideranças usam JSON routes; Dobradinhas usa server actions. Além disso, retirar o telefone da criação inline de Lideranças exige mudar o schema específico e o serviço existente, não somente esconder um campo.
## Abordagem recomendada
```mermaid
flowchart LR
ML[MunicipalityList] --> A[Wrapper Assessores]
ML --> L[Wrapper Lideranças]
ML --> D[Wrapper Dobradinhas]
A --> E[MunicipalityRelationEditor]
L --> E
D --> E
E --> O[CampaignCellEditOverlay]
E --> V[MunicipalityRelationAvatarStack]
A --> JA[JSON advisors]
L --> JL[JSON leaderships]
D --> SA[server actions de dobradinhas]
JL --> CR[criação transacional Contact + Leadership]
```
**Opções consideradas:** A) editor compartilhado com adaptadores de domínio | B) ampliar `RelationChipCell` com uma segunda apresentação | C) manter máquinas separadas e compartilhar somente peças visuais
**Recomendação:** A — concentra a única máquina de optimistic state, sequência de requests, busca, chips e criação, enquanto wrappers pequenos normalizam os transportes e preservam copy, bridges e regras próprias de cada domínio.
**Rejeitadas:** B porque mistura o editor inline de 813 linhas com outra política de interação e ameaça os call sites fora de `/campanha/municipios`; C porque entrega aparência igual mantendo três implementações do comportamento crítico, contrariando o aceite interno e deixando regressões divergentes prováveis.
### Decisões de engenharia
**Contrato do editor**
Opções: callbacks genéricos de baixo nível | componente ciente dos três endpoints | contrato normalizado de mutação
Recomendação: contrato normalizado de mutação — o shared recebe entradas `{ id, label, searchText? }`, `selectedIDs`, copy e callbacks `toggle`/`create` que retornam o conjunto confirmado e, na criação, a nova opção. O editor é dono de pending sentinels, sequência, rollback, reconcile, filtro e UI; cada wrapper é dono do transporte e do bridge de catálogo.
Alternativas rejeitadas: callbacks de baixo nível porque vazariam setters e refs, tornando o shared apenas markup; endpoints embutidos porque acoplariam um componente visual às rotas e a server actions incompatíveis.
**Display compartilhado**
Opções: um avatar stack com entradas normalizadas | manter wrappers visuais duplicados | tornar o editor responsável por iniciais específicas
Recomendação: `MunicipalityRelationAvatarStack` recebe entradas `{ id, label, initialsLabel? }`, limite e empty state; o editor deriva tooltip e acessibilidade dos labels. Assessores preserva `MissingAdvisorBadge`, e Dobradinhas informa o nome sem partido em `initialsLabel`.
Alternativas rejeitadas: duplicação porque já produziu o desalinhamento atual; regras de iniciais dentro do editor porque são apresentação de cada domínio, não estado de edição.
**Liderança criada somente com nome**
Opções: enviar telefone sintético | tornar telefone opcional em todos os formulários | aceitar somente nome no schema específico e reutilizar o serviço transacional com dedup condicional
Recomendação: o schema estrito de `municipalityLeadershipCreateSchema` passa a aceitar somente `municipalityId + name`; o núcleo existente de criação aceita telefone opcional internamente. Como `Contact.phone` já é opcional no Payload, esse caminho cria um novo `Contact` com `phone: null` e a `Leadership` na mesma transação, após fresh-actor e scope check. O telefone poderá ser preenchido depois na ficha. Com telefone nos demais fluxos, o serviço mantém lock e dedup atuais; os schemas de cadastro completo permanecem fora do escopo desta Issue.
Alternativas rejeitadas: telefone sintético não é necessário e corromperia a identidade/unicidade de `Contact`, podendo colidir com um telefone real; ampliar a mudança a todos os formulários alteraria outros fluxos e sua política de dedup sem fazer parte do aceite desta lista.
### Componentes / mudanças
- **`MunicipalityRelationEditor`** (`src/components/campaign/shared/MunicipalityRelationEditor.tsx`): nova dona da máquina compartilhada de overlay, optimistic IDs, requests concorrentes, chips, Command, busca normalizada, criação name-only e erro acessível.
- **`MunicipalityRelationAvatarStack`** (`src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx`): avatar stack único, máximo padrão de três, nomes para screen reader/tooltip e empty state injetável.
- **`MunicipalityListAdvisorsControl`**: wrapper de opções/copy/JSON e bridge de novos assessores; usa os dois componentes compartilhados sem alterar comportamento ou empty state prioritário.
- **`MunicipalityListLeadershipsControl`**: wrapper de opções/copy/JSON e bridge; remove formulário e imports de telefone, cria diretamente pelo nome pesquisado e usa avatar stack no trigger.
- **`MunicipalityStateDeputyRelationCell`**: troca `RelationChipCell` pelo editor compartilhado; adapta `commitAction`/`createAction`, mantém busca por nome e partido, links dos chips quando aplicável e nomes sem partido nas iniciais.
- **Criação de liderança** (`src/lib/schemas/leadership.ts`, route e `src/app/(campaign)/campanha/actions/leadership.ts`): o input inline envia apenas nome e persiste `Contact.phone = null`; mantém transação, fresh actor, escopo, defaults e revalidações existentes, sem telefone stub.
- **Código substituído:** remover os avatar stacks locais e a máquina duplicada que ficar órfã; `RelationChipCell` permanece porque ainda serve `LeadershipStateDeputyRelationCell` e `MunicipalityPortfolioCell`.
- **Migration:** sem migration; `Contact.phone` já é opcional.
- **Access / Consent:** nenhuma política nova. O novo caminho usa o mesmo staff gate, fresh actor, scope check e transação; não há opt-in ou Consent neste fluxo interno.
- **UI:** Impeccable B; replicar exatamente shape/craft da coluna Assessores em desktop Popover e mobile Drawer, depois criticar foco, target de 44 px, tooltip, live region, overflow e estados vazio/pending/error.
## Fases verificáveis
1. **Tracer / contrato server** — ajustar e testar a criação inline name-only de Liderança no serviço existente; provar `Contact.phone === null`, vínculo ao município, bloqueio de papel/escopo e preservação do caminho completo com telefone.
2. **Editor e display compartilhados** — extrair primeiro Assessores como referência; manter testes de overlay, optimistic reconcile, rollback, criação e busca antes de migrar os demais wrappers.
3. **Lideranças e Dobradinhas** — migrar ambas; confirmar avatar stack/tooltip, criação name-only, busca por acento e por partido, remoção, criação e adapters JSON/server action em Popover e Drawer.
4. **Polish e gates** — revisar os três call sites em desktop/mobile, eliminar código morto e executar `pnpm gate:fast`; no fechamento, `/simplify`, captura de débitos e push via `pnpm push`.
## Estratégia de testes
- **Unit do editor compartilhado:** render fechado, Popover/Drawer, busca normalizada, toggle/remove, criação otimista, resposta fora de ordem, rollback e erro/live region.
- **Unit dos wrappers:** copy e payload de cada domínio, bridge da opção criada, Lideranças sem campo de telefone, Dobradinhas pesquisável por partido e adaptação do resultado da server action.
- **Unit de composição:** `MunicipalityList` mantém visibilidade por papel e passa a renderizar as três pilhas com iniciais, limite e nomes acessíveis.
- **Integração server:** caminho name-only cria `Contact` + `Leadership` atomicamente e respeita staff/scope; caminho geral com telefone continua deduplicando pelo telefone.
## Rabbit holes / Não escopo (engenharia)
- Não migrar `LeadershipStateDeputyRelationCell`, `MunicipalityPortfolioCell` nem outros call sites de `RelationChipCell`.
- Não unificar JSON routes, server actions, providers de criação ou tipos de domínio só para obter uma API uniforme.
- Não mudar visibilidade de colunas, URLs, collections, Payload schema, Consent ou regras de access.
- Não generalizar o componente para relações fora da lista de municípios; gatilho para revisitar: outro editor Popover repetir a mesma máquina completa.
- Não alterar os cadastros completos de liderança nesta Issue; o atalho inline da lista cria somente com nome e a ficha recebe o telefone real depois.
## Riscos e mitigação
- **Abstração larga demais:** props excessivas podem esconder três políticas diferentes. Mitigar com um resultado normalizado pequeno, wrappers de domínio e testes do componente pelo comportamento, não pela implementação.
- **Concorrência otimista:** extração pode perder a regra de maior sequência confirmada. Mitigar movendo a máquina como uma unidade e pinando resposta fora de ordem + falha concorrente em unit test.
- **Criação somente com nome:** não há dedup confiável por pessoa sem identificador. Mitigar criando um novo `Contact` deliberadamente com telefone nulo, deixando o telefone real para a ficha e não usando stub nem dedup por nome.
- **Server action versus JSON:** erros têm formatos diferentes. Mitigar nos adapters, que convertem ambos para o mesmo success/error do editor sem expor `FormData` no shared.
- **Acessibilidade responsiva:** trocar o trigger pode quebrar tooltip, foco ou Drawer compartilhado. Mitigar reutilizando `CampaignCellEditOverlay` sem alterações de contrato e ampliando a matriz existente de testes Popover/Sheet para Dobradinhas.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto
- [ ] As três colunas usam um único editor e um único avatar stack
- [ ] Criação de liderança envia somente nome e persiste telefone nulo sem afrouxar outros formulários
- [ ] optimistic state, resposta fora de ordem, erro, busca e criação cobertos por testes
- [ ] Invariantes AGENTS/engineering-standards preservadas
- [ ] Sem migration, rota nova, mudança de props de `MunicipalityList` ou regressão de access
## Self-score decision-quality
5/5 — decisões caras registram opções e rejeitadas; a abordagem cabe no appetite com cortes explícitos; rabbit holes estão nomeados; reutiliza os donos existentes; o outcome e os lockdowns da intenção permanecem intactos.

View file

@ -0,0 +1,81 @@
# Serializar mutações do editor de relações por célula
Status: rascunho
Atualizado em: 2026-08-05
Issue: #378 (registrada via agent:register)
Priority: P2
Model: `composer-2.5`
Impeccable: A — N/A
Canvas UI: N/A
Appetite: ~0,5 dia eng
Responsável: —
## Intenção
O editor compartilhado de relações da lista de municípios permite interações rápidas e otimistas. Quando duas alterações da mesma célula ficam simultaneamente em trânsito, a ordem em que as transações terminam no banco pode divergir da ordem escolhida pela pessoa. O estado exibido e o estado persistido podem então representar intenções diferentes.
Esta entrega deve garantir que mutações de uma mesma célula sejam persistidas na ordem em que foram solicitadas, mantendo a resposta imediata da interface.
## Persona e fluxo
- **Persona / contexto:** coordenação e assessoria ajustando relações diretamente na lista de municípios.
- **Job principal:** fazer várias inclusões ou remoções rápidas e confiar que o último estado escolhido será o persistido.
- **Fluxo desejado:** abrir uma célula, alternar relações em sequência, continuar vendo a resposta otimista e terminar com interface e banco refletindo a mesma ordem de intenção.
- **Anti-goals de produto:** criar protocolo global de versionamento, alterar permissões ou redesenhar o editor.
## Objetivo e aceite
- Duas ou mais mutações rápidas da mesma célula são enviadas ao servidor na ordem da interação.
- Uma falha intermediária não apaga uma alteração posterior válida nem deixa o editor preso em estado de carregamento.
- Células e linhas diferentes continuam podendo salvar em paralelo.
- Testes cobrem conclusão e falha fora de ordem no transporte simulado e confirmam o estado persistido final.
## Dados (intenção)
- **Vou apresentar dados?** Não.
- **Decisões desbloqueadas:** a pessoa pode editar relações rapidamente sem precisar esperar cada salvamento terminar.
- **Forma:** N/A — não há nova apresentação de dados.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `MunicipalityRelationEditor`, wrappers de assessores/lideranças/dobradinhas e seus testes.
- **Precedente a olhar:** fila local por chave de célula, preservando a camada otimista separada do último estado confirmado.
- **Risco de acoplamento:** a fila deve ser por instância/célula; uma fila global degradaria edições independentes.
## Dependências
- B159 — editor compartilhado de relações na lista de municípios.
## Fases verificáveis
1. Modelar e testar uma fila de mutações por célula, incluindo sucesso, falha e interação posterior durante uma requisição.
2. Integrar a fila ao editor compartilhado sem alterar os contratos dos wrappers.
3. Validar os fluxos de assessores, lideranças e dobradinhas com testes focados e gates do repositório.
## Já resolvido no simplify/critique (não reabrir)
- Reconciliação de props externas sem requisição pendente.
- Deduplicação da opção criada quando o RSC atualiza antes da resposta da criação.
- Separação entre itens conhecidos para display e opções realmente selecionáveis.
## Explicitamente fora
- Versionamento otimista ou idempotency key no servidor; reavaliar apenas se outras superfícies também exigirem concorrência entre clientes.
- Mudanças de schema, migrations, acesso, rotas ou contratos públicos.
- Refatorar ações específicas de cada tipo de relação além do necessário para consumir a fila.
## Rabbit holes de produto
- **Transformar consistência local em protocolo distribuído.** Isso amplia o escopo para todas as mutações da campanha. **Corte neste item:** ordenar somente as chamadas originadas por uma instância do editor.
- **Bloquear a interface durante o salvamento.** Isso elimina a condição por restrição de uso, mas perde a edição rápida que motivou o componente. **Corte neste item:** manter o feedback otimista e serializar apenas o transporte.
## Questões em aberto (produto)
- Nenhuma; o comportamento esperado é preservar a ordem de intenção sem mudar a interação visível.
## Referências
- GitHub Issue #378 (B160)
- GitHub Issue #374 (B159)
- `docs/plans/generalizar-colunas-relacao-municipios-impl.md`
- `src/components/campaign/shared/MunicipalityRelationEditor.tsx`

View file

@ -5,7 +5,6 @@ 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,
@ -16,7 +15,6 @@ import {
} 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'
@ -26,8 +24,8 @@ 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
* Mutually exclusive shapes (B155/B159): the membership delta (`leadershipId` +
* `assigned`) and the name-only inline create can never combine — `name` is a
* write affordance over `leadership.municipalities`, `leadershipId` a delta.
*/
const bodySchema = z.union([
@ -48,8 +46,6 @@ export const POST = campaignJsonMutationRoute(
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
LEADERSHIP_STAFF_MESSAGE,
LEADERSHIP_DUPLICATE_MESSAGE,
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
BRAZILIAN_PHONE_INVALID_MESSAGE,
POSTGRES_DEDUP_LOCK_MESSAGE,
],
genericMessage:
@ -61,15 +57,12 @@ export const POST = campaignJsonMutationRoute(
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,

View file

@ -18,6 +18,7 @@ import {
leadershipStateDeputyMembershipSchema,
leadershipWizardCreateSchema,
leadershipWizardUpdateSchema,
municipalityLeadershipCreateSchema,
type LeadershipContactUpdateInput,
type LeadershipInternalUpdateInput,
type LeadershipMunicipalitiesMembershipInput,
@ -110,11 +111,14 @@ const isUniqueLeadershipConflict = (error: unknown): boolean => {
}
type LeadershipCreateData = ReturnType<typeof leadershipCreateSchema.parse>
type ValidatedLeadershipCreateData = Omit<LeadershipCreateData, 'phone'> & {
phone?: string | null
}
const createValidatedLeadershipRecord = async (
payload: Payload,
actor: CampaignUser,
data: LeadershipCreateData,
data: ValidatedLeadershipCreateData,
) => {
try {
return await withPayloadTransaction(
@ -122,27 +126,29 @@ const createValidatedLeadershipRecord = async (
async ({ req }) => {
const currentActor = await getFreshStaffActor(payload, actor, req)
await assertMunicipalitiesWithinScope(payload, currentActor, data.municipalities, req)
if (payload.db.name !== 'postgres') {
throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
}
await acquireContactPhoneLocks(payload, req, [data.phone])
// Intentional admin bypass: staff scope was freshly checked; these internal reads and
// writes atomically maintain the normalized Contact ↔ Leadership join.
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: data.phone } },
depth: 0,
limit: 2,
pagination: false,
overrideAccess: true,
req,
})
let contactID: number | undefined
if (data.phone) {
if (payload.db.name !== 'postgres') {
throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
}
await acquireContactPhoneLocks(payload, req, [data.phone])
// Intentional admin bypass: staff scope was freshly checked; these internal reads and
// writes atomically maintain the normalized Contact ↔ Leadership join.
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: data.phone } },
depth: 0,
limit: 2,
pagination: false,
overrideAccess: true,
req,
})
if (contacts.totalDocs > 1) {
throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
if (contacts.totalDocs > 1) {
throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
}
contactID = contacts.docs[0]?.id
}
let contactID = contacts.docs[0]?.id
const contactReused = Boolean(contactID)
if (!contactID) {
@ -162,7 +168,7 @@ const createValidatedLeadershipRecord = async (
collection: 'contact',
data: {
name: data.name,
phone: data.phone,
phone: data.phone ?? null,
email: data.email,
gender: data.gender,
state: 'BA' as Contact['state'],
@ -454,19 +460,43 @@ export const createLeadershipWizard = async (
/**
* 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
* Name-only municipality-surface entry point over the transactional create.
* 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 createMunicipalityLeadershipRecord = async (
payload: Payload,
actor: CampaignUser,
input: MunicipalityLeadershipCreateInput,
) => {
const data = municipalityLeadershipCreateSchema.parse(input)
const leadership = await createValidatedLeadershipRecord(payload, actor, {
name: data.name,
phone: null,
municipalities: [data.municipalityId],
email: undefined,
exclusive: true,
supportStatus: 'a_abordar',
notes: undefined,
})
const { leadershipIDsByMunicipality, summariesById } = await loadMunicipalityLeadershipSummaries(
payload,
actor,
[data.municipalityId],
)
return {
leadership,
leadershipIDs: leadershipIDsByMunicipality.get(data.municipalityId) ?? [],
createdLeadershipName: summariesById.get(leadership.id)?.name ?? data.name,
}
}
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,
})
const result = await createMunicipalityLeadershipRecord(payload, actor, input)
// Intentional admin bypass: existence is enforced by the write's own
// relationship validation; this read only resolves the revalidate target.
@ -477,21 +507,8 @@ export const createMunicipalityLeadership = async (input: MunicipalityLeadership
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,
}
revalidateLeadershipWizardPaths(result.leadership.id, municipality.slug)
return result
}
/**

View file

@ -1,9 +1,8 @@
import { CircleAlertIcon } from 'lucide-react'
import type { ReactElement } from 'react'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { MunicipalityRelationAvatarStack } from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
import { Badge } from '@/components/ui/Badge'
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
import { municipalityListCoverageLabels } from '@/utilities/municipality/municipalityLabels'
export type MunicipalityAdvisorAvatarEntry = {
@ -59,15 +58,10 @@ export const MunicipalityAdvisorAvatarStack = ({
if (!advisors.length) return <MissingAdvisorBadge isPriority={isPriority} />
return (
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
{advisors.slice(0, maxVisible).map((advisor) => (
<Avatar key={advisor.id} className="size-8 border-2 border-background">
<AvatarFallback>{campaignUserInitials(advisor.name)}</AvatarFallback>
</Avatar>
))}
</div>
<span className="sr-only">{advisors.map((advisor) => advisor.name).join(', ')}</span>
</div>
<MunicipalityRelationAvatarStack
entries={advisors.map((advisor) => ({ id: advisor.id, label: advisor.name }))}
emptyState={null}
maxVisible={maxVisible}
/>
)
}

View file

@ -1,28 +1,15 @@
'use client'
import { UserPlusIcon, XIcon } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { MunicipalityListAdvisorsResponse } from '@/app/(campaign)/campanha/(app)/municipios/advisors/types'
import {
advisorEntriesFromIds,
formatAdvisorNamesTooltip,
MunicipalityAdvisorAvatarStack,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { MissingAdvisorBadge } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { useMunicipalityAdvisorCreate } from '@/components/campaign/municipality/MunicipalityAdvisorCreateProvider'
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
import type { MunicipalityRelationEntry } from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
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 { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
import { Spinner } from '@/components/ui/Spinner'
MunicipalityRelationEditor,
type MunicipalityRelationMutationResult,
} from '@/components/campaign/shared/MunicipalityRelationEditor'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { sameIdSet } from '@/lib/sameIdSet'
import { cn } from '@/lib/utils'
import { matchesAtWordStart } from '@/lib/wordStartFilter'
import type {
EligibleAdvisorOption,
MunicipalityAdvisorSummary,
@ -31,25 +18,21 @@ import type {
const ADVISORS_ENDPOINT = '/campanha/municipios/advisors'
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar os assessores. Tente novamente.'
/** B154 — sentinel temp id for an in-flight inline create; negative = never a real account. */
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: EligibleAdvisorOption[] = []
const EMPTY_PENDING_CREATES: ReadonlyMap<number, string> = new Map()
type MunicipalityListAdvisorsControlProps = {
municipalityID: number
municipalityName: string
currentAdvisorIDs: number[]
/** Raises the empty state to "Sem responsável" — see `MissingAdvisorBadge`. */
isPriority: boolean
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
options: EligibleAdvisorOption[]
variant: CampaignCellEditOverlayVariant
}
const entryOf = (option: { id: number; name: string }): MunicipalityRelationEntry => ({
id: option.id,
label: option.name,
})
export const MunicipalityListAdvisorsControl = ({
municipalityID,
municipalityName,
@ -59,373 +42,77 @@ export const MunicipalityListAdvisorsControl = ({
options,
variant,
}: MunicipalityListAdvisorsControlProps) => {
const [open, setOpen] = useState(false)
const [selectedIDs, setSelectedIDs] = useState<number[]>(currentAdvisorIDs)
const [query, setQuery] = useState('')
const { errorMessage, setErrorMessage, reportFailure, noteOpenChange } =
useCampaignCellFailureChannel()
const [isPending, setIsPending] = useState(false)
const pendingCountRef = useRef(0)
const lastPropsIDsRef = useRef(currentAdvisorIDs)
// 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, and advancing it here would make a later
// stale re-render of that same pre-edit content look "external" and
// clobber the optimistic state back to it.
const requestSeqRef = useRef(0)
const latestConfirmedRef = useRef<{ seq: number; advisors: number[] } | null>(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(currentAdvisorIDs, lastPropsIDsRef.current)) return
lastPropsIDsRef.current = currentAdvisorIDs
setSelectedIDs(currentAdvisorIDs)
}, [currentAdvisorIDs])
// B154 — advisors created inline since the page last rendered server-side,
// shared across every row's popover through `MunicipalityAdvisorCreateProvider`
// (null when the control renders standalone, e.g. in unit tests).
const createBridge = useMunicipalityAdvisorCreate()
const createdOptions = createBridge?.createdOptions ?? EMPTY_CREATED_OPTIONS
const registerCreatedAdvisor = createBridge?.registerCreatedAdvisor
// tempId → name 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, string>>(EMPTY_PENDING_CREATES)
const createdOptions = createBridge?.createdOptions ?? []
// Every eligible account (coordinator/advisor/candidate) 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. `advisorNamesById` is the fallback for an id that fell out of
// eligibility but is still assigned; `advisorEntriesFromIds` drops the rare
// id found in neither, same as every other advisor cell in the list.
const effectiveOptions = useMemo(() => {
// The provider bridge can outlive a server re-render (sort/filter
// navigation keeps client state, and `getEligibleAdvisorOptions` then
// returns the created account again), so the same advisor can arrive
// twice — dedupe by id to keep combobox entries and React keys unique.
const byId = new Map<number, EligibleAdvisorOption>()
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 advisorLookup = useMemo(() => {
const lookup = new Map<number, { id: number; name: string }>(advisorNamesById)
for (const option of effectiveOptions)
lookup.set(option.id, { id: option.id, name: option.name })
for (const [tempId, name] of pendingCreates) lookup.set(tempId, { id: tempId, name })
return lookup
}, [advisorNamesById, effectiveOptions, pendingCreates])
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs])
const chips = useMemo(
() =>
[...advisorEntriesFromIds(selectedIDs, advisorLookup)].sort((left, right) =>
left.name.localeCompare(right.name, 'pt-BR'),
),
[selectedIDs, advisorLookup],
)
// B23's hover/focus tooltip stays on the trigger even though it repeats the
// chips: it's the no-open-popover read. `null` for an empty list — the
// trigger already reads "Sem responsável"/"Sem assessor" by extenso.
const tooltipContent = formatAdvisorNamesTooltip(chips)
const assignedLabel = chips.length
? chips.map((chip) => chip.name).join(', ')
: isPriority
? 'sem responsável'
: 'sem assessor'
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.advisors)
const toggle = async (
advisorID: number,
assigned: boolean,
): Promise<MunicipalityRelationMutationResult> => {
const { ok, payload } = await postCampaignJson<MunicipalityListAdvisorsResponse>(
ADVISORS_ENDPOINT,
{ municipalityId: municipalityID, advisorId: advisorID, assigned },
)
if (!ok || payload.status !== 'success') {
return {
status: 'error',
message: payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE,
}
}
return { status: 'success', selectedIDs: payload.advisors }
}
const toggle = (advisorId: number, assigned: boolean) => {
// A negative id is a B154 pending-create sentinel, never a real account —
// the temp chip has no remove affordance, so this only guards the path.
if (isPendingCreateId(advisorId)) return
setErrorMessage(null)
setSelectedIDs((current) => {
const next = new Set(current)
if (assigned) next.add(advisorId)
else next.delete(advisorId)
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(advisorId)
else next.add(advisorId)
return [...next]
})
const create = async (name: string): Promise<MunicipalityRelationMutationResult> => {
const { ok, payload } = await postCampaignJson<MunicipalityListAdvisorsResponse>(
ADVISORS_ENDPOINT,
{ municipalityId: municipalityID, name },
)
if (!ok || payload.status !== 'success' || !payload.createdAdvisor) {
return {
status: 'error',
message: payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE,
}
try {
const { ok, payload } = await postCampaignJson<MunicipalityListAdvisorsResponse>(
ADVISORS_ENDPOINT,
{ municipalityId: municipalityID, advisorId, 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, advisors: payload.advisors }
}
finishRequest()
} catch {
revertDelta()
reportFailure(SAVE_ERROR_MESSAGE)
finishRequest()
}
})()
}
return {
status: 'success',
selectedIDs: payload.advisors,
createdEntry: entryOf(payload.createdAdvisor),
}
}
/**
* B154 — name-only 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 cap failure reconcile exactly like
* a toggle — a failed create reverts the temp chip and shows the Alert.
*/
const createAdvisor = (name: string) => {
// A create is not an idempotent delta: re-activating the same name while a
// create is in flight would mint a second account. The temp chip already
// signals the pending one — skip.
if ([...pendingCreates.values()].includes(name)) return
setErrorMessage(null)
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))
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<MunicipalityListAdvisorsResponse>(
ADVISORS_ENDPOINT,
{ municipalityId: municipalityID, name },
)
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, advisors: payload.advisors }
}
const createdAdvisor = payload.createdAdvisor
if (createdAdvisor) {
// 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), createdAdvisor.id])
setPendingCreates((current) => {
const next = new Map(current)
next.delete(tempId)
return next
})
// Make the account selectable in every other row's popover on this
// page load — `createdOptions` is the client-side bridge until the
// next navigation's `getEligibleAdvisorOptions` picks it up.
registerCreatedAdvisor?.({
id: createdAdvisor.id,
name: createdAdvisor.name,
isCurrent: false,
})
}
finishRequest()
} catch {
revertCreate()
reportFailure(SAVE_ERROR_MESSAGE)
finishRequest()
}
})()
}
const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando assessores.' : ''
// 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
<MunicipalityRelationEditor
municipalityName={municipalityName}
currentIDs={currentAdvisorIDs}
knownEntries={[...advisorNamesById.values()].map(entryOf)}
options={[...options, ...createdOptions].map((option) => ({
...entryOf(option),
optionSuffix: option.isCurrent ? ' (você)' : undefined,
}))}
variant={variant}
open={open}
onOpenChange={handleOpenChange}
title="Atribuir assessores"
description={municipalityName}
// The avatar stack reads as initials at best, and an `aria-label` replaces
// even those — so who is assigned goes in the label, by extenso.
triggerLabel={`Editar assessores em ${municipalityName} — ${assignedLabel}`}
triggerBusy={isPending}
statusMessage={statusMessage}
tooltipContent={tooltipContent}
contentClassName="w-80 p-0"
sheetBodyClassName="px-0 pt-2"
trigger={<MunicipalityAdvisorAvatarStack advisors={chips} isPriority={isPriority} />}
>
<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">Atribuir assessores</p>}
{/* `pr-6` keeps this line clear of the sheet's spinner, which sits at
`top-0 right-4` because there is no title row above it to hold it. */}
<p className={cn('text-xs text-muted-foreground', isSheet && 'pr-6')}>
O assessor vê e gerencia somente os municípios que administra.
</p>
{isPending ? (
<Spinner
className={cn(
'absolute size-3.5 text-muted-foreground',
isSheet ? 'top-0 right-4' : 'top-3 right-3',
)}
aria-label="Salvando assessores"
/>
) : 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 account (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}
<Command shouldFilter={false} className="mt-1">
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Buscar assessor…"
aria-label="Buscar assessor"
/>
<CommandList>
{filteredOptions.length === 0 ? (
trimmedQuery.length >= 2 && trimmedQuery.length <= 160 ? (
<CommandItem
value={`create-${trimmedQuery}`}
className={isSheet ? 'min-h-11' : undefined}
onSelect={() => createAdvisor(trimmedQuery)}
>
<UserPlusIcon className="size-4 shrink-0" aria-hidden="true" />
<span className="truncate">Criar assessor “{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={`advisor-${option.id}`}
data-checked={assigned}
className={isSheet ? 'min-h-11' : undefined}
onSelect={() => toggle(option.id, !assigned)}
>
<span className="truncate">
{option.name}
{option.isCurrent ? ' (você)' : ''}
</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>
description="O assessor vê e gerencia somente os municípios que administra."
searchPlaceholder="Buscar assessor…"
searchLabel="Buscar assessor"
savingMessage="Salvando assessores."
saveErrorMessage={SAVE_ERROR_MESSAGE}
triggerLabel={(entries) =>
`Editar assessores em ${municipalityName} — ${
entries.length
? entries.map((entry) => entry.label).join(', ')
: isPriority
? 'sem responsável'
: 'sem assessor'
}`
}
emptyState={<MissingAdvisorBadge isPriority={isPriority} />}
createLabel={(name) => `Criar assessor “${name}”`}
createMaxLength={160}
onToggle={toggle}
onCreate={create}
onCreated={(entry) =>
createBridge?.registerCreatedAdvisor({ id: entry.id, name: entry.label, isCurrent: false })
}
/>
)
}

View file

@ -1,31 +1,14 @@
'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 type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
import type { MunicipalityRelationEntry } from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
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'
MunicipalityRelationEditor,
type MunicipalityRelationMutationResult,
} from '@/components/campaign/shared/MunicipalityRelationEditor'
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,
@ -34,24 +17,6 @@ import type {
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
@ -61,6 +26,11 @@ type MunicipalityListLeadershipsControlProps = {
variant: CampaignCellEditOverlayVariant
}
const entryOf = (option: { id: number; name: string }): MunicipalityRelationEntry => ({
id: option.id,
label: option.name,
})
export const MunicipalityListLeadershipsControl = ({
municipalityID,
municipalityName,
@ -69,462 +39,69 @@ export const MunicipalityListLeadershipsControl = ({
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)
const createdOptions = createBridge?.createdOptions ?? []
// 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]
})
const toggle = async (
leadershipID: number,
assigned: boolean,
): Promise<MunicipalityRelationMutationResult> => {
const { ok, payload } = await postCampaignJson<MunicipalityListLeadershipsResponse>(
LEADERSHIPS_ENDPOINT,
{ municipalityId: municipalityID, leadershipId: leadershipID, assigned },
)
if (!ok || payload.status !== 'success') {
return {
status: 'error',
message: payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE,
}
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('')
return { status: 'success', selectedIDs: payload.leadershipIDs }
}
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
})
const create = async (name: string): Promise<MunicipalityRelationMutationResult> => {
const { ok, payload } = await postCampaignJson<MunicipalityListLeadershipsResponse>(
LEADERSHIPS_ENDPOINT,
{ municipalityId: municipalityID, name },
)
if (!ok || payload.status !== 'success' || !payload.createdLeadership) {
return {
status: 'error',
message: payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE,
}
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()
}
})()
}
return {
status: 'success',
selectedIDs: payload.leadershipIDs,
createdEntry: entryOf(payload.createdLeadership),
}
}
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
<MunicipalityRelationEditor
municipalityName={municipalityName}
currentIDs={currentLeadershipIDs}
knownEntries={[...leadershipNamesById.values()].map(entryOf)}
options={[...options, ...createdOptions].map(entryOf)}
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>
)
description="A liderança passa a aparecer também na ficha dela, na área Lideranças."
searchPlaceholder="Buscar liderança…"
searchLabel="Buscar liderança"
savingMessage="Salvando lideranças."
saveErrorMessage={SAVE_ERROR_MESSAGE}
triggerLabel={(entries) =>
`Editar lideranças em ${municipalityName} — ${
entries.length ? entries.map((entry) => entry.label).join(', ') : 'nenhuma'
}`
}
>
<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>
emptyState={<span className="text-sm text-muted-foreground">Nenhuma</span>}
createLabel={(name) => `Criar liderança “${name}”`}
onToggle={toggle}
onCreate={create}
onCreated={(entry) =>
createBridge?.registerCreatedLeadership({ id: entry.id, name: entry.label })
}
/>
)
}

View file

@ -0,0 +1,40 @@
import type { ReactNode } from 'react'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
export type MunicipalityRelationEntry = {
id: number
label: string
searchText?: string
initialsLabel?: string
optionSuffix?: string
href?: string
}
export const MunicipalityRelationAvatarStack = ({
entries,
emptyState,
maxVisible = 3,
}: {
entries: MunicipalityRelationEntry[]
emptyState: ReactNode
maxVisible?: number
}) => {
if (entries.length === 0) return emptyState
return (
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
{entries.slice(0, maxVisible).map((entry) => (
<Avatar key={entry.id} className="size-8 border-2 border-background">
<AvatarFallback>
{campaignUserInitials(entry.initialsLabel ?? entry.label)}
</AvatarFallback>
</Avatar>
))}
</div>
<span className="sr-only">{entries.map((entry) => entry.label).join(', ')}</span>
</div>
)
}

View file

@ -0,0 +1,394 @@
'use client'
import { UserPlusIcon, XIcon } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
CampaignCellEditOverlay,
type CampaignCellEditOverlayVariant,
} from '@/components/campaign/shared/CampaignCellEditOverlay'
import {
MunicipalityRelationAvatarStack,
type MunicipalityRelationEntry,
} from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useCampaignCellFailureChannel'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Badge } from '@/components/ui/Badge'
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
import { Spinner } from '@/components/ui/Spinner'
import { sameIdSet } from '@/lib/sameIdSet'
import { cn } from '@/lib/utils'
import { matchesAtWordStart } from '@/lib/wordStartFilter'
export type MunicipalityRelationMutationResult =
| {
status: 'success'
selectedIDs?: number[]
createdEntry?: MunicipalityRelationEntry
}
| { status: 'error'; message: string }
type MunicipalityRelationEditorProps = {
municipalityName: string
currentIDs: number[]
knownEntries?: MunicipalityRelationEntry[]
options: MunicipalityRelationEntry[]
variant: CampaignCellEditOverlayVariant
title: string
description: string
searchPlaceholder: string
searchLabel: string
savingMessage: string
saveErrorMessage: string
createErrorMessage?: string
triggerLabel: (entries: MunicipalityRelationEntry[]) => string
emptyState: ReactNode
createLabel: (name: string) => string
createMaxLength?: number
sortSelected?: boolean
onToggle: (id: number, assigned: boolean) => Promise<MunicipalityRelationMutationResult>
onCreate: (name: string) => Promise<MunicipalityRelationMutationResult>
onCreated?: (entry: MunicipalityRelationEntry) => void
}
const isPendingCreateID = (id: number): boolean => id < 0
const EMPTY_ENTRIES: MunicipalityRelationEntry[] = []
export const MunicipalityRelationEditor = ({
municipalityName,
currentIDs,
knownEntries = EMPTY_ENTRIES,
options,
variant,
title,
description,
searchPlaceholder,
searchLabel,
savingMessage,
saveErrorMessage,
createErrorMessage = saveErrorMessage,
triggerLabel,
emptyState,
createLabel,
createMaxLength = 120,
sortSelected = true,
onToggle,
onCreate,
onCreated,
}: MunicipalityRelationEditorProps) => {
const [open, setOpen] = useState(false)
const [selectedIDs, setSelectedIDs] = useState(currentIDs)
const [query, setQuery] = useState('')
const { errorMessage, setErrorMessage, reportFailure, noteOpenChange } =
useCampaignCellFailureChannel()
const [isPending, setIsPending] = useState(false)
const [createdEntries, setCreatedEntries] = useState<MunicipalityRelationEntry[]>([])
const [pendingCreates, setPendingCreates] = useState<
ReadonlyMap<number, MunicipalityRelationEntry>
>(new Map())
const pendingCountRef = useRef(0)
const lastPropsIDsRef = useRef(currentIDs)
const requestSeqRef = useRef(0)
const latestConfirmedRef = useRef<{ seq: number; ids: number[] } | null>(null)
const handleOpenChange = (nextOpen: boolean) => {
noteOpenChange(nextOpen)
setOpen(nextOpen)
}
useEffect(() => {
if (sameIdSet(currentIDs, lastPropsIDsRef.current)) return
lastPropsIDsRef.current = currentIDs
if (pendingCountRef.current === 0) latestConfirmedRef.current = null
setSelectedIDs(currentIDs)
}, [currentIDs])
const selectableOptions = useMemo(() => {
const byID = new Map<number, MunicipalityRelationEntry>()
for (const entry of options) byID.set(entry.id, entry)
for (const entry of createdEntries) byID.set(entry.id, entry)
return [...byID.values()]
}, [options, createdEntries])
const entryByID = useMemo(() => {
const lookup = new Map(knownEntries.map((entry) => [entry.id, entry]))
for (const entry of selectableOptions) lookup.set(entry.id, entry)
for (const [id, entry] of pendingCreates) lookup.set(id, entry)
return lookup
}, [knownEntries, selectableOptions, pendingCreates])
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs])
const selectedEntries = useMemo(() => {
const entries = selectedIDs.flatMap((id) => {
const entry = entryByID.get(id)
return entry ? [entry] : []
})
return sortSelected
? entries.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
: entries
}, [selectedIDs, entryByID, sortSelected])
const tooltipContent =
selectedEntries.length === 0 ? null : (
<div className="flex flex-col">
{selectedEntries.map((entry) => (
<span key={entry.id}>{entry.label}</span>
))}
</div>
)
const filteredOptions = useMemo(
() =>
selectableOptions.filter((entry) =>
matchesAtWordStart(entry.searchText ?? entry.label, query),
),
[selectableOptions, query],
)
const finishRequest = () => {
pendingCountRef.current = Math.max(0, pendingCountRef.current - 1)
if (pendingCountRef.current > 0) return
setIsPending(false)
if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current.ids)
}
const rememberConfirmation = (requestSeq: number, result: MunicipalityRelationMutationResult) => {
if (result.status !== 'success' || !result.selectedIDs) return
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
latestConfirmedRef.current = { seq: requestSeq, ids: result.selectedIDs }
}
}
const toggle = (id: number, assigned: boolean) => {
if (isPendingCreateID(id)) return
setErrorMessage(null)
setSelectedIDs((current) => {
const next = new Set(current)
if (assigned) next.add(id)
else next.delete(id)
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(id)
else next.add(id)
return [...next]
})
}
try {
const result = await onToggle(id, assigned)
if (result.status === 'error') {
revertDelta()
reportFailure(result.message || saveErrorMessage)
finishRequest()
return
}
rememberConfirmation(requestSeq, result)
finishRequest()
} catch {
revertDelta()
reportFailure(saveErrorMessage)
finishRequest()
}
})()
}
const create = (name: string) => {
if ([...pendingCreates.values()].some((entry) => entry.label === name)) return
setErrorMessage(null)
setQuery('')
pendingCountRef.current += 1
setIsPending(true)
const requestSeq = (requestSeqRef.current += 1)
const tempID = -requestSeq
const pendingEntry = { id: tempID, label: name }
setSelectedIDs((current) => [...current, tempID])
setPendingCreates((current) => new Map(current).set(tempID, pendingEntry))
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 result = await onCreate(name)
if (result.status === 'error' || !result.createdEntry) {
revertCreate()
reportFailure(result.status === 'error' ? result.message : createErrorMessage)
finishRequest()
return
}
rememberConfirmation(requestSeq, result)
const createdEntry = result.createdEntry
setSelectedIDs((current) => [
...new Set([...current.filter((id) => id !== tempID), createdEntry.id]),
])
setPendingCreates((current) => {
const next = new Map(current)
next.delete(tempID)
return next
})
setCreatedEntries((current) =>
current.some((entry) => entry.id === createdEntry.id)
? current
: [...current, createdEntry],
)
onCreated?.(createdEntry)
finishRequest()
} catch {
revertCreate()
reportFailure(createErrorMessage)
finishRequest()
}
})()
}
const statusMessage = errorMessage ? errorMessage : isPending ? savingMessage : ''
const isSheet = variant === 'sheet'
const trimmedQuery = query.trim()
return (
<CampaignCellEditOverlay
variant={variant}
open={open}
onOpenChange={handleOpenChange}
title={title}
description={municipalityName}
triggerLabel={triggerLabel(selectedEntries)}
triggerBusy={isPending}
statusMessage={statusMessage}
tooltipContent={tooltipContent}
contentClassName="w-80 p-0"
sheetBodyClassName="px-0 pt-2"
trigger={
<MunicipalityRelationAvatarStack entries={selectedEntries} emptyState={emptyState} />
}
>
<div className={cn('relative flex shrink-0 flex-col gap-2 px-4 pb-0', !isSheet && 'pt-3')}>
{isSheet ? null : <p className="text-sm font-medium">{title}</p>}
<p className={cn('text-xs text-muted-foreground', isSheet && 'pr-6')}>{description}</p>
{isPending ? (
<Spinner
className={cn(
'absolute size-3.5 text-muted-foreground',
isSheet ? 'top-0 right-4' : 'top-3 right-3',
)}
aria-label={savingMessage}
/>
) : null}
</div>
{selectedEntries.length > 0 ? (
<div className="flex shrink-0 flex-wrap gap-1.5 px-4 pt-2">
{selectedEntries.map((entry) =>
isPendingCreateID(entry.id) ? (
<Badge
key={entry.id}
variant="secondary"
className={cn('max-w-full font-normal', isSheet && 'min-h-11')}
>
<span className="truncate">{entry.label}</span>
</Badge>
) : entry.href ? (
<Badge
key={entry.id}
variant="secondary"
className={cn('relative max-w-full gap-1 pr-5 font-normal', isSheet && 'my-3')}
>
<Link className="truncate underline-offset-2 hover:underline" href={entry.href}>
{entry.label}
</Link>
<button
type="button"
className="absolute right-0 inline-flex size-5 items-center justify-center after:absolute after:-inset-x-1 after:-inset-y-3"
aria-label={`Remover ${entry.label}`}
onClick={() => toggle(entry.id, false)}
>
<XIcon className="size-3 opacity-70" aria-hidden="true" />
</button>
</Badge>
) : (
<button
key={entry.id}
type="button"
className={cn('inline-flex items-center', isSheet && 'min-h-11')}
aria-label={`Remover ${entry.label}`}
onClick={() => toggle(entry.id, false)}
>
<Badge
variant="secondary"
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
>
<span className="truncate">{entry.label}</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={searchPlaceholder}
aria-label={searchLabel}
/>
<CommandList>
{filteredOptions.length === 0 ? (
trimmedQuery.length >= 2 && trimmedQuery.length <= createMaxLength ? (
<CommandItem
value={`create-${trimmedQuery}`}
className={isSheet ? 'min-h-11' : undefined}
onSelect={() => create(trimmedQuery)}
>
<UserPlusIcon className="size-4 shrink-0" aria-hidden="true" />
<span className="truncate">{createLabel(trimmedQuery)}</span>
</CommandItem>
) : (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Nenhum resultado.
</p>
)
) : (
filteredOptions.map((entry) => {
const assigned = selectedSet.has(entry.id)
return (
<CommandItem
key={entry.id}
value={`relation-${entry.id}`}
data-checked={assigned}
className={isSheet ? 'min-h-11' : undefined}
onSelect={() => toggle(entry.id, !assigned)}
>
<span className="truncate">
{entry.label}
{entry.optionSuffix ?? ''}
</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

@ -1,106 +1,16 @@
'use client'
import { useCallback, useMemo, type ReactElement } from 'react'
import type { MunicipalityStateDeputyCreateResult } from '@/app/(campaign)/campanha/actions/stateDeputy'
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
import type { MunicipalityRelationEntry } from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
import {
RelationChipCell,
type RelationChip,
type RelationChipCellCopy,
type RelationSearchHit,
} from '@/components/campaign/shared/RelationChipCell'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
MunicipalityRelationEditor,
type MunicipalityRelationMutationResult,
} from '@/components/campaign/shared/MunicipalityRelationEditor'
import { stateDeputyDisplayName } from '@/lib/stateDeputyNameParty'
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
const MAX_VISIBLE_AVATARS = 3
/** "Cicrano (PCdoB)" → "C" — parenthesized party never leaks into the initials. */
const deputyInitials = (label: string): string => {
const withoutParty = label.replace(/\([^()]*\)/g, '').trim()
return campaignUserInitials(withoutParty || label)
}
/**
* The closed-cell display of the "Dobradinhas" column (B157): the same avatar
* stack as the advisors column — overlapping initials circles, hover reads the
* names — with "—" for an empty município. The detail ("Fulano (PT)") lives in
* the trigger tooltip and in the editor's removable chips, which link to the
* ficha (same contract as advisors: no links inside the closed cell).
*/
const StateDeputyAvatarStack = ({ chips }: { chips: RelationChip[] }) => {
if (chips.length === 0) return <span className="text-sm text-muted-foreground">—</span>
return (
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
{chips.slice(0, MAX_VISIBLE_AVATARS).map((chip) => (
<Avatar key={chip.key} className="size-8 border-2 border-background">
<AvatarFallback>{deputyInitials(chip.label)}</AvatarFallback>
</Avatar>
))}
</div>
</div>
)
}
/** One "Nome (Partido)" per line — the hover read of the closed cell. */
const formatStateDeputyNamesTooltip = (chips: RelationChip[]): ReactElement | null =>
chips.length === 0 ? null : (
<div className="flex flex-col">
{chips.map((chip) => (
<span key={chip.key}>{chip.label}</span>
))}
</div>
)
/**
* Normalized search labels, once per `options` array rather than per row: the
* RSC builds one array and every row of the table gets that same reference, so
* a `WeakMap` shares the work where a per-row `useMemo` would repeat it (same
* reasoning as `LeadershipStateDeputyRelationCell`).
*/
const normalizedLabelsByOptions = new WeakMap<
readonly StateDeputyRelationOption[],
Map<number, string>
>()
const normalizedOptionLabels = (
options: readonly StateDeputyRelationOption[],
): Map<number, string> => {
const cached = normalizedLabelsByOptions.get(options)
if (cached) return cached
// Name AND party are searchable ("PT" finds "Fulano (PT)" — B157 aceite).
const normalized = new Map(
options.map(
(option) =>
[option.id, normalizeSearchPhrase(`${option.plainName} ${option.party ?? ''}`)] as const,
),
)
normalizedLabelsByOptions.set(options, normalized)
return normalized
}
/** Same WeakMap sharing for the id→option lookup the chips are built from. */
const optionByIdByOptions = new WeakMap<
readonly StateDeputyRelationOption[],
Map<number, StateDeputyRelationOption>
>()
const optionByIdFor = (
options: readonly StateDeputyRelationOption[],
): Map<number, StateDeputyRelationOption> => {
const cached = optionByIdByOptions.get(options)
if (cached) return cached
const byId = new Map(options.map((option) => [option.id, option]))
optionByIdByOptions.set(options, byId)
return byId
}
type MunicipalityStateDeputyRelationCellProps = {
municipalityId: number
@ -115,12 +25,6 @@ type MunicipalityStateDeputyRelationCellProps = {
editorVariant: CampaignCellEditOverlayVariant
}
/**
* The inline-create form action as the cell consumes it — the success state
* carries the created deputy so the cell can swap its optimistic chip without
* waiting for the RSC refresh. Exported once so the list and the mobile cards
* share the same type instead of re-declaring it.
*/
export type MunicipalityStateDeputyCreateAction = (
state: CampaignFormActionState,
formData: FormData,
@ -128,26 +32,30 @@ export type MunicipalityStateDeputyCreateAction = (
CampaignFormActionState & { stateDeputy?: MunicipalityStateDeputyCreateResult['stateDeputy'] }
>
const CREATE_LABEL = (rawName: string) => `Criar dobradinha “${rawName}”`
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar as dobradinhas. Tente novamente.'
const CREATE_ERROR_MESSAGE = 'Não foi possível criar a dobradinha. Tente novamente.'
const chipOf = (option: StateDeputyRelationOption): RelationChip => ({
key: String(option.id),
// `option.name` already folds the party (the loader's single spelling).
const entriesByOptions = new WeakMap<
readonly StateDeputyRelationOption[],
MunicipalityRelationEntry[]
>()
const entryOf = (option: StateDeputyRelationOption): MunicipalityRelationEntry => ({
id: option.id,
label: option.name,
initialsLabel: option.plainName,
searchText: `${option.plainName} ${option.party ?? ''}`,
href: `/campanha/dobradinhas/${option.slug}`,
ids: [option.id],
})
/**
* B157 — the "Dobradinhas" column of `/campanha/municipios`: a thin domain
* shell over `RelationChipCell` in trigger mode. The closed cell is the
* advisors-style avatar stack (hover reads "Nome (Partido)"); the editor opens
* in a Popover (desktop) / Drawer (mobile) with removable chips, a combobox
* that searches name AND party, and inline create ("Criar dobradinha 'texto'",
* `Nome (PARTIDO)` syntax). Every write goes through the same actions the
* `/campanha/dobradinhas` list already uses — nothing new on the server side
* for toggles.
*/
const entriesFor = (options: readonly StateDeputyRelationOption[]) => {
const cached = entriesByOptions.get(options)
if (cached) return cached
const entries = options.map(entryOf)
entriesByOptions.set(options, entries)
return entries
}
export const MunicipalityStateDeputyRelationCell = ({
municipalityId,
municipalityName,
@ -157,125 +65,68 @@ export const MunicipalityStateDeputyRelationCell = ({
createAction,
editorVariant,
}: MunicipalityStateDeputyRelationCellProps) => {
// Same WeakMap sharing as the normalized labels above: the RSC builds ONE
// options array for every row, so the id→option map is computed once per
// request instead of per row (25 × catalog-size inserts otherwise).
const optionById = optionByIdFor(options)
const toggle = async (
stateDeputyID: number,
assigned: boolean,
): Promise<MunicipalityRelationMutationResult> => {
const formData = new FormData()
formData.set('municipalityId', String(municipalityId))
formData.set('stateDeputyId', String(stateDeputyID))
formData.set('assigned', assigned ? 'true' : 'false')
const result = await commitAction({}, formData)
return result.status === 'success'
? { status: 'success' }
: { status: 'error', message: firstFormActionMessage(result) ?? SAVE_ERROR_MESSAGE }
}
const buildChips = useCallback(
(ids: number[]): RelationChip[] => {
const chips: RelationChip[] = []
for (const id of ids) {
const option = optionById.get(id)
if (!option) continue
chips.push(chipOf(option))
}
return chips
},
[optionById],
)
const searchHits = useCallback(
(query: string, assignedIds: ReadonlySet<number>): RelationSearchHit[] => {
const normalizedQuery = normalizeSearchPhrase(query)
const normalizedLabels = normalizedOptionLabels(options)
const hits: RelationSearchHit[] = []
for (const option of options) {
if (assignedIds.has(option.id)) continue
if (!matchesNormalizedAtWordStart(normalizedLabels.get(option.id) ?? '', normalizedQuery)) {
continue
}
hits.push({ key: String(option.id), label: option.name, ids: [option.id] })
}
return hits
},
[options],
)
const buildFormData = useCallback(
(changedIds: number[], assigned: boolean) => {
const formData = new FormData()
formData.set('municipalityId', String(municipalityId))
formData.set('stateDeputyId', String(changedIds[0]))
formData.set('assigned', assigned ? 'true' : 'false')
return formData
},
[municipalityId],
)
const buildCreateFormData = useCallback(
(rawName: string) => {
const formData = new FormData()
formData.set('municipalityId', String(municipalityId))
formData.set('rawName', rawName)
return formData
},
[municipalityId],
)
/** Maps the action's `stateDeputy` payload to the cell's `createdChip`. */
const createActionWithChip = useCallback(
async (
state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState & { createdChip?: RelationChip }> => {
const result = await createAction(state, formData)
if (result.status !== 'success' || !result.stateDeputy) return result
const created = result.stateDeputy
const create = async (rawName: string): Promise<MunicipalityRelationMutationResult> => {
const formData = new FormData()
formData.set('municipalityId', String(municipalityId))
formData.set('rawName', rawName)
const result = await createAction({}, formData)
if (result.status !== 'success' || !result.stateDeputy) {
return {
...result,
createdChip: {
key: String(created.id),
label: stateDeputyDisplayName(created.name, created.party),
href: `/campanha/dobradinhas/${created.slug}`,
ids: [created.id],
},
status: 'error',
message: firstFormActionMessage(result) ?? CREATE_ERROR_MESSAGE,
}
},
[createAction],
)
const copy = useMemo<RelationChipCellCopy>(
() => ({
searchPlaceholder: 'Buscar dobradinha…',
searchLabel: 'Buscar dobradinha',
suggestionsLabel: 'Sugestões de dobradinhas',
emptyDrawerMessage: 'Nenhuma dobradinha vinculada.',
savingMessage: 'Salvando dobradinhas.',
savedMessage: 'Dobradinhas salvas.',
removedMessage: (count) =>
count === 1 ? 'Dobradinha removida.' : `${count} dobradinhas removidas.`,
createHintMessage: 'Nenhum resultado — opção de criar dobradinha disponível.',
}),
[],
)
}
const created = result.stateDeputy
return {
status: 'success',
createdEntry: {
id: created.id,
label: stateDeputyDisplayName(created.name, created.party),
initialsLabel: created.name,
searchText: `${created.name} ${created.party ?? ''}`,
href: `/campanha/dobradinhas/${created.slug}`,
},
}
}
return (
<RelationChipCell
ownerId={municipalityId}
ownerName={municipalityName}
ids={stateDeputyIDs}
buildChips={buildChips}
searchHits={searchHits}
buildFormData={buildFormData}
commitAction={commitAction}
drawerTitle="Dobradinhas do município"
// The advisor twin's contract: the names ride in the trigger's label so
// a screen reader reads WHO is assigned without opening the editor (the
// hover tooltip is pointer-only).
triggerLabel={(chips) =>
`Editar dobradinhas em ${municipalityName}${chips.length ? ` — ${chips.map((chip) => chip.label).join(', ')}` : ''}`
<MunicipalityRelationEditor
municipalityName={municipalityName}
currentIDs={stateDeputyIDs}
options={entriesFor(options)}
variant={editorVariant}
title="Gerenciar dobradinhas"
description="Vincule os deputados estaduais que fazem dobradinha neste município."
searchPlaceholder="Buscar dobradinha…"
searchLabel="Buscar dobradinha"
savingMessage="Salvando dobradinhas."
saveErrorMessage={SAVE_ERROR_MESSAGE}
createErrorMessage={CREATE_ERROR_MESSAGE}
triggerLabel={(entries) =>
`Editar dobradinhas em ${municipalityName}${
entries.length ? ` — ${entries.map((entry) => entry.label).join(', ')}` : ''
}`
}
updateErrorMessage="Não foi possível atualizar as dobradinhas. Tente novamente."
copy={copy}
measureOverflow={false}
trigger={(chips) => <StateDeputyAvatarStack chips={chips} />}
triggerTooltip={formatStateDeputyNamesTooltip}
editorVariant={editorVariant}
createAction={createActionWithChip}
buildCreateFormData={buildCreateFormData}
createLabel={CREATE_LABEL}
createErrorMessage="Não foi possível criar a dobradinha. Tente novamente."
emptyState={<span className="text-sm text-muted-foreground">—</span>}
createLabel={(name) => `Criar dobradinha “${name}”`}
createMaxLength={160}
sortSelected={false}
onToggle={toggle}
onCreate={create}
/>
)
}

View file

@ -104,13 +104,12 @@ export const leadershipWizardCreateSchema = leadershipWizardFieldsSchema.extend(
/**
* B155 — inline create from the "Lideranças" column of `/campanha/municipios`:
* name + phone only, linked to the município whose popover created it. Strict
* name 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>

View file

@ -4,7 +4,11 @@ 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 {
createLeadershipRecord,
createMunicipalityLeadershipRecord,
} from '@/app/(campaign)/campanha/actions/leadership'
import { relationshipId } from '@/lib/relationship'
import config from '@/payload.config'
import { loadMunicipalityListPageBundle } from '@/utilities/municipality/municipalityPageData'
import {
@ -130,24 +134,52 @@ describe('B155 — lideranças na lista de municípios (leitura reversa + opçõ
expect(leaderBundle.leadershipNamesById.size).toBe(0)
})
it('sees an inline-created leadership through the column read (create → summarize)', async () => {
it('creates an inline leadership with name only and exposes it through the column read', 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],
const created = await createMunicipalityLeadershipRecord(payload, coordinator, {
municipalityId: municipality.id,
name: 'Criada na Reunião',
phone: fixtures.phone(),
})
const contactID = relationshipId(created.leadership.contact)
if (contactID === null) throw new Error('A liderança criada deve referenciar um contato.')
const contact = await payload.findByID({
collection: 'contact',
id: contactID,
depth: 0,
overrideAccess: true,
})
expect(contact.phone).toBeNull()
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')
expect(leadershipIDsByMunicipality.get(municipality.id)).toContain(created.leadership.id)
expect(summariesById.get(created.leadership.id)?.name).toBe('Criada na Reunião')
})
it('keeps phone dedup on the complete leadership create path', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const phone = fixtures.phone()
const first = await createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Nome Original',
phone,
})
await expect(
createLeadershipRecord(payload, coordinator, {
municipalities: [municipality.id],
name: 'Outro Nome',
phone,
}),
).rejects.toThrow('Esta pessoa já está cadastrada como liderança')
expect(first.contactReused).toBe(false)
})
})

View file

@ -0,0 +1,77 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { MunicipalityListLeadershipsControl } from '@/components/campaign/municipality/MunicipalityListLeadershipsControl'
import { TooltipProvider } from '@/components/ui/tooltip'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
vi.mock('@/lib/campaignJsonRequest', () => ({ postCampaignJson: vi.fn() }))
const mockedPost = vi.mocked(postCampaignJson)
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
afterAll(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
})
describe('MunicipalityListLeadershipsControl name-only create (B159)', () => {
it('creates directly from the Command without rendering or sending phone', async () => {
mockedPost.mockResolvedValue({
ok: true,
payload: {
status: 'success',
message: 'Liderança criada e vinculada.',
leadershipIDs: [11],
createdLeadership: { id: 11, name: 'Maria da Serra' },
},
})
render(
createElement(
TooltipProvider,
null,
createElement(MunicipalityListLeadershipsControl, {
municipalityID: 1,
municipalityName: 'Seabra',
currentLeadershipIDs: [],
leadershipNamesById: new Map(),
options: [],
variant: 'sheet',
}),
),
)
fireEvent.click(screen.getByRole('button', { name: /Editar lideranças em Seabra/ }))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).queryByLabelText(/Celular/)).toBeNull()
fireEvent.change(within(dialog).getByLabelText('Buscar liderança'), {
target: { value: 'Maria da Serra' },
})
fireEvent.click(await within(dialog).findByRole('option', { name: /Criar liderança/ }))
await waitFor(() =>
expect(mockedPost).toHaveBeenCalledWith('/campanha/municipios/leaderships', {
municipalityId: 1,
name: 'Maria da Serra',
}),
)
expect(screen.getByRole('button', { name: /Maria da Serra/ })).toBeTruthy()
})
})

View file

@ -0,0 +1,57 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { POST } from '@/app/(campaign)/campanha/(app)/municipios/leaderships/route'
import {
createMunicipalityLeadership,
setMunicipalityLeadershipMembership,
} from '@/app/(campaign)/campanha/actions/leadership'
vi.mock('@/app/(campaign)/campanha/actions/leadership', () => ({
createMunicipalityLeadership: vi.fn(),
setMunicipalityLeadershipMembership: vi.fn(),
}))
const mockedCreate = vi.mocked(createMunicipalityLeadership)
const mockedToggle = vi.mocked(setMunicipalityLeadershipMembership)
const postRequest = (body: unknown): Request =>
new Request('http://localhost/campanha/municipios/leaderships', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: 'http://localhost' },
body: JSON.stringify(body),
})
describe('POST /campanha/municipios/leaderships body union (B159)', () => {
beforeEach(() => vi.clearAllMocks())
it('dispatches a name-only create', async () => {
mockedCreate.mockResolvedValue({
leadership: { id: 9 },
leadershipIDs: [9],
createdLeadershipName: 'Maria da Serra',
} as Awaited<ReturnType<typeof createMunicipalityLeadership>>)
const response = await POST(postRequest({ municipalityId: 1, name: 'Maria da Serra' }))
expect(mockedCreate).toHaveBeenCalledWith({ municipalityId: 1, name: 'Maria da Serra' })
expect(mockedToggle).not.toHaveBeenCalled()
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
status: 'success',
leadershipIDs: [9],
createdLeadership: { id: 9, name: 'Maria da Serra' },
})
})
it('rejects a phone remnant instead of accepting hidden data', async () => {
const response = await POST(
postRequest({ municipalityId: 1, name: 'Maria da Serra', phone: '71999999999' }),
)
expect(response.status).toBe(400)
expect(mockedCreate).not.toHaveBeenCalled()
expect(mockedToggle).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,161 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import {
MunicipalityRelationEditor,
type MunicipalityRelationMutationResult,
} from '@/components/campaign/shared/MunicipalityRelationEditor'
import { TooltipProvider } from '@/components/ui/tooltip'
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
})
afterEach(cleanup)
afterAll(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
})
describe('MunicipalityRelationEditor optimistic reconcile', () => {
it('adopts the newest sent response when concurrent toggles settle out of order', async () => {
const pending: Array<(result: MunicipalityRelationMutationResult) => void> = []
const onToggle = vi.fn(
() =>
new Promise<MunicipalityRelationMutationResult>((resolve) => {
pending.push(resolve)
}),
)
render(
createElement(
TooltipProvider,
null,
createElement(MunicipalityRelationEditor, {
municipalityName: 'Seabra',
currentIDs: [],
options: [
{ id: 1, label: 'Álvaro Lima' },
{ id: 2, label: 'Beatriz Souza' },
],
variant: 'popover',
title: 'Gerenciar relações',
description: 'Descrição.',
searchPlaceholder: 'Buscar…',
searchLabel: 'Buscar relação',
savingMessage: 'Salvando relações.',
saveErrorMessage: 'Falha ao salvar.',
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
emptyState: createElement('span', null, 'Nenhuma'),
createLabel: (name) => `Criar “${name}”`,
onToggle,
onCreate: vi.fn(),
}),
),
)
fireEvent.click(screen.getByRole('button', { name: /Editar relações/ }))
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } })
fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' }))
fireEvent.click(await screen.findByRole('option', { name: 'Beatriz Souza' }))
expect(onToggle).toHaveBeenNthCalledWith(1, 1, true)
expect(onToggle).toHaveBeenNthCalledWith(2, 2, true)
pending[1]?.({ status: 'success', selectedIDs: [1, 2] })
pending[0]?.({ status: 'success', selectedIDs: [1] })
await waitFor(() =>
expect(screen.getByRole('button', { name: /Álvaro Lima,Beatriz Souza/ })).toBeTruthy(),
)
})
it('does not duplicate a create adopted by server props before the request resolves', async () => {
let resolveCreate: ((result: MunicipalityRelationMutationResult) => void) | undefined
const onCreate = vi.fn(
() =>
new Promise<MunicipalityRelationMutationResult>((resolve) => {
resolveCreate = resolve
}),
)
const element = (currentIDs: number[], options: Array<{ id: number; label: string }>) =>
createElement(
TooltipProvider,
null,
createElement(MunicipalityRelationEditor, {
municipalityName: 'Seabra',
currentIDs,
options,
variant: 'popover',
title: 'Gerenciar relações',
description: 'Descrição.',
searchPlaceholder: 'Buscar…',
searchLabel: 'Buscar relação',
savingMessage: 'Salvando relações.',
saveErrorMessage: 'Falha ao salvar.',
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
emptyState: createElement('span', null, 'Nenhuma'),
createLabel: (name) => `Criar “${name}”`,
onToggle: vi.fn(),
onCreate,
}),
)
const view = render(element([], []))
fireEvent.click(screen.getByRole('button', { name: /Editar relações/ }))
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'Nova Pessoa' } })
fireEvent.click(await screen.findByRole('option', { name: /Criar “Nova Pessoa”/ }))
view.rerender(element([7], [{ id: 7, label: 'Nova Pessoa' }]))
resolveCreate?.({
status: 'success',
selectedIDs: [7],
createdEntry: { id: 7, label: 'Nova Pessoa' },
})
await waitFor(() => {
const trigger = screen.getByRole('button', { name: /Editar relações — Nova Pessoa/ })
expect(trigger.querySelectorAll('[data-slot="avatar"]')).toHaveLength(1)
})
})
it('uses known entries for display without making them selectable', async () => {
render(
createElement(
TooltipProvider,
null,
createElement(MunicipalityRelationEditor, {
municipalityName: 'Seabra',
currentIDs: [7],
knownEntries: [{ id: 7, label: 'Fora da elegibilidade' }],
options: [],
variant: 'popover',
title: 'Gerenciar relações',
description: 'Descrição.',
searchPlaceholder: 'Buscar…',
searchLabel: 'Buscar relação',
savingMessage: 'Salvando relações.',
saveErrorMessage: 'Falha ao salvar.',
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
emptyState: createElement('span', null, 'Nenhuma'),
createLabel: (name) => `Criar “${name}”`,
onToggle: vi.fn(),
onCreate: vi.fn(),
}),
),
)
fireEvent.click(screen.getByRole('button', { name: /Fora da elegibilidade/ }))
fireEvent.change(screen.getByLabelText('Buscar relação'), {
target: { value: 'Fora da elegibilidade' },
})
expect(screen.queryByRole('option', { name: 'Fora da elegibilidade' })).toBeNull()
})
})

View file

@ -0,0 +1,75 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { MunicipalityStateDeputyRelationCell } from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
import { TooltipProvider } from '@/components/ui/tooltip'
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
})
afterEach(cleanup)
afterAll(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
})
describe('MunicipalityStateDeputyRelationCell shared editor (B159)', () => {
it('uses the campaign overlay and searches a deputy by party', async () => {
const commitAction = vi.fn().mockResolvedValue({ status: 'success', message: 'Salvo.' })
render(
createElement(
TooltipProvider,
null,
createElement(MunicipalityStateDeputyRelationCell, {
municipalityId: 1,
municipalityName: 'Seabra',
stateDeputyIDs: [],
options: [
{
id: 7,
name: 'Beltrana (PSB)',
plainName: 'Beltrana',
party: 'PSB',
slug: 'beltrana',
},
],
commitAction,
createAction: vi.fn(),
editorVariant: 'popover',
}),
),
)
fireEvent.click(screen.getByRole('button', { name: 'Editar dobradinhas em Seabra' }))
const dialog = await screen.findByRole('dialog', { name: 'Gerenciar dobradinhas' })
fireEvent.change(within(dialog).getByLabelText('Buscar dobradinha'), {
target: { value: 'psb' },
})
fireEvent.click(await within(dialog).findByRole('option', { name: 'Beltrana (PSB)' }))
await waitFor(() => expect(commitAction).toHaveBeenCalledTimes(1))
const formData = commitAction.mock.calls[0]?.[1] as FormData
expect(Object.fromEntries(formData)).toEqual({
municipalityId: '1',
stateDeputyId: '7',
assigned: 'true',
})
expect(
screen.getByRole('button', { name: 'Editar dobradinhas em Seabra — Beltrana (PSB)' }),
).toBeTruthy()
expect(screen.getByRole('link', { name: 'Beltrana (PSB)' }).getAttribute('href')).toBe(
'/campanha/dobradinhas/beltrana',
)
})
})