mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
B19: Gerenciar assessores (/campanha/assessores)
Lista+detalhe única de edição para coordinator/candidate: nome, e-mail e celular com auto-save debounced (edit-where-you-see-it); carteira de municípios por chips com agregação de território de identidade, adição/remoção com UI otimista; criação inline no topo da tabela; ação de WhatsApp por assessor; reset de senha via e-mail (bloqueado para placeholders @planilha.invalid). Alinha access de update/phone/atribuição de município a isCampaignUnrestricted (coordinator + candidate). Sem migration. Testes int (campaignAdvisorManagement) + unit (schema e portfólio de municípios) cobrindo access, conflito de e-mail único, cap de assessores por município e agregação por território/ZE.
This commit is contained in:
parent
4b7f2f595d
commit
01e52834e2
30 changed files with 2925 additions and 44 deletions
|
|
@ -10,7 +10,7 @@ alwaysApply: false
|
|||
- **E16 entregue em código (2026-07-25):** Dossiê do município (pré-agenda) — aba `?tab=dossie` no detalhe (`MunicipalityDossier.tsx`, server component em Suspense) compondo os loaders existentes via `municipalityDossierData.ts` (baseline TSE + rank A11, conta da cadeira E8, pledges, rede de lideranças com frescor `updatedAt`, agenda `actionPlan` `overrideAccess: false`, sinais C12, perfil IBGE A8 com caveat para zonas — reentrada do artefato órfão); caps 8 lideranças / 5 sinais / 3+2 planos com "ver tudo" por seção; novo campo staff-only `municipality.budgetNotes` ("Emendas aportadas", G11 manual-first) via migration `20260725_022155_add_municipality_budget_notes` (o `.json` da migration também sara a cadeia de snapshots pós-`campaign_goals`); form de estratégia + zod + view model estendidos; **print CSS** (chrome `print:hidden`, shells `print:h-auto print:overflow-visible`, tipografia A4 em `styles.css`) — dossiê imprime 1–2 páginas com capa autocontida ("Gerado em"). Int test `municipalityDossierData.int.spec.ts` (mock do `unstable_cache` do baseline). Gate verde (tsc/lint/knip/363 tests/build); Aikido bloqueado por sessão expirada (re-login navegador pendente). Plano: [`dossie-municipio.md`](../../../docs/plans/dossie-municipio.md).
|
||||
- **E18 entregue em código (2026-07-24):** documentação de conceitos de inteligência — rota staff-only `/campanha/conceitos` (`isCampaignStaff`, `leader` redirecionado) renderizando `src/lib/campaignIntelligenceConcepts.ts`, conteúdo curado por PR ao lado das fórmulas que descreve (não collection Payload). 7 conceitos de E8 agrupados em base do cálculo (válidos projetados, teto do campo) / diagnóstico (captura, share intracampo, roll-off) / meta e cobertura (meta e meta sugerida, cobertura da meta), cada um com o que mede, fórmula em texto, exemplo estático, por que importa e onde aparece na tela. Superfície de leitura, não cards: `article` por conceito com hairline, coluna em `max-w-prose`, índice sticky só em `lg`, `article:target` tintado para orientar quem cai por âncora. Dois caminhos de descoberta: "Saiba mais" por métrica nos tooltips do card (`MetricExplanation` ganhou `conceptID`) e link para a página inteira no Popover `CampaignInfoHint` do título — o Popover é o caminho de **teclado**, porque conteúdo de Tooltip do Radix não é tabulável; para o toque funcionar, `MunicipalityHoverTooltip` passou a tratar o próprio conteúdo como "dentro" no dismiss por `pointerdown` (antes o link era desmontado antes do clique chegar). Entrada na sidebar a pedido do usuário na mesma sessão: `staffSecondaryNav` num `SidebarGroup mt-auto` colado no rodapé da navegação — fora da `staffNav` (os 7 destinos de trabalho) e fora da barra inferior do mobile, porque é referência e não lugar de trabalhar; `getCampaignSecondaryNav` devolve vazio para `leader`. Corrigido no caminho um defeito de E8: `formatElectionNumber` renderizava a meta sugerida fracionária como "100,968" (lida como cem mil) — agora `maximumFractionDigits: 0`. Sem migration, sem collection, sem server action. Cada fatia futura do programa (E9/E10/B13/E11/E12/E13/E14) acrescenta sua seção ao array. Plano: [`documentacao-conceitos-campanha.md`](../../../docs/plans/documentacao-conceitos-campanha.md).
|
||||
- **E9 entregue em código (2026-07-24):** fila de alocação na própria lista de municípios (sem rota nova, sem coluna nova, sem migration). **Fase 0 — correção do E8:** a meta sintética deixou de ser decomposição proporcional ao teto do campo (que dava 2.911 a Vitória da Conquista, de 5.005 votos em 2022, e 813 a Campo Formoso, de 47) e passou a ser ancorada na votação própria de 2022, por cenário — `deriveSuggestedGoalsByScenario` em [`municipalityPotential.ts`](../../../src/utilities/municipalityPotential.ts): pessimista = base×(1−`margin`), central = base ("repetir 2022"), otimista = base×(`stateGoal` ÷ Σ base), com clamp em `max(central, otimista)` quando `stateGoal` < Σ base; `goalCoverage.ts` passou a receber `SuggestedGoalByScenario` no lugar do escalar, deixando `expectedVotes[S] ?? suggestedGoal[S]` simétrico. `stateGoal`/`margin` do global `campaignGoals` mantêm papel de cálculo (fator 1,163 = "2022 mais 16%"; corte pessimista, fallback 10%) — só `admin.description` mudou, daí a ausência de migration. **Fase 1 — frescor e ordenações:** `declaredAt`/`estimatedAt` entraram no `select` do agregado de pledges (`lastPledgeAt`), `lastSignalAt = max(municipality.lastUpdateAt, lastPledgeAt)` no view model, e duas sort keys novas — `deficit` (**novo default do staff**, desc) e `frescor` (sinal mais frio primeiro, nunca-teve-sinal na frente), ambas derivadas em memória sobre o bundle já carregado, no cenário `central` fixo. **Fase 2 — UI (Impeccable B):** frescor dobrado na coluna "Última atualização" ("há N dias", frio a partir de 21 dias), badge "sem responsável" na célula de assessoria quando `priority === 'alta'` e zero assessores, "coluna da vergonha" como `detail` da métrica de assessoria linkando `?priority=alta&coverage=sem_assessor`, tooltip da célula de cobertura nomeando o cenário ativo, e correção de copy Praças→Municípios no overview. O `critique` trocou o tom do sinal frio de `text-destructive` para `text-estimate-pending-foreground`: com badge de prioridade e "sem responsável" na mesma linha, o vermelho perdia impacto. `/simplify` pós-entrega (validado por três revisores paralelos): consolidou o comparador "latest de dois ISO timestamps" (`resolveMunicipalityLastSignalAt`/`laterTimestamp` eram a mesma função escrita duas vezes) num `latestIsoTimestamp` compartilhado em `campaignTime.ts`; removeu o passthrough morto `baseTotal`/`growthFactor`/`belowBase` e renomeou `computeStatewideGoalDecomposition` → `computeStatewideSuggestedGoals` (nada mais é "decomposto" desde a Fase 0); unificou o badge de assessor duplicado entre tabela/card (tinham divergido em qual ícone renderiza); e consolidou três literais de "cenário zerado" num `zeroByVoteEstimateScenario()` em `voteEstimate.ts`. **Cortes registrados no plano:** votos em jogo → **B13**, LQ/captura → **E10**, coluna dedicada de déficit → desnecessária (a célula do E8 já mostra % + déficit assinado); `sanityCheckSuggestedGoalsByTerritory` permanece sem consumidor de produção (E11/E12). Verificação: tsc/lint/knip limpos, 337 unit + 366 int testes verdes, build verde; Aikido sem achado novo. Plano: [`fila-de-alocacao.md`](../../../docs/plans/fila-de-alocacao.md).
|
||||
- **B19 registrado (2026-07-24):** gerenciar assessores em `/campanha/assessores` (lista + novo + detalhe) — só Coordenador Geral e Candidato; criar/editar contas `advisor`, carteira de municípios, reenviar senha; alinha access update/phone do `candidate`. Sem migration. Plano: [`gerenciar-assessores.md`](../../../docs/plans/gerenciar-assessores.md).
|
||||
- **B19 entregue em código (2026-07-24):** `/campanha/assessores` (lista + novo + detalhe) — só CG/candidato; criar/editar contas `advisor`, carteira com auto-save por município, reenviar senha (bloqueia `@planilha.invalid`); access de update/phone e `municipality.advisors` alinhados a `isCampaignUnrestricted`; `reloadUnrestrictedActor`; leitura privilegiada de e-mail no loader. Sem migration. Plano: [`gerenciar-assessores.md`](../../../docs/plans/gerenciar-assessores.md).
|
||||
- **E8 entregue em código (2026-07-24):** conta da cadeira — artefato TSE commitado estendido para v2 (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022` presidente/governador T1 2022, via `pnpm build:election-aggregates`; teto de bytes revisado 400→700 KB) + curadoria `src/lib/campoParties.ts` (ano→siglas do campo, validada contra `electionPartySpectrum` por unit test); global `campaignGoals` (meta estadual 150 mil + margem, migration `20260724_180000_add_campaign_goals_global`, access staff-read/coordinator-write, **sem** hook de revalidação — `/campanha` é dinâmico com auth); `municipalityPotential.ts` (válidos projetados, teto do campo, captura, share intracampo, roll-off 2022-only, decomposição proporcional da meta + sanity por TI) e `goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = Σ pledges`, nunca a expectativa da mesa — semântica travada contra o conflito da auditoria); UI Impeccable classe B: linha "Cobertura da meta" no dashboard e no overview da lista, nova coluna na lista de municípios (renomeando "Cobertura"→"Assessoria" para eliminar ambiguidade), card "Conta da cadeira" no detalhe (fixo em `central`, sem seletor de cenário). `/simplify` pós-entrega: removida a checagem de sanidade por TI do caminho quente por requisição (fica exportada/testada para o E12 consumir), lookup de potencial por município virou O(1) (`Map` em vez de `.find`), loaders quase-idênticos de votos majoritários/deputado federal unificados, tipo de tally duplicado unificado, formatadores de percentual/progresso deduplicados entre dashboard/overview/card. Desbloqueia **E9/E10/E12/E13** no roadmap. **Follow-up mesmo dia (feedback do usuário):** tooltips hover/foco nos 4 números de diagnóstico do card (`MunicipalityHoverTooltip`, sem ícone de interrogação); seed local passou a importar presidente/governador também para 2014/2018 (artefato/fórmulas continuam 2022-only); `/impeccable critique` do card (2×P1 touch dead-end + colisão de layout, 2×P2 idiomas de disclosure/cópia densa, 1×P3 progress bar) endereçado por completo — tooltip agora abre/fecha por toque com dismiss por toque fora, nunca cobre "Cobertura da meta", cópia reescrita em frase-líder + fórmula sem cross-reference, barra de progresso mais visível a 0%; registrado **E18** (documentação de conceitos de inteligência de campanha, plano futuro). Plano: [`conta-da-cadeira.md`](../../../docs/plans/conta-da-cadeira.md).
|
||||
- **C12 Registro-fundação entregue em código (2026-07-24):** trajetória de `votePledge` por versions nativas com baseline; atualização municipal `sinal` tipada (explicações, fonte e triangulação); `actionPlan.origin`; demandas vinculadas ao plano com custo derivado e criação múltipla atômica no formulário; `allocationDecision` ex-ante imutável com snapshot pequeno. Migration consolidada `20260724_180000_add_campaign_foundation_records`; testes de integração e jornada E2E dedicados.
|
||||
- **B18 registrado (2026-07-24):** filtros **salvos com nome** em `/campanha/municipios` (`localStorage`) + acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo). Soft B16/B17 (barra). Fatia concreta do “saved views” que FD2 adiava como genérico. Sem migration. Plano: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md).
|
||||
|
|
@ -90,6 +90,7 @@ alwaysApply: false
|
|||
## Decisões
|
||||
|
||||
- Em 2026-07-24, **glossário de inteligência (E18) mora em código e é staff-only:** as explicações são 1:1 com as fórmulas de `municipalityPotential.ts`/`goalCoverage.ts`, então vivem num módulo `src/lib` revisado no mesmo PR que muda a fórmula; toda a lista descreve campos que a liderança nunca vê, então a rota é `isCampaignStaff`. Descoberta por link contextual nos tooltips que já expõem os números, não por item na `staffNav`. **Rejeitado:** collection Payload com richText (cópia dessincroniza da fórmula sem teste nenhum pegar); leitura liberada a todo `campaignUser`; KaTeX (fórmula em texto basta para staff de campo).
|
||||
- Em 2026-07-24, **B19 — Candidato designa assessores e vê e-mails:** `isCampaignUnrestricted` passa a cobrir update de `campaignUser` de terceiros, phone create/update, e atribuição `municipality.advisors` (field access + action). Lista/detalhe de assessores leem e-mail com `overrideAccess: true` após o gate (job: ativar `@planilha.invalid`). **Rejeitado:** candidate só-leitura na carteira; ocultar e-mail.
|
||||
- Em 2026-07-24, **header da coluna A11 = `2022` (não "Concentração 2022"):** o título longo competia com células curtas (`%` + `12º`); no contexto da mesa o ano basta. Definição da métrica fica no hover do header (`formatMunicipalityConcentrationHint`). **Rejeitado:** manter "Concentração" no rótulo visível.
|
||||
- Em 2026-07-24, **C12 preserva fatos e decisões antes de sugerir:** pledges mantêm histórico nativo; sinais exigem tipo+fonte e distinguem triangulação; origem/custo são observados via plano→demandas; decisão de alocação é append-only para campanha (correção só por admin), sempre com racional e snapshot ex-ante pequeno. Demandas criadas junto do plano compartilham a mesma transação e fazem rollback integral em conflito. **Rejeitado:** sobrescrever trajetória, custo duplicado no plano, sugestão que decide automaticamente.
|
||||
- Em 2026-07-24, **conta da cadeira (E8) — meta × comprometido, teto do campo e derivados vão para o artefato commitado, não para queries em runtime:** `meta = expectedVotes[cenário] ?? suggestedGoal` (a expectativa da mesa nunca é substituída pela soma dos pledges no denominador de cobertura — resolve o conflito de `resolveMunicipalityStaffVoteTotal`, que faz a expectativa vencer); `comprometido = aggregate.effectiveByScenario[cenário]` (só Σ pledges). Teto do campo = presidencial do campo T1 2022 (#13) como série primária, governador como leitura secundária (assumido, para validar com produto). Decomposição da meta estadual é proporcional ao teto do campo projetado (`suggestedGoal_i = metaEfetiva × tetoProjetado_i / Σ tetoProjetado`), nunca uma fórmula composta com captura/share/roll-off como pesos — essas ficam como colunas de diagnóstico. Roll-off (brancos+nulos DF − majoritária) é 2022-only (não há tally majoritário seedado para 2014/2018). `teto do campo`/`captura`/`share intracampo`/`roll-off` são derivados de um artefato TSE **commitado** (v2 de `bahia-federal-baseline.json`, via `pnpm build:election-aggregates`), não de `unstable_cache` sobre `electionCandidateVote` (~100k/73k/52k linhas por ano) — o hardening de 2026-07-23 já havia banido esse padrão do runtime. Meta estadual mora num global editável (`campaignGoals`, 150 mil inicial); `campoParties.ts` é lib estática curada por ano, não collection administrável nem segunda fonte de verdade de partido (valida contra `electionPartySpectrum.ts` por unit test). **Rejeitado:** meta por município 100% automática; fórmula composta multifatorial de potencial; inferir "campo" pela coligação bruta do TSE; collection de metas versionadas nesta fatia (fica para C12).
|
||||
|
|
|
|||
30
.impeccable/critique/2026-07-24__campanha-assessores-b19.md
Normal file
30
.impeccable/critique/2026-07-24__campanha-assessores-b19.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Critique — /campanha/assessores (B19)
|
||||
|
||||
Date: 2026-07-24
|
||||
Target: `src/app/(campaign)/campanha/(app)/assessores/` + `src/components/campaign/Advisor*`
|
||||
|
||||
## Scores (compact)
|
||||
|
||||
- Clarity under pressure: 4/5 — lista densa; badge "Planilha" sinaliza placeholder
|
||||
- Edit where you see / auto-save: 4/5 — perfil por blur; carteira por chip (sem Salvar)
|
||||
- Feel the action: 4/5 — pending na carteira + live region; toast no save
|
||||
- Access / empty / error: 5/5 — gate unrestricted; empty search vs empty list; reset bloqueado em placeholder
|
||||
|
||||
## P0 / P1
|
||||
|
||||
Nenhum P0.
|
||||
|
||||
P1 resolvido na sessão: copy "Praças" no controle B9 tocado ao alinhar a mensagem de designação.
|
||||
|
||||
## Harden
|
||||
|
||||
- Form create: fieldErrors + safeMessages
|
||||
- Empty states: sem assessores / busca vazia
|
||||
- Reset desabilitado com razão quando e-mail é placeholder
|
||||
- optimize: out
|
||||
|
||||
## Polish applied
|
||||
|
||||
- Import order na lista
|
||||
- Bottom nav exclui Assessores explicitamente
|
||||
- Unit tests para placeholder email + reloadUnrestrictedActor
|
||||
|
|
@ -74,6 +74,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
|
|||
|
||||
**Recently resolved (2026-07-24):** **E18** Documentação dos conceitos de inteligência — staff-only route `/campanha/conceitos` (`isCampaignStaff`; `leader` redirected to `/campanha`) rendering `src/lib/campaignIntelligenceConcepts.ts`. The glossary is **curated content in code, not a Payload collection**: each entry (what it measures, the formula in plain text, a hand-written example, why it matters, where it appears) sits next to the formulas it documents (`municipalityPotential.ts` / `goalCoverage.ts`) and is reviewed in the same PR that changes them; a unit test guards anchor-safe unique IDs and content completeness. Seven E8 concepts in three groups — base do cálculo (válidos projetados, teto do campo), diagnóstico (captura, share intracampo, roll-off), meta e cobertura (meta/meta sugerida, cobertura da meta). Reading surface, not cards: one `article` per concept with hairline dividers, `max-w-prose` column, sticky index at `lg` only, `article:target` tint so anchor arrivals land oriented. Two discovery paths from `MunicipalityGoalAccountCard`: per-metric "Saiba mais" links inside the hover tooltips (`MetricExplanation` takes a `conceptID`) and a whole-page link in the title `CampaignInfoHint` **Popover** — the Popover is the keyboard path because Radix Tooltip content is not tabbable; for touch, `MunicipalityHoverTooltip` now treats its own content as "inside" in the `pointerdown` dismiss handler (the link used to unmount before the tap landed). Sidebar entry lives in `staffSecondaryNav` (`nav.ts`) rendered in its own `SidebarGroup` with `mt-auto` at the foot of the navigation — deliberately outside `staffNav` (the seven work destinations) and outside the mobile bottom bar, since it is reference material; `getCampaignSecondaryNav` returns nothing for `leader`. Fixed an E8 defect found while writing the docs: `formatElectionNumber` rendered a fractional suggested goal as "100,968" (read as a hundred thousand in pt-BR) — now `maximumFractionDigits: 0`. No migration, no collection, no server action; later slices of the intelligence program (E9/E10/B13/E11/E12/E13/E14) each append their section to the array. Plano: [`docs/plans/documentacao-conceitos-campanha.md`](docs/plans/documentacao-conceitos-campanha.md).
|
||||
|
||||
**Recently resolved (2026-07-24):** **B19** Gerenciar assessores — `/campanha/assessores` (lista + novo + detalhe) for coordinator/candidate only (`isCampaignUnrestricted`); create/edit `advisor` accounts, municipality portfolio (per-municipality auto-save + advisory lock), password-reset email (blocks `@planilha.invalid`); aligned `canUpdateCampaignUser` / phone field access / `canAssignMunicipalityAdvisors` / `assignMunicipalityAdvisorsRecord` to unrestricted; privileged email read in `advisorData` loaders after the route gate; `reloadUnrestrictedActor`. No migration. Plano: [`docs/plans/gerenciar-assessores.md`](docs/plans/gerenciar-assessores.md).
|
||||
|
||||
**Recently resolved (2026-07-23):** **A10** Cenários de estimativa (pessimista/média/otimista) — `votePledge.estimatedVotes` e `municipality.expectedVotes` viram groups (`pessimistic|central|optimistic`); migration `20260723_124200_add_vote_estimate_scenarios` (backfill scalar→`central`); agregação por cenário em `votePledgeData` com default `central`; mapa 2026 com `values2026ByScenario` + seletor local; lista/overview/dashboard com média + faixa secundária; liderança continua com um `declaredVotes` sem ver estimativas. Plano: [`docs/plans/cenarios-estimativa-votos.md`](docs/plans/cenarios-estimativa-votos.md). Desbloqueia **E8**.
|
||||
|
||||
## Posts & Tags (news / publications)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
# B19 — Gerenciar assessores (`/campanha/assessores`)
|
||||
|
||||
Status: rascunho
|
||||
Status: entregue em código (2026-07-24)
|
||||
Atualizado em: 2026-07-24
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, B19; superfície de coordenação)
|
||||
Impeccable: C — UI nova em `/campanha/assessores` (lista + novo + detalhe); sem design-ref
|
||||
Appetite: ~1,5–2 dias eng; lista + create + detalhe/edição in-context + gate de nav; sem migration; alinhamento de access `candidate`↔`coordinator` nos helpers de `campaignUser`
|
||||
Appetite: ~1,5–2 dias eng; lista + create + detalhe/edição in-context + gate de nav; sem migration; alinhamento de access `candidate`↔`coordinator` nos helpers de `campaignUser` **e** na atribuição `municipality.advisors`
|
||||
Responsável: —
|
||||
|
||||
## Revisões
|
||||
|
||||
- **2026-07-25 (edit-where-you-see na lista):** a tabela em `/campanha/assessores` passou a ser a superfície única — e-mail/celular/nome com auto-save com debounce; chips de municípios com remoção no X; busca inline por município / território de identidade (chip agregado) / ZE (chips por município); WhatsApp por linha; “+ Novo assessor” cria linha no topo (sem `/novo` nem detalhe `[id]`, que só redirecionam). Batch `setAdvisorMunicipalitiesBatch` para TI/ZE. A carteira colapsa em 3 linhas com “Ver mais…” **como último item da terceira linha**: a célula mede o layout real (linhas de chips, largura do toggle e do input de busca) e corta a lista de chips, em vez de cortar por altura — a medição é reavaliada ao alternar edição, porque o chip removível é mais largo.
|
||||
- **2026-07-24 (implementação):** entregue. Auditoria pré-build corrigiu quatro defasagens: (1) atribuição de municípios alinhada a `isCampaignUnrestricted` nas três camadas (`canAssignMunicipalityAdvisors`, `assignMunicipalityAdvisorsRecord`, mensagem segura) — decisão de produto: Candidato designa igual ao CG; (2) coluna E-mail via leitura privilegiada (`overrideAccess: true` sem `user` após o gate de rota — e-mail completo, não só badge); (3) carteira do assessor com auto-save por município (delta + lock), não o Popover "Salvar" do B9; (4) `sendAdvisorPasswordReset` próprio (resultado honesto; bloqueia `@planilha.invalid`). `reloadUnrestrictedActor` adicionado. Rotas `/campanha/assessores`, `/novo`, `/[id]`; nav gated; testes int `campaignAdvisorManagement.int.spec.ts`.
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 2 clareza sob pressão, 3 Edit where you see, 4 Auto-save, 8 Feel the action) / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · precedente de lista+CRUD [`organizacoes/page.tsx`](<../../src/app/(campaign)/campanha/(app)/organizacoes/page.tsx>) e [`dobradinhas/`](<../../src/app/(campaign)/campanha/(app)/dobradinhas/>).
|
||||
|
|
@ -59,19 +64,20 @@ Pedido de produto (2026-07-24): página na vertical `/campanha` para gerenciar a
|
|||
## Decisões travadas
|
||||
|
||||
- **Visibilidade e escrita = `isCampaignUnrestricted` (coordinator + candidate).** Pedido explícito; alinha create já existente. **Rejeitado:** só coordinator (candidate ficaria cego no onboarding); staff inteiro vê a lista (assessor não gerencia pares — vazaria e-mails/carteiras); Payload `/admin` como UX principal (CG não entra).
|
||||
- **Alinhar access de update/phone de terceiros a unrestricted.** Hoje `canUpdateCampaignUser` / `canCreateCampaignUserPhone` / `canUpdateCampaignUserPhone` são coordinator-only — candidate criaria conta e não corrigiria e-mail. **Rejeitado:** deixar assimetria (página inútil para candidate); página “só leitura” para candidate.
|
||||
- **Alinhar access de update/phone **e** atribuição de municípios a unrestricted.** `canUpdateCampaignUser` / phone create-update / `canAssignMunicipalityAdvisors` / `assignMunicipalityAdvisorsRecord` passam a `isCampaignUnrestricted` (+ admin). **Rejeitado:** deixar assimetria (página inútil para candidate na carteira); página “só leitura” para candidate.
|
||||
- **E-mail visível na lista/detalhe para unrestricted.** Loader usa `overrideAccess: true` sem `user` após o gate (hook `removePrivateAuthFields` + field access self-only apagariam o e-mail). **Rejeitado:** só badge de status; ocultar e-mail (impede ativar placeholders E4R).
|
||||
- **Escopo = só papel `advisor`.** Create força `role: 'advisor'`; lista filtra esse role. Coordinator/candidate continuam fora desta UI. **Rejeitado:** “Equipe” multi-papel (explode IAM); promover assessor a coordinator nesta tela.
|
||||
- **URL `/campanha/assessores` + detalhe por `[id]`.** Português no segmento (padrão da vertical); id interno (sem slug em `campaignUser`). **Rejeitado:** `/campanha/usuarios` genérico; slug inventado.
|
||||
- **Sem delete na UI v1.** Evita órfãos e disputa com vínculos. Desativar = remover municípios + (futuro) flag — Adiado. **Rejeitado:** soft-delete/collection `archived` neste appetite; hard-delete com cascade silenciosa.
|
||||
- **Senha: nunca exibida; onboarding via forgot-password / e-mail.** Reusa Resend + `requestCampaignPasswordReset` (ou equivalente privilegiado unbound ao titular). Placeholder `@planilha.invalid` exige troca de e-mail antes do envio. **Rejeitado:** senha temporária na tela (vazamento em share/screenshot); SMS.
|
||||
- **Atribuição de municípios no detalhe do assessor (além de B9).** Edit where you see: quem olha a carteira do assessor ajusta ali; B9 continua no sentido município→assessores. **Rejeitado:** só links “vá à lista de municípios” (onboarding sofrível); segunda planilha full-grid.
|
||||
- **i18n e naming:** identificadores em inglês (`AdvisorList`, `loadAdvisorListPageData`, `createAdvisor`, `updateAdvisorMunicipalities`, `reloadUnrestrictedActor`); strings pt-BR (“Assessores”, “Novo assessor”, “Enviar link de senha”).
|
||||
- **Senha: nunca exibida; onboarding via forgot-password / e-mail.** Action própria `sendAdvisorPasswordReset` (não o forgot público anti-enumeração). Placeholder `@planilha.invalid` exige troca de e-mail antes do envio. **Rejeitado:** senha temporária na tela (vazamento em share/screenshot); SMS.
|
||||
- **Atribuição de municípios no detalhe do assessor (além de B9) com auto-save por delta.** Um município por toggle + advisory lock; B9 continua no sentido município→assessores. **Rejeitado:** só links “vá à lista de municípios”; segunda planilha full-grid; botão Salvar no Popover (anti-goal Auto-save).
|
||||
- **i18n e naming:** identificadores em inglês (`AdvisorList`, `loadAdvisorListPageData`, `createAdvisor`, `setAdvisorMunicipalityMembership`, `reloadUnrestrictedActor`); strings pt-BR (“Assessores”, “Novo assessor”, “Enviar link de senha”).
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Nav: item no sidebar staff filtrado, ou só link a partir do Início/onboarding?** **Opções:** A) item “Assessores” no `staffNav` só se unrestricted | B) fora do nav (como Organizações hoje) + atalho no dashboard. **Recomendação:** **A** — onboarding precisa descobrir a superfície; bottom nav mobile **não** inclui (já no limite de 5; perfil/organizações também ficam de fora). _(assumido — validar no craft)_
|
||||
- **Create: celular obrigatório?** **Opções:** obrigatório | opcional. **Recomendação:** opcional — e-mail é o login staff; celular ajuda contato/WhatsApp (D3) mas seed já criou name-only.
|
||||
- **Atribuição em lote no create (municípios no mesmo form)?** **Opções:** create só conta → detalhe atribui | create com multi-select. **Recomendação:** create só conta (submit atômico curto); municípios no detalhe com auto-save — evita form monstro e falha parcial.
|
||||
- **Nav: item no sidebar staff filtrado, ou só link a partir do Início/onboarding?** **Resolvido (craft):** A — item “Assessores” no `staffNav` só se unrestricted; bottom nav mobile **não** inclui.
|
||||
- **Create: celular obrigatório?** **Resolvido:** opcional.
|
||||
- **Atribuição em lote no create (municípios no mesmo form)?** **Resolvido:** create só conta; municípios no detalhe com auto-save.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-25 (**E16 ✓** dossiê do município — aba + print + `budgetNotes` G11; **E9 ✓** fila de alocação — desbloqueia E11, e revisou a fórmula da meta sugerida do E8; **E18 ✓** documentação de conceitos (`/campanha/conceitos`); anterior 2026-07-24: B19 gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueou E9/E10/E12/E13; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
|
||||
Atualizado em: 2026-07-25 (**E16 ✓** dossiê do município — aba + print + `budgetNotes` G11; **E9 ✓** fila de alocação — desbloqueia E11, e revisou a fórmula da meta sugerida do E8; **E18 ✓** documentação de conceitos (`/campanha/conceitos`); **B19 ✓** gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueou E9/E10/E12/E13; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
|
||||
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
|
||||
|
||||
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
|
||||
|
|
@ -30,7 +30,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
|
|||
1. **Lote jurídico único** _(externo)_ — textos finais (substituem provisórios) + base LGPD art. 11: `lideranca-autopreenchimento`, `apoiador-cadastro`, `apoiador-intencao-voto`, `campanha-notificacoes-push`, **`campanha-whatsapp-canal`** (D3 — armazenamento/processamento do canal interno staff↔lideranças; se o lote já tiver fechado sem esta chave, entra no próximo passe com a assessoria — não abrir rodada jurídica só por ela), Aviso de Privacidade, avaliação RIPD.
|
||||
2. **Smoke pós-deploy** _(desbloqueado — deploy da remodelagem aplicado em produção em 2026-07-23)_ — `NEXT_PUBLIC_SITE_URL` HTTPS, login `/campanha`, município de teste; checklist no AGENTS.md.
|
||||
3. **Ativação com dados reais** assim que (1) liberar — lideranças/apoiadores reais e import em massa.
|
||||
4. **Onboarding do time** — usuários `coordinator`/`advisor`, primeiros municípios assumidos, treino de campo; **B19** `/campanha/assessores` é o caminho no app (CG/candidato — sem `/admin`); **seed da planilha de prioridades via E4R** (engenharia pronta — `pnpm db:seed:projecao`; aplicar em produção após smoke — [plano](plans/import-planilha-projecao.md)); rede/lideranças (nomes) só após o lote jurídico.
|
||||
4. **Onboarding do time** — usuários `coordinator`/`advisor`, primeiros municípios assumidos, treino de campo; ~~**B19** `/campanha/assessores`~~ (**entregue 2026-07-24** — CG/candidato sem `/admin`); **seed da planilha de prioridades via E4R** (engenharia pronta — `pnpm db:seed:projecao`; aplicar em produção após smoke — [plano](plans/import-planilha-projecao.md)); rede/lideranças (nomes) só após o lote jurídico.
|
||||
|
||||
**O0+** (escala/DRY pós-Onda 0) não bloqueia jurídico nem smoke fictício — [plano](plans/escala-dry-pos-onda0.md).
|
||||
|
||||
|
|
@ -48,6 +48,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
|
|||
- **E17 (2026-07-24)** — tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`): `territoryOverview.ts` (rollup puro client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true`) + `TerritoryOverviewTable.tsx` (tabela densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=<TI>`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Primeira fatia de E12. Plano: [tabela-ti-inicio.md](plans/tabela-ti-inicio.md).
|
||||
- **C12 (2026-07-24)** — registro-fundação: versions nativas em `votePledge` com baseline; sinais tipados em `municipalityUpdate`; origem do `actionPlan`; demandas vinculadas com custo derivado e criação múltipla atômica; `allocationDecision` ex-ante imutável. Migration `20260724_180000_add_campaign_foundation_records`; testes int + E2E. Plano: [registro-fundacao.md](plans/registro-fundacao.md).
|
||||
- **E8 (2026-07-24)** — conta da cadeira: artefato TSE commitado estendido para v2 (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022` presidente/governador T1, via `pnpm build:election-aggregates`); curadoria `src/lib/campoParties.ts` (ano→siglas do campo, validada contra `electionPartySpectrum`); global `campaignGoals` (meta estadual 150 mil + margem, access staff-read/coordinator-write, migration `20260724_180000_add_campaign_goals_global`); `municipalityPotential.ts` (válidos projetados, teto do campo, captura, share intracampo, roll-off 2022-only, meta sugerida + sanity por TI) e `goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = Σ pledges`, nunca a expectativa da mesa); encaixado no dashboard, na visão geral e na lista de municípios (nova coluna "Cobertura da meta"), e num novo card "Conta da cadeira" no detalhe. Plano: [conta-da-cadeira.md](plans/conta-da-cadeira.md). Desbloqueia **E9**, **E10**, **E12**, **E13** (e melhora **B13**/**E16**). _(A fórmula da meta sugerida foi revista no mesmo dia, durante o E9 — ver abaixo.)_
|
||||
- **B19 (2026-07-24)** — gerenciar assessores em `/campanha/assessores` (lista + novo + detalhe): criar/editar contas `advisor`, carteira de municípios (auto-save por delta), reenviar link de senha; só CG/candidato (`isCampaignUnrestricted`); access de update/phone e atribuição `municipality.advisors` alinhados ao `candidate`; leitura privilegiada de e-mail no loader (`reloadUnrestrictedActor`); sem migration. Plano: [gerenciar-assessores.md](plans/gerenciar-assessores.md).
|
||||
- **E9 (2026-07-24)** — fila de alocação na própria lista de municípios (sem rota nova, sem coluna nova, sem migration). Corrigiu antes a meta sintética do E8, que repartia os 150 mil proporcionalmente **só ao teto do campo** e por isso dava meta 2.911 a Vitória da Conquista (5.005 votos em 2022) e 813 a Campo Formoso (47) — ordenar por déficit sobre ela colocaria desertos no topo. `deriveSuggestedGoalsByScenario` (substitui `decomposeStateGoal`) ancora a meta na votação própria de 2022 por cenário: pessimista = base×(1−`margin`), central = base, otimista = base×(`stateGoal` ÷ Σ base), com clamp; `goalCoverage` passou a receber `SuggestedGoalByScenario`. Na fila: `lastPledgeAt` no agregado de pledges, `lastSignalAt` = máx(`lastUpdateAt`, pledge) no view model, sort keys `deficit` (**novo default do staff**) e `frescor` (cenário `central` fixo), frescor dobrado na coluna "Última atualização" (frio ≥ 21 dias), badge "sem responsável" em prioridade alta sem assessor, "coluna da vergonha" no detalhe da métrica de assessoria (`?priority=alta&coverage=sem_assessor`) e copy Praças→Municípios. Cortes: votos em jogo → **B13**, LQ/captura → **E10**, coluna dedicada de déficit → desnecessária. Plano: [fila-de-alocacao.md](plans/fila-de-alocacao.md). Desbloqueia **E11**.
|
||||
- **E16 (2026-07-25)** — dossiê do município (pré-agenda, pedido O6): aba `?tab=dossie` staff-only no detalhe compondo os loaders existentes (`municipalityDossierData.ts` + `MunicipalityDossier.tsx`) — capa com data de geração, série TSE/rank (A11), conta da cadeira (E8), rede de lideranças com frescor, conjuntura/dobradinhas/encaminhamentos, agenda (`actionPlan`), sinais recentes (C12), perfil IBGE (A8 — reentrada do artefato, caveat para zonas); caps 8/5/3+2 com "ver tudo"; campo staff-only `municipality.budgetNotes` ("Emendas aportadas", G11 manual-first, migration `20260725_022155`); **visão print** via CSS (chrome oculto, shells destravados, tipografia A4 — 1–2 páginas). Plano: [dossie-municipio.md](plans/dossie-municipio.md).
|
||||
|
||||
|
|
@ -88,7 +89,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
- **B16** filtros no header das colunas da lista de municípios — relocaciona `region`/`kind`/`trend`/`coverage`/`priority` para o `TableHead` (desktop) ao lado do sort B15; barra slim (busca + Limpar [+ Cenário]); mobile mantém disclosure · ~1–1,5d · Janela 1–2 · soft: B15 ✓ · cortável · [plano](plans/filtros-no-header-lista-municipios.md)
|
||||
- **B17** seletor de colunas (mostrar/ocultar) na lista de municípios — Popover “Colunas” + `localStorage`; desktop only; `name` obrigatória; soft: B15 ✓ (ids) / B16 (barra slim); prepara viewport para **E9** · ~0,5–1d · Janela 1–2 · cortável · [plano](plans/seletor-colunas-lista-municipios.md)
|
||||
- **B18** filtros salvos na lista de municípios — nomear o estado URL atual (`localStorage`); acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo) · ~1–1,5d · Janela 1–2 · soft: B16/B17 (barra) · cortável · [plano](plans/filtros-salvos-municipios.md)
|
||||
- **B19** gerenciar assessores — `/campanha/assessores` (lista + novo + detalhe): criar/editar contas `advisor`, ver/atribuir municípios, reenviar link de senha; **só Coordenador Geral e Candidato** (`isCampaignUnrestricted`); alinha access de update/phone do `candidate` · ~1,5–2d · Janela 1–2 · sem deps duras · não cortável (onboarding Onda 0 §4 — CG sem `/admin`) · [plano](plans/gerenciar-assessores.md)
|
||||
- ~~**B19** gerenciar assessores — `/campanha/assessores` (lista + novo + detalhe): criar/editar contas `advisor`, ver/atribuir municípios, reenviar link de senha; **só Coordenador Geral e Candidato** (`isCampaignUnrestricted`); alinha access de update/phone do `candidate`~~ — **entregue 2026-07-24** (também alinha atribuição `municipality.advisors` a unrestricted; e-mail na lista via leitura privilegiada; auto-save da carteira) · [plano](plans/gerenciar-assessores.md)
|
||||
- ~~**E18** documentação de conceitos de inteligência de campanha~~ — **entregue 2026-07-24** (`/campanha/conceitos` staff-only; 7 conceitos de E8 em `src/lib/campaignIntelligenceConcepts.ts` agrupados em base/diagnóstico/meta; "Saiba mais" por métrica nos tooltips + link de teclado no Popover do card; `formatElectionNumber` passou a arredondar votos) · cada fatia futura (E9/E10/B13/E11/E12/E13/E14) acrescenta sua seção ao array como parte da própria entrega · [plano](plans/documentacao-conceitos-campanha.md)
|
||||
|
||||
### Fill-ins abertos
|
||||
|
|
@ -127,7 +128,7 @@ flowchart TD
|
|||
B16n["B16 Filtros no header<br/>da lista"]
|
||||
B17n["B17 Seletor de colunas<br/>da lista"]
|
||||
B18n["B18 Filtros salvos<br/>+ atalho sidebar"]
|
||||
B19n["B19 Gerenciar assessores"]
|
||||
B19n["B19 ✓ Gerenciar assessores"]
|
||||
E4R["E4R ✓ Import planilha<br/>(seed estratégia)"]
|
||||
A11n["A11 ✓ Posição em votos"]
|
||||
E17n["E17 ✓ Tabela TI no Início"]
|
||||
|
|
@ -179,11 +180,11 @@ flowchart TD
|
|||
B8F2 -.ZE Salvador.-> B14n
|
||||
```
|
||||
|
||||
Paralelizáveis agora: **E10**/**E12** (E8 ✓ desbloqueou ambos), **E11** (E9 ✓ entregue; ainda espera E10), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B16** (filtros no header; soft B15 ✓ já entregue), **B17** (seletor de colunas; soft B15/B16 — pousa na barra atual ou slim), **B18** (filtros salvos + submenu Municípios; soft B16/B17 — botão na barra), **B19** (gerenciar assessores — onboarding sem `/admin`; sem deps duras), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9).
|
||||
Paralelizáveis agora: **E10**/**E12** (E8 ✓ desbloqueou ambos), **E11** (E9 ✓ entregue; ainda espera E10), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B16** (filtros no header; soft B15 ✓ já entregue), **B17** (seletor de colunas; soft B15/B16 — pousa na barra atual ou slim), **B18** (filtros salvos + submenu Municípios; soft B16/B17 — botão na barra), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9).
|
||||
|
||||
### Sequência por janela (só pendentes)
|
||||
|
||||
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras); **B16** (filtros no header) / **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
|
||||
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16** (filtros no header) / **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
|
||||
|
||||
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga.
|
||||
|
||||
|
|
@ -193,7 +194,7 @@ Paralelizáveis agora: **E10**/**E12** (E8 ✓ desbloqueou ambos), **E11** (E9
|
|||
|
||||
### Cortes seguros / não cortáveis
|
||||
|
||||
**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); **B19** gerenciar assessores (onboarding Onda 0 §4 — CG/candidato não entram em `/admin`; sem esta UI o time só nasce via seed ou admin Payload).
|
||||
**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`).
|
||||
|
||||
**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B17** seletor de colunas (tabela completa continua; scroll horizontal); **B16** filtros no header (barra de filtros atual continua a funcionar); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); débitos/fill-ins.
|
||||
|
||||
|
|
|
|||
127
src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx
Normal file
127
src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import config from '@payload-config'
|
||||
import { ArrowLeftIcon, MapPinIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { AdvisorDebouncedTextCell } from '@/components/campaign/AdvisorDebouncedTextCell'
|
||||
import { AdvisorPasswordResetButton } from '@/components/campaign/AdvisorPasswordResetButton'
|
||||
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
|
||||
import { loadAdvisorDetail } from '@/utilities/advisorData'
|
||||
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { formatBrazilianPhoneInput } from '@/utilities/phone'
|
||||
import {
|
||||
sendAdvisorPasswordResetFormAction,
|
||||
updateAdvisorProfileFormAction,
|
||||
} from '../formActions'
|
||||
|
||||
type AdvisorDetailPageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function AdvisorDetailPage({ params }: AdvisorDetailPageProps) {
|
||||
const { id: rawId } = await params
|
||||
const advisorId = Number(rawId)
|
||||
if (!Number.isInteger(advisorId) || advisorId <= 0) notFound()
|
||||
|
||||
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
|
||||
if (!user) redirect('/campanha/login')
|
||||
if (!isCampaignUnrestricted(user)) redirect('/campanha')
|
||||
|
||||
const advisor = await loadAdvisorDetail(payload, advisorId)
|
||||
if (!advisor) notFound()
|
||||
|
||||
const email =
|
||||
advisor.email && !isPlanilhaPlaceholderEmail(advisor.email) ? advisor.email : null
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<Button asChild variant="ghost" className="min-h-11 self-start">
|
||||
<Link href="/campanha/assessores">
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar para assessores
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{advisor.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
E-mail e celular salvam sozinhos ao digitar. A carteira de municípios é editada no modo
|
||||
edição da lista.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="advisor-account-title" className="flex flex-col gap-2">
|
||||
<h2 id="advisor-account-title" className="text-base font-medium">
|
||||
Conta
|
||||
</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<AdvisorDebouncedTextCell
|
||||
advisorId={advisor.id}
|
||||
field="email"
|
||||
type="email"
|
||||
defaultValue={advisor.email ?? ''}
|
||||
placeholder="E-mail"
|
||||
ariaLabel={`E-mail de ${advisor.name}`}
|
||||
placeholderEmailFallback={advisor.email}
|
||||
formAction={updateAdvisorProfileFormAction}
|
||||
/>
|
||||
<AdvisorDebouncedTextCell
|
||||
advisorId={advisor.id}
|
||||
field="phone"
|
||||
type="tel"
|
||||
defaultValue={advisor.phone ? formatBrazilianPhoneInput(advisor.phone) : ''}
|
||||
placeholder="Celular"
|
||||
ariaLabel={`Celular de ${advisor.name}`}
|
||||
formAction={updateAdvisorProfileFormAction}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="advisor-municipalities-title"
|
||||
className="flex flex-col gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPinIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 id="advisor-municipalities-title" className="text-base font-medium">
|
||||
Municípios administrados
|
||||
</h2>
|
||||
<Badge variant="outline">{advisor.municipalities.length}</Badge>
|
||||
</div>
|
||||
{advisor.municipalities.length ? (
|
||||
<ul className="m-0 flex list-none flex-wrap gap-1.5 [&>li]:mt-0">
|
||||
{advisor.municipalities.map((municipality) => (
|
||||
<li key={municipality.id}>
|
||||
<Badge variant="secondary" asChild>
|
||||
<Link
|
||||
href={`/campanha/municipios/${municipality.slug}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{municipality.name}
|
||||
</Link>
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nenhum município atribuído.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="advisor-password-title" className="flex flex-col gap-3">
|
||||
<h2 id="advisor-password-title" className="text-base font-medium">
|
||||
Acesso
|
||||
</h2>
|
||||
<AdvisorPasswordResetButton
|
||||
advisorId={advisor.id}
|
||||
disabled={!email}
|
||||
formAction={sendAdvisorPasswordResetFormAction}
|
||||
/>
|
||||
</section>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
159
src/app/(campaign)/campanha/(app)/assessores/formActions.ts
Normal file
159
src/app/(campaign)/campanha/(app)/assessores/formActions.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
'use server'
|
||||
|
||||
import {
|
||||
createAdvisor,
|
||||
sendAdvisorPasswordReset,
|
||||
setAdvisorMunicipalitiesBatch,
|
||||
setAdvisorMunicipalityMembership,
|
||||
updateAdvisorProfile,
|
||||
} from '@/app/(campaign)/campanha/actions/advisor'
|
||||
import {
|
||||
nullableFormText,
|
||||
requiredFormText,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import { ADVISOR_ACTION_SAFE_MESSAGES } from '@/lib/schemas/advisor'
|
||||
import {
|
||||
mapCampaignFormActionError,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
|
||||
const parseMunicipalityIds = (formData: FormData): number[] => {
|
||||
const raw = formData.getAll('municipalityIds')
|
||||
const ids = raw
|
||||
.map((value) => (typeof value === 'string' ? Number(value) : NaN))
|
||||
.filter((id) => Number.isInteger(id) && id > 0)
|
||||
return [...new Set(ids)]
|
||||
}
|
||||
|
||||
export const updateAdvisorProfileFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> => {
|
||||
try {
|
||||
const id = requiredRelationshipFormValue(formData, 'advisorId')
|
||||
const field = requiredFormText(formData, 'field')
|
||||
|
||||
if (field === 'name') {
|
||||
await updateAdvisorProfile({ id, name: requiredFormText(formData, 'name') })
|
||||
} else if (field === 'email') {
|
||||
await updateAdvisorProfile({ id, email: requiredFormText(formData, 'email') })
|
||||
} else if (field === 'phone') {
|
||||
await updateAdvisorProfile({
|
||||
id,
|
||||
phone: nullableFormText(formData, 'phone'),
|
||||
})
|
||||
} else {
|
||||
return { message: 'Campo inválido.' }
|
||||
}
|
||||
|
||||
return { status: 'success', message: 'Salvo.' }
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages: ADVISOR_ACTION_SAFE_MESSAGES,
|
||||
genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const setAdvisorMunicipalityMembershipFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> => {
|
||||
try {
|
||||
const assignedRaw = requiredFormText(formData, 'assigned')
|
||||
await setAdvisorMunicipalityMembership({
|
||||
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
|
||||
municipalityId: requiredRelationshipFormValue(formData, 'municipalityId'),
|
||||
assigned: assignedRaw === 'true',
|
||||
})
|
||||
return { status: 'success', message: 'Carteira atualizada.' }
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages: ADVISOR_ACTION_SAFE_MESSAGES,
|
||||
genericMessage: 'Não foi possível atualizar a carteira do assessor.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const setAdvisorMunicipalitiesBatchFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> => {
|
||||
try {
|
||||
const municipalityIds = parseMunicipalityIds(formData)
|
||||
if (municipalityIds.length === 0) {
|
||||
return { message: 'Selecione ao menos um município.' }
|
||||
}
|
||||
const assignedRaw = requiredFormText(formData, 'assigned')
|
||||
await setAdvisorMunicipalitiesBatch({
|
||||
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
|
||||
municipalityIds,
|
||||
assigned: assignedRaw === 'true',
|
||||
})
|
||||
return { status: 'success', message: 'Carteira atualizada.' }
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages: ADVISOR_ACTION_SAFE_MESSAGES,
|
||||
genericMessage: 'Não foi possível atualizar a carteira do assessor.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const sendAdvisorPasswordResetFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> => {
|
||||
try {
|
||||
const result = await sendAdvisorPasswordReset({
|
||||
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
|
||||
})
|
||||
return {
|
||||
status: 'success',
|
||||
message: `Link de redefinição enviado para ${result.email}.`,
|
||||
}
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages: ADVISOR_ACTION_SAFE_MESSAGES,
|
||||
genericMessage: 'Não foi possível enviar o link de redefinição de senha.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const createAdvisorFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState & { advisorId?: number }> => {
|
||||
try {
|
||||
const created = await createAdvisor({
|
||||
name: requiredFormText(formData, 'name'),
|
||||
email: requiredFormText(formData, 'email'),
|
||||
phone: nullableFormText(formData, 'phone') ?? undefined,
|
||||
})
|
||||
|
||||
const municipalityIds = parseMunicipalityIds(formData)
|
||||
if (municipalityIds.length > 0) {
|
||||
await setAdvisorMunicipalitiesBatch({
|
||||
advisorId: created.id,
|
||||
municipalityIds,
|
||||
assigned: true,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Assessor criado.',
|
||||
advisorId: created.id,
|
||||
}
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages: ADVISOR_ACTION_SAFE_MESSAGES,
|
||||
genericMessage: 'Não foi possível criar o assessor. Verifique os dados e tente novamente.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
/** Inline create lives on the assessors list — keep old bookmarks working. */
|
||||
export default function AdvisorNewRedirectPage() {
|
||||
redirect('/campanha/assessores')
|
||||
}
|
||||
91
src/app/(campaign)/campanha/(app)/assessores/page.tsx
Normal file
91
src/app/(campaign)/campanha/(app)/assessores/page.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import config from '@payload-config'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/CampaignListPending'
|
||||
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
|
||||
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
||||
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
|
||||
import { AdvisorsTable } from '@/components/campaign/AdvisorsTable'
|
||||
import {
|
||||
advisorListHrefForPage,
|
||||
loadAdvisorListPageData,
|
||||
loadAdvisorMunicipalityIndex,
|
||||
parseAdvisorListParams,
|
||||
} from '@/utilities/advisorData'
|
||||
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import {
|
||||
createAdvisorFormAction,
|
||||
sendAdvisorPasswordResetFormAction,
|
||||
setAdvisorMunicipalitiesBatchFormAction,
|
||||
setAdvisorMunicipalityMembershipFormAction,
|
||||
updateAdvisorProfileFormAction,
|
||||
} from './formActions'
|
||||
|
||||
type AdvisorsPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) {
|
||||
const rawSearchParams = await searchParams
|
||||
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
|
||||
if (!user) redirect('/campanha/login')
|
||||
if (!isCampaignUnrestricted(user)) redirect('/campanha')
|
||||
|
||||
const state = parseAdvisorListParams(rawSearchParams)
|
||||
const [{ rows, totalDocs, totalPages }, municipalityIndex] = await Promise.all([
|
||||
loadAdvisorListPageData(payload, state),
|
||||
loadAdvisorMunicipalityIndex(payload),
|
||||
])
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Assessores</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Consulte na tabela; ative Editar para alterar campos e carteira. Nome abre a ficha;
|
||||
e-mail/celular copiam; município abre o município.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignSearchForm
|
||||
ariaLabel="Buscar assessor por nome ou e-mail"
|
||||
placeholder="Buscar por nome ou e-mail…"
|
||||
initialQuery={state.q ?? ''}
|
||||
basePath="/campanha/assessores"
|
||||
/>
|
||||
|
||||
<CampaignListResults>
|
||||
<AdvisorsTable
|
||||
rows={rows}
|
||||
municipalityIndex={municipalityIndex}
|
||||
hasQuery={Boolean(state.q)}
|
||||
updateProfileAction={updateAdvisorProfileFormAction}
|
||||
membershipAction={setAdvisorMunicipalityMembershipFormAction}
|
||||
batchAction={setAdvisorMunicipalitiesBatchFormAction}
|
||||
createAction={createAdvisorFormAction}
|
||||
passwordResetAction={sendAdvisorPasswordResetFormAction}
|
||||
/>
|
||||
|
||||
{totalDocs > 0 ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalDocs} {totalDocs === 1 ? 'assessor' : 'assessores'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={(page) => advisorListHrefForPage(state, page)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -82,7 +82,7 @@ export const assignMunicipalityAdvisorsFormAction = async (
|
|||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
error,
|
||||
safeMessages: ['Somente o Coordenador Geral designa assessores.'],
|
||||
safeMessages: ['Somente a coordenação geral ou o candidato designa assessores.'],
|
||||
genericMessage:
|
||||
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
|
||||
})
|
||||
|
|
|
|||
354
src/app/(campaign)/campanha/actions/advisor.ts
Normal file
354
src/app/(campaign)/campanha/actions/advisor.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
'use server'
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import {
|
||||
ADVISOR_EMAIL_CONFLICT_MESSAGE,
|
||||
ADVISOR_UNRESTRICTED_MESSAGE,
|
||||
PLACEHOLDER_RESET_MESSAGE,
|
||||
advisorCreateSchema,
|
||||
advisorMunicipalitiesBatchSchema,
|
||||
advisorMunicipalityMembershipSchema,
|
||||
advisorPasswordResetSchema,
|
||||
advisorProfileUpdateSchema,
|
||||
isPlanilhaPlaceholderEmail,
|
||||
type AdvisorCreateInput,
|
||||
type AdvisorMunicipalitiesBatchInput,
|
||||
type AdvisorMunicipalityMembershipInput,
|
||||
type AdvisorPasswordResetInput,
|
||||
type AdvisorProfileUpdateInput,
|
||||
} from '@/lib/schemas/advisor'
|
||||
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import {
|
||||
getCampaignActionContext,
|
||||
reloadUnrestrictedActor,
|
||||
} from '@/utilities/campaignActionContext'
|
||||
import {
|
||||
assertCampaignEmailConfigured,
|
||||
isCampaignEmailConfigured,
|
||||
} from '@/utilities/campaignPasswordReset'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
|
||||
const revalidateAdvisorPaths = (advisorId?: number) => {
|
||||
revalidatePath('/campanha/assessores', 'page')
|
||||
if (advisorId !== undefined) {
|
||||
revalidatePath(`/campanha/assessores/${advisorId}`, 'page')
|
||||
}
|
||||
}
|
||||
|
||||
type AdvisorAccount = {
|
||||
id: number
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
role: CampaignUser['role']
|
||||
}
|
||||
|
||||
const assertTargetAdvisor = async (
|
||||
payload: Payload,
|
||||
advisorId: number,
|
||||
actorId: number,
|
||||
): Promise<AdvisorAccount> => {
|
||||
const target = await payload.findByID({
|
||||
collection: 'campaignUser',
|
||||
id: advisorId,
|
||||
depth: 0,
|
||||
select: { name: true, email: true, phone: true, role: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
if (target.role !== 'advisor') {
|
||||
throw new Error('Só é possível gerenciar contas com papel de Assessor nesta tela.')
|
||||
}
|
||||
if (String(target.id) === String(actorId)) {
|
||||
throw new Error('Use Meu perfil para alterar a própria conta.')
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
const translateUniqueEmailConflict = (error: unknown): never => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// Payload may surface the unique email collision as email, username, or a
|
||||
// generic "field is invalid" ValidationError depending on auth config.
|
||||
if (/email|username|duplicate key|unique/i.test(message)) {
|
||||
throw new Error(ADVISOR_EMAIL_CONFLICT_MESSAGE)
|
||||
}
|
||||
throw error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next advisor-id list after applying one membership change, or
|
||||
* `null` when the municipality is already in the desired state (no write
|
||||
* needed). Shared by the single-toggle and batch (territory/ZE) mutations.
|
||||
*/
|
||||
const nextAdvisorIdsAfterMembership = (
|
||||
currentAdvisorIDs: readonly number[],
|
||||
advisorId: number,
|
||||
assigned: boolean,
|
||||
): number[] | null => {
|
||||
const alreadyAssigned = currentAdvisorIDs.includes(advisorId)
|
||||
if (assigned === alreadyAssigned) return null
|
||||
|
||||
if (assigned) {
|
||||
if (currentAdvisorIDs.length >= MAX_ADVISORS_PER_MUNICIPALITY) {
|
||||
throw new Error(`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`)
|
||||
}
|
||||
return [...currentAdvisorIDs, advisorId]
|
||||
}
|
||||
return currentAdvisorIDs.filter((id) => id !== advisorId)
|
||||
}
|
||||
|
||||
export const createAdvisorRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: AdvisorCreateInput,
|
||||
) => {
|
||||
const data = advisorCreateSchema.parse(input)
|
||||
const currentActor = await reloadUnrestrictedActor(payload, actor, ADVISOR_UNRESTRICTED_MESSAGE)
|
||||
|
||||
try {
|
||||
const created = await payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: hookFilledCreateData<'campaignUser'>({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
role: 'advisor',
|
||||
password: randomBytes(24).toString('base64url'),
|
||||
...(data.phone !== undefined ? { phone: data.phone } : {}),
|
||||
}),
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
return created
|
||||
} catch (error) {
|
||||
return translateUniqueEmailConflict(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const createAdvisor = async (input: AdvisorCreateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const created = await createAdvisorRecord(payload, actor, input)
|
||||
revalidateAdvisorPaths()
|
||||
return created
|
||||
}
|
||||
|
||||
export const updateAdvisorProfileRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: AdvisorProfileUpdateInput,
|
||||
) => {
|
||||
const { id, ...fields } = advisorProfileUpdateSchema.parse(input)
|
||||
const currentActor = await reloadUnrestrictedActor(payload, actor, ADVISOR_UNRESTRICTED_MESSAGE)
|
||||
await assertTargetAdvisor(payload, id, currentActor.id)
|
||||
|
||||
const data: {
|
||||
name?: string
|
||||
email?: string
|
||||
phone?: string | null
|
||||
} = {}
|
||||
if (fields.name !== undefined) data.name = fields.name
|
||||
if (fields.email !== undefined) data.email = fields.email
|
||||
if (fields.phone !== undefined) data.phone = fields.phone
|
||||
|
||||
try {
|
||||
const updated = await payload.update({
|
||||
collection: 'campaignUser',
|
||||
id,
|
||||
data,
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
return updated
|
||||
} catch (error) {
|
||||
return translateUniqueEmailConflict(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const updateAdvisorProfile = async (input: AdvisorProfileUpdateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const updated = await updateAdvisorProfileRecord(payload, actor, input)
|
||||
revalidateAdvisorPaths(updated.id)
|
||||
return updated
|
||||
}
|
||||
|
||||
export const setAdvisorMunicipalityMembershipRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: AdvisorMunicipalityMembershipInput,
|
||||
) => {
|
||||
const { advisorId, municipalityId, assigned } = advisorMunicipalityMembershipSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadUnrestrictedActor(
|
||||
payload,
|
||||
actor,
|
||||
ADVISOR_UNRESTRICTED_MESSAGE,
|
||||
req,
|
||||
)
|
||||
await assertTargetAdvisor(payload, advisorId, currentActor.id)
|
||||
|
||||
await acquireTextAdvisoryLocks(payload, req, [`municipality-advisors:${municipalityId}`])
|
||||
|
||||
const municipality = await payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipalityId,
|
||||
depth: 0,
|
||||
select: { advisors: true, slug: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
const currentAdvisorIDs = [
|
||||
...new Set(
|
||||
(municipality.advisors ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null),
|
||||
),
|
||||
]
|
||||
const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisorId, assigned)
|
||||
if (nextAdvisorIDs === null) return municipality
|
||||
|
||||
// Intentional admin bypass: unrestricted role verified above; advisors
|
||||
// field update access is admin-only in the collection config.
|
||||
const updated = await payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipalityId,
|
||||
data: { advisors: nextAdvisorIDs },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
return updated
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível atualizar a carteira do assessor.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const setAdvisorMunicipalityMembership = async (
|
||||
input: AdvisorMunicipalityMembershipInput,
|
||||
) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const municipality = await setAdvisorMunicipalityMembershipRecord(payload, actor, input)
|
||||
revalidateMunicipalityListPaths({
|
||||
slug: typeof municipality.slug === 'string' ? municipality.slug : undefined,
|
||||
})
|
||||
revalidateAdvisorPaths(input.advisorId)
|
||||
return municipality
|
||||
}
|
||||
|
||||
export const setAdvisorMunicipalitiesBatchRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: AdvisorMunicipalitiesBatchInput,
|
||||
) => {
|
||||
const { advisorId, municipalityIds, assigned } = advisorMunicipalitiesBatchSchema.parse(input)
|
||||
const uniqueMunicipalityIds = [...new Set(municipalityIds)].sort((left, right) => left - right)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadUnrestrictedActor(
|
||||
payload,
|
||||
actor,
|
||||
ADVISOR_UNRESTRICTED_MESSAGE,
|
||||
req,
|
||||
)
|
||||
await assertTargetAdvisor(payload, advisorId, currentActor.id)
|
||||
|
||||
await acquireTextAdvisoryLocks(
|
||||
payload,
|
||||
req,
|
||||
uniqueMunicipalityIds.map((id) => `municipality-advisors:${id}`),
|
||||
)
|
||||
|
||||
let lastSlug: string | undefined
|
||||
|
||||
for (const municipalityId of uniqueMunicipalityIds) {
|
||||
const municipality = await payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipalityId,
|
||||
depth: 0,
|
||||
select: { advisors: true, slug: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
const currentAdvisorIDs = [
|
||||
...new Set(
|
||||
(municipality.advisors ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null),
|
||||
),
|
||||
]
|
||||
const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisorId, assigned)
|
||||
if (nextAdvisorIDs === null) continue
|
||||
|
||||
// Intentional admin bypass: unrestricted role verified above; advisors
|
||||
// field update access is admin-only in the collection config.
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipalityId,
|
||||
data: { advisors: nextAdvisorIDs },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (typeof municipality.slug === 'string') lastSlug = municipality.slug
|
||||
}
|
||||
|
||||
return { slug: lastSlug }
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível atualizar a carteira do assessor.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const setAdvisorMunicipalitiesBatch = async (input: AdvisorMunicipalitiesBatchInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const result = await setAdvisorMunicipalitiesBatchRecord(payload, actor, input)
|
||||
revalidateMunicipalityListPaths({ slug: result.slug })
|
||||
revalidateAdvisorPaths(input.advisorId)
|
||||
return result
|
||||
}
|
||||
|
||||
export const sendAdvisorPasswordResetRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: AdvisorPasswordResetInput,
|
||||
) => {
|
||||
const { advisorId } = advisorPasswordResetSchema.parse(input)
|
||||
const currentActor = await reloadUnrestrictedActor(payload, actor, ADVISOR_UNRESTRICTED_MESSAGE)
|
||||
const target = await assertTargetAdvisor(payload, advisorId, currentActor.id)
|
||||
|
||||
if (!target.email || isPlanilhaPlaceholderEmail(target.email)) {
|
||||
throw new Error(PLACEHOLDER_RESET_MESSAGE)
|
||||
}
|
||||
|
||||
assertCampaignEmailConfigured()
|
||||
|
||||
await payload.forgotPassword({
|
||||
collection: 'campaignUser',
|
||||
data: { email: target.email },
|
||||
disableEmail: !isCampaignEmailConfigured(),
|
||||
})
|
||||
|
||||
return { email: target.email }
|
||||
}
|
||||
|
||||
export const sendAdvisorPasswordReset = async (input: AdvisorPasswordResetInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return sendAdvisorPasswordResetRecord(payload, actor, input)
|
||||
}
|
||||
|
|
@ -15,8 +15,8 @@ import {
|
|||
import type { CampaignUser } from '@/payload-types'
|
||||
import {
|
||||
getCampaignActionContext,
|
||||
reloadCoordinatorActor,
|
||||
reloadStaffActor,
|
||||
reloadUnrestrictedActor,
|
||||
} from '@/utilities/campaignActionContext'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
|
@ -124,7 +124,7 @@ export const setMunicipalityExpectedVotes = async (input: MunicipalityExpectedVo
|
|||
return setMunicipalityExpectedVotesRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
/** Advisor assignment is coordinator-only; the collection hook validates eligibility. */
|
||||
/** Advisor assignment is unrestricted staff (coordinator + candidate); the hook validates eligibility. */
|
||||
export const assignMunicipalityAdvisorsRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
|
|
@ -135,16 +135,16 @@ export const assignMunicipalityAdvisorsRecord = async (
|
|||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
await reloadCoordinatorActor(
|
||||
await reloadUnrestrictedActor(
|
||||
payload,
|
||||
actor,
|
||||
'Somente o Coordenador Geral designa assessores.',
|
||||
'Somente a coordenação geral ou o candidato designa assessores.',
|
||||
req,
|
||||
)
|
||||
|
||||
await acquireTextAdvisoryLocks(payload, req, [`municipality-advisors:${municipality}`])
|
||||
|
||||
// Intentional admin bypass: coordinator role was freshly verified above;
|
||||
// Intentional admin bypass: unrestricted role was freshly verified above;
|
||||
// the advisors field is admin-only by field access.
|
||||
return payload.update({
|
||||
collection: 'municipality',
|
||||
|
|
|
|||
145
src/components/campaign/AdvisorDebouncedTextCell.tsx
Normal file
145
src/components/campaign/AdvisorDebouncedTextCell.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import {
|
||||
isPlanilhaPlaceholderEmail,
|
||||
planilhaPlaceholderEmailForAdvisor,
|
||||
} from '@/lib/schemas/advisor'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 500
|
||||
|
||||
type AdvisorDebouncedTextCellProps = {
|
||||
advisorId: number
|
||||
field: 'name' | 'email' | 'phone'
|
||||
defaultValue: string
|
||||
type?: 'text' | 'email' | 'tel'
|
||||
placeholder?: string
|
||||
ariaLabel: string
|
||||
/** When set, empty e-mail saves this (or a generated) @planilha.invalid address. */
|
||||
placeholderEmailFallback?: string | null
|
||||
formAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
export const AdvisorDebouncedTextCell = ({
|
||||
advisorId,
|
||||
field,
|
||||
defaultValue,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
placeholderEmailFallback,
|
||||
formAction,
|
||||
}: AdvisorDebouncedTextCellProps) => {
|
||||
const displayDefault =
|
||||
field === 'email' && isPlanilhaPlaceholderEmail(defaultValue) ? '' : defaultValue
|
||||
const router = useRouter()
|
||||
const [value, setValue] = useState(displayDefault)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const lastSaved = useRef(displayDefault.trim())
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const requestId = useRef(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// A refresh triggered by our own save must never clobber what is being typed.
|
||||
if (inputRef.current && document.activeElement === inputRef.current) return
|
||||
const nextDisplay =
|
||||
field === 'email' && isPlanilhaPlaceholderEmail(defaultValue) ? '' : defaultValue
|
||||
setValue(nextDisplay)
|
||||
lastSaved.current = nextDisplay.trim()
|
||||
}, [defaultValue, advisorId, field])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resolveEmailToPersist = (nextRaw: string): string => {
|
||||
const next = nextRaw.trim()
|
||||
if (next) return next
|
||||
if (placeholderEmailFallback && isPlanilhaPlaceholderEmail(placeholderEmailFallback)) {
|
||||
return placeholderEmailFallback.trim().toLowerCase()
|
||||
}
|
||||
if (isPlanilhaPlaceholderEmail(defaultValue)) return defaultValue.trim().toLowerCase()
|
||||
return planilhaPlaceholderEmailForAdvisor(advisorId)
|
||||
}
|
||||
|
||||
const save = (nextRaw: string) => {
|
||||
const displayNext = nextRaw.trim()
|
||||
if (displayNext === lastSaved.current) return
|
||||
|
||||
const persisted =
|
||||
field === 'email' ? resolveEmailToPersist(nextRaw) : displayNext
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set('advisorId', String(advisorId))
|
||||
formData.set('field', field)
|
||||
formData.set(field, persisted)
|
||||
|
||||
const id = ++requestId.current
|
||||
setIsPending(true)
|
||||
void formAction({}, formData).then((result) => {
|
||||
if (id !== requestId.current) return
|
||||
setIsPending(false)
|
||||
if (result.status === 'success') {
|
||||
lastSaved.current = displayNext
|
||||
// Keeps derived UI (WhatsApp action, password-reset availability) in sync.
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
toast.error(result.message ?? 'Não foi possível salvar.')
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleSave = (nextRaw: string) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
save(nextRaw)
|
||||
}, SAVE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-w-40">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={isPending}
|
||||
className="min-h-10 border-transparent bg-transparent px-2 shadow-none hover:border-input focus-visible:border-input"
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const next =
|
||||
type === 'tel'
|
||||
? formatBrazilianPhoneInput(sanitizeBrazilianPhoneInput(event.currentTarget.value))
|
||||
: event.currentTarget.value
|
||||
setValue(next)
|
||||
scheduleSave(next)
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
save(value)
|
||||
}}
|
||||
{...(type === 'tel' ? { inputMode: 'numeric' as const } : {})}
|
||||
/>
|
||||
{isPending ? (
|
||||
<Spinner className="pointer-events-none absolute top-1/2 right-2 size-3.5 -translate-y-1/2" />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
471
src/components/campaign/AdvisorMunicipalityCell.tsx
Normal file
471
src/components/campaign/AdvisorMunicipalityCell.tsx
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
'use client'
|
||||
|
||||
import { MapPinIcon, XIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import {
|
||||
buildAdvisorPortfolioChips,
|
||||
searchAdvisorPortfolio,
|
||||
type AdvisorMunicipalityIndexEntry,
|
||||
type AdvisorPortfolioChip,
|
||||
type AdvisorPortfolioSearchHit,
|
||||
} from '@/lib/advisorMunicipalityPortfolio'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
type MunicipalityRef = {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
/** Same box in both modes so toggling edition never reflows the row. */
|
||||
const CELL_WRAPPER_CLASS = 'relative min-w-56 rounded-md border border-transparent p-1'
|
||||
const CHIP_ROW_CLASS = 'flex min-h-8 flex-wrap items-center gap-1.5 overflow-hidden'
|
||||
|
||||
const COLLAPSED_CHIP_ROWS = 3
|
||||
/** Matches `gap-1.5` on the chip row. */
|
||||
const CHIP_GAP_PX = 6
|
||||
|
||||
type AdvisorMunicipalityCellProps = {
|
||||
advisorId: number | null
|
||||
municipalities: MunicipalityRef[]
|
||||
municipalityIndex: readonly AdvisorMunicipalityIndexEntry[]
|
||||
editing: boolean
|
||||
/** Draft mode (new row): keep changes local and report via onChange. */
|
||||
draft?: boolean
|
||||
onDraftChange?: (municipalities: MunicipalityRef[]) => void
|
||||
membershipAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
batchAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
onPersisted?: () => void
|
||||
}
|
||||
|
||||
export const AdvisorMunicipalityCell = ({
|
||||
advisorId,
|
||||
municipalities,
|
||||
municipalityIndex,
|
||||
editing,
|
||||
draft = false,
|
||||
onDraftChange,
|
||||
membershipAction,
|
||||
batchAction,
|
||||
onPersisted,
|
||||
}: AdvisorMunicipalityCellProps) => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [optimistic, setOptimistic] = useState<MunicipalityRef[] | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [collapsedHeight, setCollapsedHeight] = useState<number | null>(null)
|
||||
/** `null` means "render every chip so the next effect can measure them". */
|
||||
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
|
||||
const [measureToken, setMeasureToken] = useState(0)
|
||||
const lastRowWidth = useRef(0)
|
||||
const [, startTransition] = useTransition()
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const chipRowRef = useRef<HTMLDivElement>(null)
|
||||
const baselineRef = useRef(municipalities)
|
||||
|
||||
useEffect(() => {
|
||||
baselineRef.current = municipalities
|
||||
setOptimistic((current) => {
|
||||
if (!current) return null
|
||||
const baselineKey = municipalities
|
||||
.map((municipality) => municipality.id)
|
||||
.sort((left, right) => left - right)
|
||||
.join(',')
|
||||
const currentKey = current
|
||||
.map((municipality) => municipality.id)
|
||||
.sort((left, right) => left - right)
|
||||
.join(',')
|
||||
return baselineKey === currentKey ? null : current
|
||||
})
|
||||
}, [municipalities])
|
||||
|
||||
const effectiveMunicipalities = optimistic ?? municipalities
|
||||
|
||||
const assignedIds = useMemo(
|
||||
() => new Set(effectiveMunicipalities.map((municipality) => municipality.id)),
|
||||
[effectiveMunicipalities],
|
||||
)
|
||||
const chips = useMemo(
|
||||
() => buildAdvisorPortfolioChips(effectiveMunicipalities, municipalityIndex),
|
||||
[effectiveMunicipalities, municipalityIndex],
|
||||
)
|
||||
const hits = useMemo(
|
||||
() => (open && editing ? searchAdvisorPortfolio(query, municipalityIndex, assignedIds) : []),
|
||||
[open, editing, query, municipalityIndex, assignedIds],
|
||||
)
|
||||
|
||||
/**
|
||||
* The token is what makes a stale measurement recoverable: an invalidation
|
||||
* batched with a measurement would otherwise settle back on `null` without
|
||||
* changing any dependency, leaving the cell unmeasured forever.
|
||||
*/
|
||||
const invalidateMeasurement = useCallback(() => {
|
||||
setVisibleChipCount(null)
|
||||
setMeasureToken((token) => token + 1)
|
||||
}, [])
|
||||
|
||||
// Keyed by content, not array identity: a re-render that rebuilds the same
|
||||
// chips must not throw away a measurement that is still valid. Toggling edit
|
||||
// does invalidate it — removable chips carry an extra "×" and are wider.
|
||||
const chipsKey = chips.map((chip) => chip.key).join('|')
|
||||
useEffect(() => {
|
||||
invalidateMeasurement()
|
||||
}, [chipsKey, editing, invalidateMeasurement])
|
||||
|
||||
useEffect(() => {
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
// Seeded here so the observer's initial callback isn't read as a resize.
|
||||
lastRowWidth.current = row.clientWidth
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (row.clientWidth === lastRowWidth.current) return
|
||||
lastRowWidth.current = row.clientWidth
|
||||
invalidateMeasurement()
|
||||
})
|
||||
observer.observe(row)
|
||||
return () => observer.disconnect()
|
||||
}, [invalidateMeasurement])
|
||||
|
||||
/**
|
||||
* Runs on the frames where every chip is in the DOM, so the packing of the
|
||||
* three visible rows — and the room the toggle needs on the third one — is
|
||||
* read from the real layout instead of estimated from label lengths.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (visibleChipCount !== null) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
|
||||
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-portfolio-chip]')]
|
||||
if (chipElements.length === 0) {
|
||||
setVisibleChipCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
|
||||
const chipHeight = chipRects[0].height
|
||||
setCollapsedHeight(chipHeight * COLLAPSED_CHIP_ROWS + CHIP_GAP_PX * (COLLAPSED_CHIP_ROWS - 1))
|
||||
|
||||
const lastVisibleTop = chipRects[0].top + (chipHeight + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
|
||||
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
|
||||
|
||||
// The toggle — and, while editing, the search input — must share the last
|
||||
// visible line, otherwise they wrap into a line nobody asked for.
|
||||
const toggleWidth =
|
||||
row.querySelector<HTMLElement>('[data-portfolio-toggle]')?.getBoundingClientRect().width ?? 0
|
||||
const inputWidth = inputRef.current
|
||||
? Number.parseFloat(getComputedStyle(inputRef.current).minWidth) || 0
|
||||
: 0
|
||||
const trailingWidth = toggleWidth + CHIP_GAP_PX + inputWidth
|
||||
while (fitting > 0 && fitting < chipElements.length) {
|
||||
const trailing = chipRects[fitting - 1]
|
||||
if (trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
|
||||
fitting -= 1
|
||||
}
|
||||
|
||||
setVisibleChipCount(fitting)
|
||||
}, [measureToken, visibleChipCount])
|
||||
|
||||
const indexById = useMemo(() => {
|
||||
const map = new Map<number, AdvisorMunicipalityIndexEntry>()
|
||||
for (const entry of municipalityIndex) map.set(entry.id, entry)
|
||||
return map
|
||||
}, [municipalityIndex])
|
||||
|
||||
const refsFromIds = (ids: number[]): MunicipalityRef[] =>
|
||||
[...new Set(ids)]
|
||||
.map((id) => {
|
||||
const existing = effectiveMunicipalities.find((municipality) => municipality.id === id)
|
||||
if (existing) return existing
|
||||
const fromBaseline = baselineRef.current.find((municipality) => municipality.id === id)
|
||||
if (fromBaseline) return fromBaseline
|
||||
const entry = indexById.get(id)
|
||||
return entry ? { id: entry.id, name: entry.name, slug: entry.slug } : null
|
||||
})
|
||||
.filter((municipality): municipality is MunicipalityRef => municipality !== null)
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
|
||||
|
||||
const applyLocal = (nextIds: number[]) => {
|
||||
const next = refsFromIds(nextIds)
|
||||
if (draft) {
|
||||
onDraftChange?.(next)
|
||||
return
|
||||
}
|
||||
setOptimistic(next)
|
||||
}
|
||||
|
||||
const revertOptimistic = () => {
|
||||
setOptimistic(null)
|
||||
}
|
||||
|
||||
const runMembership = (municipalityId: number, assigned: boolean) => {
|
||||
const next = new Set(assignedIds)
|
||||
if (assigned) next.add(municipalityId)
|
||||
else next.delete(municipalityId)
|
||||
applyLocal([...next])
|
||||
setQuery('')
|
||||
setOpen(false)
|
||||
|
||||
if (draft) return
|
||||
if (advisorId === null) return
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set('advisorId', String(advisorId))
|
||||
formData.set('municipalityId', String(municipalityId))
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await membershipAction({}, formData)
|
||||
if (result.status === 'success') {
|
||||
onPersisted?.()
|
||||
return
|
||||
}
|
||||
revertOptimistic()
|
||||
toast.error(result.message ?? 'Não foi possível atualizar a carteira.')
|
||||
})
|
||||
}
|
||||
|
||||
const runBatch = (municipalityIds: number[], assigned: boolean) => {
|
||||
if (municipalityIds.length === 0) return
|
||||
const next = new Set(assignedIds)
|
||||
for (const id of municipalityIds) {
|
||||
if (assigned) next.add(id)
|
||||
else next.delete(id)
|
||||
}
|
||||
applyLocal([...next])
|
||||
setQuery('')
|
||||
setOpen(false)
|
||||
|
||||
if (draft) return
|
||||
if (advisorId === null) return
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set('advisorId', String(advisorId))
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
for (const id of municipalityIds) formData.append('municipalityIds', String(id))
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await batchAction({}, formData)
|
||||
if (result.status === 'success') {
|
||||
onPersisted?.()
|
||||
return
|
||||
}
|
||||
revertOptimistic()
|
||||
toast.error(result.message ?? 'Não foi possível atualizar a carteira.')
|
||||
})
|
||||
}
|
||||
|
||||
const removeChip = (chip: AdvisorPortfolioChip) => {
|
||||
if (chip.kind === 'territory') {
|
||||
runBatch(chip.municipalityIds, false)
|
||||
return
|
||||
}
|
||||
runMembership(chip.municipalityId, false)
|
||||
}
|
||||
|
||||
const pickHit = (hit: AdvisorPortfolioSearchHit) => {
|
||||
if (hit.kind === 'municipality') {
|
||||
runMembership(hit.municipalityId, true)
|
||||
return
|
||||
}
|
||||
runBatch(hit.municipalityIds, true)
|
||||
}
|
||||
|
||||
// Editing needs every chip reachable, so focusing the cell always expands it.
|
||||
const showAllChips = expanded || (editing && open)
|
||||
const measuring = visibleChipCount === null
|
||||
const hasHiddenChips = visibleChipCount !== null && visibleChipCount < chips.length
|
||||
const visibleChips = showAllChips || measuring ? chips : chips.slice(0, visibleChipCount)
|
||||
/**
|
||||
* Only the pre-measurement render needs a height cap; once the chip list is
|
||||
* trimmed the row is three lines tall on its own, and capping it again would
|
||||
* clip whatever shares the last line (the search input is taller than a chip).
|
||||
*/
|
||||
const clamping = measuring && !showAllChips
|
||||
const chipRowStyle =
|
||||
clamping && collapsedHeight !== null ? { maxHeight: collapsedHeight } : undefined
|
||||
|
||||
const expandToggle =
|
||||
measuring || hasHiddenChips ? (
|
||||
<button
|
||||
type="button"
|
||||
data-portfolio-toggle
|
||||
className="px-1 text-xs font-medium text-primary underline-offset-4 hover:underline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
const next = !showAllChips
|
||||
setExpanded(next)
|
||||
// While editing, focus is what keeps the cell open — collapsing must release it.
|
||||
if (!next) {
|
||||
setOpen(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showAllChips ? 'Ver menos' : 'Ver mais…'}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className={CELL_WRAPPER_CLASS}>
|
||||
<div
|
||||
ref={chipRowRef}
|
||||
className={cn(CHIP_ROW_CLASS, clamping && 'max-h-18')}
|
||||
style={chipRowStyle}
|
||||
>
|
||||
{chips.length === 0 ? (
|
||||
<span className="px-1 text-sm text-muted-foreground">—</span>
|
||||
) : (
|
||||
visibleChips.map((chip) =>
|
||||
chip.kind === 'territory' ? (
|
||||
<Badge
|
||||
key={chip.key}
|
||||
data-portfolio-chip
|
||||
variant="secondary"
|
||||
className="max-w-full gap-1 font-normal"
|
||||
>
|
||||
<MapPinIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
<span className="truncate">{chip.label}</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge key={chip.key} variant="secondary" className="max-w-full font-normal" asChild>
|
||||
<Link
|
||||
href={`/campanha/municipios/${chip.slug}`}
|
||||
data-portfolio-chip
|
||||
className="truncate underline-offset-4 hover:underline"
|
||||
>
|
||||
{chip.label}
|
||||
</Link>
|
||||
</Badge>
|
||||
),
|
||||
)
|
||||
)}
|
||||
{expandToggle}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn(
|
||||
CELL_WRAPPER_CLASS,
|
||||
'outline-none hover:bg-muted/40 focus-within:bg-muted/40',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
if ((event.target as HTMLElement).closest('[data-chip-remove]')) return
|
||||
inputRef.current?.focus()
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={chipRowRef}
|
||||
className={cn(CHIP_ROW_CLASS, clamping && 'max-h-18')}
|
||||
style={chipRowStyle}
|
||||
>
|
||||
{visibleChips.map((chip) => (
|
||||
<button
|
||||
key={chip.key}
|
||||
type="button"
|
||||
data-chip-remove
|
||||
data-portfolio-chip
|
||||
aria-label={`Remover ${chip.label}`}
|
||||
className="inline-flex max-w-full"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
removeChip(chip)
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
|
||||
>
|
||||
{chip.kind === 'territory' ? (
|
||||
<MapPinIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className="truncate">{chip.label}</span>
|
||||
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
{expandToggle}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.currentTarget.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
if (!rootRef.current?.contains(document.activeElement)) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}, 120)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}}
|
||||
placeholder={chips.length ? 'Adicionar…' : 'Buscar município, território ou ZE…'}
|
||||
aria-label="Buscar município, território de identidade ou zona eleitoral"
|
||||
className="min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && query.trim() ? (
|
||||
<div
|
||||
role="listbox"
|
||||
className="absolute z-30 mt-1 max-h-64 w-[min(100%,22rem)] overflow-auto rounded-lg border bg-popover p-1 shadow-md"
|
||||
>
|
||||
{hits.length === 0 ? (
|
||||
<p className="px-2 py-2 text-sm text-muted-foreground">Nenhum resultado.</p>
|
||||
) : (
|
||||
hits.map((hit) => (
|
||||
<button
|
||||
key={hit.key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
className="flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => pickHit(hit)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{hit.label}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{hit.kind === 'municipality'
|
||||
? 'Município'
|
||||
: hit.kind === 'territory'
|
||||
? `Território · ${hit.count} municípios`
|
||||
: `Zona eleitoral · ${hit.count} municípios`}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
src/components/campaign/AdvisorPasswordResetButton.tsx
Normal file
82
src/components/campaign/AdvisorPasswordResetButton.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
'use client'
|
||||
|
||||
import { KeyRoundIcon } from 'lucide-react'
|
||||
import { useActionState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { PLACEHOLDER_RESET_MESSAGE } from '@/lib/schemas/advisor'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
type AdvisorPasswordResetButtonProps = {
|
||||
advisorId: number
|
||||
disabled: boolean
|
||||
/** `icon` is the compact row action; `block` is the labelled button plus hint. */
|
||||
layout?: 'block' | 'icon'
|
||||
/** Names the advisor for screen readers in the `icon` layout. */
|
||||
accessibleName?: string
|
||||
formAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
export const AdvisorPasswordResetButton = ({
|
||||
advisorId,
|
||||
disabled,
|
||||
layout = 'block',
|
||||
accessibleName = 'Enviar link de senha',
|
||||
formAction,
|
||||
}: AdvisorPasswordResetButtonProps) => {
|
||||
const [state, submitAction, isPending] = useActionState(formAction, {})
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'success') {
|
||||
toast.success(state.message ?? 'Link enviado.')
|
||||
return
|
||||
}
|
||||
if (state.message && state.status !== 'success') {
|
||||
toast.error(state.message)
|
||||
}
|
||||
}, [state.message, state.status])
|
||||
|
||||
if (layout === 'icon') {
|
||||
return (
|
||||
<form action={submitAction}>
|
||||
<input type="hidden" name="advisorId" value={advisorId} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10"
|
||||
disabled={disabled || isPending}
|
||||
aria-label={disabled ? `${accessibleName} — ${PLACEHOLDER_RESET_MESSAGE}` : accessibleName}
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<KeyRoundIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={submitAction} className="flex flex-col gap-2">
|
||||
<input type="hidden" name="advisorId" value={advisorId} />
|
||||
<Button type="submit" className="min-h-11 w-fit" disabled={disabled || isPending}>
|
||||
{isPending ? <Spinner data-icon="inline-start" /> : null}
|
||||
Enviar link de senha
|
||||
</Button>
|
||||
{disabled ? (
|
||||
<p className="text-sm text-muted-foreground">{PLACEHOLDER_RESET_MESSAGE}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Envia um e-mail com link para o assessor definir a senha de acesso.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
471
src/components/campaign/AdvisorsTable.tsx
Normal file
471
src/components/campaign/AdvisorsTable.tsx
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
MessageCircleIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
SaveIcon,
|
||||
SearchXIcon,
|
||||
UserCogIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { AdvisorDebouncedTextCell } from '@/components/campaign/AdvisorDebouncedTextCell'
|
||||
import { AdvisorMunicipalityCell } from '@/components/campaign/AdvisorMunicipalityCell'
|
||||
import { AdvisorPasswordResetButton } from '@/components/campaign/AdvisorPasswordResetButton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/Table'
|
||||
import type { AdvisorMunicipalityIndexEntry } from '@/lib/advisorMunicipalityPortfolio'
|
||||
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AdvisorRowViewModel } from '@/utilities/advisorData'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import {
|
||||
formatBrazilianPhoneInput,
|
||||
normalizeBrazilianPhone,
|
||||
sanitizeBrazilianPhoneInput,
|
||||
buildWhatsAppUrl,
|
||||
} from '@/utilities/phone'
|
||||
|
||||
type MunicipalityRef = { id: number; name: string; slug: string }
|
||||
|
||||
type DraftAdvisor = {
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
municipalities: MunicipalityRef[]
|
||||
}
|
||||
|
||||
type AdvisorsTableProps = {
|
||||
rows: AdvisorRowViewModel[]
|
||||
municipalityIndex: AdvisorMunicipalityIndexEntry[]
|
||||
hasQuery: boolean
|
||||
updateProfileAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
membershipAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
batchAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
createAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState & { advisorId?: number }>
|
||||
passwordResetAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
const emptyDraft = (): DraftAdvisor => ({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
municipalities: [],
|
||||
})
|
||||
|
||||
const whatsAppHrefForPhone = (phone: string | null): string | null => {
|
||||
if (!phone || !normalizeBrazilianPhone(phone)) return null
|
||||
try {
|
||||
return buildWhatsAppUrl(phone)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = async (label: string, value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast.success(`${label} copiado.`)
|
||||
} catch {
|
||||
toast.error(`Não foi possível copiar o ${label.toLowerCase()}.`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors the inline input box (height, border, padding) so toggling edition never reflows. */
|
||||
const READ_CELL_CLASS =
|
||||
'flex min-h-10 w-full items-center rounded-full border border-transparent px-2 text-left'
|
||||
|
||||
const displayEmail = (email: string | null): string => {
|
||||
if (!email || isPlanilhaPlaceholderEmail(email)) return ''
|
||||
return email
|
||||
}
|
||||
|
||||
export const AdvisorsTable = ({
|
||||
rows,
|
||||
municipalityIndex,
|
||||
hasQuery,
|
||||
updateProfileAction,
|
||||
membershipAction,
|
||||
batchAction,
|
||||
createAction,
|
||||
passwordResetAction,
|
||||
}: AdvisorsTableProps) => {
|
||||
const router = useRouter()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState<DraftAdvisor | null>(null)
|
||||
const [isCreating, startCreate] = useTransition()
|
||||
|
||||
const refresh = () => router.refresh()
|
||||
|
||||
const startDraft = () => {
|
||||
setEditing(true)
|
||||
setDraft(emptyDraft())
|
||||
}
|
||||
|
||||
const cancelDraft = () => setDraft(null)
|
||||
|
||||
const leaveEditing = () => {
|
||||
setDraft(null)
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const saveDraft = () => {
|
||||
if (!draft) return
|
||||
if (draft.name.trim().length < 2) {
|
||||
toast.error('Informe o nome do assessor.')
|
||||
return
|
||||
}
|
||||
if (!draft.email.trim()) {
|
||||
toast.error('Informe o e-mail do assessor.')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set('name', draft.name.trim())
|
||||
formData.set('email', draft.email.trim())
|
||||
if (draft.phone.trim()) formData.set('phone', draft.phone.trim())
|
||||
for (const municipality of draft.municipalities) {
|
||||
formData.append('municipalityIds', String(municipality.id))
|
||||
}
|
||||
|
||||
startCreate(async () => {
|
||||
const result = await createAction({}, formData)
|
||||
if (result.status === 'success') {
|
||||
toast.success(result.message ?? 'Assessor criado.')
|
||||
setDraft(null)
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
toast.error(result.message ?? 'Não foi possível criar o assessor.')
|
||||
})
|
||||
}
|
||||
|
||||
const showEmpty = rows.length === 0 && !draft
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{editing ? (
|
||||
<Button type="button" variant="outline" className="min-h-11" onClick={leaveEditing}>
|
||||
Concluir edição
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="outline" className="min-h-11" onClick={() => setEditing(true)}>
|
||||
<PencilIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Editar
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
onClick={startDraft}
|
||||
disabled={Boolean(draft)}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Novo assessor
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showEmpty ? (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
{hasQuery ? <SearchXIcon aria-hidden="true" /> : <UserCogIcon aria-hidden="true" />}
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>
|
||||
{hasQuery ? 'Nenhum assessor encontrado' : 'Nenhum assessor cadastrado'}
|
||||
</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{hasQuery
|
||||
? 'Ajuste a busca ou limpe o filtro para ver todos.'
|
||||
: 'Ative a edição e crie a conta na tabela.'}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{!hasQuery ? (
|
||||
<EmptyContent>
|
||||
<Button type="button" className="min-h-11" onClick={startDraft}>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Novo assessor
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
) : null}
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table className="min-w-[56rem] table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[20%]">Nome</TableHead>
|
||||
<TableHead className="w-[24%]">E-mail</TableHead>
|
||||
<TableHead className="w-[14%]">Celular</TableHead>
|
||||
<TableHead>Municípios</TableHead>
|
||||
<TableHead className="w-28 text-right">
|
||||
<span className="sr-only">Ações</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{draft ? (
|
||||
<TableRow className="bg-muted/20">
|
||||
<TableCell>
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(event) => {
|
||||
const name = event.currentTarget.value
|
||||
setDraft((current) => (current ? { ...current, name } : current))
|
||||
}}
|
||||
placeholder="Nome"
|
||||
aria-label="Nome do novo assessor"
|
||||
className="min-h-10 border-transparent bg-transparent px-2 shadow-none hover:border-input focus-visible:border-input"
|
||||
autoFocus
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="email"
|
||||
value={draft.email}
|
||||
onChange={(event) => {
|
||||
const email = event.currentTarget.value
|
||||
setDraft((current) => (current ? { ...current, email } : current))
|
||||
}}
|
||||
placeholder="E-mail"
|
||||
aria-label="E-mail do novo assessor"
|
||||
className="min-h-10 border-transparent bg-transparent px-2 shadow-none hover:border-input focus-visible:border-input"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
value={draft.phone}
|
||||
onChange={(event) => {
|
||||
const phone = formatBrazilianPhoneInput(
|
||||
sanitizeBrazilianPhoneInput(event.currentTarget.value),
|
||||
)
|
||||
setDraft((current) => (current ? { ...current, phone } : current))
|
||||
}}
|
||||
placeholder="Celular"
|
||||
aria-label="Celular do novo assessor"
|
||||
className="min-h-10 border-transparent bg-transparent px-2 shadow-none hover:border-input focus-visible:border-input"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdvisorMunicipalityCell
|
||||
advisorId={null}
|
||||
municipalities={draft.municipalities}
|
||||
municipalityIndex={municipalityIndex}
|
||||
editing
|
||||
draft
|
||||
onDraftChange={(municipalities) =>
|
||||
setDraft((current) => (current ? { ...current, municipalities } : current))
|
||||
}
|
||||
membershipAction={membershipAction}
|
||||
batchAction={batchAction}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10"
|
||||
aria-label="Cancelar novo assessor"
|
||||
disabled={isCreating}
|
||||
onClick={cancelDraft}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="size-10"
|
||||
aria-label="Salvar novo assessor"
|
||||
disabled={isCreating}
|
||||
onClick={saveDraft}
|
||||
>
|
||||
{isCreating ? (
|
||||
<Spinner className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<SaveIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
|
||||
{rows.map((row) => {
|
||||
const whatsAppHref = whatsAppHrefForPhone(row.phone)
|
||||
const emailShown = displayEmail(row.email)
|
||||
const phoneShown = row.phone ? formatBrazilianPhoneInput(row.phone) : ''
|
||||
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
{editing ? (
|
||||
<AdvisorDebouncedTextCell
|
||||
advisorId={row.id}
|
||||
field="name"
|
||||
defaultValue={row.name}
|
||||
ariaLabel={`Nome de ${row.name}`}
|
||||
formAction={updateProfileAction}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
href={`/campanha/assessores/${row.id}`}
|
||||
className={cn(
|
||||
READ_CELL_CLASS,
|
||||
'font-medium text-primary underline-offset-4 hover:underline',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{row.name}</span>
|
||||
</Link>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editing ? (
|
||||
<AdvisorDebouncedTextCell
|
||||
advisorId={row.id}
|
||||
field="email"
|
||||
type="email"
|
||||
defaultValue={row.email ?? ''}
|
||||
placeholder="E-mail"
|
||||
ariaLabel={`E-mail de ${row.name}`}
|
||||
placeholderEmailFallback={row.email}
|
||||
formAction={updateProfileAction}
|
||||
/>
|
||||
) : emailShown ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(READ_CELL_CLASS, 'underline-offset-4 hover:underline')}
|
||||
onClick={() => void copyText('E-mail', emailShown)}
|
||||
>
|
||||
<span className="truncate">{emailShown}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className={cn(READ_CELL_CLASS, 'text-sm text-muted-foreground')}>
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editing ? (
|
||||
<AdvisorDebouncedTextCell
|
||||
advisorId={row.id}
|
||||
field="phone"
|
||||
type="tel"
|
||||
defaultValue={row.phone ? formatBrazilianPhoneInput(row.phone) : ''}
|
||||
placeholder="Celular"
|
||||
ariaLabel={`Celular de ${row.name}`}
|
||||
formAction={updateProfileAction}
|
||||
/>
|
||||
) : phoneShown ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
READ_CELL_CLASS,
|
||||
'tabular-nums underline-offset-4 hover:underline',
|
||||
)}
|
||||
onClick={() => void copyText('Celular', row.phone ?? phoneShown)}
|
||||
>
|
||||
<span className="truncate">{phoneShown}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className={cn(READ_CELL_CLASS, 'text-sm text-muted-foreground')}>
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdvisorMunicipalityCell
|
||||
advisorId={row.id}
|
||||
municipalities={row.municipalities}
|
||||
municipalityIndex={municipalityIndex}
|
||||
editing={editing}
|
||||
membershipAction={membershipAction}
|
||||
batchAction={batchAction}
|
||||
onPersisted={refresh}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{whatsAppHref ? (
|
||||
<Button asChild variant="ghost" size="icon" className="size-10">
|
||||
<a
|
||||
href={whatsAppHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Enviar WhatsApp para ${row.name}`}
|
||||
>
|
||||
<MessageCircleIcon className="size-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10"
|
||||
disabled
|
||||
aria-label={`WhatsApp indisponível — ${row.name} sem celular`}
|
||||
>
|
||||
<MessageCircleIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<AdvisorPasswordResetButton
|
||||
advisorId={row.id}
|
||||
disabled={!emailShown}
|
||||
layout="icon"
|
||||
accessibleName={`Enviar link de senha para ${row.name}`}
|
||||
formAction={passwordResetAction}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ export const MunicipalityListAdvisorsControl = ({
|
|||
<input type="hidden" name="municipalityId" value={municipalityID} />
|
||||
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
O assessor vê e gerencia somente as Praças que administra.
|
||||
O assessor vê e gerencia somente os municípios que administra.
|
||||
</p>
|
||||
<ul className="flex max-h-60 flex-col gap-1 overflow-y-auto">
|
||||
{options.map((option) => (
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ import {
|
|||
HomeIcon,
|
||||
InboxIcon,
|
||||
MapPinIcon,
|
||||
UserCogIcon,
|
||||
Users2Icon,
|
||||
UsersIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { isCampaignStaff, isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import { canAccessSupporterArea } from '@/utilities/supporterUi'
|
||||
|
||||
export type CampaignNavItem = {
|
||||
|
|
@ -28,6 +29,7 @@ const staffNav: CampaignNavItem[] = [
|
|||
{ title: 'Planos', href: '/campanha/planos', icon: CalendarDaysIcon },
|
||||
{ title: 'Demandas', href: '/campanha/demandas', icon: InboxIcon },
|
||||
{ title: 'Apoiadores', href: '/campanha/apoiadores', icon: UsersIcon },
|
||||
{ title: 'Assessores', href: '/campanha/assessores', icon: UserCogIcon },
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
@ -53,9 +55,11 @@ const leaderNav: CampaignNavItem[] = [
|
|||
export const getCampaignNav = (role: CampaignUser['role']): CampaignNavItem[] => {
|
||||
if (role === 'leader') return leaderNav
|
||||
|
||||
return staffNav.filter(
|
||||
(item) => item.href !== '/campanha/apoiadores' || canAccessSupporterArea(role),
|
||||
)
|
||||
return staffNav.filter((item) => {
|
||||
if (item.href === '/campanha/apoiadores') return canAccessSupporterArea(role)
|
||||
if (item.href === '/campanha/assessores') return isCampaignUnrestrictedRole(role)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export const getCampaignSecondaryNav = (role: CampaignUser['role']): CampaignNavItem[] =>
|
||||
|
|
@ -63,12 +67,18 @@ export const getCampaignSecondaryNav = (role: CampaignUser['role']): CampaignNav
|
|||
|
||||
/**
|
||||
* Compact set for the mobile bottom bar (max 5 items with a home slot).
|
||||
* Assessores stays sidebar-only (same as perfil/organizações).
|
||||
*/
|
||||
export const getCampaignBottomNav = (role: CampaignUser['role']): CampaignNavItem[] => {
|
||||
const nav = getCampaignNav(role)
|
||||
if (role === 'leader') return nav
|
||||
|
||||
return nav.filter((item) => item.href !== '/campanha/apoiadores').slice(0, 5)
|
||||
return nav
|
||||
.filter(
|
||||
(item) =>
|
||||
item.href !== '/campanha/apoiadores' && item.href !== '/campanha/assessores',
|
||||
)
|
||||
.slice(0, 5)
|
||||
}
|
||||
|
||||
/** Home matches only exactly; other items also match nested paths. */
|
||||
|
|
@ -81,3 +91,6 @@ export const isCampaignNavActive = (pathname: string, href: string): boolean =>
|
|||
|
||||
export const isCampaignStaffRole = (role: CampaignUser['role']): boolean =>
|
||||
isCampaignStaff({ collection: 'campaignUser', role } as CampaignUser)
|
||||
|
||||
const isCampaignUnrestrictedRole = (role: CampaignUser['role']): boolean =>
|
||||
isCampaignUnrestricted({ collection: 'campaignUser', role } as CampaignUser)
|
||||
|
|
|
|||
245
src/lib/advisorMunicipalityPortfolio.ts
Normal file
245
src/lib/advisorMunicipalityPortfolio.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import {
|
||||
bahiaIdentityTerritories,
|
||||
type BahiaIdentityTerritory,
|
||||
} from '@/lib/bahiaTerritories'
|
||||
import {
|
||||
municipalityCatalog,
|
||||
municipalityCatalogEntriesForCity,
|
||||
type MunicipalityCatalogEntry,
|
||||
} from '@/lib/municipalityCatalog'
|
||||
import { citiesForTseZone } from '@/lib/bahiaTseZones'
|
||||
import { matchesAtWordStart, normalizeSearchPhrase } from '@/utilities/wordStartFilter'
|
||||
|
||||
export type AdvisorMunicipalityIndexEntry = {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
region: BahiaIdentityTerritory
|
||||
city: string
|
||||
zoneNumber: number | null
|
||||
}
|
||||
|
||||
export type AdvisorPortfolioChip =
|
||||
| {
|
||||
kind: 'territory'
|
||||
key: string
|
||||
label: string
|
||||
territory: BahiaIdentityTerritory
|
||||
municipalityIds: number[]
|
||||
}
|
||||
| {
|
||||
kind: 'municipality'
|
||||
key: string
|
||||
label: string
|
||||
municipalityId: number
|
||||
slug: string
|
||||
}
|
||||
|
||||
export type AdvisorPortfolioSearchHit =
|
||||
| {
|
||||
kind: 'municipality'
|
||||
key: string
|
||||
label: string
|
||||
municipalityId: number
|
||||
}
|
||||
| {
|
||||
kind: 'territory'
|
||||
key: string
|
||||
label: string
|
||||
territory: BahiaIdentityTerritory
|
||||
municipalityIds: number[]
|
||||
count: number
|
||||
}
|
||||
| {
|
||||
kind: 'zone'
|
||||
key: string
|
||||
label: string
|
||||
zoneNumber: number
|
||||
municipalityIds: number[]
|
||||
count: number
|
||||
}
|
||||
|
||||
const catalogEntriesForTerritory = (
|
||||
territory: BahiaIdentityTerritory,
|
||||
): readonly MunicipalityCatalogEntry[] =>
|
||||
municipalityCatalog.filter((entry) => entry.region === territory)
|
||||
|
||||
export const catalogEntriesForTseZone = (
|
||||
zoneNumber: number,
|
||||
): readonly MunicipalityCatalogEntry[] => {
|
||||
const entries: MunicipalityCatalogEntry[] = []
|
||||
for (const city of citiesForTseZone(zoneNumber)) {
|
||||
const cityEntries = municipalityCatalogEntriesForCity(city)
|
||||
const zoneScoped = cityEntries.filter(
|
||||
(entry) => entry.kind === 'zona' && entry.zoneNumber === zoneNumber,
|
||||
)
|
||||
if (zoneScoped.length > 0) {
|
||||
entries.push(...zoneScoped)
|
||||
continue
|
||||
}
|
||||
const whole = cityEntries.find((entry) => entry.kind === 'municipio')
|
||||
if (whole) entries.push(whole)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
const indexBySlug = (
|
||||
entries: readonly AdvisorMunicipalityIndexEntry[],
|
||||
): Map<string, AdvisorMunicipalityIndexEntry> =>
|
||||
new Map(entries.map((entry) => [entry.slug, entry]))
|
||||
|
||||
const municipalityIdsForTerritory = (
|
||||
territory: BahiaIdentityTerritory,
|
||||
bySlug: Map<string, AdvisorMunicipalityIndexEntry>,
|
||||
): number[] => {
|
||||
const ids: number[] = []
|
||||
for (const entry of catalogEntriesForTerritory(territory)) {
|
||||
const resolved = bySlug.get(entry.slug)
|
||||
if (resolved) ids.push(resolved.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
const municipalityIdsForTseZone = (
|
||||
zoneNumber: number,
|
||||
bySlug: Map<string, AdvisorMunicipalityIndexEntry>,
|
||||
): number[] => {
|
||||
const ids: number[] = []
|
||||
for (const entry of catalogEntriesForTseZone(zoneNumber)) {
|
||||
const resolved = bySlug.get(entry.slug)
|
||||
if (resolved) ids.push(resolved.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/** Collapse complete territory memberships into a single territory chip. */
|
||||
export const buildAdvisorPortfolioChips = (
|
||||
assignedMunicipalities: readonly { id: number; name: string; slug: string }[],
|
||||
index: readonly AdvisorMunicipalityIndexEntry[],
|
||||
): AdvisorPortfolioChip[] => {
|
||||
const byId = new Map(index.map((entry) => [entry.id, entry]))
|
||||
const assigned = new Map(assignedMunicipalities.map((municipality) => [municipality.id, municipality]))
|
||||
const remaining = new Set(assigned.keys())
|
||||
const chips: AdvisorPortfolioChip[] = []
|
||||
|
||||
// Prefer the live index's `region` (DB) over catalog→slug resolution so chips
|
||||
// collapse even when slug casing/alias differs across environments.
|
||||
for (const territory of bahiaIdentityTerritories) {
|
||||
const territoryIds = index.filter((entry) => entry.region === territory).map((entry) => entry.id)
|
||||
if (territoryIds.length === 0) continue
|
||||
if (!territoryIds.every((id) => remaining.has(id))) continue
|
||||
|
||||
chips.push({
|
||||
kind: 'territory',
|
||||
key: `territory:${territory}`,
|
||||
label: territory,
|
||||
territory,
|
||||
municipalityIds: territoryIds,
|
||||
})
|
||||
for (const id of territoryIds) remaining.delete(id)
|
||||
}
|
||||
|
||||
const leftover = [...remaining]
|
||||
.map((id) => assigned.get(id))
|
||||
.filter((municipality): municipality is { id: number; name: string; slug: string } =>
|
||||
Boolean(municipality),
|
||||
)
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
|
||||
|
||||
for (const municipality of leftover) {
|
||||
const indexed = byId.get(municipality.id)
|
||||
chips.push({
|
||||
kind: 'municipality',
|
||||
key: `municipality:${municipality.id}`,
|
||||
label: municipality.name,
|
||||
municipalityId: municipality.id,
|
||||
slug: indexed?.slug ?? municipality.slug,
|
||||
})
|
||||
}
|
||||
|
||||
return chips
|
||||
}
|
||||
|
||||
const uniqueZoneNumbers = (): number[] => {
|
||||
const zones = new Set<number>()
|
||||
for (const entry of municipalityCatalog) {
|
||||
for (const zone of entry.tseZones) zones.add(zone)
|
||||
}
|
||||
return [...zones].sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
const zoneLabel = (zoneNumber: number): string => `ZE ${zoneNumber}`
|
||||
|
||||
const queryLooksLikeZone = (query: string): number | null => {
|
||||
const normalized = normalizeSearchPhrase(query)
|
||||
const match = normalized.match(/^(?:ze|zona)?\s*(\d{1,3})$/)
|
||||
if (!match) return null
|
||||
const zoneNumber = Number(match[1])
|
||||
return Number.isInteger(zoneNumber) && zoneNumber > 0 ? zoneNumber : null
|
||||
}
|
||||
|
||||
export const searchAdvisorPortfolio = (
|
||||
query: string,
|
||||
index: readonly AdvisorMunicipalityIndexEntry[],
|
||||
alreadyAssignedIds: ReadonlySet<number>,
|
||||
limit = 12,
|
||||
): AdvisorPortfolioSearchHit[] => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return []
|
||||
|
||||
const bySlug = indexBySlug(index)
|
||||
const hits: AdvisorPortfolioSearchHit[] = []
|
||||
const zoneFromQuery = queryLooksLikeZone(trimmed)
|
||||
|
||||
for (const entry of index) {
|
||||
if (alreadyAssignedIds.has(entry.id)) continue
|
||||
if (!matchesAtWordStart(entry.name, trimmed)) continue
|
||||
hits.push({
|
||||
kind: 'municipality',
|
||||
key: `municipality:${entry.id}`,
|
||||
label: entry.name,
|
||||
municipalityId: entry.id,
|
||||
})
|
||||
if (hits.length >= limit) return hits
|
||||
}
|
||||
|
||||
for (const territory of bahiaIdentityTerritories) {
|
||||
if (!matchesAtWordStart(territory, trimmed)) continue
|
||||
const municipalityIds = municipalityIdsForTerritory(territory, bySlug).filter(
|
||||
(id) => !alreadyAssignedIds.has(id),
|
||||
)
|
||||
if (municipalityIds.length === 0) continue
|
||||
hits.push({
|
||||
kind: 'territory',
|
||||
key: `territory:${territory}`,
|
||||
label: territory,
|
||||
territory,
|
||||
municipalityIds,
|
||||
count: municipalityIds.length,
|
||||
})
|
||||
if (hits.length >= limit) return hits
|
||||
}
|
||||
|
||||
const zonesToScan =
|
||||
zoneFromQuery !== null
|
||||
? [zoneFromQuery]
|
||||
: uniqueZoneNumbers().filter((zoneNumber) => matchesAtWordStart(zoneLabel(zoneNumber), trimmed))
|
||||
|
||||
for (const zoneNumber of zonesToScan) {
|
||||
const municipalityIds = municipalityIdsForTseZone(zoneNumber, bySlug).filter(
|
||||
(id) => !alreadyAssignedIds.has(id),
|
||||
)
|
||||
if (municipalityIds.length === 0) continue
|
||||
hits.push({
|
||||
kind: 'zone',
|
||||
key: `zone:${zoneNumber}`,
|
||||
label: zoneLabel(zoneNumber),
|
||||
zoneNumber,
|
||||
municipalityIds,
|
||||
count: municipalityIds.length,
|
||||
})
|
||||
if (hits.length >= limit) return hits
|
||||
}
|
||||
|
||||
return hits
|
||||
}
|
||||
85
src/lib/schemas/advisor.ts
Normal file
85
src/lib/schemas/advisor.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
|
||||
import {
|
||||
brazilianMobile,
|
||||
optionalPersistedEmail,
|
||||
positiveRelationshipId,
|
||||
trimmedOptionalText,
|
||||
} from '@/lib/schemas/primitives'
|
||||
|
||||
const PLANILHA_INVALID_EMAIL_SUFFIX = '@planilha.invalid'
|
||||
|
||||
export const isPlanilhaPlaceholderEmail = (email: string | null | undefined): boolean =>
|
||||
typeof email === 'string' && email.trim().toLowerCase().endsWith(PLANILHA_INVALID_EMAIL_SUFFIX)
|
||||
|
||||
/** Stable seed-style placeholder used when the e-mail field is cleared in the list UI. */
|
||||
export const planilhaPlaceholderEmailForAdvisor = (advisorId: number): string =>
|
||||
`assessor-${advisorId}${PLANILHA_INVALID_EMAIL_SUFFIX}`
|
||||
|
||||
const advisorPhoneInput = z.union([brazilianMobile, z.literal(''), z.null()]).optional()
|
||||
|
||||
const optionalAdvisorPhone = advisorPhoneInput.transform((value) =>
|
||||
value === '' || value === null || value === undefined ? undefined : value,
|
||||
)
|
||||
|
||||
const nullableAdvisorPhone = advisorPhoneInput.transform((value) =>
|
||||
value === '' || value === undefined ? null : value,
|
||||
)
|
||||
|
||||
export const PLACEHOLDER_RESET_MESSAGE =
|
||||
'Troque o e-mail placeholder da planilha por um e-mail real antes de enviar o link de senha.'
|
||||
|
||||
export const ADVISOR_UNRESTRICTED_MESSAGE =
|
||||
'Somente a coordenação geral ou o candidato gerencia assessores.'
|
||||
|
||||
export const ADVISOR_EMAIL_CONFLICT_MESSAGE = 'Já existe uma conta com este e-mail.'
|
||||
|
||||
export const ADVISOR_ACTION_SAFE_MESSAGES = [
|
||||
ADVISOR_UNRESTRICTED_MESSAGE,
|
||||
ADVISOR_EMAIL_CONFLICT_MESSAGE,
|
||||
PLACEHOLDER_RESET_MESSAGE,
|
||||
'Só é possível gerenciar contas com papel de Assessor nesta tela.',
|
||||
'Use Meu perfil para alterar a própria conta.',
|
||||
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
|
||||
] as const
|
||||
|
||||
export const advisorCreateSchema = z.object({
|
||||
name: z.string().trim().min(2).max(160),
|
||||
email: z.email('E-mail inválido.'),
|
||||
phone: optionalAdvisorPhone,
|
||||
})
|
||||
|
||||
export const advisorProfileUpdateSchema = z
|
||||
.object({
|
||||
id: positiveRelationshipId,
|
||||
name: trimmedOptionalText(160),
|
||||
email: optionalPersistedEmail,
|
||||
phone: nullableAdvisorPhone,
|
||||
})
|
||||
.refine(
|
||||
(value) => value.name !== undefined || value.email !== undefined || value.phone !== undefined,
|
||||
{ message: 'Informe ao menos um campo para atualizar.' },
|
||||
)
|
||||
|
||||
export const advisorMunicipalityMembershipSchema = z.object({
|
||||
advisorId: positiveRelationshipId,
|
||||
municipalityId: positiveRelationshipId,
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export const advisorMunicipalitiesBatchSchema = z.object({
|
||||
advisorId: positiveRelationshipId,
|
||||
municipalityIds: z.array(positiveRelationshipId).min(1).max(435),
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export const advisorPasswordResetSchema = z.object({
|
||||
advisorId: positiveRelationshipId,
|
||||
})
|
||||
|
||||
export type AdvisorCreateInput = z.input<typeof advisorCreateSchema>
|
||||
export type AdvisorProfileUpdateInput = z.input<typeof advisorProfileUpdateSchema>
|
||||
export type AdvisorMunicipalityMembershipInput = z.input<typeof advisorMunicipalityMembershipSchema>
|
||||
export type AdvisorMunicipalitiesBatchInput = z.input<typeof advisorMunicipalitiesBatchSchema>
|
||||
export type AdvisorPasswordResetInput = z.input<typeof advisorPasswordResetSchema>
|
||||
|
|
@ -37,11 +37,13 @@ export const parsePoliticalTrendStatusFormValue = (
|
|||
: null
|
||||
}
|
||||
|
||||
export const MAX_ADVISORS_PER_MUNICIPALITY = 10
|
||||
|
||||
export const municipalityAdvisorsAssignmentSchema = z.object({
|
||||
municipality: positiveRelationshipId,
|
||||
advisors: z
|
||||
.array(positiveRelationshipId)
|
||||
.max(10)
|
||||
.max(MAX_ADVISORS_PER_MUNICIPALITY)
|
||||
.transform((ids) => [...new Set(ids)]),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
|
|||
import type { DynamicFind } from '@/utilities/access/shared'
|
||||
import {
|
||||
getFreshCampaignUser,
|
||||
isCampaignCoordinator,
|
||||
isCampaignLeader,
|
||||
isCampaignStaff,
|
||||
isCampaignUnrestricted,
|
||||
|
|
@ -45,7 +44,7 @@ const canSelfOrStaffUpdateCampaignUser = async (
|
|||
id: string | number | undefined,
|
||||
): Promise<boolean> => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
if (isCampaignCoordinator(await getFreshCampaignUser(req))) return true
|
||||
if (isCampaignUnrestricted(await getFreshCampaignUser(req))) return true
|
||||
if (!isCampaignUser(req.user) || id === undefined) return false
|
||||
|
||||
return String(id) === String(req.user.id)
|
||||
|
|
@ -140,7 +139,7 @@ export const canReadCampaignUserPhone: FieldAccess = async ({ doc, id, req }) =>
|
|||
|
||||
export const canCreateCampaignUserPhone: FieldAccess = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
return isCampaignCoordinator(await getFreshCampaignUser(req))
|
||||
return isCampaignUnrestricted(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
export const canUpdateCampaignUserPhone: FieldAccess = async ({ id, req }) => {
|
||||
|
|
@ -149,5 +148,5 @@ export const canUpdateCampaignUserPhone: FieldAccess = async ({ id, req }) => {
|
|||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (!currentUser) return false
|
||||
if (id !== undefined && String(id) === String(currentUser.id)) return true
|
||||
return isCampaignCoordinator(currentUser)
|
||||
return isCampaignUnrestricted(currentUser)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import type {
|
|||
} from '@/utilities/access/shared'
|
||||
import {
|
||||
getFreshCampaignUser,
|
||||
isCampaignCoordinator,
|
||||
isCampaignLeader,
|
||||
isCampaignUnrestricted,
|
||||
isCampaignUser,
|
||||
|
|
@ -215,11 +214,11 @@ export const canUpdateMunicipality: Access = async ({ req }) => {
|
|||
|
||||
export const canDeleteMunicipality: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
/** Advisor assignment is coordinator-only (server actions use overrideAccess). */
|
||||
/** Advisor assignment is unrestricted staff (coordinator + candidate; actions use overrideAccess). */
|
||||
export const canAssignMunicipalityAdvisors: FieldAccess = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
return isCampaignCoordinator(await getFreshCampaignUser(req))
|
||||
return isCampaignUnrestricted(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
export const canManageMunicipalityAdvisors: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
|
|
|||
216
src/utilities/advisorData.ts
Normal file
216
src/utilities/advisorData.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import type { AdvisorMunicipalityIndexEntry } from '@/lib/advisorMunicipalityPortfolio'
|
||||
import {
|
||||
buildListHref,
|
||||
firstValue,
|
||||
normalizedText,
|
||||
strictDecimalInteger,
|
||||
type RawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
|
||||
const advisorPageSize = 25
|
||||
const advisorListBasePath = '/campanha/assessores'
|
||||
|
||||
export type AdvisorListState = {
|
||||
page: number
|
||||
q?: string
|
||||
}
|
||||
|
||||
type AdvisorMunicipalityViewModel = {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export type AdvisorRowViewModel = {
|
||||
id: number
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
municipalities: AdvisorMunicipalityViewModel[]
|
||||
}
|
||||
|
||||
export type AdvisorDetailViewModel = AdvisorRowViewModel
|
||||
|
||||
export const parseAdvisorListParams = (searchParams: RawSearchParams): AdvisorListState => {
|
||||
const q = normalizedText(firstValue(searchParams.q))
|
||||
const page = strictDecimalInteger(firstValue(searchParams.page)) ?? 1
|
||||
|
||||
return {
|
||||
page,
|
||||
...(q ? { q } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const buildAdvisorListSearchParams = (state: AdvisorListState, page = state.page) => {
|
||||
const params = new URLSearchParams()
|
||||
if (state.q) params.set('q', state.q)
|
||||
if (page > 1) params.set('page', String(page))
|
||||
return params
|
||||
}
|
||||
|
||||
export const advisorListHrefForPage = (state: AdvisorListState, page: number): string =>
|
||||
buildListHref(state, buildAdvisorListSearchParams, advisorListBasePath, page)
|
||||
|
||||
const municipalitiesByAdvisorIds = async (
|
||||
payload: Payload,
|
||||
advisorIDs: number[],
|
||||
): Promise<Map<number, AdvisorMunicipalityViewModel[]>> => {
|
||||
const byAdvisor = new Map<number, AdvisorMunicipalityViewModel[]>()
|
||||
if (advisorIDs.length === 0) return byAdvisor
|
||||
|
||||
const advisorIdSet = new Set(advisorIDs)
|
||||
|
||||
// Intentional admin bypass: portfolio labels only, over advisor rows already
|
||||
// resolved after the unrestricted route gate.
|
||||
const municipalities = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { advisors: { in: advisorIDs } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'name',
|
||||
select: { name: true, slug: true, advisors: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
for (const municipality of municipalities.docs) {
|
||||
const view: AdvisorMunicipalityViewModel = {
|
||||
id: municipality.id,
|
||||
name: municipality.name,
|
||||
slug: municipality.slug,
|
||||
}
|
||||
for (const advisor of municipality.advisors ?? []) {
|
||||
const id = relationshipId(advisor)
|
||||
if (id === null || !advisorIdSet.has(id)) continue
|
||||
const list = byAdvisor.get(id)
|
||||
if (list) list.push(view)
|
||||
else byAdvisor.set(id, [view])
|
||||
}
|
||||
}
|
||||
|
||||
return byAdvisor
|
||||
}
|
||||
|
||||
/**
|
||||
* Full municipality index for client-side portfolio search (município / TI / ZE).
|
||||
* Intentional admin bypass after the unrestricted route gate.
|
||||
*/
|
||||
export const loadAdvisorMunicipalityIndex = async (
|
||||
payload: Payload,
|
||||
): Promise<AdvisorMunicipalityIndexEntry[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'municipality',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'name',
|
||||
select: { name: true, slug: true, region: true, city: true, zoneNumber: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
return result.docs.map((municipality) => ({
|
||||
id: municipality.id,
|
||||
name: municipality.name,
|
||||
slug: municipality.slug,
|
||||
region: municipality.region as BahiaIdentityTerritory,
|
||||
city: municipality.city,
|
||||
zoneNumber: municipality.zoneNumber ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists advisor accounts for the unrestricted management UI.
|
||||
*
|
||||
* Intentional admin bypass (no `user`): `email`/`username` are stripped by
|
||||
* `removePrivateAuthFields` and gated by self-only field access — the page
|
||||
* gate already asserted `isCampaignUnrestricted`, and the job is to surface
|
||||
* placeholder `@planilha.invalid` emails so staff can activate accounts.
|
||||
*/
|
||||
export const loadAdvisorListPageData = async (
|
||||
payload: Payload,
|
||||
state: AdvisorListState,
|
||||
): Promise<{ rows: AdvisorRowViewModel[]; totalDocs: number; totalPages: number }> => {
|
||||
const result = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: {
|
||||
and: [
|
||||
{ role: { equals: 'advisor' } },
|
||||
...(state.q
|
||||
? [
|
||||
{
|
||||
or: [{ name: { contains: state.q } }, { email: { contains: state.q } }],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: advisorPageSize,
|
||||
page: state.page,
|
||||
sort: 'name',
|
||||
select: { name: true, email: true, phone: true, role: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const advisorIDs = result.docs.map((doc) => doc.id)
|
||||
const municipalitiesByAdvisor = await municipalitiesByAdvisorIds(payload, advisorIDs)
|
||||
|
||||
return {
|
||||
rows: result.docs.map((doc) => {
|
||||
const email = doc.email ?? null
|
||||
return {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
email,
|
||||
phone: doc.phone ?? null,
|
||||
municipalities: municipalitiesByAdvisor.get(doc.id) ?? [],
|
||||
}
|
||||
}),
|
||||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
}
|
||||
}
|
||||
|
||||
export const loadAdvisorDetail = async (
|
||||
payload: Payload,
|
||||
advisorId: number,
|
||||
): Promise<AdvisorDetailViewModel | null> => {
|
||||
if (!Number.isInteger(advisorId) || advisorId <= 0) return null
|
||||
|
||||
let advisor: {
|
||||
id: number
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
role: string
|
||||
}
|
||||
try {
|
||||
// Intentional admin bypass — same rationale as `loadAdvisorListPageData`.
|
||||
advisor = await payload.findByID({
|
||||
collection: 'campaignUser',
|
||||
id: advisorId,
|
||||
depth: 0,
|
||||
select: { name: true, email: true, phone: true, role: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (advisor.role !== 'advisor') return null
|
||||
|
||||
const municipalitiesByAdvisor = await municipalitiesByAdvisorIds(payload, [advisor.id])
|
||||
return {
|
||||
id: advisor.id,
|
||||
name: advisor.name,
|
||||
email: advisor.email ?? null,
|
||||
phone: advisor.phone ?? null,
|
||||
municipalities: municipalitiesByAdvisor.get(advisor.id) ?? [],
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import config from '@payload-config'
|
|||
import { getPayload, type Payload } from 'payload'
|
||||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { isCampaignStaff, isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
|
||||
|
|
@ -69,6 +69,21 @@ export const reloadCoordinatorActor = async (
|
|||
return currentActor
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the actor's fresh role and asserts they are unrestricted
|
||||
* (coordinator or candidate). Throws `errorMessage` otherwise.
|
||||
*/
|
||||
export const reloadUnrestrictedActor = async (
|
||||
payload: CampaignActorPayload,
|
||||
actor: CampaignUser,
|
||||
errorMessage: string,
|
||||
req?: PayloadTransactionRequest,
|
||||
): Promise<CampaignUser> => {
|
||||
const currentActor = await reloadCampaignActor(payload, actor, req)
|
||||
if (!isCampaignUnrestricted(currentActor)) throw new Error(errorMessage)
|
||||
return currentActor
|
||||
}
|
||||
|
||||
export const getCampaignActionContext = async (): Promise<CampaignActionContext> => {
|
||||
const [payload, actor] = await Promise.all([getPayload({ config }), requireCampaignUser()])
|
||||
return { payload, actor }
|
||||
|
|
|
|||
244
tests/int/campaignAdvisorManagement.int.spec.ts
Normal file
244
tests/int/campaignAdvisorManagement.int.spec.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createAdvisorRecord,
|
||||
sendAdvisorPasswordResetRecord,
|
||||
setAdvisorMunicipalitiesBatchRecord,
|
||||
setAdvisorMunicipalityMembershipRecord,
|
||||
updateAdvisorProfileRecord,
|
||||
} from '@/app/(campaign)/campanha/actions/advisor'
|
||||
import { assignMunicipalityAdvisorsRecord } from '@/app/(campaign)/campanha/actions/municipality'
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('campaign advisor management (B19)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it.each(['coordinator', 'candidate'] as const)(
|
||||
'lets a %s create an advisor and assign a municipality',
|
||||
async (role) => {
|
||||
const fixtures = campaignFixtures()
|
||||
const actor = await fixtures.createCampaignUser(role)
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const email = `${fixtures.value('advisor')}@example.com`
|
||||
|
||||
const created = await createAdvisorRecord(payload, actor, {
|
||||
name: 'Assessor Novo',
|
||||
email,
|
||||
phone: fixtures.phone(),
|
||||
})
|
||||
expect(created.role).toBe('advisor')
|
||||
|
||||
const stored = await payload.findByID({
|
||||
collection: 'campaignUser',
|
||||
id: created.id,
|
||||
depth: 0,
|
||||
select: { email: true, role: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(stored.email).toBe(email)
|
||||
expect(stored.role).toBe('advisor')
|
||||
|
||||
const assigned = await setAdvisorMunicipalityMembershipRecord(payload, actor, {
|
||||
advisorId: created.id,
|
||||
municipalityId: municipality.id,
|
||||
assigned: true,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
expect(
|
||||
assigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
|
||||
).toContain(created.id)
|
||||
|
||||
const updated = await updateAdvisorProfileRecord(payload, actor, {
|
||||
id: created.id,
|
||||
name: 'Assessor Atualizado',
|
||||
})
|
||||
expect(updated.name).toBe('Assessor Atualizado')
|
||||
},
|
||||
)
|
||||
|
||||
it('denies advisor and leader from creating or assigning', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const leader = await fixtures.createCampaignUser('leader')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const target = await fixtures.createCampaignUser('advisor')
|
||||
|
||||
await expect(
|
||||
createAdvisorRecord(payload, advisor, {
|
||||
name: 'Bloqueado',
|
||||
email: `${fixtures.value('blocked')}@example.com`,
|
||||
}),
|
||||
).rejects.toThrow(/coordenação geral ou o candidato/i)
|
||||
|
||||
await expect(
|
||||
createAdvisorRecord(payload, leader, {
|
||||
name: 'Bloqueado',
|
||||
email: `${fixtures.value('blocked-leader')}@example.com`,
|
||||
}),
|
||||
).rejects.toThrow(/coordenação geral ou o candidato/i)
|
||||
|
||||
await expect(
|
||||
setAdvisorMunicipalityMembershipRecord(payload, advisor, {
|
||||
advisorId: target.id,
|
||||
municipalityId: municipality.id,
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow(/coordenação geral ou o candidato/i)
|
||||
})
|
||||
|
||||
it('rejects duplicate advisor emails with a safe message', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const email = `${fixtures.value('dup')}@example.com`
|
||||
await createAdvisorRecord(payload, coordinator, {
|
||||
name: 'Primeiro',
|
||||
email,
|
||||
})
|
||||
|
||||
await expect(
|
||||
createAdvisorRecord(payload, coordinator, {
|
||||
name: 'Segundo',
|
||||
email,
|
||||
}),
|
||||
).rejects.toThrow('Já existe uma conta com este e-mail.')
|
||||
})
|
||||
|
||||
it('blocks password reset for planilha placeholder emails', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const placeholder = await fixtures.createCampaignUser('advisor', {
|
||||
email: `${fixtures.value('slug')}@planilha.invalid`,
|
||||
})
|
||||
|
||||
await expect(
|
||||
sendAdvisorPasswordResetRecord(payload, coordinator, { advisorId: placeholder.id }),
|
||||
).rejects.toThrow(/e-mail placeholder da planilha/i)
|
||||
})
|
||||
|
||||
it('keeps preventAssignedAdvisorDowngrade when the advisor still has municipalities', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
await assignMunicipalityAdvisorsRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
advisors: [advisor.id],
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'campaignUser',
|
||||
id: advisor.id,
|
||||
data: { role: 'leader' },
|
||||
user: coordinator,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow(/Remova ou substitua este usuário da assessoria/i)
|
||||
})
|
||||
|
||||
it('is idempotent when toggling municipality membership', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
await setAdvisorMunicipalityMembershipRecord(payload, coordinator, {
|
||||
advisorId: advisor.id,
|
||||
municipalityId: municipality.id,
|
||||
assigned: true,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
const again = await setAdvisorMunicipalityMembershipRecord(payload, coordinator, {
|
||||
advisorId: advisor.id,
|
||||
municipalityId: municipality.id,
|
||||
assigned: true,
|
||||
})
|
||||
expect(
|
||||
again.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
|
||||
).toEqual([advisor.id])
|
||||
|
||||
await setAdvisorMunicipalityMembershipRecord(payload, coordinator, {
|
||||
advisorId: advisor.id,
|
||||
municipalityId: municipality.id,
|
||||
assigned: false,
|
||||
})
|
||||
const cleared = await setAdvisorMunicipalityMembershipRecord(payload, coordinator, {
|
||||
advisorId: advisor.id,
|
||||
municipalityId: municipality.id,
|
||||
assigned: false,
|
||||
})
|
||||
expect(cleared.advisors ?? []).toEqual([])
|
||||
})
|
||||
|
||||
it('assigns and removes multiple municipalities in one batch', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const first = await fixtures.getMunicipality()
|
||||
const second = (
|
||||
await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { id: { not_equals: first.id } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
).docs[0]
|
||||
expect(second).toBeTruthy()
|
||||
|
||||
await setAdvisorMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
advisorId: advisor.id,
|
||||
municipalityIds: [first.id, second.id],
|
||||
assigned: true,
|
||||
})
|
||||
fixtures.touchMunicipality(first.id)
|
||||
fixtures.touchMunicipality(second.id)
|
||||
|
||||
const assigned = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { advisors: { contains: advisor.id } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(assigned.docs.map((doc) => doc.id).sort((a, b) => a - b)).toEqual(
|
||||
[first.id, second.id].sort((a, b) => a - b),
|
||||
)
|
||||
|
||||
await setAdvisorMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
advisorId: advisor.id,
|
||||
municipalityIds: [first.id, second.id],
|
||||
assigned: false,
|
||||
})
|
||||
|
||||
const cleared = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { advisors: { contains: advisor.id } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(cleared.docs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -232,7 +232,7 @@ describe('municipality catalog seed and per-role access', () => {
|
|||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('restricts advisor assignment to the coordinator and validates roles', async () => {
|
||||
it('restricts advisor assignment to unrestricted staff and validates roles', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
|
|
@ -244,7 +244,7 @@ describe('municipality catalog seed and per-role access', () => {
|
|||
municipality: municipality.id,
|
||||
advisors: [advisor.id],
|
||||
}),
|
||||
).rejects.toThrow('Coordenador Geral')
|
||||
).rejects.toThrow('coordenação geral ou o candidato')
|
||||
|
||||
await expect(
|
||||
assignMunicipalityAdvisorsRecord(payload, coordinator, {
|
||||
|
|
@ -253,10 +253,9 @@ describe('municipality catalog seed and per-role access', () => {
|
|||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
// The candidate is eligible: the projection sheet lists him as responsible
|
||||
// for some municipalities (decision 2026-07-24).
|
||||
// The candidate is eligible as both assignee and assigner (B19 + E4R).
|
||||
const candidate = await fixtures.createCampaignUser('candidate')
|
||||
const assigned = await assignMunicipalityAdvisorsRecord(payload, coordinator, {
|
||||
const assigned = await assignMunicipalityAdvisorsRecord(payload, candidate, {
|
||||
municipality: municipality.id,
|
||||
advisors: [advisor.id, candidate.id],
|
||||
})
|
||||
|
|
|
|||
73
tests/unit/advisorMunicipalityPortfolio.unit.spec.ts
Normal file
73
tests/unit/advisorMunicipalityPortfolio.unit.spec.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildAdvisorPortfolioChips,
|
||||
catalogEntriesForTseZone,
|
||||
searchAdvisorPortfolio,
|
||||
type AdvisorMunicipalityIndexEntry,
|
||||
} from '@/lib/advisorMunicipalityPortfolio'
|
||||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import { citiesForTerritory } from '@/lib/bahiaTerritories'
|
||||
|
||||
const buildIndex = (): AdvisorMunicipalityIndexEntry[] =>
|
||||
municipalityCatalog.map((entry, index) => ({
|
||||
id: index + 1,
|
||||
name: entry.name,
|
||||
slug: entry.slug,
|
||||
region: entry.region,
|
||||
city: entry.city,
|
||||
zoneNumber: entry.zoneNumber ?? null,
|
||||
}))
|
||||
|
||||
describe('advisorMunicipalityPortfolio', () => {
|
||||
it('collapses a full identity territory into a single chip', () => {
|
||||
const index = buildIndex()
|
||||
const irece = citiesForTerritory('Irecê')
|
||||
const assigned = index
|
||||
.filter((entry) => irece.includes(entry.city))
|
||||
.map((entry) => ({ id: entry.id, name: entry.name, slug: entry.slug }))
|
||||
|
||||
const chips = buildAdvisorPortfolioChips(assigned, index)
|
||||
expect(chips).toHaveLength(1)
|
||||
expect(chips[0]).toMatchObject({ kind: 'territory', label: 'Irecê' })
|
||||
})
|
||||
|
||||
it('keeps partial territory membership as municipality chips', () => {
|
||||
const index = buildIndex()
|
||||
const one = index.find((entry) => entry.city === 'Irecê')
|
||||
expect(one).toBeTruthy()
|
||||
const chips = buildAdvisorPortfolioChips(
|
||||
[{ id: one!.id, name: one!.name, slug: one!.slug }],
|
||||
index,
|
||||
)
|
||||
expect(chips).toEqual([
|
||||
{
|
||||
kind: 'municipality',
|
||||
key: `municipality:${one!.id}`,
|
||||
label: one!.name,
|
||||
municipalityId: one!.id,
|
||||
slug: one!.slug,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves Salvador ZE to the zone municipality entry only', () => {
|
||||
const entries = catalogEntriesForTseZone(3)
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]?.slug).toBe('salvador-ze-3')
|
||||
})
|
||||
|
||||
it('searches municipalities, territories and zones', () => {
|
||||
const index = buildIndex()
|
||||
const municipalityHits = searchAdvisorPortfolio('Feira', index, new Set())
|
||||
expect(municipalityHits.some((hit) => hit.kind === 'municipality')).toBe(true)
|
||||
|
||||
const territoryHits = searchAdvisorPortfolio('Irecê', index, new Set())
|
||||
expect(territoryHits.some((hit) => hit.kind === 'territory' && hit.label === 'Irecê')).toBe(
|
||||
true,
|
||||
)
|
||||
|
||||
const zoneHits = searchAdvisorPortfolio('ZE 3', index, new Set())
|
||||
expect(zoneHits.some((hit) => hit.kind === 'zone' && hit.zoneNumber === 3)).toBe(true)
|
||||
})
|
||||
})
|
||||
26
tests/unit/advisorSchema.unit.spec.ts
Normal file
26
tests/unit/advisorSchema.unit.spec.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
isPlanilhaPlaceholderEmail,
|
||||
planilhaPlaceholderEmailForAdvisor,
|
||||
} from '@/lib/schemas/advisor'
|
||||
|
||||
describe('isPlanilhaPlaceholderEmail', () => {
|
||||
it('detects the E4R seed suffix case-insensitively', () => {
|
||||
expect(isPlanilhaPlaceholderEmail('edizio@planilha.invalid')).toBe(true)
|
||||
expect(isPlanilhaPlaceholderEmail('Edizio@Planilha.Invalid')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects real emails and empty values', () => {
|
||||
expect(isPlanilhaPlaceholderEmail('edizio@example.com')).toBe(false)
|
||||
expect(isPlanilhaPlaceholderEmail('')).toBe(false)
|
||||
expect(isPlanilhaPlaceholderEmail(null)).toBe(false)
|
||||
expect(isPlanilhaPlaceholderEmail(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planilhaPlaceholderEmailForAdvisor', () => {
|
||||
it('builds a stable assessor-{id}@planilha.invalid address', () => {
|
||||
expect(planilhaPlaceholderEmailForAdvisor(42)).toBe('assessor-42@planilha.invalid')
|
||||
})
|
||||
})
|
||||
|
|
@ -3,7 +3,11 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { reloadCampaignActor, requireCampaignUser } from '@/utilities/campaignActionContext'
|
||||
import {
|
||||
reloadCampaignActor,
|
||||
reloadUnrestrictedActor,
|
||||
requireCampaignUser,
|
||||
} from '@/utilities/campaignActionContext'
|
||||
|
||||
describe('campaign action context', () => {
|
||||
it('preserves the generic authentication error', async () => {
|
||||
|
|
@ -25,4 +29,19 @@ describe('campaign action context', () => {
|
|||
req,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts coordinator and candidate as unrestricted', async () => {
|
||||
const findByID = vi.fn()
|
||||
for (const role of ['coordinator', 'candidate'] as const) {
|
||||
const actor = { id: 1, role, collection: 'campaignUser' } as CampaignUser
|
||||
findByID.mockResolvedValueOnce(actor)
|
||||
await expect(reloadUnrestrictedActor({ findByID }, actor, 'denied')).resolves.toBe(actor)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects advisor for unrestricted reload', async () => {
|
||||
const actor = { id: 2, role: 'advisor', collection: 'campaignUser' } as CampaignUser
|
||||
const findByID = vi.fn().mockResolvedValue(actor)
|
||||
await expect(reloadUnrestrictedActor({ findByID }, actor, 'denied')).rejects.toThrow('denied')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue