teqo-backup/docs/plans/chips-municipios-lista-liderancas.md
Francisco Ignácio Fontoura Solla 812e3a0bee B34: editable município chips on the leaderships list.
The "Municípios" column was dead text; it is now chips that navigate to the
município, with a territory chip when a leadership covers a whole TI, and it is
editable in place. What picks inline vs. Drawer is the `pointer:` media query,
not viewport width: on a fine pointer the editor is always mounted (the "×" sits
at the chip's corner, revealed on hover/focus) and on a coarse pointer a tap
anywhere in the cell opens a Drawer with 44 px targets.

`AdvisorMunicipalityCell` is promoted to `shared/MunicipalityPortfolioCell` and
/campanha/assessores migrates onto it in the same change, so the second call site
is born with the first. One batch action only; the single-id advisor twin is
deleted with its int tests migrated. The anti-goal on always-mounted list inputs
is amended for chip-based relations on fine pointers, not reopened.

Also fixes a latent bug: `CampaignTableHead`'s `description` seam broke any RSC
caller, because its two helpers lived in a 'use client' module.
2026-07-27 22:35:19 -03:00

36 KiB
Raw Blame History

Chips editáveis de municípios na lista de lideranças (paridade com Assessores)

Status: entregue (2026-07-27) Atualizado em: 2026-07-27 Item do roadmap: docs/roadmap.md (Trilha B, item B34) Impeccable: B — encaixe na coluna "Municípios" de /campanha/liderancas; sem rota nova Appetite: ~1–1,25 dia eng — promoção de lib/loader puros existentes, 1 componente de célula novo (Popover + Command), 2 server actions + schema, provider de catálogo; sem migration Responsável: —

Entregue (2026-07-27) — as-built

O conflito foi resolvido a favor do pedido literal, emendando o anti-goal em vez de reabrir B28/B31/B32. A coluna é sempre editável em ponteiro fino (pointer-fine): "×" no canto do chip só no hover/foco (posicionado em absolute, para revelar não mexer no tamanho do chip nem na linha) e input de busca no fim da linha de chips. Em ponteiro grosso (pointer-coarse) o toque em qualquer ponto da célula abre um Drawer com "×" de 44 px — um polegar rolando a tabela não pode ser convidado a distinguir "abrir o município" de "apagar o vínculo". O que decide não é largura de viewport, é a mídia pointer: (um tablet com mouse recebe o editor inline; um laptop touch recebe o Drawer). PRODUCT.md (princípio 3) e .cursor/rules/campanha-edit-where-you-see.mdc passaram a admitir input sempre montado para relações por chips em pointer-fine: o input É o readout, e um Popover por linha seria um clique a mais para um alvo que já está na tela. Nada aqui virou "modo de tabela" — o toggle de AdvisorsTable foi renomeado para "Editar nome e contato", que é tudo o que ele ainda destrava depois desta entrega.

Divergências do plano abaixo (que fica como registro do rascunho):

  • AdvisorMunicipalityCell FOI promovido a src/components/campaign/shared/MunicipalityPortfolioCell.tsx, ao contrário do que este plano decidiu. A inversão vem do container: com a célula sempre visível, a máquina de ResizeObserver + "Ver mais…" que o plano chamou de peso morto passou a ser exatamente o requisito. Costuras da peça compartilhada: minItems (piso), maxItems (teto), addableIds (escopo do assessor), draft/onDraftChange (linha nova de assessor), commitAction, drawerTitle. /campanha/assessores migrou no mesmo PR — o 2º call site nasceu junto, não depois.
  • Sem provider de contexto. O índice do catálogo e addableIds viajam por props da factory de colunas (leadershipColumns), que o RSC monta uma vez por tabela — o provider resolveria um problema de payload por linha que a factory já não tem.
  • Uma única action de lote. setLeadershipMunicipalitiesMembership recebe municipalityIds repetidos + assigned; a variante de um id só nunca nasceu, e a gêmea de assessor (setAdvisorMunicipalityMembershipRecord + advisorMunicipalityMembershipSchema) foi deletada, com os int tests que a exercitavam migrados para a de lote — testar o caminho que a produção usa vale mais do que preservar dois caminhos.
  • LeadershipRowViewModel.municipalityNames → municipalityIDs: number[] (não o municipalities: {id,name,slug}[] planejado): a célula já recebe o catálogo completo, então nome e slug se resolvem no cliente e o loader deixa de repetir 435 strings por linha.
  • Piso de 1 com aria-disabled + razão no toast, não disabled: disabled tira o botão da ordem de tabulação e a razão só existia no aria-label dele, ou seja, ficava inalcançável justamente para quem navega por teclado. Mesma regra no Drawer.
  • Teto de 30 recusado no cliente antes do apply otimista — um lote de TI que estouraria o teto voltava inteiro depois do round-trip, com uma mensagem que não dizia qual limite tinha estourado; a sugestão que estouraria já aparece esmaecida com "excede o limite".
  • Undo em remoção de lote: remover um chip de TI/ZE apaga até 30 vínculos num gesto e não deixa rastro na linha; a remoção múltipla ganha toast com "Desfazer" (a de um município só não — está a uma busca de voltar).

/impeccable critique — P0/P1 fechados nesta entrega: (1) as sugestões eram só de mouse — agora o input é um combobox ARIA 1.2 de verdade (setas/Home/End/Enter, aria-activedescendant, primeira opção pré-selecionada, listbox portalizado com nome próprio e o "Nenhum resultado." fora do listbox, onde não conta como opção selecionável); (2) o auto-save era mudo — aria-busy + linha esmaecida + live region sr-only ("Salvando…"/"Salvos."/contagem de resultados), com rollback só do delta que falhou (reverter para o baseline apagaria um toggle vizinho já salvo); (3) alvo do "×" de 16 px → 24 px de área de ponteiro via before:-inset-1 (SC 2.5.8 não tem exceção de mouse, e a equivalente de 44 px do Drawer é pointer-fine:hidden), borda do círculo com contraste real (a do token media 1,17:1 sobre o chip secondary) e "Ver mais…" com min-h-6; (4) o overlay de ponteiro grosso cobria os links dos chips — os chips voltaram a ser links dentro do Drawer.

Bug latente corrigido de graça: o seam description de CampaignTableHead (B22 ✓) quebrava qualquer chamador RSC (Attempted to call campaignHoverTooltipAlign() from the server) porque campaignHoverTooltipAlign e campaignHoverExplanationClassName moravam no módulo 'use client' do CampaignHoverTooltip, e um 'use client' transforma todo export em referência de cliente. Foram para src/lib/campaignHoverTooltip.ts; até aqui o seam só tinha chamadores client (MunicipalitySortableHead, TerritorySortableHead), então nunca havia sido exercido de um server component.

Gate: tsc --noEmit, pnpm lint (0 warnings), format:check, knip (só o erro pré-existente de payload.config.ts), check:cycles (0 ciclos), unit + int (53 arquivos / 428 testes), e2e de lideranças/municípios/territórios verde (o e2e novo cobre adicionar por clique, adicionar por teclado, piso e persistência), Aikido 0 findings.

/simplify — o que a revisão de qualidade mudou depois do gate: dois bugs reais e uma dívida de payload. (1) Todo delta passa a ser funcional, ida e volta (withDelta(current ?? municipalityIds, …)): o caminho de sucesso computava a partir do assignedIds do render que o capturou, então o "Desfazer" do toast, pressionado depois de um novo chip, apagava esse chip da tela sem apagá-lo no servidor — e o efeito de reconciliação só limpa o otimista quando as listas batem exatamente, então a divergência sobrevivia a todo revalidate seguinte. (2) saved/errorMessage eram grudentos e sequestravam a live region: a partir do primeiro save a região dizia "Municípios salvos." para sempre e nunca mais anunciava a contagem de resultados que o combobox precisa. Viraram um feedback só, limpo ao digitar, com prioridade explícita (pendente > erro > busca > sucesso). (3) A política de ponteiro estava codificada duas vezes — um matchMedia('(pointer: fine)') no onClick da raiz; o overlay grosso agora faz stopPropagation e a decisão volta a viver só nas media queries. (4) MunicipalityPortfolioIndexEntry.city/zoneNumber não eram lidos por ninguém (a resolução de TI/ZE roda contra o catálogo estático por slug) e viajavam nas 435 entradas do payload RSC de duas rotas: saíram do tipo e do select. (5) parseMunicipalityIds local de assessores (que engolia id inválido em silêncio) deu lugar ao repeatedRelationshipFormValues compartilhado, que é o que lideranças já usava; a mensagem de escopo do assessor virou LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE (era literal solta em 4 arquivos, e safeMessages casa por string exata); os dois guardas de "lista vazia" morreram porque o zod já os cobre. (6) O helper de membership devolve { next, added } — o caller recomputava added para o teste de escopo, duas expressões que precisavam concordar. (7) onPersisted={refresh} saiu: setAdvisorMunicipalitiesBatch já chama revalidatePath, e o seam ficou sem consumidor. Uma correção nasceu da própria simplificação: ao unificar o gate das sugestões, a lista inline (que é portalizada) passaria a abrir junto com o Drawer, onde pointer-coarse:hidden não a alcança — daí inlineSuggesting e drawerSuggesting separados, que também fecham o caso pré-existente de abrir o Drawer com o popover inline já aberto.

Débitos registrados: LeadershipStateDeputyRelationCell (B31 ✓) segue com Popover + máquina de medição próprios e "Ver mais…" de 16 px — é ele, não o B37, o gatilho de extração de um editor de chips agnóstico; AdvisorsTable ainda mistura um modo de edição (nome/e-mail/celular) com uma coluna sempre viva; e o /simplify mediu que dois e2e falham de forma determinística a 1 worker numa árvore main limpa (campaignNearestMunicipality:156, campaignMunicipalities:394) — não é o flake de 2 workers já fichado, e um gate cronicamente vermelho é como uma falha real embarca (P1 no ledger).

Rascunho original (2026-07-25) — conflito registrado antes da decisão

O pedido de produto pedia um botão de "modo de edição" para a coluna inteira, espelhando o toggle "Editar" de /campanha/assessores (AdvisorsTable), enquanto a mesma tabela já havia travado a decisão oposta três vezes em 2026-07-25 (B28, B31, B32). O rascunho seguia o precedente (Popover por célula) e marcava a decisão como proposta a validar com produto; a validação aconteceu em 2026-07-27 e escolheu o caminho descrito acima. Tudo abaixo é o rascunho como estava.

Design (Impeccable)

Âncoras: PRODUCT.md (princípio 3 Edit where you see com o anti-goal explícito "spreadsheet mode" / "always-mounted inputs on every row", princípio 4 Auto-save, no Save button, princípio 8 Feel the action) / DESIGN.md (register product, Field Desk) · tema data-theme='campaign' · precedentes vivos: MunicipalityListAdvisorsControl.tsx (Popover disparado pela célula, B9 ✓), combobox-assessores-lista-municipios.md (B27 — chips removíveis + Command dentro do Popover, gravação por delta sem "Salvar"), dobradinhas-lista-liderancas.md (B31 — mesma tabela, mesma decisão de container, delta com lock + revalidatePath + provider de catálogo uma vez por tabela), AdvisorMunicipalityCell.tsx / advisorMunicipalityPortfolio.ts (B19 ✓ — a lógica pura de agrupar território/ZE e buscar, que este item reaproveita sem portar a UI inteira).

Na implementação (implement-roadmap-item): craft compacto (Popover + Command já são peças do produto) → critique → polish.

Brief compacto:

  • Persona / contexto: CG/Assessor dividindo carteira e corrigindo cobertura territorial das lideranças durante o onboarding, varrendo /campanha/liderancas — hoje só é possível ver o nome dos municípios (texto morto, sem link) e só é possível editar abrindo a ficha e usando o RelationMultiSelect.
  • Job principal: ver, navegar e ajustar os municípios de uma liderança sem sair da lista, no mesmo gesto de digitar-e-adicionar já em produção em /campanha/assessores e /campanha/municipios.
  • Estratégia de cor: Restrained — chips Badge variant="secondary", chip de território com MapPinIcon, sem cor semântica nova.
  • Edit where you see: sim — canManageLeadership já autoriza a escrita; a célula é hoje um readout morto do mesmo dado que o formulário completo edita.
  • Anti-goals: modo "Editar" de tabela inteira (ver conflito acima); inputs montados em todas as linhas; "Salvar" no Popover; segundo design system de célula de relação (reaproveitar Command/Popover/Badge, não inventar).

Dados → decisão → apresentação

  • Vou apresentar dados? Sim, superfície já existente (LeadershipRowViewModel.municipalityNames, hoje texto puro) — este item troca a apresentação, não introduz agregação nova.
  • Decisões desbloqueadas: CG/Assessor: "que municípios esta liderança cobre — e onde erro a carteira?" (hoje só se sabe abrindo a ficha, um clique por liderança, no meio da varredura de 25+ linhas); Assessor: "isso já está dentro do que eu administro?" (feedback imediato ao adicionar, o servidor recusa fora do escopo). Navegação cruzada: da liderança para a ficha do município (o inverso, MunicipalityLeadershipsPanel/dossiê E16 ✓, já existe).
  • Forma escolhida: tabela/lista — chips nomeados com link, igual ao tratamento de Organizações/Dobradinhas da mesma tabela. Rejeitado: contagem ("3 municípios" — some o nome que decide a carteira); mapa (é vínculo nominal, não leitura territorial agregada — isso é papel do mapa em /campanha, não desta célula).
  • Profile: categórico; granularidade leadership × municipality; tamanho típico 1–5 chips (piso 1, teto de schema 30); catálogo de 435; absoluto (o nome em si).
  • Anti-goals de dado: sem métrica eleitoral na célula; sem ranking de lideranças por município.

Self-check dados: 5/5.

Contexto

/campanha/liderancas (page.tsx) lista lideranças com a coluna "Municípios" renderizando row.municipalityNames.join(', ') — texto puro, sem link, sem edição (leadershipData.ts:106-109). A única forma de mudar os municípios de uma liderança hoje é abrir /liderancas/[id], usar o RelationMultiSelect do LeadershipInternalForm (substituição integral do array) e submeter.

leadership.municipalities é hasMany, required: true, maxRows: MAX_LEADERSHIP_MUNICIPALITIES (30) (src/lib/schemas/leadership.ts:38-46), com o hook requireAtLeastOneMunicipality (src/collections/Leadership.ts) recusando qualquer update que zere o array — diferente do lado assessor↔município (B19 ✓), onde o vínculo é a reverse relationship municipality.advisors e pode ir a zero. assertMunicipalitiesWithinScope (actions/leadership.ts) já restringe assessores a municípios que administram, hoje aplicado ao array inteiro recebido por createLeadershipRecord/updateLeadershipInternalRecord.

Em /campanha/assessores (B19 ✓), a coluna "Municípios" resolve o padrão pedido — mas com o container que B27/B28/B31/B32 já deixaram obsoleto para esta geração de listas: AdvisorMunicipalityCell.tsx (~470 linhas) é uma célula sempre-visível com toggle de tabela inteira (AdvisorsTable) e um ResizeObserver para empacotar chips em 3 linhas antes de "Ver mais…". A lógica pura de agrupar município/território/ZE e buscar (buildAdvisorPortfolioChips/searchAdvisorPortfolio, advisorMunicipalityPortfolio.ts) é boa e reaproveitável; a UI de célula não é o alvo certo para copiar aqui.

Objetivos

  • Coluna "Municípios" de /campanha/liderancas mostra chips (não mais texto plano); cada chip de município navega para /campanha/municipios/[slug]; chip de território (quando a liderança cobre o TI inteiro) mostra MapPinIcon e não navega.
  • Um ícone de edição ao fim da célula abre um Popover com os chips já atribuídos (removíveis) sobre um combobox Command com busca acento-insensível por município, território de identidade ou zona eleitoral — adicionar/remover grava na hora, sem botão "Salvar".
  • Adicionar/remover um território ou ZE inteiro de uma vez (lote), igual ao padrão de /campanha/assessores — paridade explicitamente pedida ("todas as outras funcionalidades").
  • Nunca é possível zerar os municípios de uma liderança pela lista: o cliente desabilita a remoção do último município (ou do último território/ZE que cobriria o resto) e o servidor recusa com a mesma mensagem do formulário completo, como defesa em profundidade.
  • Assessor só pode adicionar municípios/territórios/ZE que administra (mesma regra de assertMunicipalitiesWithinScope); as sugestões de busca já vêm restritas a esse escopo para não oferecer becos sem saída. Os chips já atribuídos (mesmo fora do escopo do assessor) continuam visíveis e agrupados corretamente contra o catálogo completo.
  • Access idêntico ao já existente (canManageLeadership escreve, leitura já resolvida no loader); nenhuma migration, nenhuma collection, nenhum Consent novo, nenhuma mudança no contrato de URL da lista.

Decisões travadas

  • Popover disparado por célula, não modo "Editar" de tabela inteira. Ver o aviso de conflito no topo. É a mesma decisão travada três vezes na mesma tabela em 2026-07-25 (B28, B31, B32): o toggle de AdvisorsTable existe lá porque todas as colunas daquela tela são editáveis; aqui só uma coluna é. Rejeitado: toggle "Editar" de tabela inteira (pedido literal do usuário — anti-goal do princípio 3, reabriria B28/B31/B32); célula sempre em edição sem Popover (inputs montados em 25+ linhas).
  • Mutação por delta direto em leadership.municipalities (ler o array atual do próprio documento, aplicar o toggle/lote, payload.update), nunca substituição do array inteiro via updateLeadershipInternalRecord. Por quê: aquele record valida o array inteiro contra o escopo do assessor — reenviá-lo quebraria qualquer liderança cujos municípios já ultrapassem o escopo de um assessor (criada pela coordenação, por outro assessor, ou herdada da remodelagem). A rota de delta valida só o(s) id(s) sendo adicionado(s), espelhando setAdvisorMunicipalityMembership. Rejeitado: reenviar o array completo (quebra o caso cross-boundary); recalcular a validação para aceitar "o que já estava lá" (duplica lógica que o delta resolve de graça).
  • Lock único por liderança (leadership-municipalities:{id}), não por município tocado. Ao contrário do B19 (o array mora no municipality.advisors, então cada delta trava o município), aqui o array mora no próprio leadership, então uma única trava por documento basta — mesmo padrão do B31 (leadership-state-deputies:{id}). Rejeitado: travar por município como no B19 (a escrita real é num único documento; travar N municípios para um lote de território seria lock desnecessário e mais chance de deadlock entre lotes concorrentes).
  • Piso de 1 respeitado em duas camadas: cliente desabilita a remoção que zeraria o array (chip individual ou território/ZE cujo municipalityIds cobre o restante); servidor mantém requireAtLeastOneMunicipality como defesa em profundidade (a mesma mensagem "Vincule a liderança a pelo menos um município." já existe e passa pelo mapCampaignFormActionError). Rejeitado: só servidor (deixaria o clique de remover o único chip terminar em toast de erro para uma ação obviamente inválida).
  • Território/ZE em lote incluído (não cortado) — reaproveita searchAdvisorPortfolio/buildAdvisorPortfolioChips sem custo extra de lib; o trabalho novo real é só a action de lote espelhando setAdvisorMunicipalitiesBatch. Pedido explícito do usuário ("todas as outras funcionalidades").
  • Promover a lógica pura para nomes neutros — src/lib/advisorMunicipalityPortfolio.ts → src/lib/municipalityPortfolio.ts, loadAdvisorMunicipalityIndex (advisorData.ts) → loadMunicipalityPortfolioIndex (src/utilities/municipalityPortfolioIndex.ts) — mas NÃO portar/generalizar a UI de AdvisorMunicipalityCell. A lib e o índice não têm nada de específico de assessor (catálogo/busca de município), e o prefixo advisor* violaria a convenção de prefixo de domínio (codebase-map.mdc) assim que um 2º domínio consumir. A UI, porém, resolve um problema que este item não tem (medir quantos chips cabem em 3 linhas de uma célula sempre-visível) — construir um componente novo e mais simples (Popover + Command, sem ResizeObserver) é mais barato e mais alinhado ao container já decidido acima. Rejeitado: portar AdvisorMunicipalityCell inteiro para shared/ (draft anterior deste plano) — a máquina de medição de layout não serve ao container em Popover, e extrair um "editor de chips" genérico a partir de um caso e meio (este + B27, se ainda não entregue) é prematuro (ver Rabbit holes/Adiado).
  • Sugestões de busca restritas ao escopo do assessor; chips já atribuídos agrupados pelo catálogo completo. O agrupamento "cobre o TI inteiro" só é correto matematicamente com o catálogo completo; já as sugestões de adição devem ficar restritas ao escopo do assessor (mesma regra que RelationMultiSelect já aplica no formulário completo) — sugerir município fora do escopo é beco sem saída, o servidor vai recusar. Rejeitado: índice único sem escopo em toda parte (sugere o que não pode ser adicionado); índice único escopado em toda parte (quebra o agrupamento de território para a maioria dos visitantes desta lista, que são assessores).
  • Server action + revalidatePath, não endpoint JSON (diferente do B27, igual ao B31). A lista de lideranças tem 25 linhas (não 435), e há dois derivados a jusante que ficariam obsoletos: o próprio detalhe /campanha/liderancas/[id] (mesma liderança) e o painel de lideranças do município tocado (MunicipalityLeadershipsPanel/dossiê E16 ✓, que lê leadership.municipalities pelo lado do município). Revalidar /campanha/liderancas, /campanha/liderancas/[id] e /campanha/municipios/[slug] de cada município tocado (add e remove) na mesma chamada. Rejeitado: endpoint JSON espelho do expected-votes/ (barato aqui, mas deixaria o painel do município mentindo); só router.refresh() (não limpa a rota do município).
  • Catálogo entregue uma vez por tabela via provider cliente (MunicipalityPortfolioOptionsProvider, mesmo padrão do MunicipalityEstimateScenarioContext e do provider de dobradinhas do B31): { index: catálogo completo (435), addableIds: Set<number> | null (restrição do assessor) }. Rejeitado: props por célula (multiplicaria o payload por linha); buscar o catálogo no cliente a cada tecla (é estático, 435 entradas cabem no cliente).
  • Ícone de edição como alvo de clique separado dos chips, não a célula inteira. Os chips de município são Links de navegação; um <button> (Popover trigger) não pode conter outro elemento interativo (<a>) sem quebrar semântica/acessibilidade, e usar a célula inteira como trigger criaria ambiguidade entre "clicou no chip = navegar" e "clicou no fundo = editar". Um ícone (lápis/+) ao fim da linha de chips é o alvo de clique inequívoco. Rejeitado: célula inteira como PopoverTrigger (conflito de hit-target com os chips-link); chip clicável só em modo edição via toggle (reabre o container rejeitado acima).
  • i18n e naming (AGENTS.md): identificadores em inglês (LeadershipMunicipalitiesCell, MunicipalityPortfolioOptionsProvider, setLeadershipMunicipalityMembership, setLeadershipMunicipalitiesBatch, nextMunicipalityIdsAfterLeadershipMembership), strings visíveis em pt-BR ("Editar municípios", "Buscar município, território ou ZE…", "Remover Salvador", "Vincule a liderança a pelo menos um município.").

Questões em aberto

  • Toggle de tabela (pedido literal) vs Popover por célula (precedente travado). Já registrado como conflito no topo — Recomendação: Popover, por consistência com B28/B31/B32 na mesma tabela no mesmo dia. (proposto — validar com produto antes de codar; é a decisão mais cara de reverter deste plano.)
  • "Ver mais…" quando há muitos chips? Opções: A) não — whitespace-normal deixa a célula crescer, sem medição por ResizeObserver | B) portar a medição do AdvisorMunicipalityCell. Recomendação: A — sem evidência de liderança com carteira grande hoje (teto de schema é 30, mas o uso real é de poucos municípios por pessoa); a medição é a parte mais cara do componente de origem e não se paga sem evidência. Registrado em Adiado com gatilho.
  • Ícone do trigger de edição. Opções: A) PencilIcon pequeno, aria-label="Editar municípios" | B) reaproveitar o mesmo affordance visual do MunicipalityListAdvisorsControl (hover na célula muda o cursor, sem ícone visível). Recomendação: A — os chips já são links visualmente distintos; um ícone explícito evita que o alvo de edição fique invisível numa célula que mistura navegação e edição. (assumido)

Abordagem proposta

flowchart LR
  loader["loadLeadershipListPageData<br/>+ municipalities (id/nome/slug)"] --> page["liderancas/page.tsx<br/>coluna Municípios"]
  index["loadMunicipalityPortfolioIndex<br/>(catálogo completo, 435)"] --> provider["MunicipalityPortfolioOptionsProvider<br/>(1× por tabela)"]
  scope["loadMunicipalityOptions<br/>(escopo do assessor, se advisor)"] --> provider
  provider --> cell["LeadershipMunicipalitiesCell<br/>chips + ícone editar + Popover + Command"]
  page --> cell
  cell -->|delta ou lote| action["setLeadershipMunicipalityMembership /\nsetLeadershipMunicipalitiesBatch<br/>(tx + lock + escopo)"]
  action --> revalidate["revalidatePath<br/>liderancas · liderancas/[id] · municipios/[slug]×N"]

Componentes:

  • src/lib/municipalityPortfolio.ts (renomeado de advisorMunicipalityPortfolio.ts): mesma lógica pura, tipos/funções neutros (MunicipalityPortfolioIndexEntry, MunicipalityPortfolioChip, MunicipalityPortfolioSearchHit, buildMunicipalityPortfolioChips, searchMunicipalityPortfolio); AdvisorMunicipalityCell.tsx passa a importar daqui (import mecânico, comportamento intacto). tests/unit/advisorMunicipalityPortfolio.unit.spec.ts → tests/unit/municipalityPortfolio.unit.spec.ts.
  • src/utilities/municipalityPortfolioIndex.ts (novo, server-only): loadMunicipalityPortfolioIndex(payload), corpo movido de loadAdvisorMunicipalityIndex (bypass admin intencional, dado de referência só-leitura); advisorData.ts e assessores/page.tsx passam a importar daqui.
  • src/components/campaign/leadership/MunicipalityPortfolioOptionsProvider.tsx (novo, cliente): contexto com { index, addableIds }, montado uma vez em volta do CampaignTable de /campanha/liderancas.
  • src/components/campaign/leadership/LeadershipMunicipalitiesCell.tsx (novo, cliente): chips de leitura (Badge + Link para município; Badge + MapPinIcon para território completo, via buildMunicipalityPortfolioChips contra o índice completo do provider) + ícone de edição (PopoverTrigger); PopoverContent com chips removíveis (mesmo componente de chip, agora com "×") sobre Command (shouldFilter={false}, itens de searchMunicipalityPortfolio contra addableIds-restrito quando ator é assessor); estado otimista local (useState/useTransition, sem ResizeObserver); disabled no botão de remover quando removeria o último município; toast.error + reversão em falha.
  • src/lib/schemas/leadership.ts: leadershipMunicipalityMembershipSchema (leadershipId, municipalityId, assigned: z.boolean()) e leadershipMunicipalitiesBatchSchema (leadershipId, municipalityIds: z.array(positiveRelationshipId).min(1).max(MAX_LEADERSHIP_MUNICIPALITIES), assigned), espelhando os schemas de assessor (src/lib/schemas/advisor.ts).
  • src/app/(campaign)/campanha/actions/leadership.ts (acréscimos): nextMunicipalityIdsAfterLeadershipMembership (helper puro — null se não muda nada, lança a mensagem do piso de 1 se o próximo array ficaria vazio, lança a mensagem do teto de 30 se estourasse); setLeadershipMunicipalityMembershipRecord/setLeadershipMunicipalityMembership e a variante em lote, cada uma em withPayloadTransaction + getFreshStaffActor + acquireTextAdvisoryLocks(['leadership-municipalities:{id}']) + assertMunicipalitiesWithinScope só nos ids adicionados + leitura/escrita do próprio doc com user/overrideAccess: false + revalidatePath das rotas listadas acima (slugs dos municípios lidos na mesma transação).
  • src/app/(campaign)/campanha/(app)/liderancas/formActions.ts (novo arquivo na rota da lista, precedente: [id]/formActions.ts/nova/formActions.ts): casca sobre runCampaignFormAction.
  • src/utilities/leadershipData.ts: LeadershipRowViewModel.municipalities: { id: number; name: string; slug: string }[] substitui municipalityNames (estender o select de namesForIds para trazer slug, ou reusar loadMunicipalityPortfolioIndex filtrado pelos ids do lote — decidir no craft pelo menor número de queries); consumidores de municipalityNames (nenhum fora desta página, a confirmar por git grep) acompanham o rename.
  • src/app/(campaign)/campanha/(app)/liderancas/page.tsx: Promise.all carrega loadMunicipalityPortfolioIndex(payload) e, só se user.role === 'advisor', loadMunicipalityOptions(payload, user); envolve <CampaignTable/> em <MunicipalityPortfolioOptionsProvider>; a coluna municipalities troca o texto por <LeadershipMunicipalitiesCell leadershipId={row.id} municipalities={row.municipalities} />.
  • Testes: unit de nextMunicipalityIdsAfterLeadershipMembership (add/remove/no-op/piso 1/teto 30) e da lib renomeada (cobertura já existente, só imports); int das duas actions — assessor fora do escopo é recusado ao adicionar, assessor no escopo grava, remover o único município é recusado (mensagem do hook), teto de 30 respeitado, delta concorrente serializado pelo lock; e2e opcional (abrir Popover, digitar, ver chip sem "Salvar").
  • Migration: nenhuma.

Depth check: reusa Popover/Command/Badge do kit, CampaignTable, wordStartFilter (via matchesAtWordStart já em municipalityPortfolio.ts), withPayloadTransaction, acquireTextAdvisoryLocks, runCampaignFormAction, loadMunicipalityOptions, campaignAccess. Não constrói useAutosave/EditableCell/multi-select genérico novo.

Dependências

  • Nenhuma dura. Soft: B19 ✓ (origem da lib pura e do cálculo de próximo array a espelhar), B27 (mesma peça de célula — Popover + Command + delta; quem entrar depois herda o padrão do primeiro), B31 (mesma tabela e mesma decisão de container — extração de um editor de chips genérico só no 3º call site real; se B31 e este chegarem antes de qualquer extração, os dois contam como os dois primeiros call sites, não gatilho ainda), B28/B29/B30/B32 (mesma tabela — colunas/ordem/ação de linha), B17 (a tabela cresce; seletor de colunas fica mais valioso), E16 ✓ (o painel de lideranças do município que este item revalida).
  • Reusa sem alterar: assertMunicipalitiesWithinScope, getFreshStaffActor, withPayloadTransaction, acquireTextAdvisoryLocks, canManageLeadership (src/utilities/access/leaderships.ts), loadMunicipalityOptions (src/utilities/campaignRelationOptions.ts), runCampaignFormAction.

Não escopo

  • Mesmo padrão de chips editáveis nas colunas "Organizações" e "Dobradinhas" de Lideranças — a de Dobradinhas é o B31 (item próprio, mesma tabela); Organizações fica para pedido futuro.
  • Endpoint JSON dedicado (padrão do B27) — a lista de lideranças não tem a escala dos 435 municípios fixos; revalidatePath via server action basta (ver Decisões travadas).
  • Corrigir o comportamento pré-existente de updateLeadershipInternalRecord/RelationMultiSelect (valida o array inteiro contra o escopo do assessor, mesmo para municípios que já estavam lá antes de qualquer assessor existir) — bug não relacionado, fora do pedido.
  • Criação inline de liderança na própria lista (draft row) — /liderancas/nova continua página própria.
  • "Ver mais…" / colapso de chips por medição de layout — ver Adiado com gatilho.

Rabbit holes

  • Generalizar demais o editor de chips agora. Extrair um RelationshipChipEditor agnóstico de coluna a partir de um caso e meio (este + B31, se ainda não entregue) produz parâmetros genéricos sem um 3º call site real para validar a forma. Mitigação: construir a célula do domínio (LeadershipMunicipalitiesCell); extração só sob o gatilho abaixo.
  • "Aproveitar e consertar" o bug de escopo do formulário completo já que o arquivo actions/leadership.ts está aberto. Mitigação: só acrescentar funções novas e aditivas; não tocar no fluxo de formulário completo.
  • Portar a máquina de ResizeObserver do AdvisorMunicipalityCell "já que existe". Ela resolve um problema (empacotar 3 linhas numa célula sempre-visível) que este item não tem, porque o container é um Popover. Mitigação: decisão travada acima; revisitar só com evidência de overflow real.
  • Renomeação (advisorMunicipalityPortfolio → municipalityPortfolio) vazar para mais lugares do que o esperado. Mitigação: confirmar com git grep -w antes de apagar o arquivo antigo; cobrir com os testes existentes de Assessores (unit + campaignAdvisorManagement.int.spec.ts) antes de escrever código novo de Lideranças.

Adiado com gatilho

  • "Ver mais…" / colapso de chips por medição (ResizeObserver). Revisitar quando aparecer liderança com carteira grande o suficiente para a célula ficar visualmente pesada na lista (sem evidência hoje).
  • RelationshipChipEditor agnóstico de relação (Municípios/Dobradinhas/Organizações num único primitivo). Revisitar quando existir o 3º call site real depois deste item e do B31.
  • Migrar para o padrão de endpoint JSON do B27. Revisitar só se a base de lideranças crescer a ponto de revalidatePath por clique ficar perceptivelmente lento (sem evidência hoje — ao contrário dos 435 municípios fixos).

Referências