diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index ba0c0a45..392a197b 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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). diff --git a/.impeccable/critique/2026-07-24__campanha-assessores-b19.md b/.impeccable/critique/2026-07-24__campanha-assessores-b19.md new file mode 100644 index 00000000..5f3891d1 --- /dev/null +++ b/.impeccable/critique/2026-07-24__campanha-assessores-b19.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md index a36baf15..da3cd005 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/docs/plans/gerenciar-assessores.md b/docs/plans/gerenciar-assessores.md index a7b82843..68664579 100644 --- a/docs/plans/gerenciar-assessores.md +++ b/docs/plans/gerenciar-assessores.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 02773fcf..6696c092 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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=`). 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
da lista"] B17n["B17 Seletor de colunas
da lista"] B18n["B18 Filtros salvos
+ atalho sidebar"] - B19n["B19 Gerenciar assessores"] + B19n["B19 ✓ Gerenciar assessores"] E4R["E4R ✓ Import planilha
(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. diff --git a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx new file mode 100644 index 00000000..ef9337db --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx @@ -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 ( + +
+ +

{advisor.name}

+

+ E-mail e celular salvam sozinhos ao digitar. A carteira de municípios é editada no modo + edição da lista. +

+
+ +
+

+ Conta +

+
+ + +
+
+ +
+
+
+ {advisor.municipalities.length ? ( +
    + {advisor.municipalities.map((municipality) => ( +
  • + + + {municipality.name} + + +
  • + ))} +
+ ) : ( +

Nenhum município atribuído.

+ )} +
+ +
+

+ Acesso +

+ +
+
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts new file mode 100644 index 00000000..7af36a20 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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.', + }) + } +} diff --git a/src/app/(campaign)/campanha/(app)/assessores/novo/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/novo/page.tsx new file mode 100644 index 00000000..97e012f9 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/assessores/novo/page.tsx @@ -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') +} diff --git a/src/app/(campaign)/campanha/(app)/assessores/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/page.tsx new file mode 100644 index 00000000..0ae45a1f --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/assessores/page.tsx @@ -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> +} + +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 ( + +
+

Assessores

+

+ Consulte na tabela; ative Editar para alterar campos e carteira. Nome abre a ficha; + e-mail/celular copiam; município abre o município. +

+
+ + + + + + + + {totalDocs > 0 ? ( +
+

+ {totalDocs} {totalDocs === 1 ? 'assessor' : 'assessores'} +

+ advisorListHrefForPage(state, page)} + /> +
+ ) : null} +
+
+
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts index 34210ea3..4cfb7668 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts @@ -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.', }) diff --git a/src/app/(campaign)/campanha/actions/advisor.ts b/src/app/(campaign)/campanha/actions/advisor.ts new file mode 100644 index 00000000..4b1a5291 --- /dev/null +++ b/src/app/(campaign)/campanha/actions/advisor.ts @@ -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 => { + 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) +} diff --git a/src/app/(campaign)/campanha/actions/municipality.ts b/src/app/(campaign)/campanha/actions/municipality.ts index 36e34c69..ce68e454 100644 --- a/src/app/(campaign)/campanha/actions/municipality.ts +++ b/src/app/(campaign)/campanha/actions/municipality.ts @@ -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', diff --git a/src/components/campaign/AdvisorDebouncedTextCell.tsx b/src/components/campaign/AdvisorDebouncedTextCell.tsx new file mode 100644 index 00000000..e87a7bf4 --- /dev/null +++ b/src/components/campaign/AdvisorDebouncedTextCell.tsx @@ -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 +} + +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 | null>(null) + const requestId = useRef(0) + const inputRef = useRef(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 ( +
+ ) => { + 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 ? ( + + ) : null} +
+ ) +} diff --git a/src/components/campaign/AdvisorMunicipalityCell.tsx b/src/components/campaign/AdvisorMunicipalityCell.tsx new file mode 100644 index 00000000..ffed8aab --- /dev/null +++ b/src/components/campaign/AdvisorMunicipalityCell.tsx @@ -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 + batchAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise + 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(null) + const [expanded, setExpanded] = useState(false) + const [collapsedHeight, setCollapsedHeight] = useState(null) + /** `null` means "render every chip so the next effect can measure them". */ + const [visibleChipCount, setVisibleChipCount] = useState(null) + const [measureToken, setMeasureToken] = useState(0) + const lastRowWidth = useRef(0) + const [, startTransition] = useTransition() + const rootRef = useRef(null) + const inputRef = useRef(null) + const chipRowRef = useRef(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('[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('[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() + 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 ? ( + + ) : null + + if (!editing) { + return ( +
+
+ {chips.length === 0 ? ( + — + ) : ( + visibleChips.map((chip) => + chip.kind === 'territory' ? ( + + + ) : ( + + + {chip.label} + + + ), + ) + )} + {expandToggle} +
+
+ ) + } + + return ( +
{ + if ((event.target as HTMLElement).closest('[data-chip-remove]')) return + inputRef.current?.focus() + setOpen(true) + }} + > +
+ {visibleChips.map((chip) => ( + + ))} + {expandToggle} + { + 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" + /> +
+ + {open && query.trim() ? ( +
+ {hits.length === 0 ? ( +

Nenhum resultado.

+ ) : ( + hits.map((hit) => ( + + )) + )} +
+ ) : null} +
+ ) +} diff --git a/src/components/campaign/AdvisorPasswordResetButton.tsx b/src/components/campaign/AdvisorPasswordResetButton.tsx new file mode 100644 index 00000000..561ddc7e --- /dev/null +++ b/src/components/campaign/AdvisorPasswordResetButton.tsx @@ -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 +} + +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 ( +
+ + +
+ ) + } + + return ( +
+ + + {disabled ? ( +

{PLACEHOLDER_RESET_MESSAGE}

+ ) : ( +

+ Envia um e-mail com link para o assessor definir a senha de acesso. +

+ )} +
+ ) +} diff --git a/src/components/campaign/AdvisorsTable.tsx b/src/components/campaign/AdvisorsTable.tsx new file mode 100644 index 00000000..dd58ea7c --- /dev/null +++ b/src/components/campaign/AdvisorsTable.tsx @@ -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 + membershipAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise + batchAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise + createAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise + passwordResetAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise +} + +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(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 ( +
+
+ {editing ? ( + + ) : ( + + )} + +
+ + {showEmpty ? ( + + + + {hasQuery ? + + {hasQuery ? 'Nenhum assessor encontrado' : 'Nenhum assessor cadastrado'} + + + {hasQuery + ? 'Ajuste a busca ou limpe o filtro para ver todos.' + : 'Ative a edição e crie a conta na tabela.'} + + + {!hasQuery ? ( + + + + ) : null} + + ) : ( +
+ + + + Nome + E-mail + Celular + Municípios + + Ações + + + + + {draft ? ( + + + { + 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 + /> + + + { + 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" + /> + + + { + 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" + /> + + + + setDraft((current) => (current ? { ...current, municipalities } : current)) + } + membershipAction={membershipAction} + batchAction={batchAction} + /> + + +
+ + +
+
+
+ ) : null} + + {rows.map((row) => { + const whatsAppHref = whatsAppHrefForPhone(row.phone) + const emailShown = displayEmail(row.email) + const phoneShown = row.phone ? formatBrazilianPhoneInput(row.phone) : '' + + return ( + + + {editing ? ( + + ) : ( + + {row.name} + + )} + + + {editing ? ( + + ) : emailShown ? ( + + ) : ( + + — + + )} + + + {editing ? ( + + ) : phoneShown ? ( + + ) : ( + + — + + )} + + + + + +
+ {whatsAppHref ? ( + + ) : ( + + )} + +
+
+
+ ) + })} +
+
+
+ )} +
+ ) +} diff --git a/src/components/campaign/MunicipalityListAdvisorsControl.tsx b/src/components/campaign/MunicipalityListAdvisorsControl.tsx index ba99177c..6427e1ba 100644 --- a/src/components/campaign/MunicipalityListAdvisorsControl.tsx +++ b/src/components/campaign/MunicipalityListAdvisorsControl.tsx @@ -68,7 +68,7 @@ export const MunicipalityListAdvisorsControl = ({

- O assessor vê e gerencia somente as Praças que administra. + O assessor vê e gerencia somente os municípios que administra.

    {options.map((option) => ( diff --git a/src/components/campaign/nav.ts b/src/components/campaign/nav.ts index bc746760..d704545b 100644 --- a/src/components/campaign/nav.ts +++ b/src/components/campaign/nav.ts @@ -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) diff --git a/src/lib/advisorMunicipalityPortfolio.ts b/src/lib/advisorMunicipalityPortfolio.ts new file mode 100644 index 00000000..8570a334 --- /dev/null +++ b/src/lib/advisorMunicipalityPortfolio.ts @@ -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 => + new Map(entries.map((entry) => [entry.slug, entry])) + +const municipalityIdsForTerritory = ( + territory: BahiaIdentityTerritory, + bySlug: Map, +): 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, +): 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() + 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, + 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 +} diff --git a/src/lib/schemas/advisor.ts b/src/lib/schemas/advisor.ts new file mode 100644 index 00000000..11cc61f3 --- /dev/null +++ b/src/lib/schemas/advisor.ts @@ -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 +export type AdvisorProfileUpdateInput = z.input +export type AdvisorMunicipalityMembershipInput = z.input +export type AdvisorMunicipalitiesBatchInput = z.input +export type AdvisorPasswordResetInput = z.input diff --git a/src/lib/schemas/municipality.ts b/src/lib/schemas/municipality.ts index 89a38dad..b6acfaf6 100644 --- a/src/lib/schemas/municipality.ts +++ b/src/lib/schemas/municipality.ts @@ -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)]), }) diff --git a/src/utilities/access/campaignUsers.ts b/src/utilities/access/campaignUsers.ts index be4d36a0..69bcc6ab 100644 --- a/src/utilities/access/campaignUsers.ts +++ b/src/utilities/access/campaignUsers.ts @@ -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 => { 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) } diff --git a/src/utilities/access/municipalities.ts b/src/utilities/access/municipalities.ts index bf895064..e72fd905 100644 --- a/src/utilities/access/municipalities.ts +++ b/src/utilities/access/municipalities.ts @@ -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) diff --git a/src/utilities/advisorData.ts b/src/utilities/advisorData.ts new file mode 100644 index 00000000..60436952 --- /dev/null +++ b/src/utilities/advisorData.ts @@ -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> => { + const byAdvisor = new Map() + 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 => { + 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 => { + 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) ?? [], + } +} diff --git a/src/utilities/campaignActionContext.ts b/src/utilities/campaignActionContext.ts index 7008ce9d..6ff6997c 100644 --- a/src/utilities/campaignActionContext.ts +++ b/src/utilities/campaignActionContext.ts @@ -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 => { + const currentActor = await reloadCampaignActor(payload, actor, req) + if (!isCampaignUnrestricted(currentActor)) throw new Error(errorMessage) + return currentActor +} + export const getCampaignActionContext = async (): Promise => { const [payload, actor] = await Promise.all([getPayload({ config }), requireCampaignUser()]) return { payload, actor } diff --git a/tests/int/campaignAdvisorManagement.int.spec.ts b/tests/int/campaignAdvisorManagement.int.spec.ts new file mode 100644 index 00000000..2e38ea30 --- /dev/null +++ b/tests/int/campaignAdvisorManagement.int.spec.ts @@ -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) + }) +}) diff --git a/tests/int/campaignMunicipalityAccess.int.spec.ts b/tests/int/campaignMunicipalityAccess.int.spec.ts index a072e364..686f46ed 100644 --- a/tests/int/campaignMunicipalityAccess.int.spec.ts +++ b/tests/int/campaignMunicipalityAccess.int.spec.ts @@ -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], }) diff --git a/tests/unit/advisorMunicipalityPortfolio.unit.spec.ts b/tests/unit/advisorMunicipalityPortfolio.unit.spec.ts new file mode 100644 index 00000000..f69b9fb1 --- /dev/null +++ b/tests/unit/advisorMunicipalityPortfolio.unit.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/advisorSchema.unit.spec.ts b/tests/unit/advisorSchema.unit.spec.ts new file mode 100644 index 00000000..68a3c4c7 --- /dev/null +++ b/tests/unit/advisorSchema.unit.spec.ts @@ -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') + }) +}) diff --git a/tests/unit/campaignActionContext.unit.spec.ts b/tests/unit/campaignActionContext.unit.spec.ts index 13acd690..5f8f999d 100644 --- a/tests/unit/campaignActionContext.unit.spec.ts +++ b/tests/unit/campaignActionContext.unit.spec.ts @@ -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') + }) })