24 KiB
Impl: B157 — Coluna de dobradinhas na lista de municípios (+ criação inline)
Status: aprovado Atualizado em: 2026-08-04 Issue: #362 Intenção: docs/plans/dobradinhas-coluna-municipios.md (mergeada no main em 2026-08-04, PR #363 — revisado contra ela após sync) Appetite restante: ~1–1,25 dia eng (coluna com display estilo assessores + editor popover/sheet + create inline com partido; sem migration)
Leitura da intenção
- Outcome: na lista
/campanha/municipios, uma coluna "Dobradinhas" entre "Assessores" e "Tendência" — vínculos demunicipality.stateDeputiescom busca por nome e partido, adicionar/remover por delta (auto-save, otimista com rollback + toast) e criação inline ("+ Criar dobradinha 'texto'", compartyopcional via sintaxeNome (PARTIDO)), criada e automaticamente vinculada ao município. Canvas:~/.cursor/projects/teqo/canvases/plan-b157-ui-draft.canvas.tsx. - Decisão de display do usuário (2026-08-04, gate): o fechamento da célula segue o estilo da coluna de Assessores — agregado de círculos de perfil (iniciais, sobrepostos, máx. 3) com hover mostrando o detalhe (nome + partido), não chips na célula. Vazio = "—". O editor (adicionar/remover/criar) abre em popover no desktop / sheet no mobile, mesmo gesto do assessor (B27/B154).
- O que NÃO negociar:
- Coluna somente para
isCampaignUnrestricted(coordenador + candidato). Assessor não vê (nem read-only — lê na ficha,MunicipalityStrategyCard);leadertambém não. StateDeputy.nameé unique — nome já existente → erro + toast, sem duplicata.- Não é formulário completo (notas, editar nome/partido inline) →
/campanha/dobradinhas/<slug>; sem dedup por similaridade; sem filtro/ordenação por dobradinha (gatilho B29). - Sem migration, sem collection nova, sem Consent novo, contratos de URL intactos; relação continua
municipality.stateDeputies.
- Coluna somente para
- O que reavaliar (Direção no codebase é hipótese): a intenção hipotetiza control bespoke (
MunicipalityListStateDeputiesControlespelhando o B27/B154) + endpoint JSON/campanha/municipios/state-deputies+ recordsetMunicipalityStateDeputyMembershipRecord+loadStateDeputySummariesextra. O codebase atual já tem a máquina compartilhada (RelationChipCell, extraída no B36/B37) e o caminho de escrita delta já existe (setStateDeputyMunicipalitiesBatch, B37). B157 monta em cima dos dois, com a máquina compartilhada ganhando um modo "trigger" (display fechado custom + overlay popover/sheet) para honrar o display estilo assessores sem duplicar ~400 linhas. Correção de fato: a intenção afirma quecanManageCampaignStaffFieldé "restrito aisCampaignUnrestricted" — no código atual ele é staff-wide (o int spec do B37 prova assessor escrevendo). A decisão de produto (coluna só para unrestricted) é UI-gate, sem mudança de access; a superfície B37 (lista de dobradinhas) fica como está — fora deste item.
Abordagem recomendada
flowchart LR
page["municipios/page.tsx<br/>+ loadStateDeputyOptions (1×, staff)"] --> cols["municipalityListColumns(props)<br/>coluna stateDeputies · gate isCampaignUnrestricted"]
cols --> cell["MunicipalityStateDeputyRelationCell<br/>(wrapper · trigger avatar-stack + tooltip)"]
cell -->|"RelationChipCell modo trigger<br/>(popover desktop / sheet mobile)"| fa["setMunicipalityStateDeputiesFormAction<br/>(reusa setStateDeputyMunicipalitiesBatch invertido)"]
cell -->|criar inline| ca["createMunicipalityStateDeputyFormAction<br/>parse Nome (PARTIDO) · create+assign em 1 tx"]
fa --> reval["revalidateStateDeputyMunicipalityPaths<br/>(já existente, B37)"]
ca --> reval
Opções consideradas: A (modo "trigger" no RelationChipCell — display custom + popover/sheet — + reuso do B37 + seams de create) | B (control bespoke MunicipalityListStateDeputiesControl espelhando o B27/B154 + endpoint JSON + bridge — a hipótese literal da intenção) | C (chips na célula, container inline+drawer do RelationChipCell atual — minha 1ª proposta, substituída pelo display estilo assessores)
Recomendação: A — o display pedido (círculos + hover) é exatamente o trigger do control de assessores, mas a máquina por trás (delta otimista, undo, combobox ARIA, create, floor/cap) já é compartilhada no RelationChipCell. Em vez de duplicar o control do assessor, o RelationChipCell ganha um modo opcional: trigger (nó de display fechado) + editorVariant ('popover' | 'sheet') + triggerTooltip — o corpo do editor (chips removíveis + busca + criar) é o mesmo do Drawer de hoje, extraído uma vez. Sem as props novas, B36/B37 se comportam exatamente como hoje.
Rejeitadas: B — ~400 linhas de máquina duplicada (a mesma que B36/B37 extraíram para matar) + endpoint JSON + bridge de catálogo desnecessária (server action + revalidatePath ⇒ refresh RSC atualiza o catálogo de todas as linhas); C — chips na célula contrariam a decisão de display do gate.
Decisões de engenharia
D1 — Display fechado: estilo assessores (círculos + hover), sem chips na célula.
- Trigger do
RelationChipCell(modo trigger): círculos de iniciais sobrepostos (Avatar/AvatarFallbackdo kit +campaignUserInitials— helper genérico de iniciais;-space-x-2,size-8, máx. 3 visíveis — mesmo visual doMunicipalityAdvisorAvatarStack), comsr-onlylistando os nomes. - Hover = detalhe:
tooltipContentdo overlay (popover) com uma linha por vínculo — "Nome (Partido)" (ex.: "Fulaninho (PT)"), mesmo formato doformatAdvisorNamesTooltipmas com partido (helper novo no wrapper). Vazio → "—" (aceite da intenção; semMissingAdvisorBadge— aquilo é semântica de assessor/prioridade). - Link para a ficha
/campanha/dobradinhas/<slug>: affordance move para o editor (os chips removíveis do popover/sheet são links — oRelationChipCelljá renderizahrefnos chips), mesmo contrato da coluna de assessores (célula fechada sem link). - Opções consideradas: A (acima) | B (chips na célula, como na lista de dobradinhas) | C (círculos sem tooltip, nome só no editor). Recomendação: A — é o pedido literal do gate ("círculos + hover para ver o detalhe"); B foi a proposta anterior, substituída; C perderia a leitura sem abrir (o hover do assessor existe justamente para isso — B23).
D2 — Editor: modo "trigger" no RelationChipCell (popover desktop / sheet mobile).
- Novas props opcionais no
RelationChipCell:trigger?: (chips, isPending) => ReactNode(display fechado custom),editorVariant?: 'popover' | 'sheet',triggerLabel: string,triggerTooltip?: ReactNode. Comtriggerpresente: renderiza o nó custom envolvido numCampaignCellEditOverlaydo variant pedido (precedente: o control de assessores recebevariant="popover"no desktop evariant="sheet"nos cards) com o corpo do editor (chips removíveis + combobox + criar) — o mesmo JSX do Drawer atual, extraído para um renderer compartilhadoeditorBody; semtrigger, comportamento inline+drawer atual intacto. - Call sites: tabela desktop passa
editorVariant="popover"; cards mobileeditorVariant="sheet"(oCampaignListSheetProviderdoMunicipalityListMobileSectionjá hospeda o sheet — nenhum provider novo no desktop, já que o popover não usa Drawer; remove o item de risco da revisão anterior). - Opções: A (modo trigger na máquina compartilhada) | B (control bespoke completo) | C (manter inline+drawer com chips). Recomendação: A — mesmo gesto do assessor (popover/sheet) com a máquina compartilhada; B duplica; C foi superado pelo display do gate.
D3 — Busca: por nome E partido.
Opções: A) matchesNormalizedAtWordStart sobre plainName ou party normalizados (WeakMap de labels pré-normalizados, padrão do irmão B36) | B) só nome (padrão B31).
Recomendação: A — a intenção e o canvas dizem "filtrando por nome e partido"; digitar "PT" acha "Fulano (PT)". Hit label = Nome (Partido).
D4 — Delta: reusar setStateDeputyMunicipalitiesBatch invertido.
Opções: A) wrapper de form action setMunicipalityStateDeputiesFormAction na rota municipios mapeando { municipalityId, stateDeputyId, assigned } → setStateDeputyMunicipalitiesBatch({ stateDeputyId, municipalityIds: [municipalityId], assigned }) | B) record novo setMunicipalityStateDeputyMembershipRecord (a hipótese da intenção).
Recomendação: A — o record B37 já faz tudo (acquireTextAdvisoryLocks(['municipality-state-deputies:{id}']), user + overrideAccess: false, cap via nextStateDeputyIdsAfterMunicipalityMembership, no-op sem revalidate, revalidateStateDeputyMunicipalityPaths). Wrapper novo ~20 linhas em municipalityStaffFormActions.ts. B é twin.
D5 — Criação inline: parser Nome (PARTIDO) + seams no RelationChipCell + record atômico.
- Parser puro
parseStateDeputyNameParty(text)emsrc/lib/stateDeputyNameParty.ts(client-safe): extrai UM grupo(...)final →{ name, party }; sem parênteses → partynull; partido trimado, ≤32. Usado pelo schema zod (server, autoridade) e pelo chip temp (cliente). municipalityStateDeputyCreateSchema(src/lib/schemas/stateDeputy.ts):{ municipalityId, rawName }→ transform →{ municipalityId, name, party }, name 2..160 pós-parse, party ≤32;(PT)sozinho → name vazio → erro de validação (toast).createMunicipalityStateDeputyRecord(actions/stateDeputy.ts): 1 transação —reloadStaffActor(staff;STATE_DEPUTY_STAFF_MESSAGE) → lockmunicipality-state-deputies:{id}→payload.create('stateDeputy', { name, ...(party ? { party } : {}) })comuser/overrideAccess: false, conflict (unique name/slug) →STATE_DEPUTY_CONFLICT_MESSAGE→ leitura do município + delta (nextStateDeputyIdsAfterMunicipalityMembership) + update — espelho decreateMunicipalityAdvisorRecord(B154): se a atribuição falhar, o create rola junto (sem órfão). Retorna{ stateDeputy: { id, name, slug, party }, municipalitySlug }; wrapper revalida viarevalidateStateDeputyMunicipalityPaths.RelationChipCell: seams opcionaiscreateAction?+buildCreateFormData?; quandohits.length === 0e query 2..160 → itemCriar dobradinha “{query}”no lugar do "Nenhum resultado" (padrão B154), no corpo do editor (popover e drawer); chip temp sem remover (precedente B154); sucesso → swap para id real + delta otimista + mapa de criados (filtro!ids.includes(id) && effectiveIds.includes(id)— sem chip fantasma pós-remoção, sem duplicata pós-refresh); erro → revert + feedback/toast existentes. Zero mudança de comportamento sem as props novas.- Sem bridge provider (diferença estrutural vs. B154): server action +
revalidatePath⇒ refresh RSC ⇒loadStateDeputyOptionsre-carrega ⇒ catálogo atualizado em todas as linhas.
D6 — Dados: stateDeputyIDs no view model + catálogo como fonte única.
Opções: A) municipalityListSelect += stateDeputies: true; MunicipalityListViewModel += stateDeputyIDs: number[]; página carrega loadStateDeputyOptions(payload, user) 1× e passa como prop (factory de colunas + mobile) | B) também loadStateDeputySummaries (hipótese da intenção).
Recomendação: A — o catálogo (dezenas de registros, plainName+party+slug) cobre display, tooltip e busca; zero query por linha; payload = ids inteiros. B é query duplicada (adiada no /simplify do B31).
D7 — Coluna: id/label/description/picker + posição + gate + mobile.
MunicipalityListColumnId+='stateDeputies'(B17: id = chave do picker = cookie = description);municipalityColumnLabels+='Dobradinhas';municipalityColumnDescriptions+= entrada (B22).- Posição: entre
advisorsetrend(decisão A da intenção, "grupo rede"); head sem sort/filtro (CampaignTableHead+ description). SemcellTooltipna column def — a célula embrulha o próprio tooltip no overlay (mesmo comentário da coluna de assessores — não dobrar no mesmo gesto). - Gate
isCampaignUnrestricted(coordenador + candidato — explícito na intenção; diferente do popover de assessores que éisCoordinator): coluna desktop, entrada mobile e entrada do picker só nesse branch.MunicipalityListProps+=isCampaignUnrestricted: boolean(a página já computa o mesmo predicado paracanMoveEngagementLevel). - Mobile: entrada "Dobradinhas" no
dlstaff deMunicipalityListMobileCardscomeditorVariant="sheet"(oCampaignListSheetProviderdoMunicipalityListMobileSectionjá hospeda o Drawer compartilhado — miss #52 respeitada).
D8 — Revalidação e locks: nenhuma peça nova. Delta e create revalidam via revalidateStateDeputyMunicipalityPaths (já em actions/stateDeputy.ts): lista de municípios + detalhe do município tocado + ficha da dobradinha. Lock municipality-state-deputies:{id} serializa toggles concorrentes na mesma linha.
Componentes / mudanças
src/lib/stateDeputyNameParty.ts(novo, puro):parseStateDeputyNameParty+ unit tests (bordas: sem parênteses,(PT)sozinho, parênteses no meio, grupo final com espaços, party >32).src/lib/schemas/stateDeputy.ts:municipalityStateDeputyCreateSchema(com transform do parser).src/utilities/municipality/municipalityViewModels.ts:municipalityListSelect+=stateDeputies: true;MunicipalityListViewModel+=stateDeputyIDs: number[];toMunicipalityListViewModelmapeia (padrãoadvisorIDs).src/utilities/municipality/municipalityLabels.ts:MunicipalityListColumnId+='stateDeputies';municipalityColumnDescriptions+= entrada.src/utilities/municipality/municipalityListUrl.ts:municipalityColumnLabels+=stateDeputies: 'Dobradinhas'.src/app/(campaign)/campanha/actions/stateDeputy.ts:createMunicipalityStateDeputyRecord+ wrappercreateMunicipalityStateDeputy(revalida viarevalidateStateDeputyMunicipalityPaths).src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts:setMunicipalityStateDeputiesFormAction(delta, reusa o batch) +createMunicipalityStateDeputyFormAction(create+assign; success devolvestateDeputyvia o generic dorunCampaignFormAction).src/components/campaign/shared/RelationChipCell.tsx: (1) modo trigger — props opcionaistrigger?/editorVariant?/triggerLabel?/triggerTooltip?; corpo do editor extraído para renderer compartilhado (chips removíveis + combobox + criar) usado pelo Drawer atual e pelo overlay do modo trigger (CampaignCellEditOverlaycom o variant pedido); (2) seams de create —createAction?/buildCreateFormData?+ item "Criar…" + chip temp + swap + mapa de criados. Sem as props novas, comportamento atual idêntico.src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx(novo): wrapper fino —buildChips(labelNome (Partido), href da ficha),searchHits(nome ou partido, WeakMap de labels),buildFormData(municipalityId+stateDeputyId+assigned),buildCreateFormData(municipalityId+rawName), trigger display (círculos de iniciaisAvatar/AvatarFallback+campaignUserInitials,-space-x-2, máx. 3,sr-onlycom nomes; vazio "—") e tooltip de detalhe ("Nome (Partido)" por linha, helperformatStateDeputyNamesTooltip), copy pt-BR,triggerLabel"Editar dobradinhas em {nome}".src/components/campaign/municipality/MunicipalityList.tsx: colunastateDeputies(gateisCampaignUnrestricted, entreadvisorsetrend); props novas (stateDeputyOptions,stateDeputyCreateAction,isCampaignUnrestricted); editorvariant="popover"no desktop.src/components/campaign/municipality/MunicipalityListMobileCards.tsx(+MobileSection): entrada "Dobradinhas" no dl (gate unrestricted,editorVariant="sheet"); props repassadas.src/app/(campaign)/campanha/(app)/municipios/page.tsx:loadStateDeputyOptionsnoPromise.all(staff view); props na lista.- Migration: sem migration (relação já existe desde E4R).
- Access / Consent: nenhuma peça nova; o create herda
canCreateStateDeputy(staff) e o delta herdacanUpdateMunicipality— ambos comuser/overrideAccess: false; a intenção fica satisfeita no nível da UI (gateisCampaignUnrestricted). Observação registrada (não escopo): a API continua permitindo assessor escrever emmunicipality.stateDeputies(B37) — se produto quiser restringir, é item separado.
Dados → forma (se aplicável)
- Forma: tabela — célula fechada com círculos de perfil + hover com o detalhe "Nome (Partido)" (decisão do gate; precedente
MunicipalityAdvisorAvatarStack). Rejeitado: chips na célula (display anterior, superado); contagem ("3 dobradinhas" esconde o nome que decide); KPI/chart (dado categórico nominal). - Busca: nome e partido (D3); vazio = "—" (aceite da intenção).
Fases verificáveis
- Server + view model + testes de domínio — parser puro + schemas, select/view model, record create+assign, wrappers de form action, revalidate; int tests (
campaignMunicipalityStateDeputyCreate.int.spec.ts): coordinator/candidate criam+atribuem; assessor fora do escopo → rollback do create; leader recusado; nome duplicado →STATE_DEPUTY_CONFLICT_MESSAGE; parse do partido persiste (partycorreto); delta de 1 município via wrapper (no-op sem revalidate).pnpm gate:fastparcial. - UI — modo trigger + seams de create no
RelationChipCell→ wrapper (display + tooltip + busca) → coluna + picker + labels/descriptions + mobile; unit pins (campaignComponents.unit.spec.ts: fixturesstateDeputyIDs: []; tooltip count staff segue 10 — a coluna nova só renderiza comisCampaignUnrestricted: true, novo teste com count 11; leader segue 4;municipalityListDefaultPropsganhastateDeputyOptions: []+isCampaignUnrestricted: false+ stub create action; teste do display: iniciais + sr-only + vazio "—"). - Gates — unit do parser; e2e espelhando o teste B154 (coordenador abre "Editar dobradinhas em {município}", cria "Cicrano (PCdoB)" inline, chip aparece no popover, busca por partido acha, "Criar" some após criar, remover grava) em
campaignMunicipalities.e2e.spec.ts;pnpm gate:fast→pnpm push.
Rabbit holes / Não escopo (engenharia)
- Sort/filtro por dobradinhas no header — gatilho B29 registrado na própria intenção; não inventar.
- Coluna para assessor (read-only) — corte explícito da intenção; não adicionar
cellTooltip/variante. - Partido com formatação complexa — regra do parser documentada (um grupo
(...)final; resto é name);(PT)sozinho → erro de validação. - B155 (#359, in-progress) — coluna irmã na mesma tabela; escopos disjuntos (ids/labels/view model próprios); B155 sem branch de implementação — conflitos mecânicos de merge apenas.
- Bridge provider de criados (padrão B154) — desnecessário (server action + revalidatePath).
- Renomear/estender
LeadershipStateDeputyRelationCell— código landed B36/B37; sem ganho de produto. loadStateDeputySummariespor IDs na lista — query duplicada; o catálogo cobre.- Restringir access de assessor na API (B37) — item separado; só registro no gate.
- Adotar o modo trigger no control de assessores (B27/B154) — o control bespoke continua como está; migrá-lo para o
RelationChipCellé item próprio (tem seq/reconcile de endpoint JSON que o modo trigger não precisa).
Riscos e mitigação
RelationChipCellé compartilhado (B36/B37) — modo trigger e seams de create só com props opcionais; comportamento atual inalterado; pins de unit/integ cobrem os consumidores.- Modo trigger vs. drawer existente — o corpo do editor é extraído UMA vez e compartilhado; testes existentes do drawer continuam valendo (mesmo renderer).
- Chip temp × medição de overflow — em modo trigger não há medição de linha (o display fechado é o trigger); o chip temp vive dentro do editor (chips + busca), sem remove (precedente B154).
- Colisão de nome na criação inline — busca vazia ⇒ nome fora do catálogo; conflito só em corrida →
STATE_DEPUTY_CONFLICT_MESSAGEnosafeMessages. - Guarda
sharedSheetHostConventions— só o sheet (mobile) exige provider, e oMunicipalityListMobileSectionjá tem; desktop usa popover, sem provider novo.
Revisão na entrega (2026-08-04)
O plano acima executou como escrito, com ajustes do gate e do /simplify (3 revisores paralelos — qualidade, performance, reuso):
- Gate do usuário: display da coluna mudou para o estilo assessores (círculos + hover) —
RelationChipCellganhou o modo "trigger" (display fechado custom + overlay popover/sheet com o corpo do editor compartilhado) em vez de chips inline. SemCampaignListSheetProvidernovo no desktop (popover não usa Drawer). - Correção de fato: Payload valida unique ANTES do insert com um
ValidationErrorlocalizado ("O campo a seguir está inválido: name") — o pattern do policy não pega o caso normal de duplicata. O mapeamento de conflito foi movido para o dono (mapStaffEntityConflictemcampaignEntityActions.ts, usado porrunStaffEntityMutatione pelo record novo) — os formulários de organização/dobradinha herdam a mensagem de conflito por tabela. /simplifyfixados: (1) raça docreatedChips(create→remove rápido→re-add duplicava o chip com a mesma key) — o copy agora cai quando o catálogo (baseIds) resolve o id, sem depender deidschegar; (2) a11y — os nomes atribuídos vão noaria-labeldo trigger ("Editar dobradinhas em X — Fulano (PT)"), padrão do assessor;sr-onlymorto removido; (3) cleanup docreatedChipscom bail de no-op (B36/B37 não pagam render pass); (4) supressão do "Criar…" por igualdade normalizada com chip atribuído (prefixo ainda permite criar nome novo) e gate para não rodar sem wiring de create; (5) fold "Nome (Partido)" em fonte única (stateDeputyDisplayNameno lib;option.namedo catálogo reusado); (6)optionByIdcompartilhado por WeakMap entre linhas; (7) cap message nosafeMessagesdo create; (8) statusMessage com "opção de criar" quando o único item é o create; (9) padding único do corpo do editor (16px nas duas superfícies); (10)MunicipalityStateDeputyCreateActiontipo único exportado; (11)payload-types.tsfora do diff (drift de regeneração não relacionado, revertido).- Adiados com gatilho (ver débitos): extração de
AvatarStackgenérico (2º call site — regra do repo); migração doitemLabeldo irmão B36 parastateDeputyDisplayName(próximo toque no arquivo); cache do catálogo (medição); bounds do schema viapick(wording pt-BR); adapter de busca emlib/(próximo par de relação); observação de produto: a API continua permitindo assessor escrevermunicipality.stateDeputies(B37) — a intenção supunhaisCampaignUnrestricted; se produto quiser fechar, é item separado.
Gate completo verde (tsc, lint --max-warnings=0, format:check, check:cycles, knip com o P3 pré-existente, unit 1358/1358, int 573/573, e2e B157 + vizinhos, build). Aikido: CLI não disponível no ambiente — padrões idênticos aos do B37/B154 já escaneados.
Aceite de engenharia
- Aceite de produto da intenção ainda coberto (coluna entre Assessores e Tendência, display estilo assessores — círculos + hover com "Nome (Partido)", vazio "—", busca nome+partido, auto-save por delta otimista com rollback+toast, criar inline
Nome (PARTIDO)com vínculo automático, gateisCampaignUnrestricted, unique name → toast) - Invariantes AGENTS/engineering-standards (
overrideAccess: falsecomuser, transação no create+assign, sem migration, sem Consent, identificadores en/strings pt-BR, cap respeitado) - Testes de domínio: int do create+assign (roles, rollback, conflito, partido), unit do parser e dos pins da lista; e2e do create inline
- Self-score decision-quality: decisões caras com rejeitadas (D1–D8) · cabe no appetite (~1–1,25 dia) · rabbit holes nomeados · depth check reusa
RelationChipCell/batch B37/runCampaignFormAction/withPayloadTransaction/locks · intenção preservada (revisado contradobradinhas-coluna-municipios.md+ canvas + decisão de display do gate)