From 9fa701633ec9579b282cb85ad7857c4c68cfbd0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 5 Aug 2026 02:01:40 -0300 Subject: [PATCH] feat(campaign): unify municipality relation editors --- docs/CHANGELOG-AGENTS.md | 2 + ...ralizar-colunas-relacao-municipios-impl.md | 108 ++++ ...izar-mutacoes-editor-relacao-municipios.md | 81 +++ .../(app)/municipios/leaderships/route.ts | 11 +- .../(campaign)/campanha/actions/leadership.ts | 103 ++-- .../MunicipalityAdvisorAvatarStack.tsx | 18 +- .../MunicipalityListAdvisorsControl.tsx | 459 +++------------ .../MunicipalityListLeadershipsControl.tsx | 547 ++---------------- .../MunicipalityRelationAvatarStack.tsx | 40 ++ .../shared/MunicipalityRelationEditor.tsx | 394 +++++++++++++ .../MunicipalityStateDeputyRelationCell.tsx | 309 +++------- src/lib/schemas/leadership.ts | 3 +- ...aignMunicipalityLeadershipList.int.spec.ts | 52 +- ...unicipalityLeadershipsCreate.unit.spec.tsx | 77 +++ ...nMunicipalityLeadershipsRoute.unit.spec.ts | 57 ++ .../municipalityRelationEditor.unit.spec.tsx | 161 ++++++ ...alityStateDeputyRelationCell.unit.spec.tsx | 75 +++ 17 files changed, 1321 insertions(+), 1176 deletions(-) create mode 100644 docs/plans/generalizar-colunas-relacao-municipios-impl.md create mode 100644 docs/plans/serializar-mutacoes-editor-relacao-municipios.md create mode 100644 src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx create mode 100644 src/components/campaign/shared/MunicipalityRelationEditor.tsx create mode 100644 tests/unit/campaignMunicipalityLeadershipsCreate.unit.spec.tsx create mode 100644 tests/unit/campaignMunicipalityLeadershipsRoute.unit.spec.ts create mode 100644 tests/unit/municipalityRelationEditor.unit.spec.tsx create mode 100644 tests/unit/municipalityStateDeputyRelationCell.unit.spec.tsx diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 79775ea1..8ba0d7f8 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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 ''" quando a busca não casa ninguém; cria `campaignUser` advisor (e-mail stub `@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). diff --git a/docs/plans/generalizar-colunas-relacao-municipios-impl.md b/docs/plans/generalizar-colunas-relacao-municipios-impl.md new file mode 100644 index 00000000..c756772f --- /dev/null +++ b/docs/plans/generalizar-colunas-relacao-municipios-impl.md @@ -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. diff --git a/docs/plans/serializar-mutacoes-editor-relacao-municipios.md b/docs/plans/serializar-mutacoes-editor-relacao-municipios.md new file mode 100644 index 00000000..12f48327 --- /dev/null +++ b/docs/plans/serializar-mutacoes-editor-relacao-municipios.md @@ -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` diff --git a/src/app/(campaign)/campanha/(app)/municipios/leaderships/route.ts b/src/app/(campaign)/campanha/(app)/municipios/leaderships/route.ts index dc55c386..e2136c7e 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/leaderships/route.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/leaderships/route.ts @@ -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({ 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, diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index 2e3608e1..66e4072a 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -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 +type ValidatedLeadershipCreateData = Omit & { + 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 } /** diff --git a/src/components/campaign/municipality/MunicipalityAdvisorAvatarStack.tsx b/src/components/campaign/municipality/MunicipalityAdvisorAvatarStack.tsx index 2af93cc0..89380dd3 100644 --- a/src/components/campaign/municipality/MunicipalityAdvisorAvatarStack.tsx +++ b/src/components/campaign/municipality/MunicipalityAdvisorAvatarStack.tsx @@ -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 return ( -
-
- {advisors.slice(0, maxVisible).map((advisor) => ( - - {campaignUserInitials(advisor.name)} - - ))} -
- {advisors.map((advisor) => advisor.name).join(', ')} -
+ ({ id: advisor.id, label: advisor.name }))} + emptyState={null} + maxVisible={maxVisible} + /> ) } diff --git a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx index 481eceef..8d5e1075 100644 --- a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx @@ -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 = new Map() - type MunicipalityListAdvisorsControlProps = { municipalityID: number municipalityName: string currentAdvisorIDs: number[] - /** Raises the empty state to "Sem responsável" — see `MissingAdvisorBadge`. */ isPriority: boolean advisorNamesById: ReadonlyMap 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(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>(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() - 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(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 => { + const { ok, payload } = await postCampaignJson( + 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 => { + const { ok, payload } = await postCampaignJson( + 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( - 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( - 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 ( - ({ + ...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={} - > -
- {/* The Drawer's own header already titles it. */} - {isSheet ? null :

Atribuir assessores

} - {/* `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. */} -

- O assessor vê e gerencia somente os municípios que administra. -

- {isPending ? ( - - ) : null} -
- {chips.length > 0 ? ( -
- {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) ? ( - - {chip.name} - - ) : ( - - ), - )} -
- ) : null} - - - - {filteredOptions.length === 0 ? ( - trimmedQuery.length >= 2 && trimmedQuery.length <= 160 ? ( - createAdvisor(trimmedQuery)} - > - - ) : ( -

- Nenhum resultado. -

- ) - ) : ( - filteredOptions.map((option) => { - const assigned = selectedSet.has(option.id) - return ( - toggle(option.id, !assigned)} - > - - {option.name} - {option.isCurrent ? ' (você)' : ''} - - - ) - }) - )} -
-
- {errorMessage ? ( -
- - {errorMessage} - -
- ) : null} -
+ 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={} + createLabel={(name) => `Criar assessor “${name}”`} + createMaxLength={160} + onToggle={toggle} + onCreate={create} + onCreated={(entry) => + createBridge?.registerCreatedAdvisor({ id: entry.id, name: entry.label, isCurrent: false }) + } + /> ) } diff --git a/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx b/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx index 48d15e17..1caf192b 100644 --- a/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx @@ -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 = new Map() - -/** Resolves leadership ids to summaries, dropping any without a name (deleted/unknown). */ -const leadershipEntriesFromIds = ( - leadershipIDs: readonly number[], - namesById: ReadonlyMap, -): 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(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(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>(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() - 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(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 => { + const { ok, payload } = await postCampaignJson( + 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( - 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 => { + const { ok, payload } = await postCampaignJson( + 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( - 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) => { - 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 ( - Nenhuma - ) : ( - - {chips.map((chip) => ( - - {chip.name} - - ))} - - ) + 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' + }` } - > -
- {/* The Drawer's own header already titles it. */} - {isSheet ? null :

Gerenciar lideranças

} -

- A liderança passa a aparecer também na ficha dela, na área Lideranças. -

- {isPending ? ( - - ) : null} -
- {chips.length > 0 ? ( -
- {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) ? ( - - {chip.name} - - ) : ( - - ), - )} -
- ) : null} - {createFormOpen ? ( -
-
- - setCreateName(event.currentTarget.value)} - placeholder="Nome completo" - autoComplete="name" - maxLength={120} - autoFocus - /> -
-
- - handlePhoneChange(event.currentTarget.value)} - placeholder="(71) 99999-9999" - /> -
-
- - -
-
- ) : ( - - - - {filteredOptions.length === 0 ? ( - trimmedQuery.length >= 2 && trimmedQuery.length <= 120 ? ( - { - setCreateFormOpen(true) - setCreateName(trimmedQuery) - }} - > - - ) : ( -

- Nenhum resultado. -

- ) - ) : ( - filteredOptions.map((option) => { - const assigned = selectedSet.has(option.id) - return ( - toggle(option.id, !assigned)} - > - {option.name} - - ) - }) - )} -
-
- )} - {errorMessage ? ( -
- - {errorMessage} - -
- ) : null} -
+ emptyState={Nenhuma} + createLabel={(name) => `Criar liderança “${name}”`} + onToggle={toggle} + onCreate={create} + onCreated={(entry) => + createBridge?.registerCreatedLeadership({ id: entry.id, name: entry.label }) + } + /> ) } diff --git a/src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx b/src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx new file mode 100644 index 00000000..d2f8c833 --- /dev/null +++ b/src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx @@ -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 ( +
+
+ {entries.slice(0, maxVisible).map((entry) => ( + + + {campaignUserInitials(entry.initialsLabel ?? entry.label)} + + + ))} +
+ {entries.map((entry) => entry.label).join(', ')} +
+ ) +} diff --git a/src/components/campaign/shared/MunicipalityRelationEditor.tsx b/src/components/campaign/shared/MunicipalityRelationEditor.tsx new file mode 100644 index 00000000..17f5f874 --- /dev/null +++ b/src/components/campaign/shared/MunicipalityRelationEditor.tsx @@ -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 + onCreate: (name: string) => Promise + 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([]) + const [pendingCreates, setPendingCreates] = useState< + ReadonlyMap + >(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() + 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 : ( +
+ {selectedEntries.map((entry) => ( + {entry.label} + ))} +
+ ) + 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 ( + + } + > +
+ {isSheet ? null :

{title}

} +

{description}

+ {isPending ? ( + + ) : null} +
+ {selectedEntries.length > 0 ? ( +
+ {selectedEntries.map((entry) => + isPendingCreateID(entry.id) ? ( + + {entry.label} + + ) : entry.href ? ( + + + {entry.label} + + + + ) : ( + + ), + )} +
+ ) : null} + + + + {filteredOptions.length === 0 ? ( + trimmedQuery.length >= 2 && trimmedQuery.length <= createMaxLength ? ( + create(trimmedQuery)} + > + + ) : ( +

+ Nenhum resultado. +

+ ) + ) : ( + filteredOptions.map((entry) => { + const assigned = selectedSet.has(entry.id) + return ( + toggle(entry.id, !assigned)} + > + + {entry.label} + {entry.optionSuffix ?? ''} + + + ) + }) + )} +
+
+ {errorMessage ? ( +
+ + {errorMessage} + +
+ ) : null} +
+ ) +} diff --git a/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx b/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx index dff5cd88..f66764e2 100644 --- a/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx +++ b/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx @@ -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 — - - return ( -
-
- {chips.slice(0, MAX_VISIBLE_AVATARS).map((chip) => ( - - {deputyInitials(chip.label)} - - ))} -
-
- ) -} - -/** One "Nome (Partido)" per line — the hover read of the closed cell. */ -const formatStateDeputyNamesTooltip = (chips: RelationChip[]): ReactElement | null => - chips.length === 0 ? null : ( -
- {chips.map((chip) => ( - {chip.label} - ))} -
- ) - -/** - * 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 ->() - -const normalizedOptionLabels = ( - options: readonly StateDeputyRelationOption[], -): Map => { - 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 ->() - -const optionByIdFor = ( - options: readonly StateDeputyRelationOption[], -): Map => { - 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 => { + 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): 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 => { - const result = await createAction(state, formData) - if (result.status !== 'success' || !result.stateDeputy) return result - const created = result.stateDeputy + const create = async (rawName: string): Promise => { + 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( - () => ({ - 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 ( - - `Editar dobradinhas em ${municipalityName}${chips.length ? ` — ${chips.map((chip) => chip.label).join(', ')}` : ''}` + + `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) => } - triggerTooltip={formatStateDeputyNamesTooltip} - editorVariant={editorVariant} - createAction={createActionWithChip} - buildCreateFormData={buildCreateFormData} - createLabel={CREATE_LABEL} - createErrorMessage="Não foi possível criar a dobradinha. Tente novamente." + emptyState={—} + createLabel={(name) => `Criar dobradinha “${name}”`} + createMaxLength={160} + sortSelected={false} + onToggle={toggle} + onCreate={create} /> ) } diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts index a0086092..13391937 100644 --- a/src/lib/schemas/leadership.ts +++ b/src/lib/schemas/leadership.ts @@ -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 diff --git a/tests/int/campaignMunicipalityLeadershipList.int.spec.ts b/tests/int/campaignMunicipalityLeadershipList.int.spec.ts index 1b21b2f0..207d9b48 100644 --- a/tests/int/campaignMunicipalityLeadershipList.int.spec.ts +++ b/tests/int/campaignMunicipalityLeadershipList.int.spec.ts @@ -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) }) }) diff --git a/tests/unit/campaignMunicipalityLeadershipsCreate.unit.spec.tsx b/tests/unit/campaignMunicipalityLeadershipsCreate.unit.spec.tsx new file mode 100644 index 00000000..00895039 --- /dev/null +++ b/tests/unit/campaignMunicipalityLeadershipsCreate.unit.spec.tsx @@ -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() + }) +}) diff --git a/tests/unit/campaignMunicipalityLeadershipsRoute.unit.spec.ts b/tests/unit/campaignMunicipalityLeadershipsRoute.unit.spec.ts new file mode 100644 index 00000000..1178c856 --- /dev/null +++ b/tests/unit/campaignMunicipalityLeadershipsRoute.unit.spec.ts @@ -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>) + + 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() + }) +}) diff --git a/tests/unit/municipalityRelationEditor.unit.spec.tsx b/tests/unit/municipalityRelationEditor.unit.spec.tsx new file mode 100644 index 00000000..3ef299aa --- /dev/null +++ b/tests/unit/municipalityRelationEditor.unit.spec.tsx @@ -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((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((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() + }) +}) diff --git a/tests/unit/municipalityStateDeputyRelationCell.unit.spec.tsx b/tests/unit/municipalityStateDeputyRelationCell.unit.spec.tsx new file mode 100644 index 00000000..f7e30478 --- /dev/null +++ b/tests/unit/municipalityStateDeputyRelationCell.unit.spec.tsx @@ -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', + ) + }) +})