feat(campaign): unify municipality relation editors
This commit is contained in:
parent
58e35ad0c6
commit
9fa701633e
17 changed files with 1321 additions and 1176 deletions
|
|
@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-05):** **B159** (Issue #374) — as colunas Assessores, Lideranças e Dobradinhas de `/campanha/municipios` agora compartilham `MunicipalityRelationEditor` (`CampaignCellEditOverlay` + Command + chips + optimistic reconcile) e `MunicipalityRelationAvatarStack`; Dobradinhas deixa `RelationChipCell` só nesta superfície, preserva busca por partido e links para a ficha, e Lideranças cria inline somente com nome (`Contact.phone = null`, sem stub; telefone real depois na ficha). Sem migration, rota nova ou mudança de access. Intenção [`generalizar-colunas-relacao-municipios.md`](docs/plans/generalizar-colunas-relacao-municipios.md); impl [`generalizar-colunas-relacao-municipios-impl.md`](docs/plans/generalizar-colunas-relacao-municipios-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-04):** **B155** (Issue #359) — coluna de lideranças na lista de municípios (espelho de Assessores): coluna "Lideranças" após "Assessores" (staff; leader fora), chips com nome do contato, popover/sheet com Command + delta otimista (seq/pending/rollback) e criação inline (mini-form Nome + Celular). **Sem migration**: leitura por query reversa de `leadership.municipalities` (batch no bundle, `loadMunicipalityLeadershipSummaries`) e escrita reusando B34 (`setLeadershipMunicipalitiesMembershipRecord`) + wizard create (`createLeadershipWizardRecord`) — fonte única de verdade, consistência automática com `/campanha/liderancas`. Rota nova `POST /campanha/municipios/leaderships` (`campaignJsonMutationRoute`, union toggle|create, safe-messages allowlist); provider bridge espelho do B154. Regra travada com o humano: **célula → rota, form → server action** (débito de migração das 3 anomalias — sinal da lista e colunas B31/B34 de `/campanha/liderancas`). Intenção [`liderancas-coluna-municipios.md`](docs/plans/liderancas-coluna-municipios.md); impl [`liderancas-coluna-municipios-impl.md`](docs/plans/liderancas-coluna-municipios-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-03):** **B154** (Issue #352) — criar assessor inline no popover da lista de municípios: opção "+ Criar assessor '<nome>'" quando a busca não casa ninguém; cria `campaignUser` advisor (e-mail stub `<slug>@criado.invalid`, senha aleatória) e atribui ao município na MESMA transação (cap 10 com rollback, lock `municipality-advisors:{id}`); provider client compartilha o assessor criado entre linhas; chip otimista com id temporário negativo; `advisorNameSchema` unificado em `primitives.ts`; predicado de placeholder estende a `@criado.invalid`. Bônus pré-existentes destravando o e2e do spec municipios: key warning do fragment `trailing` de `MunicipalityFilters` (array com keys) e settle-wait da animação do Drawer no teste B136. Intenção [`criar-assessor-inline-popover-municipios.md`](docs/plans/criar-assessor-inline-popover-municipios.md); impl [`criar-assessor-inline-popover-municipios-impl.md`](docs/plans/criar-assessor-inline-popover-municipios-impl.md).
|
||||
|
|
|
|||
108
docs/plans/generalizar-colunas-relacao-municipios-impl.md
Normal file
108
docs/plans/generalizar-colunas-relacao-municipios-impl.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Impl: Generalizar editor e display das colunas de relação na lista de municípios
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-08-05
|
||||
Issue: #374
|
||||
Intenção: docs/plans/generalizar-colunas-relacao-municipios.md
|
||||
Appetite restante: ~0,75 dia eng; manter sem migration, rota nova ou mudança de access
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** Assessores, Lideranças e Dobradinhas devem ter a mesma linguagem de interação na lista de municípios: avatar stack fechado e `CampaignCellEditOverlay` com busca, chips removíveis e criação inline por nome.
|
||||
- **O que NÃO negociar:** Assessores continua sendo a referência visual; o contrato de props de `MunicipalityList` e as regras de visibilidade por papel não mudam; busca com acento, optimistic UI, reconciliação de respostas fora de ordem, erros e bridges de opções recém-criadas continuam funcionando; `RelationChipCell` permanece dono dos editores inline fora desta lista.
|
||||
- **O que reavaliar:** a extração não pode presumir que os três writes usam o mesmo transporte. Assessores e Lideranças usam JSON routes; Dobradinhas usa server actions. Além disso, retirar o telefone da criação inline de Lideranças exige mudar o schema específico e o serviço existente, não somente esconder um campo.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ML[MunicipalityList] --> A[Wrapper Assessores]
|
||||
ML --> L[Wrapper Lideranças]
|
||||
ML --> D[Wrapper Dobradinhas]
|
||||
A --> E[MunicipalityRelationEditor]
|
||||
L --> E
|
||||
D --> E
|
||||
E --> O[CampaignCellEditOverlay]
|
||||
E --> V[MunicipalityRelationAvatarStack]
|
||||
A --> JA[JSON advisors]
|
||||
L --> JL[JSON leaderships]
|
||||
D --> SA[server actions de dobradinhas]
|
||||
JL --> CR[criação transacional Contact + Leadership]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) editor compartilhado com adaptadores de domínio | B) ampliar `RelationChipCell` com uma segunda apresentação | C) manter máquinas separadas e compartilhar somente peças visuais
|
||||
**Recomendação:** A — concentra a única máquina de optimistic state, sequência de requests, busca, chips e criação, enquanto wrappers pequenos normalizam os transportes e preservam copy, bridges e regras próprias de cada domínio.
|
||||
**Rejeitadas:** B porque mistura o editor inline de 813 linhas com outra política de interação e ameaça os call sites fora de `/campanha/municipios`; C porque entrega aparência igual mantendo três implementações do comportamento crítico, contrariando o aceite interno e deixando regressões divergentes prováveis.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**Contrato do editor**
|
||||
Opções: callbacks genéricos de baixo nível | componente ciente dos três endpoints | contrato normalizado de mutação
|
||||
Recomendação: contrato normalizado de mutação — o shared recebe entradas `{ id, label, searchText? }`, `selectedIDs`, copy e callbacks `toggle`/`create` que retornam o conjunto confirmado e, na criação, a nova opção. O editor é dono de pending sentinels, sequência, rollback, reconcile, filtro e UI; cada wrapper é dono do transporte e do bridge de catálogo.
|
||||
Alternativas rejeitadas: callbacks de baixo nível porque vazariam setters e refs, tornando o shared apenas markup; endpoints embutidos porque acoplariam um componente visual às rotas e a server actions incompatíveis.
|
||||
|
||||
**Display compartilhado**
|
||||
Opções: um avatar stack com entradas normalizadas | manter wrappers visuais duplicados | tornar o editor responsável por iniciais específicas
|
||||
Recomendação: `MunicipalityRelationAvatarStack` recebe entradas `{ id, label, initialsLabel? }`, limite e empty state; o editor deriva tooltip e acessibilidade dos labels. Assessores preserva `MissingAdvisorBadge`, e Dobradinhas informa o nome sem partido em `initialsLabel`.
|
||||
Alternativas rejeitadas: duplicação porque já produziu o desalinhamento atual; regras de iniciais dentro do editor porque são apresentação de cada domínio, não estado de edição.
|
||||
|
||||
**Liderança criada somente com nome**
|
||||
Opções: enviar telefone sintético | tornar telefone opcional em todos os formulários | aceitar somente nome no schema específico e reutilizar o serviço transacional com dedup condicional
|
||||
Recomendação: o schema estrito de `municipalityLeadershipCreateSchema` passa a aceitar somente `municipalityId + name`; o núcleo existente de criação aceita telefone opcional internamente. Como `Contact.phone` já é opcional no Payload, esse caminho cria um novo `Contact` com `phone: null` e a `Leadership` na mesma transação, após fresh-actor e scope check. O telefone poderá ser preenchido depois na ficha. Com telefone nos demais fluxos, o serviço mantém lock e dedup atuais; os schemas de cadastro completo permanecem fora do escopo desta Issue.
|
||||
Alternativas rejeitadas: telefone sintético não é necessário e corromperia a identidade/unicidade de `Contact`, podendo colidir com um telefone real; ampliar a mudança a todos os formulários alteraria outros fluxos e sua política de dedup sem fazer parte do aceite desta lista.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`MunicipalityRelationEditor`** (`src/components/campaign/shared/MunicipalityRelationEditor.tsx`): nova dona da máquina compartilhada de overlay, optimistic IDs, requests concorrentes, chips, Command, busca normalizada, criação name-only e erro acessível.
|
||||
- **`MunicipalityRelationAvatarStack`** (`src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx`): avatar stack único, máximo padrão de três, nomes para screen reader/tooltip e empty state injetável.
|
||||
- **`MunicipalityListAdvisorsControl`**: wrapper de opções/copy/JSON e bridge de novos assessores; usa os dois componentes compartilhados sem alterar comportamento ou empty state prioritário.
|
||||
- **`MunicipalityListLeadershipsControl`**: wrapper de opções/copy/JSON e bridge; remove formulário e imports de telefone, cria diretamente pelo nome pesquisado e usa avatar stack no trigger.
|
||||
- **`MunicipalityStateDeputyRelationCell`**: troca `RelationChipCell` pelo editor compartilhado; adapta `commitAction`/`createAction`, mantém busca por nome e partido, links dos chips quando aplicável e nomes sem partido nas iniciais.
|
||||
- **Criação de liderança** (`src/lib/schemas/leadership.ts`, route e `src/app/(campaign)/campanha/actions/leadership.ts`): o input inline envia apenas nome e persiste `Contact.phone = null`; mantém transação, fresh actor, escopo, defaults e revalidações existentes, sem telefone stub.
|
||||
- **Código substituído:** remover os avatar stacks locais e a máquina duplicada que ficar órfã; `RelationChipCell` permanece porque ainda serve `LeadershipStateDeputyRelationCell` e `MunicipalityPortfolioCell`.
|
||||
- **Migration:** sem migration; `Contact.phone` já é opcional.
|
||||
- **Access / Consent:** nenhuma política nova. O novo caminho usa o mesmo staff gate, fresh actor, scope check e transação; não há opt-in ou Consent neste fluxo interno.
|
||||
- **UI:** Impeccable B; replicar exatamente shape/craft da coluna Assessores em desktop Popover e mobile Drawer, depois criticar foco, target de 44 px, tooltip, live region, overflow e estados vazio/pending/error.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / contrato server** — ajustar e testar a criação inline name-only de Liderança no serviço existente; provar `Contact.phone === null`, vínculo ao município, bloqueio de papel/escopo e preservação do caminho completo com telefone.
|
||||
2. **Editor e display compartilhados** — extrair primeiro Assessores como referência; manter testes de overlay, optimistic reconcile, rollback, criação e busca antes de migrar os demais wrappers.
|
||||
3. **Lideranças e Dobradinhas** — migrar ambas; confirmar avatar stack/tooltip, criação name-only, busca por acento e por partido, remoção, criação e adapters JSON/server action em Popover e Drawer.
|
||||
4. **Polish e gates** — revisar os três call sites em desktop/mobile, eliminar código morto e executar `pnpm gate:fast`; no fechamento, `/simplify`, captura de débitos e push via `pnpm push`.
|
||||
|
||||
## Estratégia de testes
|
||||
|
||||
- **Unit do editor compartilhado:** render fechado, Popover/Drawer, busca normalizada, toggle/remove, criação otimista, resposta fora de ordem, rollback e erro/live region.
|
||||
- **Unit dos wrappers:** copy e payload de cada domínio, bridge da opção criada, Lideranças sem campo de telefone, Dobradinhas pesquisável por partido e adaptação do resultado da server action.
|
||||
- **Unit de composição:** `MunicipalityList` mantém visibilidade por papel e passa a renderizar as três pilhas com iniciais, limite e nomes acessíveis.
|
||||
- **Integração server:** caminho name-only cria `Contact` + `Leadership` atomicamente e respeita staff/scope; caminho geral com telefone continua deduplicando pelo telefone.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não migrar `LeadershipStateDeputyRelationCell`, `MunicipalityPortfolioCell` nem outros call sites de `RelationChipCell`.
|
||||
- Não unificar JSON routes, server actions, providers de criação ou tipos de domínio só para obter uma API uniforme.
|
||||
- Não mudar visibilidade de colunas, URLs, collections, Payload schema, Consent ou regras de access.
|
||||
- Não generalizar o componente para relações fora da lista de municípios; gatilho para revisitar: outro editor Popover repetir a mesma máquina completa.
|
||||
- Não alterar os cadastros completos de liderança nesta Issue; o atalho inline da lista cria somente com nome e a ficha recebe o telefone real depois.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Abstração larga demais:** props excessivas podem esconder três políticas diferentes. Mitigar com um resultado normalizado pequeno, wrappers de domínio e testes do componente pelo comportamento, não pela implementação.
|
||||
- **Concorrência otimista:** extração pode perder a regra de maior sequência confirmada. Mitigar movendo a máquina como uma unidade e pinando resposta fora de ordem + falha concorrente em unit test.
|
||||
- **Criação somente com nome:** não há dedup confiável por pessoa sem identificador. Mitigar criando um novo `Contact` deliberadamente com telefone nulo, deixando o telefone real para a ficha e não usando stub nem dedup por nome.
|
||||
- **Server action versus JSON:** erros têm formatos diferentes. Mitigar nos adapters, que convertem ambos para o mesmo success/error do editor sem expor `FormData` no shared.
|
||||
- **Acessibilidade responsiva:** trocar o trigger pode quebrar tooltip, foco ou Drawer compartilhado. Mitigar reutilizando `CampaignCellEditOverlay` sem alterações de contrato e ampliando a matriz existente de testes Popover/Sheet para Dobradinhas.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto
|
||||
- [ ] As três colunas usam um único editor e um único avatar stack
|
||||
- [ ] Criação de liderança envia somente nome e persiste telefone nulo sem afrouxar outros formulários
|
||||
- [ ] optimistic state, resposta fora de ordem, erro, busca e criação cobertos por testes
|
||||
- [ ] Invariantes AGENTS/engineering-standards preservadas
|
||||
- [ ] Sem migration, rota nova, mudança de props de `MunicipalityList` ou regressão de access
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
5/5 — decisões caras registram opções e rejeitadas; a abordagem cabe no appetite com cortes explícitos; rabbit holes estão nomeados; reutiliza os donos existentes; o outcome e os lockdowns da intenção permanecem intactos.
|
||||
81
docs/plans/serializar-mutacoes-editor-relacao-municipios.md
Normal file
81
docs/plans/serializar-mutacoes-editor-relacao-municipios.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Serializar mutações do editor de relações por célula
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-05
|
||||
Issue: #378 (registrada via agent:register)
|
||||
Priority: P2
|
||||
Model: `composer-2.5`
|
||||
Impeccable: A — N/A
|
||||
Canvas UI: N/A
|
||||
Appetite: ~0,5 dia eng
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
O editor compartilhado de relações da lista de municípios permite interações rápidas e otimistas. Quando duas alterações da mesma célula ficam simultaneamente em trânsito, a ordem em que as transações terminam no banco pode divergir da ordem escolhida pela pessoa. O estado exibido e o estado persistido podem então representar intenções diferentes.
|
||||
|
||||
Esta entrega deve garantir que mutações de uma mesma célula sejam persistidas na ordem em que foram solicitadas, mantendo a resposta imediata da interface.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** coordenação e assessoria ajustando relações diretamente na lista de municípios.
|
||||
- **Job principal:** fazer várias inclusões ou remoções rápidas e confiar que o último estado escolhido será o persistido.
|
||||
- **Fluxo desejado:** abrir uma célula, alternar relações em sequência, continuar vendo a resposta otimista e terminar com interface e banco refletindo a mesma ordem de intenção.
|
||||
- **Anti-goals de produto:** criar protocolo global de versionamento, alterar permissões ou redesenhar o editor.
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Duas ou mais mutações rápidas da mesma célula são enviadas ao servidor na ordem da interação.
|
||||
- Uma falha intermediária não apaga uma alteração posterior válida nem deixa o editor preso em estado de carregamento.
|
||||
- Células e linhas diferentes continuam podendo salvar em paralelo.
|
||||
- Testes cobrem conclusão e falha fora de ordem no transporte simulado e confirmam o estado persistido final.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não.
|
||||
- **Decisões desbloqueadas:** a pessoa pode editar relações rapidamente sem precisar esperar cada salvamento terminar.
|
||||
- **Forma:** N/A — não há nova apresentação de dados.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** `MunicipalityRelationEditor`, wrappers de assessores/lideranças/dobradinhas e seus testes.
|
||||
- **Precedente a olhar:** fila local por chave de célula, preservando a camada otimista separada do último estado confirmado.
|
||||
- **Risco de acoplamento:** a fila deve ser por instância/célula; uma fila global degradaria edições independentes.
|
||||
|
||||
## Dependências
|
||||
|
||||
- B159 — editor compartilhado de relações na lista de municípios.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. Modelar e testar uma fila de mutações por célula, incluindo sucesso, falha e interação posterior durante uma requisição.
|
||||
2. Integrar a fila ao editor compartilhado sem alterar os contratos dos wrappers.
|
||||
3. Validar os fluxos de assessores, lideranças e dobradinhas com testes focados e gates do repositório.
|
||||
|
||||
## Já resolvido no simplify/critique (não reabrir)
|
||||
|
||||
- Reconciliação de props externas sem requisição pendente.
|
||||
- Deduplicação da opção criada quando o RSC atualiza antes da resposta da criação.
|
||||
- Separação entre itens conhecidos para display e opções realmente selecionáveis.
|
||||
|
||||
## Explicitamente fora
|
||||
|
||||
- Versionamento otimista ou idempotency key no servidor; reavaliar apenas se outras superfícies também exigirem concorrência entre clientes.
|
||||
- Mudanças de schema, migrations, acesso, rotas ou contratos públicos.
|
||||
- Refatorar ações específicas de cada tipo de relação além do necessário para consumir a fila.
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **Transformar consistência local em protocolo distribuído.** Isso amplia o escopo para todas as mutações da campanha. **Corte neste item:** ordenar somente as chamadas originadas por uma instância do editor.
|
||||
- **Bloquear a interface durante o salvamento.** Isso elimina a condição por restrição de uso, mas perde a edição rápida que motivou o componente. **Corte neste item:** manter o feedback otimista e serializar apenas o transporte.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- Nenhuma; o comportamento esperado é preservar a ordem de intenção sem mudar a interação visível.
|
||||
|
||||
## Referências
|
||||
|
||||
- GitHub Issue #378 (B160)
|
||||
- GitHub Issue #374 (B159)
|
||||
- `docs/plans/generalizar-colunas-relacao-municipios-impl.md`
|
||||
- `src/components/campaign/shared/MunicipalityRelationEditor.tsx`
|
||||
|
|
@ -5,7 +5,6 @@ import {
|
|||
createMunicipalityLeadership,
|
||||
setMunicipalityLeadershipMembership,
|
||||
} from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import { BRAZILIAN_PHONE_INVALID_MESSAGE } from '@/lib/phone'
|
||||
import {
|
||||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
|
|
@ -16,7 +15,6 @@ import {
|
|||
} from '@/lib/schemas/leadership'
|
||||
import { positiveRelationshipId } from '@/lib/schemas/primitives'
|
||||
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
|
||||
import { CONTACT_PHONE_AMBIGUOUS_MESSAGE } from '@/utilities/contactPhoneInvariant'
|
||||
import { POSTGRES_DEDUP_LOCK_MESSAGE } from '@/utilities/postgresTransactionLocks'
|
||||
|
||||
import type { MunicipalityListLeadershipsResponse } from './types'
|
||||
|
|
@ -26,8 +24,8 @@ export type { MunicipalityListLeadershipsResponse } from './types'
|
|||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Mutually exclusive shapes (B155): the membership delta (`leadershipId` +
|
||||
* `assigned`) and the name+phone inline create can never combine — `name` is a
|
||||
* Mutually exclusive shapes (B155/B159): the membership delta (`leadershipId` +
|
||||
* `assigned`) and the name-only inline create can never combine — `name` is a
|
||||
* write affordance over `leadership.municipalities`, `leadershipId` a delta.
|
||||
*/
|
||||
const bodySchema = z.union([
|
||||
|
|
@ -48,8 +46,6 @@ export const POST = campaignJsonMutationRoute(
|
|||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
LEADERSHIP_STAFF_MESSAGE,
|
||||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
BRAZILIAN_PHONE_INVALID_MESSAGE,
|
||||
POSTGRES_DEDUP_LOCK_MESSAGE,
|
||||
],
|
||||
genericMessage:
|
||||
|
|
@ -61,15 +57,12 @@ export const POST = campaignJsonMutationRoute(
|
|||
await createMunicipalityLeadership({
|
||||
municipalityId: body.municipalityId,
|
||||
name: body.name,
|
||||
phone: body.phone,
|
||||
})
|
||||
|
||||
return NextResponse.json<MunicipalityListLeadershipsResponse>({
|
||||
status: 'success',
|
||||
message: 'Liderança criada e vinculada.',
|
||||
leadershipIDs,
|
||||
// The real contact name, not the typed one: a phone-matched create
|
||||
// (`contactReused`) keeps the stored Contact's name.
|
||||
createdLeadership: {
|
||||
id: leadership.id,
|
||||
name: createdLeadershipName,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
leadershipStateDeputyMembershipSchema,
|
||||
leadershipWizardCreateSchema,
|
||||
leadershipWizardUpdateSchema,
|
||||
municipalityLeadershipCreateSchema,
|
||||
type LeadershipContactUpdateInput,
|
||||
type LeadershipInternalUpdateInput,
|
||||
type LeadershipMunicipalitiesMembershipInput,
|
||||
|
|
@ -110,11 +111,14 @@ const isUniqueLeadershipConflict = (error: unknown): boolean => {
|
|||
}
|
||||
|
||||
type LeadershipCreateData = ReturnType<typeof leadershipCreateSchema.parse>
|
||||
type ValidatedLeadershipCreateData = Omit<LeadershipCreateData, 'phone'> & {
|
||||
phone?: string | null
|
||||
}
|
||||
|
||||
const createValidatedLeadershipRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
data: LeadershipCreateData,
|
||||
data: ValidatedLeadershipCreateData,
|
||||
) => {
|
||||
try {
|
||||
return await withPayloadTransaction(
|
||||
|
|
@ -122,27 +126,29 @@ const createValidatedLeadershipRecord = async (
|
|||
async ({ req }) => {
|
||||
const currentActor = await getFreshStaffActor(payload, actor, req)
|
||||
await assertMunicipalitiesWithinScope(payload, currentActor, data.municipalities, req)
|
||||
if (payload.db.name !== 'postgres') {
|
||||
throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
|
||||
}
|
||||
await acquireContactPhoneLocks(payload, req, [data.phone])
|
||||
// Intentional admin bypass: staff scope was freshly checked; these internal reads and
|
||||
// writes atomically maintain the normalized Contact ↔ Leadership join.
|
||||
const contacts = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { equals: data.phone } },
|
||||
depth: 0,
|
||||
limit: 2,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
let contactID: number | undefined
|
||||
if (data.phone) {
|
||||
if (payload.db.name !== 'postgres') {
|
||||
throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
|
||||
}
|
||||
await acquireContactPhoneLocks(payload, req, [data.phone])
|
||||
// Intentional admin bypass: staff scope was freshly checked; these internal reads and
|
||||
// writes atomically maintain the normalized Contact ↔ Leadership join.
|
||||
const contacts = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { equals: data.phone } },
|
||||
depth: 0,
|
||||
limit: 2,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
if (contacts.totalDocs > 1) {
|
||||
throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
|
||||
if (contacts.totalDocs > 1) {
|
||||
throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
|
||||
}
|
||||
contactID = contacts.docs[0]?.id
|
||||
}
|
||||
|
||||
let contactID = contacts.docs[0]?.id
|
||||
const contactReused = Boolean(contactID)
|
||||
|
||||
if (!contactID) {
|
||||
|
|
@ -162,7 +168,7 @@ const createValidatedLeadershipRecord = async (
|
|||
collection: 'contact',
|
||||
data: {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
phone: data.phone ?? null,
|
||||
email: data.email,
|
||||
gender: data.gender,
|
||||
state: 'BA' as Contact['state'],
|
||||
|
|
@ -454,19 +460,43 @@ export const createLeadershipWizard = async (
|
|||
|
||||
/**
|
||||
* B155 — inline create from the "Lideranças" column of `/campanha/municipios`.
|
||||
* Thin municipality-surface entry point over the wizard create (Contact dedup
|
||||
* by phone, transaction, locks). The list we are ON is deliberately not
|
||||
* Name-only municipality-surface entry point over the transactional create.
|
||||
* The list we are ON is deliberately not
|
||||
* revalidated — the chip already shows optimistically (B34 contract); the
|
||||
* leadership list and the município detail are, so the new row appears there
|
||||
* without waiting for a refresh.
|
||||
*/
|
||||
export const createMunicipalityLeadershipRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: MunicipalityLeadershipCreateInput,
|
||||
) => {
|
||||
const data = municipalityLeadershipCreateSchema.parse(input)
|
||||
const leadership = await createValidatedLeadershipRecord(payload, actor, {
|
||||
name: data.name,
|
||||
phone: null,
|
||||
municipalities: [data.municipalityId],
|
||||
email: undefined,
|
||||
exclusive: true,
|
||||
supportStatus: 'a_abordar',
|
||||
notes: undefined,
|
||||
})
|
||||
|
||||
const { leadershipIDsByMunicipality, summariesById } = await loadMunicipalityLeadershipSummaries(
|
||||
payload,
|
||||
actor,
|
||||
[data.municipalityId],
|
||||
)
|
||||
return {
|
||||
leadership,
|
||||
leadershipIDs: leadershipIDsByMunicipality.get(data.municipalityId) ?? [],
|
||||
createdLeadershipName: summariesById.get(leadership.id)?.name ?? data.name,
|
||||
}
|
||||
}
|
||||
|
||||
export const createMunicipalityLeadership = async (input: MunicipalityLeadershipCreateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const leadership = await createLeadershipWizardRecord(payload, actor, {
|
||||
name: input.name,
|
||||
phone: input.phone,
|
||||
municipalityId: input.municipalityId,
|
||||
})
|
||||
const result = await createMunicipalityLeadershipRecord(payload, actor, input)
|
||||
|
||||
// Intentional admin bypass: existence is enforced by the write's own
|
||||
// relationship validation; this read only resolves the revalidate target.
|
||||
|
|
@ -477,21 +507,8 @@ export const createMunicipalityLeadership = async (input: MunicipalityLeadership
|
|||
select: { slug: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
revalidateLeadershipWizardPaths(leadership.id, municipality.slug)
|
||||
|
||||
// Scoped reverse read doubles as the reconcile payload and the chip name:
|
||||
// a phone-matched create (`contactReused`) keeps the Contact's own name,
|
||||
// which the optimistic chip must show from the start.
|
||||
const { leadershipIDsByMunicipality, summariesById } = await loadMunicipalityLeadershipSummaries(
|
||||
payload,
|
||||
actor,
|
||||
[input.municipalityId],
|
||||
)
|
||||
return {
|
||||
leadership,
|
||||
leadershipIDs: leadershipIDsByMunicipality.get(input.municipalityId) ?? [],
|
||||
createdLeadershipName: summariesById.get(leadership.id)?.name ?? input.name,
|
||||
}
|
||||
revalidateLeadershipWizardPaths(result.leadership.id, municipality.slug)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { CircleAlertIcon } from 'lucide-react'
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { MunicipalityRelationAvatarStack } from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
|
||||
import { municipalityListCoverageLabels } from '@/utilities/municipality/municipalityLabels'
|
||||
|
||||
export type MunicipalityAdvisorAvatarEntry = {
|
||||
|
|
@ -59,15 +58,10 @@ export const MunicipalityAdvisorAvatarStack = ({
|
|||
if (!advisors.length) return <MissingAdvisorBadge isPriority={isPriority} />
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-2">
|
||||
{advisors.slice(0, maxVisible).map((advisor) => (
|
||||
<Avatar key={advisor.id} className="size-8 border-2 border-background">
|
||||
<AvatarFallback>{campaignUserInitials(advisor.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<span className="sr-only">{advisors.map((advisor) => advisor.name).join(', ')}</span>
|
||||
</div>
|
||||
<MunicipalityRelationAvatarStack
|
||||
entries={advisors.map((advisor) => ({ id: advisor.id, label: advisor.name }))}
|
||||
emptyState={null}
|
||||
maxVisible={maxVisible}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,15 @@
|
|||
'use client'
|
||||
|
||||
import { UserPlusIcon, XIcon } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { MunicipalityListAdvisorsResponse } from '@/app/(campaign)/campanha/(app)/municipios/advisors/types'
|
||||
import {
|
||||
advisorEntriesFromIds,
|
||||
formatAdvisorNamesTooltip,
|
||||
MunicipalityAdvisorAvatarStack,
|
||||
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||||
import { MissingAdvisorBadge } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||||
import { useMunicipalityAdvisorCreate } from '@/components/campaign/municipality/MunicipalityAdvisorCreateProvider'
|
||||
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import type { MunicipalityRelationEntry } from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
|
||||
import {
|
||||
CampaignCellEditOverlay,
|
||||
type CampaignCellEditOverlayVariant,
|
||||
} from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useCampaignCellFailureChannel'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
MunicipalityRelationEditor,
|
||||
type MunicipalityRelationMutationResult,
|
||||
} from '@/components/campaign/shared/MunicipalityRelationEditor'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { matchesAtWordStart } from '@/lib/wordStartFilter'
|
||||
import type {
|
||||
EligibleAdvisorOption,
|
||||
MunicipalityAdvisorSummary,
|
||||
|
|
@ -31,25 +18,21 @@ import type {
|
|||
const ADVISORS_ENDPOINT = '/campanha/municipios/advisors'
|
||||
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar os assessores. Tente novamente.'
|
||||
|
||||
/** B154 — sentinel temp id for an in-flight inline create; negative = never a real account. */
|
||||
const isPendingCreateId = (id: number): boolean => id < 0
|
||||
|
||||
// Shared empty values so the memos stay referentially stable when the bridge
|
||||
// is absent (standalone renders in unit tests) or nothing is pending.
|
||||
const EMPTY_CREATED_OPTIONS: EligibleAdvisorOption[] = []
|
||||
const EMPTY_PENDING_CREATES: ReadonlyMap<number, string> = new Map()
|
||||
|
||||
type MunicipalityListAdvisorsControlProps = {
|
||||
municipalityID: number
|
||||
municipalityName: string
|
||||
currentAdvisorIDs: number[]
|
||||
/** Raises the empty state to "Sem responsável" — see `MissingAdvisorBadge`. */
|
||||
isPriority: boolean
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
|
||||
options: EligibleAdvisorOption[]
|
||||
variant: CampaignCellEditOverlayVariant
|
||||
}
|
||||
|
||||
const entryOf = (option: { id: number; name: string }): MunicipalityRelationEntry => ({
|
||||
id: option.id,
|
||||
label: option.name,
|
||||
})
|
||||
|
||||
export const MunicipalityListAdvisorsControl = ({
|
||||
municipalityID,
|
||||
municipalityName,
|
||||
|
|
@ -59,373 +42,77 @@ export const MunicipalityListAdvisorsControl = ({
|
|||
options,
|
||||
variant,
|
||||
}: MunicipalityListAdvisorsControlProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedIDs, setSelectedIDs] = useState<number[]>(currentAdvisorIDs)
|
||||
const [query, setQuery] = useState('')
|
||||
const { errorMessage, setErrorMessage, reportFailure, noteOpenChange } =
|
||||
useCampaignCellFailureChannel()
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const pendingCountRef = useRef(0)
|
||||
const lastPropsIDsRef = useRef(currentAdvisorIDs)
|
||||
// Requests can settle out of send order (network jitter), so the send order
|
||||
// is tracked explicitly: only the response with the highest sequence number
|
||||
// seen so far is kept, and it is adopted — win or fail — once every
|
||||
// in-flight delta has settled (`pendingCountRef` back to 0). No response
|
||||
// ever writes `lastPropsIDsRef`: that ref exists solely to tell a genuine
|
||||
// external prop change (nav/refresh) apart from a no-op re-render carrying
|
||||
// the same pre-edit content, and advancing it here would make a later
|
||||
// stale re-render of that same pre-edit content look "external" and
|
||||
// clobber the optimistic state back to it.
|
||||
const requestSeqRef = useRef(0)
|
||||
const latestConfirmedRef = useRef<{ seq: number; advisors: number[] } | null>(null)
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
noteOpenChange(nextOpen)
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
// Adopt server props only when they change from outside (navigation / RSC
|
||||
// refresh) — an in-flight delta's optimistic state must not be clobbered by
|
||||
// a prop identical in content to what we already had.
|
||||
useEffect(() => {
|
||||
if (sameIdSet(currentAdvisorIDs, lastPropsIDsRef.current)) return
|
||||
lastPropsIDsRef.current = currentAdvisorIDs
|
||||
setSelectedIDs(currentAdvisorIDs)
|
||||
}, [currentAdvisorIDs])
|
||||
|
||||
// B154 — advisors created inline since the page last rendered server-side,
|
||||
// shared across every row's popover through `MunicipalityAdvisorCreateProvider`
|
||||
// (null when the control renders standalone, e.g. in unit tests).
|
||||
const createBridge = useMunicipalityAdvisorCreate()
|
||||
const createdOptions = createBridge?.createdOptions ?? EMPTY_CREATED_OPTIONS
|
||||
const registerCreatedAdvisor = createBridge?.registerCreatedAdvisor
|
||||
// tempId → name for in-flight creates: the chip renders optimistically while
|
||||
// the request is out, and the id is not known until the response arrives.
|
||||
const [pendingCreates, setPendingCreates] =
|
||||
useState<ReadonlyMap<number, string>>(EMPTY_PENDING_CREATES)
|
||||
const createdOptions = createBridge?.createdOptions ?? []
|
||||
|
||||
// Every eligible account (coordinator/advisor/candidate) is listed here
|
||||
// regardless of current assignment, so it doubles as the name lookup for an
|
||||
// id the popover just optimistically added — no round trip needed to label
|
||||
// it. `advisorNamesById` is the fallback for an id that fell out of
|
||||
// eligibility but is still assigned; `advisorEntriesFromIds` drops the rare
|
||||
// id found in neither, same as every other advisor cell in the list.
|
||||
const effectiveOptions = useMemo(() => {
|
||||
// The provider bridge can outlive a server re-render (sort/filter
|
||||
// navigation keeps client state, and `getEligibleAdvisorOptions` then
|
||||
// returns the created account again), so the same advisor can arrive
|
||||
// twice — dedupe by id to keep combobox entries and React keys unique.
|
||||
const byId = new Map<number, EligibleAdvisorOption>()
|
||||
for (const option of options) byId.set(option.id, option)
|
||||
for (const option of createdOptions) byId.set(option.id, option)
|
||||
return [...byId.values()]
|
||||
}, [options, createdOptions])
|
||||
|
||||
const advisorLookup = useMemo(() => {
|
||||
const lookup = new Map<number, { id: number; name: string }>(advisorNamesById)
|
||||
for (const option of effectiveOptions)
|
||||
lookup.set(option.id, { id: option.id, name: option.name })
|
||||
for (const [tempId, name] of pendingCreates) lookup.set(tempId, { id: tempId, name })
|
||||
return lookup
|
||||
}, [advisorNamesById, effectiveOptions, pendingCreates])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs])
|
||||
const chips = useMemo(
|
||||
() =>
|
||||
[...advisorEntriesFromIds(selectedIDs, advisorLookup)].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, 'pt-BR'),
|
||||
),
|
||||
[selectedIDs, advisorLookup],
|
||||
)
|
||||
|
||||
// B23's hover/focus tooltip stays on the trigger even though it repeats the
|
||||
// chips: it's the no-open-popover read. `null` for an empty list — the
|
||||
// trigger already reads "Sem responsável"/"Sem assessor" by extenso.
|
||||
const tooltipContent = formatAdvisorNamesTooltip(chips)
|
||||
const assignedLabel = chips.length
|
||||
? chips.map((chip) => chip.name).join(', ')
|
||||
: isPriority
|
||||
? 'sem responsável'
|
||||
: 'sem assessor'
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() => effectiveOptions.filter((option) => matchesAtWordStart(option.name, query)),
|
||||
[effectiveOptions, query],
|
||||
)
|
||||
|
||||
// Single settle point shared by the toggle and the inline create (success,
|
||||
// mapped error, network failure): decrements the pending count and, once it
|
||||
// reaches 0, stops the spinner and reconciles to the latest confirmed server
|
||||
// set — even on a request's own failure, an earlier delta in the same batch
|
||||
// may have already confirmed one.
|
||||
const finishRequest = () => {
|
||||
pendingCountRef.current = Math.max(0, pendingCountRef.current - 1)
|
||||
if (pendingCountRef.current > 0) return
|
||||
setIsPending(false)
|
||||
if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current.advisors)
|
||||
const toggle = async (
|
||||
advisorID: number,
|
||||
assigned: boolean,
|
||||
): Promise<MunicipalityRelationMutationResult> => {
|
||||
const { ok, payload } = await postCampaignJson<MunicipalityListAdvisorsResponse>(
|
||||
ADVISORS_ENDPOINT,
|
||||
{ municipalityId: municipalityID, advisorId: advisorID, assigned },
|
||||
)
|
||||
if (!ok || payload.status !== 'success') {
|
||||
return {
|
||||
status: 'error',
|
||||
message: payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE,
|
||||
}
|
||||
}
|
||||
return { status: 'success', selectedIDs: payload.advisors }
|
||||
}
|
||||
|
||||
const toggle = (advisorId: number, assigned: boolean) => {
|
||||
// A negative id is a B154 pending-create sentinel, never a real account —
|
||||
// the temp chip has no remove affordance, so this only guards the path.
|
||||
if (isPendingCreateId(advisorId)) return
|
||||
setErrorMessage(null)
|
||||
setSelectedIDs((current) => {
|
||||
const next = new Set(current)
|
||||
if (assigned) next.add(advisorId)
|
||||
else next.delete(advisorId)
|
||||
return [...next]
|
||||
})
|
||||
setQuery('')
|
||||
|
||||
pendingCountRef.current += 1
|
||||
setIsPending(true)
|
||||
const requestSeq = (requestSeqRef.current += 1)
|
||||
|
||||
void (async () => {
|
||||
const revertDelta = () => {
|
||||
setSelectedIDs((current) => {
|
||||
const next = new Set(current)
|
||||
if (assigned) next.delete(advisorId)
|
||||
else next.add(advisorId)
|
||||
return [...next]
|
||||
})
|
||||
const create = async (name: string): Promise<MunicipalityRelationMutationResult> => {
|
||||
const { ok, payload } = await postCampaignJson<MunicipalityListAdvisorsResponse>(
|
||||
ADVISORS_ENDPOINT,
|
||||
{ municipalityId: municipalityID, name },
|
||||
)
|
||||
if (!ok || payload.status !== 'success' || !payload.createdAdvisor) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE,
|
||||
}
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<MunicipalityListAdvisorsResponse>(
|
||||
ADVISORS_ENDPOINT,
|
||||
{ municipalityId: municipalityID, advisorId, assigned },
|
||||
)
|
||||
|
||||
if (!ok || payload.status !== 'success') {
|
||||
revertDelta()
|
||||
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
return
|
||||
}
|
||||
|
||||
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
|
||||
latestConfirmedRef.current = { seq: requestSeq, advisors: payload.advisors }
|
||||
}
|
||||
finishRequest()
|
||||
} catch {
|
||||
revertDelta()
|
||||
reportFailure(SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
}
|
||||
})()
|
||||
}
|
||||
return {
|
||||
status: 'success',
|
||||
selectedIDs: payload.advisors,
|
||||
createdEntry: entryOf(payload.createdAdvisor),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* B154 — name-only inline create, assigned to this município. The chip goes
|
||||
* up immediately under a sentinel temp id (the real id only exists once the
|
||||
* response lands); the request rides the same seq/pending machinery as the
|
||||
* toggle, so out-of-order settles and the cap failure reconcile exactly like
|
||||
* a toggle — a failed create reverts the temp chip and shows the Alert.
|
||||
*/
|
||||
const createAdvisor = (name: string) => {
|
||||
// A create is not an idempotent delta: re-activating the same name while a
|
||||
// create is in flight would mint a second account. The temp chip already
|
||||
// signals the pending one — skip.
|
||||
if ([...pendingCreates.values()].includes(name)) return
|
||||
setErrorMessage(null)
|
||||
setQuery('')
|
||||
|
||||
pendingCountRef.current += 1
|
||||
setIsPending(true)
|
||||
const requestSeq = (requestSeqRef.current += 1)
|
||||
const tempId = -requestSeq
|
||||
|
||||
setSelectedIDs((current) => [...current, tempId])
|
||||
setPendingCreates((current) => new Map(current).set(tempId, name))
|
||||
|
||||
void (async () => {
|
||||
const revertCreate = () => {
|
||||
setSelectedIDs((current) => current.filter((id) => id !== tempId))
|
||||
setPendingCreates((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(tempId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<MunicipalityListAdvisorsResponse>(
|
||||
ADVISORS_ENDPOINT,
|
||||
{ municipalityId: municipalityID, name },
|
||||
)
|
||||
|
||||
if (!ok || payload.status !== 'success') {
|
||||
revertCreate()
|
||||
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
return
|
||||
}
|
||||
|
||||
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
|
||||
latestConfirmedRef.current = { seq: requestSeq, advisors: payload.advisors }
|
||||
}
|
||||
|
||||
const createdAdvisor = payload.createdAdvisor
|
||||
if (createdAdvisor) {
|
||||
// Swap the sentinel for the real id right away: if another delta is
|
||||
// still in flight the reconcile is deferred, and the chip must not
|
||||
// vanish in the gap (the lookup drops a temp id the moment its
|
||||
// pending entry is removed).
|
||||
setSelectedIDs((current) => [...current.filter((id) => id !== tempId), createdAdvisor.id])
|
||||
setPendingCreates((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(tempId)
|
||||
return next
|
||||
})
|
||||
// Make the account selectable in every other row's popover on this
|
||||
// page load — `createdOptions` is the client-side bridge until the
|
||||
// next navigation's `getEligibleAdvisorOptions` picks it up.
|
||||
registerCreatedAdvisor?.({
|
||||
id: createdAdvisor.id,
|
||||
name: createdAdvisor.name,
|
||||
isCurrent: false,
|
||||
})
|
||||
}
|
||||
finishRequest()
|
||||
} catch {
|
||||
revertCreate()
|
||||
reportFailure(SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando assessores.' : ''
|
||||
// The body pads its own sections (`px-4`, aligned with the Drawer header) so
|
||||
// the `Command` can stay full-bleed in both containers — hence `px-0` on the
|
||||
// sheet body and `p-0` on the popover content.
|
||||
const isSheet = variant === 'sheet'
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
return (
|
||||
<CampaignCellEditOverlay
|
||||
<MunicipalityRelationEditor
|
||||
municipalityName={municipalityName}
|
||||
currentIDs={currentAdvisorIDs}
|
||||
knownEntries={[...advisorNamesById.values()].map(entryOf)}
|
||||
options={[...options, ...createdOptions].map((option) => ({
|
||||
...entryOf(option),
|
||||
optionSuffix: option.isCurrent ? ' (você)' : undefined,
|
||||
}))}
|
||||
variant={variant}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title="Atribuir assessores"
|
||||
description={municipalityName}
|
||||
// The avatar stack reads as initials at best, and an `aria-label` replaces
|
||||
// even those — so who is assigned goes in the label, by extenso.
|
||||
triggerLabel={`Editar assessores em ${municipalityName} — ${assignedLabel}`}
|
||||
triggerBusy={isPending}
|
||||
statusMessage={statusMessage}
|
||||
tooltipContent={tooltipContent}
|
||||
contentClassName="w-80 p-0"
|
||||
sheetBodyClassName="px-0 pt-2"
|
||||
trigger={<MunicipalityAdvisorAvatarStack advisors={chips} isPriority={isPriority} />}
|
||||
>
|
||||
<div className={cn('relative flex shrink-0 flex-col gap-2 px-4 pb-0', !isSheet && 'pt-3')}>
|
||||
{/* The Drawer's own header already titles it. */}
|
||||
{isSheet ? null : <p className="text-sm font-medium">Atribuir assessores</p>}
|
||||
{/* `pr-6` keeps this line clear of the sheet's spinner, which sits at
|
||||
`top-0 right-4` because there is no title row above it to hold it. */}
|
||||
<p className={cn('text-xs text-muted-foreground', isSheet && 'pr-6')}>
|
||||
O assessor vê e gerencia somente os municípios que administra.
|
||||
</p>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className={cn(
|
||||
'absolute size-3.5 text-muted-foreground',
|
||||
isSheet ? 'top-0 right-4' : 'top-3 right-3',
|
||||
)}
|
||||
aria-label="Salvando assessores"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{chips.length > 0 ? (
|
||||
<div className="flex shrink-0 flex-wrap gap-1.5 px-4 pt-2">
|
||||
{chips.map((chip) =>
|
||||
// A pending-create chip has no remove affordance: the server-side
|
||||
// create cannot be cancelled mid-flight, so removal waits for the
|
||||
// real account (and the real toggle) once the response lands.
|
||||
isPendingCreateId(chip.id) ? (
|
||||
<Badge
|
||||
key={chip.id}
|
||||
variant="secondary"
|
||||
className={cn('max-w-full font-normal', isSheet && 'min-h-11')}
|
||||
>
|
||||
<span className="truncate">{chip.name}</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
// A 20px pill is a fine mouse target and a bad thumb one: the
|
||||
// Drawer pads it to the 44px minimum without growing the pill.
|
||||
className={cn('inline-flex items-center', isSheet && 'min-h-11')}
|
||||
aria-label={`Remover ${chip.name}`}
|
||||
onClick={() => toggle(chip.id, false)}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
|
||||
>
|
||||
<span className="truncate">{chip.name}</span>
|
||||
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</Badge>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<Command shouldFilter={false} className="mt-1">
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Buscar assessor…"
|
||||
aria-label="Buscar assessor"
|
||||
/>
|
||||
<CommandList>
|
||||
{filteredOptions.length === 0 ? (
|
||||
trimmedQuery.length >= 2 && trimmedQuery.length <= 160 ? (
|
||||
<CommandItem
|
||||
value={`create-${trimmedQuery}`}
|
||||
className={isSheet ? 'min-h-11' : undefined}
|
||||
onSelect={() => createAdvisor(trimmedQuery)}
|
||||
>
|
||||
<UserPlusIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">Criar assessor “{trimmedQuery}”</span>
|
||||
</CommandItem>
|
||||
) : (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
filteredOptions.map((option) => {
|
||||
const assigned = selectedSet.has(option.id)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`advisor-${option.id}`}
|
||||
data-checked={assigned}
|
||||
className={isSheet ? 'min-h-11' : undefined}
|
||||
onSelect={() => toggle(option.id, !assigned)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{option.name}
|
||||
{option.isCurrent ? ' (você)' : ''}
|
||||
</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
{errorMessage ? (
|
||||
<div className="shrink-0 px-4 pt-2 pb-3">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : null}
|
||||
</CampaignCellEditOverlay>
|
||||
description="O assessor vê e gerencia somente os municípios que administra."
|
||||
searchPlaceholder="Buscar assessor…"
|
||||
searchLabel="Buscar assessor"
|
||||
savingMessage="Salvando assessores."
|
||||
saveErrorMessage={SAVE_ERROR_MESSAGE}
|
||||
triggerLabel={(entries) =>
|
||||
`Editar assessores em ${municipalityName} — ${
|
||||
entries.length
|
||||
? entries.map((entry) => entry.label).join(', ')
|
||||
: isPriority
|
||||
? 'sem responsável'
|
||||
: 'sem assessor'
|
||||
}`
|
||||
}
|
||||
emptyState={<MissingAdvisorBadge isPriority={isPriority} />}
|
||||
createLabel={(name) => `Criar assessor “${name}”`}
|
||||
createMaxLength={160}
|
||||
onToggle={toggle}
|
||||
onCreate={create}
|
||||
onCreated={(entry) =>
|
||||
createBridge?.registerCreatedAdvisor({ id: entry.id, name: entry.label, isCurrent: false })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,14 @@
|
|||
'use client'
|
||||
|
||||
import { UserPlusIcon, XIcon } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useRef, useState, type FormEvent } from 'react'
|
||||
|
||||
import type { MunicipalityListLeadershipsResponse } from '@/app/(campaign)/campanha/(app)/municipios/leaderships/types'
|
||||
import { useMunicipalityLeadershipCreate } from '@/components/campaign/municipality/MunicipalityLeadershipCreateProvider'
|
||||
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import type { MunicipalityRelationEntry } from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
|
||||
import {
|
||||
CampaignCellEditOverlay,
|
||||
type CampaignCellEditOverlayVariant,
|
||||
} from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useCampaignCellFailureChannel'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
MunicipalityRelationEditor,
|
||||
type MunicipalityRelationMutationResult,
|
||||
} from '@/components/campaign/shared/MunicipalityRelationEditor'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import {
|
||||
formatBrazilianPhoneInput,
|
||||
normalizeBrazilianPhone,
|
||||
sanitizeBrazilianPhoneInput,
|
||||
} from '@/lib/phone'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { matchesAtWordStart } from '@/lib/wordStartFilter'
|
||||
import type {
|
||||
EligibleLeadershipOption,
|
||||
MunicipalityLeadershipSummary,
|
||||
|
|
@ -34,24 +17,6 @@ import type {
|
|||
const LEADERSHIPS_ENDPOINT = '/campanha/municipios/leaderships'
|
||||
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar as lideranças. Tente novamente.'
|
||||
|
||||
/** B155 — sentinel temp id for an in-flight inline create; negative = never a real leadership. */
|
||||
const isPendingCreateId = (id: number): boolean => id < 0
|
||||
|
||||
// Shared empty values so the memos stay referentially stable when the bridge
|
||||
// is absent (standalone renders in unit tests) or nothing is pending.
|
||||
const EMPTY_CREATED_OPTIONS: EligibleLeadershipOption[] = []
|
||||
const EMPTY_PENDING_CREATES: ReadonlyMap<number, { name: string; phone: string }> = new Map()
|
||||
|
||||
/** Resolves leadership ids to summaries, dropping any without a name (deleted/unknown). */
|
||||
const leadershipEntriesFromIds = (
|
||||
leadershipIDs: readonly number[],
|
||||
namesById: ReadonlyMap<number, MunicipalityLeadershipSummary>,
|
||||
): MunicipalityLeadershipSummary[] =>
|
||||
leadershipIDs.flatMap((id) => {
|
||||
const leadership = namesById.get(id)
|
||||
return leadership ? [{ id: leadership.id, name: leadership.name }] : []
|
||||
})
|
||||
|
||||
type MunicipalityListLeadershipsControlProps = {
|
||||
municipalityID: number
|
||||
municipalityName: string
|
||||
|
|
@ -61,6 +26,11 @@ type MunicipalityListLeadershipsControlProps = {
|
|||
variant: CampaignCellEditOverlayVariant
|
||||
}
|
||||
|
||||
const entryOf = (option: { id: number; name: string }): MunicipalityRelationEntry => ({
|
||||
id: option.id,
|
||||
label: option.name,
|
||||
})
|
||||
|
||||
export const MunicipalityListLeadershipsControl = ({
|
||||
municipalityID,
|
||||
municipalityName,
|
||||
|
|
@ -69,462 +39,69 @@ export const MunicipalityListLeadershipsControl = ({
|
|||
options,
|
||||
variant,
|
||||
}: MunicipalityListLeadershipsControlProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedIDs, setSelectedIDs] = useState<number[]>(currentLeadershipIDs)
|
||||
const [query, setQuery] = useState('')
|
||||
const { errorMessage, setErrorMessage, reportFailure, noteOpenChange } =
|
||||
useCampaignCellFailureChannel()
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const pendingCountRef = useRef(0)
|
||||
const lastPropsIDsRef = useRef(currentLeadershipIDs)
|
||||
// Requests can settle out of send order (network jitter), so the send order
|
||||
// is tracked explicitly: only the response with the highest sequence number
|
||||
// seen so far is kept, and it is adopted — win or fail — once every
|
||||
// in-flight delta has settled (`pendingCountRef` back to 0). No response
|
||||
// ever writes `lastPropsIDsRef`: that ref exists solely to tell a genuine
|
||||
// external prop change (nav/refresh) apart from a no-op re-render carrying
|
||||
// the same pre-edit content.
|
||||
const requestSeqRef = useRef(0)
|
||||
const latestConfirmedRef = useRef<{ seq: number; leadershipIDs: number[] } | null>(null)
|
||||
|
||||
// B155 — the inline-create mini-form, hidden until the user picks
|
||||
// "+ Criar liderança" in the no-results branch.
|
||||
const [createFormOpen, setCreateFormOpen] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [createPhone, setCreatePhone] = useState('')
|
||||
// Unique per mounted instance: the desktop table and the mobile cards are
|
||||
// sibling trees, so the same município renders two controls at once.
|
||||
const createFormId = useId()
|
||||
// Search box to return focus to once the create form closes (the form
|
||||
// unmounts, so focus would otherwise fall to the body).
|
||||
const commandInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
noteOpenChange(nextOpen)
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
// Adopt server props only when they change from outside (navigation / RSC
|
||||
// refresh) — an in-flight delta's optimistic state must not be clobbered by
|
||||
// a prop identical in content to what we already had.
|
||||
useEffect(() => {
|
||||
if (sameIdSet(currentLeadershipIDs, lastPropsIDsRef.current)) return
|
||||
lastPropsIDsRef.current = currentLeadershipIDs
|
||||
setSelectedIDs(currentLeadershipIDs)
|
||||
}, [currentLeadershipIDs])
|
||||
|
||||
// B155 — leaderships created inline since the page last rendered server-side,
|
||||
// shared across every row's popover through `MunicipalityLeadershipCreateProvider`
|
||||
// (null when the control renders standalone, e.g. in unit tests).
|
||||
const createBridge = useMunicipalityLeadershipCreate()
|
||||
const createdOptions = createBridge?.createdOptions ?? EMPTY_CREATED_OPTIONS
|
||||
const registerCreatedLeadership = createBridge?.registerCreatedLeadership
|
||||
// tempId → {name, phone} for in-flight creates: the chip renders optimistically
|
||||
// while the request is out, and the id is not known until the response arrives.
|
||||
const [pendingCreates, setPendingCreates] =
|
||||
useState<ReadonlyMap<number, { name: string; phone: string }>>(EMPTY_PENDING_CREATES)
|
||||
const createdOptions = createBridge?.createdOptions ?? []
|
||||
|
||||
// Every leadership the actor may add is listed here regardless of current
|
||||
// assignment, so it doubles as the name lookup for an id the popover just
|
||||
// optimistically added — no round trip needed to label it.
|
||||
// `leadershipNamesById` is the fallback for an id that fell out of
|
||||
// eligibility but is still assigned; `leadershipEntriesFromIds` drops the
|
||||
// rare id found in neither, same as every other relation cell in the list.
|
||||
const effectiveOptions = useMemo(() => {
|
||||
// The provider bridge can outlive a server re-render (sort/filter
|
||||
// navigation keeps client state, and `getEligibleLeadershipOptions` then
|
||||
// returns the created record again), so the same leadership can arrive
|
||||
// twice — dedupe by id to keep combobox entries and React keys unique.
|
||||
const byId = new Map<number, EligibleLeadershipOption>()
|
||||
for (const option of options) byId.set(option.id, option)
|
||||
for (const option of createdOptions) byId.set(option.id, option)
|
||||
return [...byId.values()]
|
||||
}, [options, createdOptions])
|
||||
|
||||
const leadershipLookup = useMemo(() => {
|
||||
const lookup = new Map<number, MunicipalityLeadershipSummary>(leadershipNamesById)
|
||||
for (const option of effectiveOptions)
|
||||
lookup.set(option.id, { id: option.id, name: option.name })
|
||||
for (const [tempId, pending] of pendingCreates)
|
||||
lookup.set(tempId, { id: tempId, name: pending.name })
|
||||
return lookup
|
||||
}, [leadershipNamesById, effectiveOptions, pendingCreates])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs])
|
||||
const chips = useMemo(
|
||||
() =>
|
||||
[...leadershipEntriesFromIds(selectedIDs, leadershipLookup)].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, 'pt-BR'),
|
||||
),
|
||||
[selectedIDs, leadershipLookup],
|
||||
)
|
||||
|
||||
// The trigger already reads the names by extenso — no hover tooltip needed.
|
||||
const assignedLabel = chips.length ? chips.map((chip) => chip.name).join(', ') : 'nenhuma'
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() => effectiveOptions.filter((option) => matchesAtWordStart(option.name, query)),
|
||||
[effectiveOptions, query],
|
||||
)
|
||||
|
||||
// Single settle point shared by the toggle and the inline create (success,
|
||||
// mapped error, network failure): decrements the pending count and, once it
|
||||
// reaches 0, stops the spinner and reconciles to the latest confirmed server
|
||||
// set — even on a request's own failure, an earlier delta in the same batch
|
||||
// may have already confirmed one.
|
||||
const finishRequest = () => {
|
||||
pendingCountRef.current = Math.max(0, pendingCountRef.current - 1)
|
||||
if (pendingCountRef.current > 0) return
|
||||
setIsPending(false)
|
||||
if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current.leadershipIDs)
|
||||
}
|
||||
|
||||
const toggle = (leadershipId: number, assigned: boolean) => {
|
||||
// A negative id is a B155 pending-create sentinel, never a real record —
|
||||
// the temp chip has no remove affordance, so this only guards the path.
|
||||
if (isPendingCreateId(leadershipId)) return
|
||||
setErrorMessage(null)
|
||||
setSelectedIDs((current) => {
|
||||
const next = new Set(current)
|
||||
if (assigned) next.add(leadershipId)
|
||||
else next.delete(leadershipId)
|
||||
return [...next]
|
||||
})
|
||||
setQuery('')
|
||||
|
||||
pendingCountRef.current += 1
|
||||
setIsPending(true)
|
||||
const requestSeq = (requestSeqRef.current += 1)
|
||||
|
||||
void (async () => {
|
||||
const revertDelta = () => {
|
||||
setSelectedIDs((current) => {
|
||||
const next = new Set(current)
|
||||
if (assigned) next.delete(leadershipId)
|
||||
else next.add(leadershipId)
|
||||
return [...next]
|
||||
})
|
||||
const toggle = async (
|
||||
leadershipID: number,
|
||||
assigned: boolean,
|
||||
): Promise<MunicipalityRelationMutationResult> => {
|
||||
const { ok, payload } = await postCampaignJson<MunicipalityListLeadershipsResponse>(
|
||||
LEADERSHIPS_ENDPOINT,
|
||||
{ municipalityId: municipalityID, leadershipId: leadershipID, assigned },
|
||||
)
|
||||
if (!ok || payload.status !== 'success') {
|
||||
return {
|
||||
status: 'error',
|
||||
message: payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE,
|
||||
}
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<MunicipalityListLeadershipsResponse>(
|
||||
LEADERSHIPS_ENDPOINT,
|
||||
{ municipalityId: municipalityID, leadershipId, assigned },
|
||||
)
|
||||
|
||||
if (!ok || payload.status !== 'success') {
|
||||
revertDelta()
|
||||
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
return
|
||||
}
|
||||
|
||||
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
|
||||
latestConfirmedRef.current = { seq: requestSeq, leadershipIDs: payload.leadershipIDs }
|
||||
}
|
||||
finishRequest()
|
||||
} catch {
|
||||
revertDelta()
|
||||
reportFailure(SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/**
|
||||
* B155 — name+phone inline create, assigned to this município. The chip goes
|
||||
* up immediately under a sentinel temp id (the real id only exists once the
|
||||
* response lands); the request rides the same seq/pending machinery as the
|
||||
* toggle, so out-of-order settles and the floor/cap failure reconcile exactly
|
||||
* like a toggle — a failed create reverts the temp chip and shows the Alert.
|
||||
*/
|
||||
const createLeadership = (name: string, phone: string) => {
|
||||
// A create is not an idempotent delta: re-activating the same person while
|
||||
// a create is in flight would mint a second record. The temp chip already
|
||||
// signals the pending one — skip.
|
||||
if (
|
||||
[...pendingCreates.values()].some(
|
||||
(pending) => pending.name === name && pending.phone === phone,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setErrorMessage(null)
|
||||
setCreateFormOpen(false)
|
||||
setCreateName('')
|
||||
setCreatePhone('')
|
||||
setQuery('')
|
||||
return { status: 'success', selectedIDs: payload.leadershipIDs }
|
||||
}
|
||||
|
||||
pendingCountRef.current += 1
|
||||
setIsPending(true)
|
||||
const requestSeq = (requestSeqRef.current += 1)
|
||||
const tempId = -requestSeq
|
||||
|
||||
setSelectedIDs((current) => [...current, tempId])
|
||||
setPendingCreates((current) => new Map(current).set(tempId, { name, phone }))
|
||||
|
||||
void (async () => {
|
||||
const revertCreate = () => {
|
||||
setSelectedIDs((current) => current.filter((id) => id !== tempId))
|
||||
setPendingCreates((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(tempId)
|
||||
return next
|
||||
})
|
||||
const create = async (name: string): Promise<MunicipalityRelationMutationResult> => {
|
||||
const { ok, payload } = await postCampaignJson<MunicipalityListLeadershipsResponse>(
|
||||
LEADERSHIPS_ENDPOINT,
|
||||
{ municipalityId: municipalityID, name },
|
||||
)
|
||||
if (!ok || payload.status !== 'success' || !payload.createdLeadership) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE,
|
||||
}
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<MunicipalityListLeadershipsResponse>(
|
||||
LEADERSHIPS_ENDPOINT,
|
||||
{ municipalityId: municipalityID, name, phone },
|
||||
)
|
||||
|
||||
if (!ok || payload.status !== 'success') {
|
||||
revertCreate()
|
||||
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
return
|
||||
}
|
||||
|
||||
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
|
||||
latestConfirmedRef.current = { seq: requestSeq, leadershipIDs: payload.leadershipIDs }
|
||||
}
|
||||
|
||||
const createdLeadership = payload.createdLeadership
|
||||
if (createdLeadership) {
|
||||
// Swap the sentinel for the real id right away: if another delta is
|
||||
// still in flight the reconcile is deferred, and the chip must not
|
||||
// vanish in the gap (the lookup drops a temp id the moment its
|
||||
// pending entry is removed).
|
||||
setSelectedIDs((current) => [
|
||||
...current.filter((id) => id !== tempId),
|
||||
createdLeadership.id,
|
||||
])
|
||||
setPendingCreates((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(tempId)
|
||||
return next
|
||||
})
|
||||
// Make the record selectable in every other row's popover on this
|
||||
// page load — `createdOptions` is the client-side bridge until the
|
||||
// next navigation's `getEligibleLeadershipOptions` picks it up.
|
||||
registerCreatedLeadership?.({ id: createdLeadership.id, name: createdLeadership.name })
|
||||
}
|
||||
finishRequest()
|
||||
} catch {
|
||||
revertCreate()
|
||||
reportFailure(SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
}
|
||||
})()
|
||||
}
|
||||
return {
|
||||
status: 'success',
|
||||
selectedIDs: payload.leadershipIDs,
|
||||
createdEntry: entryOf(payload.createdLeadership),
|
||||
}
|
||||
}
|
||||
|
||||
const closeCreateForm = () => {
|
||||
setCreateFormOpen(false)
|
||||
// The form unmounts with it; return focus to the search box once the
|
||||
// Command remounts (otherwise keyboard/SR focus falls to the body).
|
||||
requestAnimationFrame(() => commandInputRef.current?.focus())
|
||||
}
|
||||
|
||||
const handleCreateSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const name = createName.trim()
|
||||
const phone = normalizeBrazilianPhone(createPhone)
|
||||
if (name.length < 2 || !phone) return
|
||||
createLeadership(name, phone)
|
||||
}
|
||||
|
||||
const handlePhoneChange = (value: string) => {
|
||||
setCreatePhone(formatBrazilianPhoneInput(sanitizeBrazilianPhoneInput(value)))
|
||||
}
|
||||
|
||||
// Mirrors the zod schema (`name 2–120`, `brazilianMobile`) so a malformed
|
||||
// submit never reaches the server's generic error.
|
||||
const canCreate =
|
||||
createName.trim().length >= 2 && createName.trim().length <= 120
|
||||
? normalizeBrazilianPhone(createPhone) !== null
|
||||
: false
|
||||
|
||||
const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando lideranças.' : ''
|
||||
// The body pads its own sections (`px-4`, aligned with the Drawer header) so
|
||||
// the `Command` can stay full-bleed in both containers — hence `px-0` on the
|
||||
// sheet body and `p-0` on the popover content.
|
||||
const isSheet = variant === 'sheet'
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
return (
|
||||
<CampaignCellEditOverlay
|
||||
<MunicipalityRelationEditor
|
||||
municipalityName={municipalityName}
|
||||
currentIDs={currentLeadershipIDs}
|
||||
knownEntries={[...leadershipNamesById.values()].map(entryOf)}
|
||||
options={[...options, ...createdOptions].map(entryOf)}
|
||||
variant={variant}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title="Gerenciar lideranças"
|
||||
description={municipalityName}
|
||||
triggerLabel={`Editar lideranças em ${municipalityName} — ${assignedLabel}`}
|
||||
triggerBusy={isPending}
|
||||
statusMessage={statusMessage}
|
||||
contentClassName="w-80 p-0"
|
||||
sheetBodyClassName="px-0 pt-2"
|
||||
trigger={
|
||||
chips.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">Nenhuma</span>
|
||||
) : (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{chips.map((chip) => (
|
||||
<Badge key={chip.id} variant="secondary" className="max-w-full font-normal">
|
||||
<span className="truncate">{chip.name}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
description="A liderança passa a aparecer também na ficha dela, na área Lideranças."
|
||||
searchPlaceholder="Buscar liderança…"
|
||||
searchLabel="Buscar liderança"
|
||||
savingMessage="Salvando lideranças."
|
||||
saveErrorMessage={SAVE_ERROR_MESSAGE}
|
||||
triggerLabel={(entries) =>
|
||||
`Editar lideranças em ${municipalityName} — ${
|
||||
entries.length ? entries.map((entry) => entry.label).join(', ') : 'nenhuma'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<div className={cn('relative flex shrink-0 flex-col gap-2 px-4 pb-0', !isSheet && 'pt-3')}>
|
||||
{/* The Drawer's own header already titles it. */}
|
||||
{isSheet ? null : <p className="text-sm font-medium">Gerenciar lideranças</p>}
|
||||
<p className={cn('text-xs text-muted-foreground', isSheet && 'pr-6')}>
|
||||
A liderança passa a aparecer também na ficha dela, na área Lideranças.
|
||||
</p>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className={cn(
|
||||
'absolute size-3.5 text-muted-foreground',
|
||||
isSheet ? 'top-0 right-4' : 'top-3 right-3',
|
||||
)}
|
||||
aria-label="Salvando lideranças"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{chips.length > 0 ? (
|
||||
<div className="flex shrink-0 flex-wrap gap-1.5 px-4 pt-2">
|
||||
{chips.map((chip) =>
|
||||
// A pending-create chip has no remove affordance: the server-side
|
||||
// create cannot be cancelled mid-flight, so removal waits for the
|
||||
// real record (and the real toggle) once the response lands.
|
||||
isPendingCreateId(chip.id) ? (
|
||||
<Badge
|
||||
key={chip.id}
|
||||
variant="secondary"
|
||||
className={cn('max-w-full font-normal', isSheet && 'min-h-11')}
|
||||
>
|
||||
<span className="truncate">{chip.name}</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
// A 20px pill is a fine mouse target and a bad thumb one: the
|
||||
// Drawer pads it to the 44px minimum without growing the pill.
|
||||
className={cn('inline-flex items-center', isSheet && 'min-h-11')}
|
||||
aria-label={`Remover ${chip.name}`}
|
||||
onClick={() => toggle(chip.id, false)}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
|
||||
>
|
||||
<span className="truncate">{chip.name}</span>
|
||||
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</Badge>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{createFormOpen ? (
|
||||
<form className="flex shrink-0 flex-col gap-3 px-4 pb-3 pt-3" onSubmit={handleCreateSubmit}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={`leadership-create-name-${createFormId}`}>Nome</Label>
|
||||
<Input
|
||||
id={`leadership-create-name-${createFormId}`}
|
||||
value={createName}
|
||||
onChange={(event) => setCreateName(event.currentTarget.value)}
|
||||
placeholder="Nome completo"
|
||||
autoComplete="name"
|
||||
maxLength={120}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={`leadership-create-phone-${createFormId}`}>Celular (com DDD)</Label>
|
||||
<Input
|
||||
id={`leadership-create-phone-${createFormId}`}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
value={createPhone}
|
||||
onChange={(event) => handlePhoneChange(event.currentTarget.value)}
|
||||
placeholder="(71) 99999-9999"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="submit" size="sm" disabled={!canCreate || isPending}>
|
||||
Criar liderança
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={closeCreateForm}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Command shouldFilter={false} className="mt-1">
|
||||
<CommandInput
|
||||
ref={commandInputRef}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Buscar liderança…"
|
||||
aria-label="Buscar liderança"
|
||||
/>
|
||||
<CommandList>
|
||||
{filteredOptions.length === 0 ? (
|
||||
trimmedQuery.length >= 2 && trimmedQuery.length <= 120 ? (
|
||||
<CommandItem
|
||||
value={`create-${trimmedQuery}`}
|
||||
className={isSheet ? 'min-h-11' : undefined}
|
||||
onSelect={() => {
|
||||
setCreateFormOpen(true)
|
||||
setCreateName(trimmedQuery)
|
||||
}}
|
||||
>
|
||||
<UserPlusIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">Criar liderança “{trimmedQuery}”</span>
|
||||
</CommandItem>
|
||||
) : (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
filteredOptions.map((option) => {
|
||||
const assigned = selectedSet.has(option.id)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`leadership-${option.id}`}
|
||||
data-checked={assigned}
|
||||
className={isSheet ? 'min-h-11' : undefined}
|
||||
onSelect={() => toggle(option.id, !assigned)}
|
||||
>
|
||||
<span className="truncate">{option.name}</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
)}
|
||||
{errorMessage ? (
|
||||
<div className="shrink-0 px-4 pt-2 pb-3">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : null}
|
||||
</CampaignCellEditOverlay>
|
||||
emptyState={<span className="text-sm text-muted-foreground">Nenhuma</span>}
|
||||
createLabel={(name) => `Criar liderança “${name}”`}
|
||||
onToggle={toggle}
|
||||
onCreate={create}
|
||||
onCreated={(entry) =>
|
||||
createBridge?.registerCreatedLeadership({ id: entry.id, name: entry.label })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
|
||||
|
||||
export type MunicipalityRelationEntry = {
|
||||
id: number
|
||||
label: string
|
||||
searchText?: string
|
||||
initialsLabel?: string
|
||||
optionSuffix?: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
export const MunicipalityRelationAvatarStack = ({
|
||||
entries,
|
||||
emptyState,
|
||||
maxVisible = 3,
|
||||
}: {
|
||||
entries: MunicipalityRelationEntry[]
|
||||
emptyState: ReactNode
|
||||
maxVisible?: number
|
||||
}) => {
|
||||
if (entries.length === 0) return emptyState
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-2">
|
||||
{entries.slice(0, maxVisible).map((entry) => (
|
||||
<Avatar key={entry.id} className="size-8 border-2 border-background">
|
||||
<AvatarFallback>
|
||||
{campaignUserInitials(entry.initialsLabel ?? entry.label)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
<span className="sr-only">{entries.map((entry) => entry.label).join(', ')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
394
src/components/campaign/shared/MunicipalityRelationEditor.tsx
Normal file
394
src/components/campaign/shared/MunicipalityRelationEditor.tsx
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
'use client'
|
||||
|
||||
import { UserPlusIcon, XIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
CampaignCellEditOverlay,
|
||||
type CampaignCellEditOverlayVariant,
|
||||
} from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import {
|
||||
MunicipalityRelationAvatarStack,
|
||||
type MunicipalityRelationEntry,
|
||||
} from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
|
||||
import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useCampaignCellFailureChannel'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { matchesAtWordStart } from '@/lib/wordStartFilter'
|
||||
|
||||
export type MunicipalityRelationMutationResult =
|
||||
| {
|
||||
status: 'success'
|
||||
selectedIDs?: number[]
|
||||
createdEntry?: MunicipalityRelationEntry
|
||||
}
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
type MunicipalityRelationEditorProps = {
|
||||
municipalityName: string
|
||||
currentIDs: number[]
|
||||
knownEntries?: MunicipalityRelationEntry[]
|
||||
options: MunicipalityRelationEntry[]
|
||||
variant: CampaignCellEditOverlayVariant
|
||||
title: string
|
||||
description: string
|
||||
searchPlaceholder: string
|
||||
searchLabel: string
|
||||
savingMessage: string
|
||||
saveErrorMessage: string
|
||||
createErrorMessage?: string
|
||||
triggerLabel: (entries: MunicipalityRelationEntry[]) => string
|
||||
emptyState: ReactNode
|
||||
createLabel: (name: string) => string
|
||||
createMaxLength?: number
|
||||
sortSelected?: boolean
|
||||
onToggle: (id: number, assigned: boolean) => Promise<MunicipalityRelationMutationResult>
|
||||
onCreate: (name: string) => Promise<MunicipalityRelationMutationResult>
|
||||
onCreated?: (entry: MunicipalityRelationEntry) => void
|
||||
}
|
||||
|
||||
const isPendingCreateID = (id: number): boolean => id < 0
|
||||
const EMPTY_ENTRIES: MunicipalityRelationEntry[] = []
|
||||
|
||||
export const MunicipalityRelationEditor = ({
|
||||
municipalityName,
|
||||
currentIDs,
|
||||
knownEntries = EMPTY_ENTRIES,
|
||||
options,
|
||||
variant,
|
||||
title,
|
||||
description,
|
||||
searchPlaceholder,
|
||||
searchLabel,
|
||||
savingMessage,
|
||||
saveErrorMessage,
|
||||
createErrorMessage = saveErrorMessage,
|
||||
triggerLabel,
|
||||
emptyState,
|
||||
createLabel,
|
||||
createMaxLength = 120,
|
||||
sortSelected = true,
|
||||
onToggle,
|
||||
onCreate,
|
||||
onCreated,
|
||||
}: MunicipalityRelationEditorProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedIDs, setSelectedIDs] = useState(currentIDs)
|
||||
const [query, setQuery] = useState('')
|
||||
const { errorMessage, setErrorMessage, reportFailure, noteOpenChange } =
|
||||
useCampaignCellFailureChannel()
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const [createdEntries, setCreatedEntries] = useState<MunicipalityRelationEntry[]>([])
|
||||
const [pendingCreates, setPendingCreates] = useState<
|
||||
ReadonlyMap<number, MunicipalityRelationEntry>
|
||||
>(new Map())
|
||||
const pendingCountRef = useRef(0)
|
||||
const lastPropsIDsRef = useRef(currentIDs)
|
||||
const requestSeqRef = useRef(0)
|
||||
const latestConfirmedRef = useRef<{ seq: number; ids: number[] } | null>(null)
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
noteOpenChange(nextOpen)
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (sameIdSet(currentIDs, lastPropsIDsRef.current)) return
|
||||
lastPropsIDsRef.current = currentIDs
|
||||
if (pendingCountRef.current === 0) latestConfirmedRef.current = null
|
||||
setSelectedIDs(currentIDs)
|
||||
}, [currentIDs])
|
||||
|
||||
const selectableOptions = useMemo(() => {
|
||||
const byID = new Map<number, MunicipalityRelationEntry>()
|
||||
for (const entry of options) byID.set(entry.id, entry)
|
||||
for (const entry of createdEntries) byID.set(entry.id, entry)
|
||||
return [...byID.values()]
|
||||
}, [options, createdEntries])
|
||||
|
||||
const entryByID = useMemo(() => {
|
||||
const lookup = new Map(knownEntries.map((entry) => [entry.id, entry]))
|
||||
for (const entry of selectableOptions) lookup.set(entry.id, entry)
|
||||
for (const [id, entry] of pendingCreates) lookup.set(id, entry)
|
||||
return lookup
|
||||
}, [knownEntries, selectableOptions, pendingCreates])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs])
|
||||
const selectedEntries = useMemo(() => {
|
||||
const entries = selectedIDs.flatMap((id) => {
|
||||
const entry = entryByID.get(id)
|
||||
return entry ? [entry] : []
|
||||
})
|
||||
return sortSelected
|
||||
? entries.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
|
||||
: entries
|
||||
}, [selectedIDs, entryByID, sortSelected])
|
||||
const tooltipContent =
|
||||
selectedEntries.length === 0 ? null : (
|
||||
<div className="flex flex-col">
|
||||
{selectedEntries.map((entry) => (
|
||||
<span key={entry.id}>{entry.label}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
selectableOptions.filter((entry) =>
|
||||
matchesAtWordStart(entry.searchText ?? entry.label, query),
|
||||
),
|
||||
[selectableOptions, query],
|
||||
)
|
||||
|
||||
const finishRequest = () => {
|
||||
pendingCountRef.current = Math.max(0, pendingCountRef.current - 1)
|
||||
if (pendingCountRef.current > 0) return
|
||||
setIsPending(false)
|
||||
if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current.ids)
|
||||
}
|
||||
|
||||
const rememberConfirmation = (requestSeq: number, result: MunicipalityRelationMutationResult) => {
|
||||
if (result.status !== 'success' || !result.selectedIDs) return
|
||||
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
|
||||
latestConfirmedRef.current = { seq: requestSeq, ids: result.selectedIDs }
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = (id: number, assigned: boolean) => {
|
||||
if (isPendingCreateID(id)) return
|
||||
setErrorMessage(null)
|
||||
setSelectedIDs((current) => {
|
||||
const next = new Set(current)
|
||||
if (assigned) next.add(id)
|
||||
else next.delete(id)
|
||||
return [...next]
|
||||
})
|
||||
setQuery('')
|
||||
pendingCountRef.current += 1
|
||||
setIsPending(true)
|
||||
const requestSeq = (requestSeqRef.current += 1)
|
||||
|
||||
void (async () => {
|
||||
const revertDelta = () => {
|
||||
setSelectedIDs((current) => {
|
||||
const next = new Set(current)
|
||||
if (assigned) next.delete(id)
|
||||
else next.add(id)
|
||||
return [...next]
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await onToggle(id, assigned)
|
||||
if (result.status === 'error') {
|
||||
revertDelta()
|
||||
reportFailure(result.message || saveErrorMessage)
|
||||
finishRequest()
|
||||
return
|
||||
}
|
||||
rememberConfirmation(requestSeq, result)
|
||||
finishRequest()
|
||||
} catch {
|
||||
revertDelta()
|
||||
reportFailure(saveErrorMessage)
|
||||
finishRequest()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const create = (name: string) => {
|
||||
if ([...pendingCreates.values()].some((entry) => entry.label === name)) return
|
||||
setErrorMessage(null)
|
||||
setQuery('')
|
||||
pendingCountRef.current += 1
|
||||
setIsPending(true)
|
||||
const requestSeq = (requestSeqRef.current += 1)
|
||||
const tempID = -requestSeq
|
||||
const pendingEntry = { id: tempID, label: name }
|
||||
setSelectedIDs((current) => [...current, tempID])
|
||||
setPendingCreates((current) => new Map(current).set(tempID, pendingEntry))
|
||||
|
||||
void (async () => {
|
||||
const revertCreate = () => {
|
||||
setSelectedIDs((current) => current.filter((id) => id !== tempID))
|
||||
setPendingCreates((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(tempID)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await onCreate(name)
|
||||
if (result.status === 'error' || !result.createdEntry) {
|
||||
revertCreate()
|
||||
reportFailure(result.status === 'error' ? result.message : createErrorMessage)
|
||||
finishRequest()
|
||||
return
|
||||
}
|
||||
rememberConfirmation(requestSeq, result)
|
||||
const createdEntry = result.createdEntry
|
||||
setSelectedIDs((current) => [
|
||||
...new Set([...current.filter((id) => id !== tempID), createdEntry.id]),
|
||||
])
|
||||
setPendingCreates((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(tempID)
|
||||
return next
|
||||
})
|
||||
setCreatedEntries((current) =>
|
||||
current.some((entry) => entry.id === createdEntry.id)
|
||||
? current
|
||||
: [...current, createdEntry],
|
||||
)
|
||||
onCreated?.(createdEntry)
|
||||
finishRequest()
|
||||
} catch {
|
||||
revertCreate()
|
||||
reportFailure(createErrorMessage)
|
||||
finishRequest()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const statusMessage = errorMessage ? errorMessage : isPending ? savingMessage : ''
|
||||
const isSheet = variant === 'sheet'
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
return (
|
||||
<CampaignCellEditOverlay
|
||||
variant={variant}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={title}
|
||||
description={municipalityName}
|
||||
triggerLabel={triggerLabel(selectedEntries)}
|
||||
triggerBusy={isPending}
|
||||
statusMessage={statusMessage}
|
||||
tooltipContent={tooltipContent}
|
||||
contentClassName="w-80 p-0"
|
||||
sheetBodyClassName="px-0 pt-2"
|
||||
trigger={
|
||||
<MunicipalityRelationAvatarStack entries={selectedEntries} emptyState={emptyState} />
|
||||
}
|
||||
>
|
||||
<div className={cn('relative flex shrink-0 flex-col gap-2 px-4 pb-0', !isSheet && 'pt-3')}>
|
||||
{isSheet ? null : <p className="text-sm font-medium">{title}</p>}
|
||||
<p className={cn('text-xs text-muted-foreground', isSheet && 'pr-6')}>{description}</p>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className={cn(
|
||||
'absolute size-3.5 text-muted-foreground',
|
||||
isSheet ? 'top-0 right-4' : 'top-3 right-3',
|
||||
)}
|
||||
aria-label={savingMessage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedEntries.length > 0 ? (
|
||||
<div className="flex shrink-0 flex-wrap gap-1.5 px-4 pt-2">
|
||||
{selectedEntries.map((entry) =>
|
||||
isPendingCreateID(entry.id) ? (
|
||||
<Badge
|
||||
key={entry.id}
|
||||
variant="secondary"
|
||||
className={cn('max-w-full font-normal', isSheet && 'min-h-11')}
|
||||
>
|
||||
<span className="truncate">{entry.label}</span>
|
||||
</Badge>
|
||||
) : entry.href ? (
|
||||
<Badge
|
||||
key={entry.id}
|
||||
variant="secondary"
|
||||
className={cn('relative max-w-full gap-1 pr-5 font-normal', isSheet && 'my-3')}
|
||||
>
|
||||
<Link className="truncate underline-offset-2 hover:underline" href={entry.href}>
|
||||
{entry.label}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 inline-flex size-5 items-center justify-center after:absolute after:-inset-x-1 after:-inset-y-3"
|
||||
aria-label={`Remover ${entry.label}`}
|
||||
onClick={() => toggle(entry.id, false)}
|
||||
>
|
||||
<XIcon className="size-3 opacity-70" aria-hidden="true" />
|
||||
</button>
|
||||
</Badge>
|
||||
) : (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className={cn('inline-flex items-center', isSheet && 'min-h-11')}
|
||||
aria-label={`Remover ${entry.label}`}
|
||||
onClick={() => toggle(entry.id, false)}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
|
||||
>
|
||||
<span className="truncate">{entry.label}</span>
|
||||
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</Badge>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<Command shouldFilter={false} className="mt-1">
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label={searchLabel}
|
||||
/>
|
||||
<CommandList>
|
||||
{filteredOptions.length === 0 ? (
|
||||
trimmedQuery.length >= 2 && trimmedQuery.length <= createMaxLength ? (
|
||||
<CommandItem
|
||||
value={`create-${trimmedQuery}`}
|
||||
className={isSheet ? 'min-h-11' : undefined}
|
||||
onSelect={() => create(trimmedQuery)}
|
||||
>
|
||||
<UserPlusIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{createLabel(trimmedQuery)}</span>
|
||||
</CommandItem>
|
||||
) : (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
filteredOptions.map((entry) => {
|
||||
const assigned = selectedSet.has(entry.id)
|
||||
return (
|
||||
<CommandItem
|
||||
key={entry.id}
|
||||
value={`relation-${entry.id}`}
|
||||
data-checked={assigned}
|
||||
className={isSheet ? 'min-h-11' : undefined}
|
||||
onSelect={() => toggle(entry.id, !assigned)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{entry.label}
|
||||
{entry.optionSuffix ?? ''}
|
||||
</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
{errorMessage ? (
|
||||
<div className="shrink-0 px-4 pt-2 pb-3">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : null}
|
||||
</CampaignCellEditOverlay>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,106 +1,16 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useMemo, type ReactElement } from 'react'
|
||||
|
||||
import type { MunicipalityStateDeputyCreateResult } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import type { MunicipalityRelationEntry } from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
|
||||
import {
|
||||
RelationChipCell,
|
||||
type RelationChip,
|
||||
type RelationChipCellCopy,
|
||||
type RelationSearchHit,
|
||||
} from '@/components/campaign/shared/RelationChipCell'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
MunicipalityRelationEditor,
|
||||
type MunicipalityRelationMutationResult,
|
||||
} from '@/components/campaign/shared/MunicipalityRelationEditor'
|
||||
import { stateDeputyDisplayName } from '@/lib/stateDeputyNameParty'
|
||||
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
|
||||
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
|
||||
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 3
|
||||
|
||||
/** "Cicrano (PCdoB)" → "C" — parenthesized party never leaks into the initials. */
|
||||
const deputyInitials = (label: string): string => {
|
||||
const withoutParty = label.replace(/\([^()]*\)/g, '').trim()
|
||||
return campaignUserInitials(withoutParty || label)
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed-cell display of the "Dobradinhas" column (B157): the same avatar
|
||||
* stack as the advisors column — overlapping initials circles, hover reads the
|
||||
* names — with "—" for an empty município. The detail ("Fulano (PT)") lives in
|
||||
* the trigger tooltip and in the editor's removable chips, which link to the
|
||||
* ficha (same contract as advisors: no links inside the closed cell).
|
||||
*/
|
||||
const StateDeputyAvatarStack = ({ chips }: { chips: RelationChip[] }) => {
|
||||
if (chips.length === 0) return <span className="text-sm text-muted-foreground">—</span>
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-2">
|
||||
{chips.slice(0, MAX_VISIBLE_AVATARS).map((chip) => (
|
||||
<Avatar key={chip.key} className="size-8 border-2 border-background">
|
||||
<AvatarFallback>{deputyInitials(chip.label)}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One "Nome (Partido)" per line — the hover read of the closed cell. */
|
||||
const formatStateDeputyNamesTooltip = (chips: RelationChip[]): ReactElement | null =>
|
||||
chips.length === 0 ? null : (
|
||||
<div className="flex flex-col">
|
||||
{chips.map((chip) => (
|
||||
<span key={chip.key}>{chip.label}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* Normalized search labels, once per `options` array rather than per row: the
|
||||
* RSC builds one array and every row of the table gets that same reference, so
|
||||
* a `WeakMap` shares the work where a per-row `useMemo` would repeat it (same
|
||||
* reasoning as `LeadershipStateDeputyRelationCell`).
|
||||
*/
|
||||
const normalizedLabelsByOptions = new WeakMap<
|
||||
readonly StateDeputyRelationOption[],
|
||||
Map<number, string>
|
||||
>()
|
||||
|
||||
const normalizedOptionLabels = (
|
||||
options: readonly StateDeputyRelationOption[],
|
||||
): Map<number, string> => {
|
||||
const cached = normalizedLabelsByOptions.get(options)
|
||||
if (cached) return cached
|
||||
|
||||
// Name AND party are searchable ("PT" finds "Fulano (PT)" — B157 aceite).
|
||||
const normalized = new Map(
|
||||
options.map(
|
||||
(option) =>
|
||||
[option.id, normalizeSearchPhrase(`${option.plainName} ${option.party ?? ''}`)] as const,
|
||||
),
|
||||
)
|
||||
normalizedLabelsByOptions.set(options, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
/** Same WeakMap sharing for the id→option lookup the chips are built from. */
|
||||
const optionByIdByOptions = new WeakMap<
|
||||
readonly StateDeputyRelationOption[],
|
||||
Map<number, StateDeputyRelationOption>
|
||||
>()
|
||||
|
||||
const optionByIdFor = (
|
||||
options: readonly StateDeputyRelationOption[],
|
||||
): Map<number, StateDeputyRelationOption> => {
|
||||
const cached = optionByIdByOptions.get(options)
|
||||
if (cached) return cached
|
||||
const byId = new Map(options.map((option) => [option.id, option]))
|
||||
optionByIdByOptions.set(options, byId)
|
||||
return byId
|
||||
}
|
||||
|
||||
type MunicipalityStateDeputyRelationCellProps = {
|
||||
municipalityId: number
|
||||
|
|
@ -115,12 +25,6 @@ type MunicipalityStateDeputyRelationCellProps = {
|
|||
editorVariant: CampaignCellEditOverlayVariant
|
||||
}
|
||||
|
||||
/**
|
||||
* The inline-create form action as the cell consumes it — the success state
|
||||
* carries the created deputy so the cell can swap its optimistic chip without
|
||||
* waiting for the RSC refresh. Exported once so the list and the mobile cards
|
||||
* share the same type instead of re-declaring it.
|
||||
*/
|
||||
export type MunicipalityStateDeputyCreateAction = (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
|
|
@ -128,26 +32,30 @@ export type MunicipalityStateDeputyCreateAction = (
|
|||
CampaignFormActionState & { stateDeputy?: MunicipalityStateDeputyCreateResult['stateDeputy'] }
|
||||
>
|
||||
|
||||
const CREATE_LABEL = (rawName: string) => `Criar dobradinha “${rawName}”`
|
||||
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar as dobradinhas. Tente novamente.'
|
||||
const CREATE_ERROR_MESSAGE = 'Não foi possível criar a dobradinha. Tente novamente.'
|
||||
|
||||
const chipOf = (option: StateDeputyRelationOption): RelationChip => ({
|
||||
key: String(option.id),
|
||||
// `option.name` already folds the party (the loader's single spelling).
|
||||
const entriesByOptions = new WeakMap<
|
||||
readonly StateDeputyRelationOption[],
|
||||
MunicipalityRelationEntry[]
|
||||
>()
|
||||
|
||||
const entryOf = (option: StateDeputyRelationOption): MunicipalityRelationEntry => ({
|
||||
id: option.id,
|
||||
label: option.name,
|
||||
initialsLabel: option.plainName,
|
||||
searchText: `${option.plainName} ${option.party ?? ''}`,
|
||||
href: `/campanha/dobradinhas/${option.slug}`,
|
||||
ids: [option.id],
|
||||
})
|
||||
|
||||
/**
|
||||
* B157 — the "Dobradinhas" column of `/campanha/municipios`: a thin domain
|
||||
* shell over `RelationChipCell` in trigger mode. The closed cell is the
|
||||
* advisors-style avatar stack (hover reads "Nome (Partido)"); the editor opens
|
||||
* in a Popover (desktop) / Drawer (mobile) with removable chips, a combobox
|
||||
* that searches name AND party, and inline create ("Criar dobradinha 'texto'",
|
||||
* `Nome (PARTIDO)` syntax). Every write goes through the same actions the
|
||||
* `/campanha/dobradinhas` list already uses — nothing new on the server side
|
||||
* for toggles.
|
||||
*/
|
||||
const entriesFor = (options: readonly StateDeputyRelationOption[]) => {
|
||||
const cached = entriesByOptions.get(options)
|
||||
if (cached) return cached
|
||||
const entries = options.map(entryOf)
|
||||
entriesByOptions.set(options, entries)
|
||||
return entries
|
||||
}
|
||||
|
||||
export const MunicipalityStateDeputyRelationCell = ({
|
||||
municipalityId,
|
||||
municipalityName,
|
||||
|
|
@ -157,125 +65,68 @@ export const MunicipalityStateDeputyRelationCell = ({
|
|||
createAction,
|
||||
editorVariant,
|
||||
}: MunicipalityStateDeputyRelationCellProps) => {
|
||||
// Same WeakMap sharing as the normalized labels above: the RSC builds ONE
|
||||
// options array for every row, so the id→option map is computed once per
|
||||
// request instead of per row (25 × catalog-size inserts otherwise).
|
||||
const optionById = optionByIdFor(options)
|
||||
const toggle = async (
|
||||
stateDeputyID: number,
|
||||
assigned: boolean,
|
||||
): Promise<MunicipalityRelationMutationResult> => {
|
||||
const formData = new FormData()
|
||||
formData.set('municipalityId', String(municipalityId))
|
||||
formData.set('stateDeputyId', String(stateDeputyID))
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
const result = await commitAction({}, formData)
|
||||
return result.status === 'success'
|
||||
? { status: 'success' }
|
||||
: { status: 'error', message: firstFormActionMessage(result) ?? SAVE_ERROR_MESSAGE }
|
||||
}
|
||||
|
||||
const buildChips = useCallback(
|
||||
(ids: number[]): RelationChip[] => {
|
||||
const chips: RelationChip[] = []
|
||||
for (const id of ids) {
|
||||
const option = optionById.get(id)
|
||||
if (!option) continue
|
||||
chips.push(chipOf(option))
|
||||
}
|
||||
return chips
|
||||
},
|
||||
[optionById],
|
||||
)
|
||||
|
||||
const searchHits = useCallback(
|
||||
(query: string, assignedIds: ReadonlySet<number>): RelationSearchHit[] => {
|
||||
const normalizedQuery = normalizeSearchPhrase(query)
|
||||
const normalizedLabels = normalizedOptionLabels(options)
|
||||
const hits: RelationSearchHit[] = []
|
||||
for (const option of options) {
|
||||
if (assignedIds.has(option.id)) continue
|
||||
if (!matchesNormalizedAtWordStart(normalizedLabels.get(option.id) ?? '', normalizedQuery)) {
|
||||
continue
|
||||
}
|
||||
hits.push({ key: String(option.id), label: option.name, ids: [option.id] })
|
||||
}
|
||||
return hits
|
||||
},
|
||||
[options],
|
||||
)
|
||||
|
||||
const buildFormData = useCallback(
|
||||
(changedIds: number[], assigned: boolean) => {
|
||||
const formData = new FormData()
|
||||
formData.set('municipalityId', String(municipalityId))
|
||||
formData.set('stateDeputyId', String(changedIds[0]))
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
return formData
|
||||
},
|
||||
[municipalityId],
|
||||
)
|
||||
|
||||
const buildCreateFormData = useCallback(
|
||||
(rawName: string) => {
|
||||
const formData = new FormData()
|
||||
formData.set('municipalityId', String(municipalityId))
|
||||
formData.set('rawName', rawName)
|
||||
return formData
|
||||
},
|
||||
[municipalityId],
|
||||
)
|
||||
|
||||
/** Maps the action's `stateDeputy` payload to the cell's `createdChip`. */
|
||||
const createActionWithChip = useCallback(
|
||||
async (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState & { createdChip?: RelationChip }> => {
|
||||
const result = await createAction(state, formData)
|
||||
if (result.status !== 'success' || !result.stateDeputy) return result
|
||||
const created = result.stateDeputy
|
||||
const create = async (rawName: string): Promise<MunicipalityRelationMutationResult> => {
|
||||
const formData = new FormData()
|
||||
formData.set('municipalityId', String(municipalityId))
|
||||
formData.set('rawName', rawName)
|
||||
const result = await createAction({}, formData)
|
||||
if (result.status !== 'success' || !result.stateDeputy) {
|
||||
return {
|
||||
...result,
|
||||
createdChip: {
|
||||
key: String(created.id),
|
||||
label: stateDeputyDisplayName(created.name, created.party),
|
||||
href: `/campanha/dobradinhas/${created.slug}`,
|
||||
ids: [created.id],
|
||||
},
|
||||
status: 'error',
|
||||
message: firstFormActionMessage(result) ?? CREATE_ERROR_MESSAGE,
|
||||
}
|
||||
},
|
||||
[createAction],
|
||||
)
|
||||
|
||||
const copy = useMemo<RelationChipCellCopy>(
|
||||
() => ({
|
||||
searchPlaceholder: 'Buscar dobradinha…',
|
||||
searchLabel: 'Buscar dobradinha',
|
||||
suggestionsLabel: 'Sugestões de dobradinhas',
|
||||
emptyDrawerMessage: 'Nenhuma dobradinha vinculada.',
|
||||
savingMessage: 'Salvando dobradinhas.',
|
||||
savedMessage: 'Dobradinhas salvas.',
|
||||
removedMessage: (count) =>
|
||||
count === 1 ? 'Dobradinha removida.' : `${count} dobradinhas removidas.`,
|
||||
createHintMessage: 'Nenhum resultado — opção de criar dobradinha disponível.',
|
||||
}),
|
||||
[],
|
||||
)
|
||||
}
|
||||
const created = result.stateDeputy
|
||||
return {
|
||||
status: 'success',
|
||||
createdEntry: {
|
||||
id: created.id,
|
||||
label: stateDeputyDisplayName(created.name, created.party),
|
||||
initialsLabel: created.name,
|
||||
searchText: `${created.name} ${created.party ?? ''}`,
|
||||
href: `/campanha/dobradinhas/${created.slug}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<RelationChipCell
|
||||
ownerId={municipalityId}
|
||||
ownerName={municipalityName}
|
||||
ids={stateDeputyIDs}
|
||||
buildChips={buildChips}
|
||||
searchHits={searchHits}
|
||||
buildFormData={buildFormData}
|
||||
commitAction={commitAction}
|
||||
drawerTitle="Dobradinhas do município"
|
||||
// The advisor twin's contract: the names ride in the trigger's label so
|
||||
// a screen reader reads WHO is assigned without opening the editor (the
|
||||
// hover tooltip is pointer-only).
|
||||
triggerLabel={(chips) =>
|
||||
`Editar dobradinhas em ${municipalityName}${chips.length ? ` — ${chips.map((chip) => chip.label).join(', ')}` : ''}`
|
||||
<MunicipalityRelationEditor
|
||||
municipalityName={municipalityName}
|
||||
currentIDs={stateDeputyIDs}
|
||||
options={entriesFor(options)}
|
||||
variant={editorVariant}
|
||||
title="Gerenciar dobradinhas"
|
||||
description="Vincule os deputados estaduais que fazem dobradinha neste município."
|
||||
searchPlaceholder="Buscar dobradinha…"
|
||||
searchLabel="Buscar dobradinha"
|
||||
savingMessage="Salvando dobradinhas."
|
||||
saveErrorMessage={SAVE_ERROR_MESSAGE}
|
||||
createErrorMessage={CREATE_ERROR_MESSAGE}
|
||||
triggerLabel={(entries) =>
|
||||
`Editar dobradinhas em ${municipalityName}${
|
||||
entries.length ? ` — ${entries.map((entry) => entry.label).join(', ')}` : ''
|
||||
}`
|
||||
}
|
||||
updateErrorMessage="Não foi possível atualizar as dobradinhas. Tente novamente."
|
||||
copy={copy}
|
||||
measureOverflow={false}
|
||||
trigger={(chips) => <StateDeputyAvatarStack chips={chips} />}
|
||||
triggerTooltip={formatStateDeputyNamesTooltip}
|
||||
editorVariant={editorVariant}
|
||||
createAction={createActionWithChip}
|
||||
buildCreateFormData={buildCreateFormData}
|
||||
createLabel={CREATE_LABEL}
|
||||
createErrorMessage="Não foi possível criar a dobradinha. Tente novamente."
|
||||
emptyState={<span className="text-sm text-muted-foreground">—</span>}
|
||||
createLabel={(name) => `Criar dobradinha “${name}”`}
|
||||
createMaxLength={160}
|
||||
sortSelected={false}
|
||||
onToggle={toggle}
|
||||
onCreate={create}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,13 +104,12 @@ export const leadershipWizardCreateSchema = leadershipWizardFieldsSchema.extend(
|
|||
|
||||
/**
|
||||
* B155 — inline create from the "Lideranças" column of `/campanha/municipios`:
|
||||
* name + phone only, linked to the município whose popover created it. Strict
|
||||
* name only, linked to the município whose popover created it. Strict
|
||||
* so the toggle shape (`leadershipId`/`assigned`) can never slip through.
|
||||
*/
|
||||
export const municipalityLeadershipCreateSchema = z.strictObject({
|
||||
municipalityId: positiveRelationshipId,
|
||||
name: z.string().trim().min(2).max(120),
|
||||
phone: brazilianMobile,
|
||||
})
|
||||
|
||||
export type MunicipalityLeadershipCreateInput = z.input<typeof municipalityLeadershipCreateSchema>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
57
tests/unit/campaignMunicipalityLeadershipsRoute.unit.spec.ts
Normal file
57
tests/unit/campaignMunicipalityLeadershipsRoute.unit.spec.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { POST } from '@/app/(campaign)/campanha/(app)/municipios/leaderships/route'
|
||||
import {
|
||||
createMunicipalityLeadership,
|
||||
setMunicipalityLeadershipMembership,
|
||||
} from '@/app/(campaign)/campanha/actions/leadership'
|
||||
|
||||
vi.mock('@/app/(campaign)/campanha/actions/leadership', () => ({
|
||||
createMunicipalityLeadership: vi.fn(),
|
||||
setMunicipalityLeadershipMembership: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockedCreate = vi.mocked(createMunicipalityLeadership)
|
||||
const mockedToggle = vi.mocked(setMunicipalityLeadershipMembership)
|
||||
|
||||
const postRequest = (body: unknown): Request =>
|
||||
new Request('http://localhost/campanha/municipios/leaderships', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: 'http://localhost' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
describe('POST /campanha/municipios/leaderships body union (B159)', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('dispatches a name-only create', async () => {
|
||||
mockedCreate.mockResolvedValue({
|
||||
leadership: { id: 9 },
|
||||
leadershipIDs: [9],
|
||||
createdLeadershipName: 'Maria da Serra',
|
||||
} as Awaited<ReturnType<typeof createMunicipalityLeadership>>)
|
||||
|
||||
const response = await POST(postRequest({ municipalityId: 1, name: 'Maria da Serra' }))
|
||||
|
||||
expect(mockedCreate).toHaveBeenCalledWith({ municipalityId: 1, name: 'Maria da Serra' })
|
||||
expect(mockedToggle).not.toHaveBeenCalled()
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
status: 'success',
|
||||
leadershipIDs: [9],
|
||||
createdLeadership: { id: 9, name: 'Maria da Serra' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a phone remnant instead of accepting hidden data', async () => {
|
||||
const response = await POST(
|
||||
postRequest({ municipalityId: 1, name: 'Maria da Serra', phone: '71999999999' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockedCreate).not.toHaveBeenCalled()
|
||||
expect(mockedToggle).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
161
tests/unit/municipalityRelationEditor.unit.spec.tsx
Normal file
161
tests/unit/municipalityRelationEditor.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
MunicipalityRelationEditor,
|
||||
type MunicipalityRelationMutationResult,
|
||||
} from '@/components/campaign/shared/MunicipalityRelationEditor'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
|
||||
})
|
||||
|
||||
describe('MunicipalityRelationEditor optimistic reconcile', () => {
|
||||
it('adopts the newest sent response when concurrent toggles settle out of order', async () => {
|
||||
const pending: Array<(result: MunicipalityRelationMutationResult) => void> = []
|
||||
const onToggle = vi.fn(
|
||||
() =>
|
||||
new Promise<MunicipalityRelationMutationResult>((resolve) => {
|
||||
pending.push(resolve)
|
||||
}),
|
||||
)
|
||||
render(
|
||||
createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
createElement(MunicipalityRelationEditor, {
|
||||
municipalityName: 'Seabra',
|
||||
currentIDs: [],
|
||||
options: [
|
||||
{ id: 1, label: 'Álvaro Lima' },
|
||||
{ id: 2, label: 'Beatriz Souza' },
|
||||
],
|
||||
variant: 'popover',
|
||||
title: 'Gerenciar relações',
|
||||
description: 'Descrição.',
|
||||
searchPlaceholder: 'Buscar…',
|
||||
searchLabel: 'Buscar relação',
|
||||
savingMessage: 'Salvando relações.',
|
||||
saveErrorMessage: 'Falha ao salvar.',
|
||||
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
|
||||
emptyState: createElement('span', null, 'Nenhuma'),
|
||||
createLabel: (name) => `Criar “${name}”`,
|
||||
onToggle,
|
||||
onCreate: vi.fn(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Editar relações/ }))
|
||||
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } })
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Beatriz Souza' }))
|
||||
|
||||
expect(onToggle).toHaveBeenNthCalledWith(1, 1, true)
|
||||
expect(onToggle).toHaveBeenNthCalledWith(2, 2, true)
|
||||
pending[1]?.({ status: 'success', selectedIDs: [1, 2] })
|
||||
pending[0]?.({ status: 'success', selectedIDs: [1] })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /Álvaro Lima,Beatriz Souza/ })).toBeTruthy(),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not duplicate a create adopted by server props before the request resolves', async () => {
|
||||
let resolveCreate: ((result: MunicipalityRelationMutationResult) => void) | undefined
|
||||
const onCreate = vi.fn(
|
||||
() =>
|
||||
new Promise<MunicipalityRelationMutationResult>((resolve) => {
|
||||
resolveCreate = resolve
|
||||
}),
|
||||
)
|
||||
const element = (currentIDs: number[], options: Array<{ id: number; label: string }>) =>
|
||||
createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
createElement(MunicipalityRelationEditor, {
|
||||
municipalityName: 'Seabra',
|
||||
currentIDs,
|
||||
options,
|
||||
variant: 'popover',
|
||||
title: 'Gerenciar relações',
|
||||
description: 'Descrição.',
|
||||
searchPlaceholder: 'Buscar…',
|
||||
searchLabel: 'Buscar relação',
|
||||
savingMessage: 'Salvando relações.',
|
||||
saveErrorMessage: 'Falha ao salvar.',
|
||||
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
|
||||
emptyState: createElement('span', null, 'Nenhuma'),
|
||||
createLabel: (name) => `Criar “${name}”`,
|
||||
onToggle: vi.fn(),
|
||||
onCreate,
|
||||
}),
|
||||
)
|
||||
const view = render(element([], []))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Editar relações/ }))
|
||||
fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'Nova Pessoa' } })
|
||||
fireEvent.click(await screen.findByRole('option', { name: /Criar “Nova Pessoa”/ }))
|
||||
view.rerender(element([7], [{ id: 7, label: 'Nova Pessoa' }]))
|
||||
resolveCreate?.({
|
||||
status: 'success',
|
||||
selectedIDs: [7],
|
||||
createdEntry: { id: 7, label: 'Nova Pessoa' },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const trigger = screen.getByRole('button', { name: /Editar relações — Nova Pessoa/ })
|
||||
expect(trigger.querySelectorAll('[data-slot="avatar"]')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('uses known entries for display without making them selectable', async () => {
|
||||
render(
|
||||
createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
createElement(MunicipalityRelationEditor, {
|
||||
municipalityName: 'Seabra',
|
||||
currentIDs: [7],
|
||||
knownEntries: [{ id: 7, label: 'Fora da elegibilidade' }],
|
||||
options: [],
|
||||
variant: 'popover',
|
||||
title: 'Gerenciar relações',
|
||||
description: 'Descrição.',
|
||||
searchPlaceholder: 'Buscar…',
|
||||
searchLabel: 'Buscar relação',
|
||||
savingMessage: 'Salvando relações.',
|
||||
saveErrorMessage: 'Falha ao salvar.',
|
||||
triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`,
|
||||
emptyState: createElement('span', null, 'Nenhuma'),
|
||||
createLabel: (name) => `Criar “${name}”`,
|
||||
onToggle: vi.fn(),
|
||||
onCreate: vi.fn(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Fora da elegibilidade/ }))
|
||||
fireEvent.change(screen.getByLabelText('Buscar relação'), {
|
||||
target: { value: 'Fora da elegibilidade' },
|
||||
})
|
||||
expect(screen.queryByRole('option', { name: 'Fora da elegibilidade' })).toBeNull()
|
||||
})
|
||||
})
|
||||
75
tests/unit/municipalityStateDeputyRelationCell.unit.spec.tsx
Normal file
75
tests/unit/municipalityStateDeputyRelationCell.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { MunicipalityStateDeputyRelationCell } from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
|
||||
})
|
||||
|
||||
describe('MunicipalityStateDeputyRelationCell shared editor (B159)', () => {
|
||||
it('uses the campaign overlay and searches a deputy by party', async () => {
|
||||
const commitAction = vi.fn().mockResolvedValue({ status: 'success', message: 'Salvo.' })
|
||||
render(
|
||||
createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
createElement(MunicipalityStateDeputyRelationCell, {
|
||||
municipalityId: 1,
|
||||
municipalityName: 'Seabra',
|
||||
stateDeputyIDs: [],
|
||||
options: [
|
||||
{
|
||||
id: 7,
|
||||
name: 'Beltrana (PSB)',
|
||||
plainName: 'Beltrana',
|
||||
party: 'PSB',
|
||||
slug: 'beltrana',
|
||||
},
|
||||
],
|
||||
commitAction,
|
||||
createAction: vi.fn(),
|
||||
editorVariant: 'popover',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Editar dobradinhas em Seabra' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Gerenciar dobradinhas' })
|
||||
fireEvent.change(within(dialog).getByLabelText('Buscar dobradinha'), {
|
||||
target: { value: 'psb' },
|
||||
})
|
||||
fireEvent.click(await within(dialog).findByRole('option', { name: 'Beltrana (PSB)' }))
|
||||
|
||||
await waitFor(() => expect(commitAction).toHaveBeenCalledTimes(1))
|
||||
const formData = commitAction.mock.calls[0]?.[1] as FormData
|
||||
expect(Object.fromEntries(formData)).toEqual({
|
||||
municipalityId: '1',
|
||||
stateDeputyId: '7',
|
||||
assigned: 'true',
|
||||
})
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Editar dobradinhas em Seabra — Beltrana (PSB)' }),
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole('link', { name: 'Beltrana (PSB)' }).getAttribute('href')).toBe(
|
||||
'/campanha/dobradinhas/beltrana',
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue