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.
36 KiB
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):
AdvisorMunicipalityCellFOI promovido asrc/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 deResizeObserver+ "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/assessoresmigrou no mesmo PR — o 2º call site nasceu junto, não depois.- Sem provider de contexto. O índice do catálogo e
addableIdsviajam 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.
setLeadershipMunicipalitiesMembershiprecebemunicipalityIdsrepetidos +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 omunicipalities: {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ãodisabled:disabledtira o botão da ordem de tabulação e a razão só existia noaria-labeldele, 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 oRelationMultiSelect. - 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/assessorese/campanha/municipios. - Estratégia de cor: Restrained — chips
Badge variant="secondary", chip de território comMapPinIcon, sem cor semântica nova. - Edit where you see: sim —
canManageLeadershipjá 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/liderancasmostra 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) mostraMapPinIcone 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
Commandcom 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 (
canManageLeadershipescreve, 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
AdvisorsTableexiste 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 viaupdateLeadershipInternalRecord. 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), espelhandosetAdvisorMunicipalityMembership. 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 nomunicipality.advisors, então cada delta trava o município), aqui o array mora no próprioleadership, 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
municipalityIdscobre o restante); servidor mantémrequireAtLeastOneMunicipalitycomo defesa em profundidade (a mesma mensagem "Vincule a liderança a pelo menos um município." já existe e passa pelomapCampaignFormActionError). 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/buildAdvisorPortfolioChipssem custo extra de lib; o trabalho novo real é só a action de lote espelhandosetAdvisorMunicipalitiesBatch. 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 deAdvisorMunicipalityCell. A lib e o índice não têm nada de específico de assessor (catálogo/busca de município), e o prefixoadvisor*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, semResizeObserver) é mais barato e mais alinhado ao container já decidido acima. Rejeitado: portarAdvisorMunicipalityCellinteiro parashared/(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
RelationMultiSelectjá 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.municipalitiespelo 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 doexpected-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 doMunicipalityEstimateScenarioContexte 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 comoPopoverTrigger(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-normaldeixa a célula crescer, sem medição porResizeObserver| B) portar a medição doAdvisorMunicipalityCell. 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)
PencilIconpequeno,aria-label="Editar municípios"| B) reaproveitar o mesmo affordance visual doMunicipalityListAdvisorsControl(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 deadvisorMunicipalityPortfolio.ts): mesma lógica pura, tipos/funções neutros (MunicipalityPortfolioIndexEntry,MunicipalityPortfolioChip,MunicipalityPortfolioSearchHit,buildMunicipalityPortfolioChips,searchMunicipalityPortfolio);AdvisorMunicipalityCell.tsxpassa 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 deloadAdvisorMunicipalityIndex(bypass admin intencional, dado de referência só-leitura);advisorData.tseassessores/page.tsxpassam a importar daqui.src/components/campaign/leadership/MunicipalityPortfolioOptionsProvider.tsx(novo, cliente): contexto com{ index, addableIds }, montado uma vez em volta doCampaignTablede/campanha/liderancas.src/components/campaign/leadership/LeadershipMunicipalitiesCell.tsx(novo, cliente): chips de leitura (Badge+Linkpara município;Badge+MapPinIconpara território completo, viabuildMunicipalityPortfolioChipscontra o índice completo do provider) + ícone de edição (PopoverTrigger);PopoverContentcom chips removíveis (mesmo componente de chip, agora com "×") sobreCommand(shouldFilter={false}, itens desearchMunicipalityPortfoliocontraaddableIds-restrito quando ator é assessor); estado otimista local (useState/useTransition, semResizeObserver);disabledno 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()) eleadershipMunicipalitiesBatchSchema(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 —nullse 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/setLeadershipMunicipalityMembershipe a variante em lote, cada uma emwithPayloadTransaction+getFreshStaffActor+acquireTextAdvisoryLocks(['leadership-municipalities:{id}'])+assertMunicipalitiesWithinScopesó nos ids adicionados + leitura/escrita do próprio doc comuser/overrideAccess: false+revalidatePathdas 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 sobrerunCampaignFormAction.src/utilities/leadershipData.ts:LeadershipRowViewModel.municipalities: { id: number; name: string; slug: string }[]substituimunicipalityNames(estender oselectdenamesForIdspara trazerslug, ou reusarloadMunicipalityPortfolioIndexfiltrado pelos ids do lote — decidir no craft pelo menor número de queries); consumidores demunicipalityNames(nenhum fora desta página, a confirmar porgit grep) acompanham o rename.src/app/(campaign)/campanha/(app)/liderancas/page.tsx:Promise.allcarregaloadMunicipalityPortfolioIndex(payload)e, só seuser.role === 'advisor',loadMunicipalityOptions(payload, user); envolve<CampaignTable/>em<MunicipalityPortfolioOptionsProvider>; a colunamunicipalitiestroca 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;
revalidatePathvia 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/novacontinua 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
RelationshipChipEditoragnó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.tsestá aberto. Mitigação: só acrescentar funções novas e aditivas; não tocar no fluxo de formulário completo. - Portar a máquina de
ResizeObserverdoAdvisorMunicipalityCell"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 comgit grep -wantes 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). RelationshipChipEditoragnó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
revalidatePathpor clique ficar perceptivelmente lento (sem evidência hoje — ao contrário dos 435 municípios fixos).
Referências
docs/roadmap.md(Trilha B, item B34; grafo; Janela 1–2)liderancas/page.tsx,leadershipData.ts— superfície e view model a estenderMunicipalityListAdvisorsControl.tsx— Popover disparado pela célula (B9 ✓)combobox-assessores-lista-municipios.md(B27) — chips +Command+ delta sem "Salvar", contrato a espelhardobradinhas-lista-liderancas.md(B31) — mesma tabela, mesma decisão de container/lock/revalidatePath/providerAdvisorMunicipalityCell.tsx/advisorMunicipalityPortfolio.ts/advisorData.ts— lógica pura e índice a promoveractions/advisor.ts(nextAdvisorIdsAfterMembership,setAdvisorMunicipalityMembershipRecord,setAdvisorMunicipalitiesBatchRecord) — espelho direto das novas actionsactions/leadership.ts,src/lib/schemas/leadership.ts,src/collections/Leadership.ts(hookrequireAtLeastOneMunicipality,MAX_LEADERSHIP_MUNICIPALITIES)src/utilities/access/leaderships.ts(canManageLeadership)campaignRelationOptions.ts(loadMunicipalityOptions) — escopo do assessor para sugestõesMunicipalityEstimateScenarioContext.tsx— precedente de provider cliente escopadotests/int/campaignLeadershipActions.int.spec.ts,tests/int/campaignAdvisorManagement.int.spec.ts,tests/unit/advisorMunicipalityPortfolio.unit.spec.ts,tests/helpers/campaignFixtures.ts- AGENTS.md — Campaign Municípios model, Transaction Safety, naming inglês/copy pt-BR
.cursor/rules/campanha-edit-where-you-see.mdc,.cursor/rules/campanha-action-feedback.mdc