From 8e434c14af2756dce6d3ac59180e5edad345dfca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 4 Aug 2026 01:05:45 -0300 Subject: [PATCH 1/2] =?UTF-8?q?docs(plan):=20impl=20B156=20=E2=80=94=20ass?= =?UTF-8?q?essores=20por=20dobradinha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/assessores-dobradinhas-impl.md | 92 +++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/plans/assessores-dobradinhas-impl.md diff --git a/docs/plans/assessores-dobradinhas-impl.md b/docs/plans/assessores-dobradinhas-impl.md new file mode 100644 index 00000000..97987096 --- /dev/null +++ b/docs/plans/assessores-dobradinhas-impl.md @@ -0,0 +1,92 @@ +# Impl: B156 — Assessores por dobradinha + +Status: rascunho +Atualizado em: 2026-08-04 +Issue: #360 +Intenção: docs/plans/assessores-dobradinhas.md +Appetite restante: herdado (~0,75 dia eng) — sem corte + +## Leitura da intenção + +- **Outcome:** a coordenação registra no sistema quais assessores respondem por cada dobradinha — campo `advisors` em `StateDeputy`, coluna "Assessores" na lista `/campanha/dobradinhas` e seção no detalhe, com chips linkados para `/campanha/assessores/[id]` e edição inline (busca + toggle). +- **O que NÃO negociar:** + - Escrita: somente `unrestricted` (coordinator + candidate); staff lê. Mesmo padrão de `Municipality.advisors`. + - Validação: assessores designados devem ser staff elegível (`eligibleCampaignStaffWhere`), fail-closed como `validateMunicipalityAdvisors`. + - Campo no `StateDeputy`, não em tabela de join nem em superfície paralela; não alterar `Municipality.advisors` nem a página de assessores. +- **O que reavaliar:** a hipótese de "generalizar `MunicipalityPortfolioCell`" (opção A da intenção) está **errada para este caso**: `MunicipalityPortfolioCell` é especialização de municípios (chips de território/ZE, `municipalityIndex`, batch de 435) — nada disso se aplica a uma relação simples `StateDeputy.advisors`. O padrão certo já existe e é outro: `LeadershipStateDeputyRelationCell`, que é uma casca fina sobre a máquina compartilhada `RelationChipCell` com `items + options + membershipAction`. Este é o **segundo uso** desse formato de célula — o momento canônico de extrair o genérico (engineering-standards: "edit the owner… extract in the same delivery"). + +## Abordagem recomendada + +```mermaid +flowchart LR + A[StateDeputy.advisors
campo hasMany → campaignUser] --> B[beforeValidate
validateStateDeputyAdvisors] + B --> C[access/stateDeputies.ts
canAssign / canManage advisors] + C --> D[actions/stateDeputy.ts
setStateDeputyAdvisorMembership] + D --> E[formActions.ts da rota] + E --> F[StateDeputyAdvisorRelationCell
casca fina] + F --> G[RelationOptionCell
extraído de LeadershipStateDeputyRelationCell] + G --> H[RelationChipCell
+ readOnly] + D --> I[loaders stateDeputyData.ts
advisorIDs + nomes] + I --> J[coluna lista + seção detalhe] +``` + +**Opções consideradas:** + +- **A — Generalizar `MunicipalityPortfolioCell`** (recomendação da intenção): custo alto, ganho nulo. O componente é carregado de lógica de municípios (chips de território/ZE, índice de portfolio, batch com floor) que não existe na relação assessor×dobradinha. Generalizar "qualquer `{ id, label, href }`" significa esvaziar exatamente a parte que o torna útil. +- **B — Copiar `LeadershipStateDeputyRelationCell` num `StateDeputyAdvisorRelationCell` autônomo** (mitigação do rabbit hole da intenção): funciona, mas é um twin — o conhecimento de "célula de catálogo com busca" ficaria duplicado, e o engineering-standards manda extrair no segundo uso. +- **C — Extrair `RelationOptionCell` genérico de `LeadershipStateDeputyRelationCell` + `StateDeputyAdvisorRelationCell` como segunda casca fina** (recomendada): o genérico recebe `items`, `options`, `buildFormData`, `copy`, `commitAction`, `readOnly`; a casca de liderança mantém a API pública atual (`direction`, `fixedId`, `membershipAction`, …) — os 3 call sites de lideranças e o unit spec existente **não mudam** — e a casca de assessores é nova e pequena (~60 linhas de copy + `buildFormData`). + +**Rejeitadas:** A porque o componente não é a fonte certa de conhecimento (especialização de municípios, não de catálogo); B porque duplica a máquina de busca/chips que a extração resolve com menos código total. + +### Componentes / mudanças + +- **`StateDeputy`** (`src/collections/StateDeputy.ts`): campo `advisors` (`relationship` hasMany → `campaignUser`, `index`, `filterOptions: eligibleCampaignStaffWhere`, access `create: canAssignStateDeputyAdvisors` / `update: canManageStateDeputyAdvisors`) + hook `beforeValidate` `validateStateDeputyAdvisors` (espelho de `validateMunicipalityAdvisors`: cada id deve satisfazer `eligibleCampaignStaffWhere`, senão `APIError` 400). +- **`access/stateDeputies.ts`**: `canAssignStateDeputyAdvisors` (unrestricted) e `canManageStateDeputyAdvisors` (admin-only, como o par de municípios — a escrita real passa pela action com `overrideAccess: true` após `reloadUnrestrictedActor`). Re-export em `campaignAccess.ts`. +- **`lib/schemas/stateDeputy.ts`**: `MAX_ADVISORS_PER_STATE_DEPUTY` (10, espelhando `MAX_ADVISORS_PER_MUNICIPALITY`), `STATE_DEPUTY_ADVISORS_CAP_MESSAGE`, `STATE_DEPUTY_ADVISORS_UNRESTRICTED_MESSAGE` ("Somente a coordenação geral ou o candidato gerencia assessores de dobradinhas."), `stateDeputyAdvisorMembershipSchema` (`{ stateDeputyId, advisorId, assigned }`) e `STATE_DEPUTY_ADVISOR_SAFE_MESSAGES`. +- **`lib/stateDeputyAdvisorMembership.ts`** (novo, mesmo molde de `lib/municipalityAdvisorMembership.ts`): `nextStateDeputyAdvisorIdsAfterMembership` sobre `nextIdsAfterMembership` com o cap acima. +- **`actions/stateDeputy.ts`**: `setStateDeputyAdvisorMembershipRecord` — `withPayloadTransaction`, `reloadUnrestrictedActor`, lock aditivo por `state-deputy-advisors:`, delta com `nextStateDeputyAdvisorIdsAfterMembership`, no-op sem escrita, read/write com `overrideAccess: true` (campo é admin-only na config; a role já foi verificada — espelho exato de `setAdvisorMunicipalitiesBatchRecord`). `setStateDeputyAdvisorMembership` revalida `/campanha/dobradinhas/[slug]` (a lista não: a célula está nela e já mostra o toggle — precedente B31/B37). +- **`dobradinhas/formActions.ts`**: `setStateDeputyAdvisorMembershipFormAction` (casca `runCampaignFormAction` com `safeMessages`). +- **`campaignRelationOptions.ts`**: `loadCampaignUserSummaries(payload, user, ids)` (`{ id, name }`, honrando `canReadCampaignUsers`+eligible) e `loadEligibleAdvisorOptions(payload, user)` (`RelationOption[]`, `sort: 'name'`, `where: eligibleCampaignStaffWhere`). +- **`stateDeputyData.ts`**: `StateDeputyRowViewModel.advisorIDs` + `StateDeputyDetailViewModel.advisors: { id, name }[]`; selects ganham `advisors`; nomes resolvidos via `loadCampaignUserSummaries`. +- **UI (Impeccable C — superfície existente):** + - `RelationChipCell.tsx`: prop `readOnly?: boolean` — renderiza só os chips (linkados), sem trigger de drawer, sem hover-remover, sem combobox; aria-label por extenso. + - `RelationOptionCell.tsx` (novo, `shared/`): o genérico extraído — `ownerId`, `ownerName`, `items`, `options`, `buildFormData(changedId, assigned)`, `commitAction`, `copy`, `drawerTitle`, `triggerLabel`, `updateErrorMessage`, `readOnly`, `measureOverflow`. + - `LeadershipStateDeputyRelationCell.tsx`: vira casca fina sobre `RelationOptionCell` mantendo a API pública atual (call sites de lideranças e unit spec intactos). + - `StateDeputyAdvisorRelationCell.tsx` (novo, `components/campaign/stateDeputy/`): casca fina com copy pt ("Buscar assessor…", "Assessores da dobradinha"), chips com href `/campanha/assessores/[id]`, `buildFormData` `{ stateDeputyId, advisorId, assigned }`, `readOnly`. + - `dobradinhas/page.tsx`: coluna `advisors` após "Lideranças" (leitura: geografia → pessoas → gestão); cabeçalho com `CampaignTableHead` + description "Edite aqui: passe o mouse em um chip para remover, ou busque para adicionar."; `canEditAdvisors = isCampaignUnrestricted(user)`; options carregadas só quando a coluna está visível E `canEditAdvisors` (mesmo guard de `isLeadershipVisible`). + - `dobradinhas/[slug]/page.tsx`: seção "Assessores responsáveis" (ícone `UserCog` + `` com contagem) como terceiro card no grid 2-col, com a mesma célula. +- **Migration:** `pnpm migrate:create add_state_deputy_advisors` (tabela de relação `stateDeputy_rels` path `advisors`) → `pnpm generate:types`. + +### Dados → forma + +- Forma: chips linkados + drawer de busca (mesma superfície das colunas "Municípios"/"Lideranças" da própria página) — o job "quem cobre qual dobradinha" é comparativo entre linhas e a célula já prova o padrão com advisors em municípios. +- Rejeitadas: avatar stack do estilo `MunicipalityAdvisorAvatarStack` (perde o link e a edição onde se vê); popover `MunicipalityListAdvisorsControl` (endpoint e create-inline são municipais; coordinator-only, mas aqui o candidato também edita — e a página já usa o padrão drawer de `RelationChipCell`). + +## Fases verificáveis + +1. **Schema + server** — coleção (campo + hook + access), schemas, action + formAction, loaders/options, migration + tipos, int spec `campaignStateDeputyAdvisorMembership.int.spec.ts` (coordinator/candidate atribuem e removem; advisor e leader negados; idempotente; cap de 10; ineligible role rejeitada pelo hook). Verificação: `pnpm gate:fast` + int spec isolado. +2. **UI** — `readOnly` no `RelationChipCell`, extração `RelationOptionCell`, cascas, coluna na lista, seção no detalhe, unit spec da célula de assessores. Verificação: `pnpm test:unit` + int dos loaders. +3. **Gates** — `pnpm gate:fast` na iteração; fechamento com `pnpm push` (gate:push → gate:ci); `tsc`, `lint`, `format:check`, `knip`, `check:cycles`, `pnpm test`, `pnpm build`. + +## Rabbit holes / Não escopo (engenharia) + +- Não generalizar além das duas cascas: `RelationOptionCell` nasce do formato `items + options` — não absorver `MunicipalityPortfolioCell` (chips de território/ZE ficam onde estão). +- Não adicionar filtro/ordenação por assessor, nem hits de assessor na busca global (B52) — ambos adiados com gatilho na intenção. +- Não tocar `Municipality.advisors`, `loadAdvisorSummaries`/`getEligibleAdvisorOptions` de municípios nem a página `/campanha/assessores`. +- Sem Consent novo (staff interno gerenciando staff). + +## Riscos e mitigação + +- **Extrair `RelationOptionCell` regride as colunas de lideranças** → API pública da casca preservada; unit spec existente (`leadershipStateDeputyRelationCell.unit.spec.ts`) + int specs B31/B36 continuam cobrindo; rodar `pnpm test` completo antes do push. +- **`overrideAccess: true` na escrita** → role verificada por `reloadUnrestrictedActor` antes de qualquer read/write (mesmo contrato de `setAdvisorMunicipalitiesBatchRecord`); hook `beforeValidate` continua rodando mesmo com bypass e valida a elegibilidade dos ids. +- **Cap de 10 assessores é decisão de produto que a intenção não fixou** → default segue o precedente de municípios; fácil reverter (constante + mensagem + teste). Levantado no gate humano. +- **Coluna nova na lista de dobradinhas carrega catálogo de staff por página** → options só quando coluna visível E `canEdit`; catálogo é pequeno (staff de campanha, ~dezenas). + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: campo, coluna, seção, validação e access conforme travado +- [ ] Invariantes AGENTS/engineering-standards: Local API `user` + `overrideAccess: false` onde o padrão manda (bypass só com comentário, após `reloadUnrestrictedActor`); escrita em transação com lock aditivo; zero warnings; sem twin (extração no mesmo delivery) +- [ ] Testes previstos: int do membership (roles, no-op, cap, ineligible) + unit da célula nova; existentes verdes +- [ ] Migration commitada (`.ts` + `.json` + `index.ts`) e `generate:types` rodado + +Self-score decision-quality: 5/5 — (1) decisões caras (cell, access, cap) com rejeitadas; (2) cabe no appetite (mecânica sobre padrões existentes); (3) rabbit holes nomeados; (4) reusa `RelationChipCell`, `withPayloadTransaction`, `reloadUnrestrictedActor`, `nextIdsAfterMembership`, shells de lista; (5) outcome de produto intacto. From 6b8864debbaca2776d0ce006bdc4dee34b7a3dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 4 Aug 2026 04:15:13 -0300 Subject: [PATCH 2/2] =?UTF-8?q?B156=20=E2=80=94=20assessores=20por=20dobra?= =?UTF-8?q?dinha=20(campo,=20coluna=20e=20se=C3=A7=C3=A3o;=20RelationOptio?= =?UTF-8?q?nCell=20extra=C3=ADdo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/assessores-dobradinhas-impl.md | 20 +- docs/plans/reconciliar-rationale-alocacao.md | 30 + .../(app)/dobradinhas/[slug]/formActions.ts | 34 +- .../(app)/dobradinhas/[slug]/page.tsx | 48 +- .../campanha/(app)/dobradinhas/formActions.ts | 28 +- .../campanha/(app)/dobradinhas/page.tsx | 52 +- .../campanha/actions/stateDeputy.ts | 80 +- src/collections/StateDeputy.ts | 50 +- .../LeadershipStateDeputyRelationCell.tsx | 108 +- .../campaign/shared/RelationChipCell.tsx | 155 +- .../campaign/shared/RelationOptionCell.tsx | 154 + .../StateDeputyAdvisorRelationCell.tsx | 99 + src/lib/schemas/stateDeputy.ts | 30 + src/lib/stateDeputyAdvisorMembership.ts | 21 + ...0804_061017_add_state_deputy_advisors.json | 12895 ++++++++++++++++ ...260804_061017_add_state_deputy_advisors.ts | 25 + src/migrations/index.ts | 8 +- src/payload-types.ts | 2 + src/utilities/access/stateDeputies.ts | 23 +- src/utilities/campaignAccess.ts | 2 + src/utilities/campaignRelationOptions.ts | 63 + src/utilities/stateDeputyData.ts | 42 +- ...gnStateDeputyAdvisorMembership.int.spec.ts | 161 + ...tateDeputyAdvisorRelationCell.unit.spec.ts | 141 + 24 files changed, 14086 insertions(+), 185 deletions(-) create mode 100644 docs/plans/reconciliar-rationale-alocacao.md create mode 100644 src/components/campaign/shared/RelationOptionCell.tsx create mode 100644 src/components/campaign/stateDeputy/StateDeputyAdvisorRelationCell.tsx create mode 100644 src/lib/stateDeputyAdvisorMembership.ts create mode 100644 src/migrations/20260804_061017_add_state_deputy_advisors.json create mode 100644 src/migrations/20260804_061017_add_state_deputy_advisors.ts create mode 100644 tests/int/campaignStateDeputyAdvisorMembership.int.spec.ts create mode 100644 tests/unit/stateDeputyAdvisorRelationCell.unit.spec.ts diff --git a/docs/plans/assessores-dobradinhas-impl.md b/docs/plans/assessores-dobradinhas-impl.md index 97987096..a45c9b41 100644 --- a/docs/plans/assessores-dobradinhas-impl.md +++ b/docs/plans/assessores-dobradinhas-impl.md @@ -1,6 +1,6 @@ # Impl: B156 — Assessores por dobradinha -Status: rascunho +Status: aprovado Atualizado em: 2026-08-04 Issue: #360 Intenção: docs/plans/assessores-dobradinhas.md @@ -84,9 +84,19 @@ flowchart LR ## Aceite de engenharia -- [ ] Aceite de produto da intenção coberto: campo, coluna, seção, validação e access conforme travado -- [ ] Invariantes AGENTS/engineering-standards: Local API `user` + `overrideAccess: false` onde o padrão manda (bypass só com comentário, após `reloadUnrestrictedActor`); escrita em transação com lock aditivo; zero warnings; sem twin (extração no mesmo delivery) -- [ ] Testes previstos: int do membership (roles, no-op, cap, ineligible) + unit da célula nova; existentes verdes -- [ ] Migration commitada (`.ts` + `.json` + `index.ts`) e `generate:types` rodado +- [x] Aceite de produto da intenção coberto: campo, coluna, seção, validação e access conforme travado +- [x] Invariantes AGENTS/engineering-standards: Local API `user` + `overrideAccess: false` onde o padrão manda (bypass só com comentário, após `reloadUnrestrictedActor`); escrita em transação com lock aditivo; zero warnings; sem twin (extração no mesmo delivery) +- [x] Testes previstos: int do membership (roles, no-op, cap, ineligible) + unit da célula nova; existentes verdes +- [x] Migration commitada (`.ts` + `.json` + `index.ts`) e `generate:types` rodado + +## Execução + +- /simplify: 3 revisores read-only, sem BLOCKING. Fixes aplicados: chips sem href em readOnly (rota `/campanha/assessores/[id]` é unrestricted-only — dead-end para staff), gate da description do head por `canEditAdvisors`, `lg:col-span-2` na seção do detalhe, copy "Nenhum assessor vinculado.", `summaryById` Map no loader, `stateDeputySlug === undefined` no teste de no-op, `readOnly` removido da casca de lideranças (especulativo). Débitos deferidos registrados em Issues. + +## Adiado com gatilho (débitos do /simplify) + +- **Catálogo de staff duplicado** (`loadCampaignUserSummaries`/`loadEligibleAdvisorOptions` em `campaignRelationOptions` vs `loadAdvisorSummaries`/`getEligibleAdvisorOptions` em `municipalityViewModels`). Gatilho: 3º consumidor do catálogo (ex. advisors de atividade na lista) ou próximo toque no domínio de municípios — aí o `campaignRelationOptions` vira dono único e os loaders de municípios delegam. +- **`validateStateDeputyAdvisors` duplica `validateMunicipalityAdvisors`**. Gatilho: 3ª collection com campo `advisors` (ex. `activity`) — extrair `validateEligibleStaffAdvisors` compartilhado (cuidado com o pin de bypass do conventions test ao mover o `overrideAccess`). +- **Célula sem `maxItems`** (cap 10 só no servidor, com flicker+revert em vez de recusa pré-otimista). Gatilho: o toggle de assessores ganhar batch ou o cap virar atrito medido. Self-score decision-quality: 5/5 — (1) decisões caras (cell, access, cap) com rejeitadas; (2) cabe no appetite (mecânica sobre padrões existentes); (3) rabbit holes nomeados; (4) reusa `RelationChipCell`, `withPayloadTransaction`, `reloadUnrestrictedActor`, `nextIdsAfterMembership`, shells de lista; (5) outcome de produto intacto. diff --git a/docs/plans/reconciliar-rationale-alocacao.md b/docs/plans/reconciliar-rationale-alocacao.md new file mode 100644 index 00000000..fb1640f7 --- /dev/null +++ b/docs/plans/reconciliar-rationale-alocacao.md @@ -0,0 +1,30 @@ +# Reconciliar drift — `allocation_decision.rationale` NOT NULL vs config + +Status: rascunho +Atualizado em: 2026-08-04 +Issue: #364 (registrada via agent:register) +Origem: débito B156 (triage S5/S14) — descoberto no `migrate:create` de `20260804_061017_add_state_deputy_advisors` +Appetite: ~0,25 dia eng +Model: `composer-2.5` + +## Contexto + +- `20260724_180000_add_campaign_foundation_records` criou `allocation_decision.rationale` como `varchar NOT NULL`. +- B134 (#288) tornou o motivo **opcional** na config (`AllocationDecision.rationale.required: false`), sem migration reconciliando a coluna. +- Consequência: todo `pnpm migrate:create` volta a emitir `ALTER TABLE allocation_decision ALTER COLUMN rationale DROP NOT NULL` (diff recorrente, fora do escopo da migration em questão — foi removido manualmente da migration do B156). +- Risco de runtime: uma escrita E14 com motivo omitido envia `null` e o Postgres rejeita (coluna `NOT NULL`) — o fluxo "motivo opcional" do B134 pode falhar em prod. + +## Fases verificáveis + +1. **Migration própria e idempotente** — `pnpm migrate:create reconcile_allocation_decision_rationale_nullable`; validar que o SQL gerado é apenas o `DROP NOT NULL` (guard: só executar quando `is_nullable = 'NO'`, precedente do consent `varchar → jsonb`); aplicar em dev e test. +2. **Verificação do drift** — rodar `pnpm migrate:create ` e confirmar que não re-emite mais o ALTER (ou conferir `migrate:status` + diff); manter o probe descartado. +3. **Teste** — int spec do caminho E14 com movimento sem motivo (ou unit do hook/action que escreve `rationale` nulo) para provar o write aceito; gates completos (`pnpm gate:fast`, `pnpm push`). + +## Já resolvido no simplify/critique (não reabrir) + +- N/A — débito novo desta triage. + +## Explicitamente fora + +- Não alterar o fluxo E14 em si (motivo opcional do wizard/lista) — escopo do #288 (B134), do qual este item é a reconciliação de schema. +- Não tocar `snapshot`/`reversalSignals` (vivem só no JSON do snapshot; sem migration). diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts index a32b198b..fac6c17b 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/formActions.ts @@ -2,9 +2,19 @@ import { revalidatePath } from 'next/cache' -import { updateStateDeputy } from '@/app/(campaign)/campanha/actions/stateDeputy' -import { nullableFormText, requiredRelationshipFormValue } from '@/lib/formData' -import { STATE_DEPUTY_STAFF_MESSAGE } from '@/lib/schemas/stateDeputy' +import { + setStateDeputyAdvisorMembership, + updateStateDeputy, +} from '@/app/(campaign)/campanha/actions/stateDeputy' +import { + nullableFormText, + requiredFormBoolean, + requiredRelationshipFormValue, +} from '@/lib/formData' +import { + STATE_DEPUTY_ADVISOR_SAFE_MESSAGES, + STATE_DEPUTY_STAFF_MESSAGE, +} from '@/lib/schemas/stateDeputy' import { runCampaignFormAction, type CampaignFormActionState, @@ -27,3 +37,21 @@ export const updateStateDeputyFormAction = async ( safeMessages: [STATE_DEPUTY_STAFF_MESSAGE], genericMessage: 'Não foi possível salvar a dobradinha. Verifique seu acesso e tente novamente.', }) + +/** One chip toggle in the "Assessores responsáveis" section (B156). */ +export const setStateDeputyAdvisorMembershipFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + await setStateDeputyAdvisorMembership({ + stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'), + advisorId: requiredRelationshipFormValue(formData, 'advisorId'), + assigned: requiredFormBoolean(formData, 'assigned'), + }) + return { message: 'Assessores atualizados.' } + }, + safeMessages: STATE_DEPUTY_ADVISOR_SAFE_MESSAGES, + genericMessage: 'Não foi possível atualizar os assessores. Tente novamente.', + }) diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx index 65f5db15..eafc0648 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx @@ -1,17 +1,23 @@ import config from '@payload-config' -import { HandshakeIcon, MapPinIcon } from 'lucide-react' +import { HandshakeIcon, MapPinIcon, UserCogIcon } from 'lucide-react' import Link from 'next/link' import { notFound } from 'next/navigation' import { getPayload } from 'payload' import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { StateDeputyAdvisorRelationCell } from '@/components/campaign/stateDeputy/StateDeputyAdvisorRelationCell' import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm' import { Badge } from '@/components/ui/Badge' import { campaignPageMetadata } from '@/lib/campaignPageChrome' +import { isCampaignUnrestricted } from '@/utilities/campaignAccess' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { loadEligibleAdvisorOptions } from '@/utilities/campaignRelationOptions' import { loadStateDeputyDetail } from '@/utilities/stateDeputyData' -import { updateStateDeputyFormAction } from './formActions' +import { + setStateDeputyAdvisorMembershipFormAction, + updateStateDeputyFormAction, +} from './formActions' export async function generateMetadata({ params }: StateDeputyDetailPageProps) { const { slug } = await params @@ -44,6 +50,10 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai const stateDeputy = await loadStateDeputyDetail(payload, user, slug) if (!stateDeputy) notFound() + // B156 — only coordinator/candidate assign advisors; the rest of staff reads. + const canEditAdvisors = isCampaignUnrestricted(user) + const advisorOptions = canEditAdvisors ? await loadEligibleAdvisorOptions(payload, user) : [] + return ( )} + +
+
+
+ ({ + id: option.id, + searchLabel: option.name, + item: { + id: option.id, + label: option.name, + href: `/campanha/assessores/${option.id}`, + }, + })) + : [] + } + membershipAction={setStateDeputyAdvisorMembershipFormAction} + readOnly={!canEditAdvisors} + measureOverflow={false} + /> +
diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts index dbdc8331..f658d595 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts @@ -1,7 +1,10 @@ 'use server' import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership' -import { setStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy' +import { + setStateDeputyAdvisorMembership, + setStateDeputyMunicipalitiesBatch, +} from '@/app/(campaign)/campanha/actions/stateDeputy' import { repeatedRelationshipFormValues, requiredFormBoolean, @@ -11,7 +14,10 @@ import { LEADERSHIP_STAFF_MESSAGE, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE, } from '@/lib/schemas/leadership' -import { STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES } from '@/lib/schemas/stateDeputy' +import { + STATE_DEPUTY_ADVISOR_SAFE_MESSAGES, + STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES, +} from '@/lib/schemas/stateDeputy' import { runCampaignFormAction, type CampaignFormActionState, @@ -37,6 +43,24 @@ export const setLeadershipStateDeputyMembershipFormAction = async ( genericMessage: 'Não foi possível atualizar as lideranças. Tente novamente.', }) +/** One chip toggle in the "Assessores" column/section of a dobradinha (B156). */ +export const setStateDeputyAdvisorMembershipFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + await setStateDeputyAdvisorMembership({ + stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'), + advisorId: requiredRelationshipFormValue(formData, 'advisorId'), + assigned: requiredFormBoolean(formData, 'assigned'), + }) + return { message: 'Assessores atualizados.' } + }, + safeMessages: STATE_DEPUTY_ADVISOR_SAFE_MESSAGES, + genericMessage: 'Não foi possível atualizar os assessores. Tente novamente.', + }) + /** * Add or remove municipalities in the "Municípios" column of * `/campanha/dobradinhas` (B37) — one chip or a whole território/ZE. `ownerId` diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx index f107903f..161f47a4 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx @@ -23,6 +23,7 @@ import { } from '@/components/campaign/shared/LeadershipStateDeputyRelationCell' import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { StateDeputyAdvisorRelationCell } from '@/components/campaign/stateDeputy/StateDeputyAdvisorRelationCell' import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters' import { StateDeputySortableHead } from '@/components/campaign/stateDeputy/StateDeputySortableHead' import { Button, buttonVariants } from '@/components/ui/button' @@ -38,10 +39,13 @@ import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio' import { cn } from '@/lib/utils' -import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess' +import { getAdvisorMunicipalityIds, isCampaignUnrestricted } from '@/utilities/campaignAccess' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' -import { loadLeadershipOptions } from '@/utilities/campaignRelationOptions' +import { + loadEligibleAdvisorOptions, + loadLeadershipOptions, +} from '@/utilities/campaignRelationOptions' import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' import { loadStateDeputyListPageData, @@ -63,6 +67,7 @@ import { import { setLeadershipStateDeputyMembershipFormAction, + setStateDeputyAdvisorMembershipFormAction, setStateDeputyMunicipalitiesFormAction, } from './formActions' @@ -102,6 +107,8 @@ const stateDeputyColumns = ( partyFilterOptions: StateDeputyFilterOption[], hasNoPartyOption: boolean, leadershipOptions: RelationCellOption[], + advisorOptions: RelationCellOption[], + canEditAdvisors: boolean, municipalityIndex: MunicipalityPortfolioIndexEntry[], addableMunicipalityIds: ReadonlySet | undefined, ): Array> => [ @@ -174,6 +181,30 @@ const stateDeputyColumns = ( /> ), }, + { + id: 'advisors', + label: 'Assessores', + head: canEditAdvisors ? ( + + Assessores + + ) : ( + + Assessores + + ), + cellClassName: 'max-w-72 whitespace-normal', + cell: (row) => ( + + ), + }, ] export default async function StateDeputiesPage({ searchParams }: StateDeputiesPageProps) { @@ -188,15 +219,22 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP const columnVisibility = await readCampaignColumnVisibility('dobradinhas') const isLeadershipVisible = !columnVisibility.hiddenColumnIds.includes('leaderships') + const isAdvisorsVisible = !columnVisibility.hiddenColumnIds.includes('advisors') + // Advisor assignment is unrestricted-only (B156); the rest of staff reads. + const canEditAdvisors = isCampaignUnrestricted(user) const [ { rows, totalDocs, totalPages, filterFacets }, leadershipOptions, + advisorOptions, municipalityIndex, administeredIds, ] = await Promise.all([ loadStateDeputyListPageData(payload, user, canonicalUrl.state), isLeadershipVisible ? loadLeadershipOptions(payload, user) : Promise.resolve([]), + isAdvisorsVisible && canEditAdvisors + ? loadEligibleAdvisorOptions(payload, user) + : Promise.resolve([]), loadMunicipalityPortfolioIndex(), user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null, ]) @@ -220,6 +258,16 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP href: `/campanha/liderancas/${option.id}`, }, })), + advisorOptions.map((option) => ({ + id: option.id, + searchLabel: option.name, + item: { + id: option.id, + label: option.name, + href: `/campanha/assessores/${option.id}`, + }, + })), + canEditAdvisors, municipalityIndex, administeredIds ? new Set(administeredIds) : undefined, ) diff --git a/src/app/(campaign)/campanha/actions/stateDeputy.ts b/src/app/(campaign)/campanha/actions/stateDeputy.ts index 653a764f..a5b2e207 100644 --- a/src/app/(campaign)/campanha/actions/stateDeputy.ts +++ b/src/app/(campaign)/campanha/actions/stateDeputy.ts @@ -6,19 +6,27 @@ import type { Payload } from 'payload' import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership' import { uniqueRelationshipIds } from '@/lib/relationship' import { + STATE_DEPUTY_ADVISORS_UNRESTRICTED_MESSAGE, STATE_DEPUTY_CONFLICT_MESSAGE, STATE_DEPUTY_STAFF_MESSAGE, municipalityStateDeputyCreateSchema, + stateDeputyAdvisorMembershipSchema, stateDeputyCreateSchema, stateDeputyMunicipalitiesBatchSchema, stateDeputyUpdateSchema, type MunicipalityStateDeputyCreateInput, + type StateDeputyAdvisorMembershipInput, type StateDeputyCreateInput, type StateDeputyMunicipalitiesBatchInput, type StateDeputyUpdateInput, } from '@/lib/schemas/stateDeputy' +import { nextStateDeputyAdvisorIdsAfterMembership } from '@/lib/stateDeputyAdvisorMembership' import type { CampaignUser } from '@/payload-types' -import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext' +import { + getCampaignActionContext, + reloadStaffActor, + reloadUnrestrictedActor, +} from '@/utilities/campaignActionContext' import { mapStaffEntityConflict, runStaffEntityMutation, @@ -110,6 +118,76 @@ const revalidateStateDeputyMunicipalityPaths = ( * (`canManageCampaignStaffField`) is staff-wide, so `overrideAccess: false` * on both the read and the write is what refuses an out-of-scope município. */ +export const setStateDeputyAdvisorMembershipRecord = async ( + payload: Payload, + actor: CampaignUser, + input: StateDeputyAdvisorMembershipInput, +) => { + const { stateDeputyId, advisorId, assigned } = stateDeputyAdvisorMembershipSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + // Role gate only — the read/write below run under overrideAccess (the + // `advisors` field's update access is admin-only in the collection + // config), so the fresh actor exists solely to make the assertion. + await reloadUnrestrictedActor(payload, actor, STATE_DEPUTY_ADVISORS_UNRESTRICTED_MESSAGE, req) + + await acquireTextAdvisoryLocks(payload, req, [`state-deputy-advisors:${stateDeputyId}`]) + + const stateDeputy = await payload.findByID({ + collection: 'stateDeputy', + id: stateDeputyId, + depth: 0, + select: { advisors: true, slug: true }, + // Intentional admin bypass: the unrestricted role was verified above; + // the `advisors` field's update access is admin-only in the collection + // config, same contract as `setAdvisorMunicipalitiesBatchRecord`. The + // `beforeValidate` eligibility hook still runs under overrideAccess. + overrideAccess: true, + req, + }) + + const currentAdvisorIDs = uniqueRelationshipIds(stateDeputy.advisors) + const nextAdvisorIDs = nextStateDeputyAdvisorIdsAfterMembership( + currentAdvisorIDs, + advisorId, + assigned, + ) + + // No-op: nothing to write, and nothing for the caller to revalidate — + // the slug lookup below exists only to target that revalidate. + if (nextAdvisorIDs === null) { + return { stateDeputySlug: undefined } + } + + // Same intentional bypass as the read: the unrestricted role was verified + // above, and the `beforeValidate` eligibility hook still runs under it. + await payload.update({ + collection: 'stateDeputy', + id: stateDeputyId, + data: { advisors: nextAdvisorIDs }, + depth: 0, + overrideAccess: true, + req, + }) + + return { stateDeputySlug: stateDeputy.slug } + }, + { beginFailureMessage: 'Não foi possível atualizar os assessores da dobradinha.' }, + ) +} + +export const setStateDeputyAdvisorMembership = async (input: StateDeputyAdvisorMembershipInput) => { + const { payload, actor } = await getCampaignActionContext() + const { stateDeputySlug } = await setStateDeputyAdvisorMembershipRecord(payload, actor, input) + // The list is deliberately absent: the chip cell that calls this IS on it and + // already shows the toggle. The detail page is the route an actor cannot see + // from here — same reasoning as the leadership twin (B31). + if (stateDeputySlug) revalidatePath(`/campanha/dobradinhas/${stateDeputySlug}`, 'page') + return stateDeputySlug +} + export const setStateDeputyMunicipalitiesBatchRecord = async ( payload: Payload, actor: CampaignUser, diff --git a/src/collections/StateDeputy.ts b/src/collections/StateDeputy.ts index 95bfd005..dc022607 100644 --- a/src/collections/StateDeputy.ts +++ b/src/collections/StateDeputy.ts @@ -1,15 +1,19 @@ import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload' import { APIError } from 'payload' +import { uniqueRelationshipIds } from '@/lib/relationship' import { STATE_DEPUTY_NAME_REQUIRED_MESSAGE } from '@/lib/schemas/stateDeputy' import { slugify } from '@/lib/slug' import { trimmedText } from '@/lib/text' import { + canAssignStateDeputyAdvisors, canCreateStateDeputy, canDeleteStateDeputy, canManageStateDeputy, + canManageStateDeputyAdvisors, canReadStateDeputy, canSetCampaignSystemField, + eligibleCampaignStaffWhere, } from '@/utilities/campaignAccess' import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields' @@ -32,6 +36,37 @@ const setCanonicalStateDeputySlug: CollectionBeforeValidateHook = ({ return data } +const validateStateDeputyAdvisors: CollectionBeforeValidateHook = async ({ data, req }) => { + if (!data || data.advisors === undefined) return data + + const advisorIDs = Array.isArray(data.advisors) ? uniqueRelationshipIds(data.advisors) : [] + if (advisorIDs.length === 0) return data + + const eligibleAdvisors = await req.payload.find({ + collection: 'campaignUser', + depth: 0, + pagination: false, + where: { + and: [{ id: { in: advisorIDs } }, eligibleCampaignStaffWhere], + }, + select: { name: true }, + // Intentional admin bypass: eligibility is a pure role check that must run + // for every caller (including /admin), independent of the actor's read + // scope over `campaignUser` — mirrors `validateMunicipalityAdvisors`. + overrideAccess: true, + req, + }) + + if (eligibleAdvisors.docs.length !== advisorIDs.length) { + throw new APIError( + 'Cada assessor deve ter papel de Coordenador Geral, Assessor ou Candidato.', + 400, + ) + } + + return data +} + export const StateDeputy: CollectionConfig = { slug: 'stateDeputy', labels: { @@ -52,7 +87,7 @@ export const StateDeputy: CollectionConfig = { delete: canDeleteStateDeputy, }, hooks: { - beforeValidate: [setCanonicalStateDeputySlug], + beforeValidate: [setCanonicalStateDeputySlug, validateStateDeputyAdvisors], beforeChange: [stampCampaignCreatedBy], }, fields: [ @@ -94,6 +129,19 @@ export const StateDeputy: CollectionConfig = { label: 'Observações', maxLength: 4000, }, + { + name: 'advisors', + type: 'relationship', + relationTo: 'campaignUser', + label: 'Assessores', + hasMany: true, + index: true, + access: { + create: canAssignStateDeputyAdvisors, + update: canManageStateDeputyAdvisors, + }, + filterOptions: eligibleCampaignStaffWhere, + }, systemStampedActorField({ setAccess: canSetCampaignSystemField }), ], } diff --git a/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx b/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx index c0742c78..30354cec 100644 --- a/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx +++ b/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx @@ -2,27 +2,18 @@ import { useCallback, useMemo } from 'react' +import type { RelationChipCellCopy } from '@/components/campaign/shared/RelationChipCell' import { - RelationChipCell, - type RelationChip, - type RelationChipCellCopy, - type RelationSearchHit, -} from '@/components/campaign/shared/RelationChipCell' -import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter' + RelationOptionCell, + type RelationCellItem, + type RelationCellOption, +} from '@/components/campaign/shared/RelationOptionCell' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' -export type RelationCellItem = { - id: number - label: string - href: string - party?: string -} - -export type RelationCellOption = { - id: number - searchLabel: string - item: RelationCellItem -} +export type { + RelationCellItem, + RelationCellOption, +} from '@/components/campaign/shared/RelationOptionCell' type LeadershipStateDeputyRelationDirection = 'fromLeadership' | 'fromStateDeputy' @@ -41,28 +32,6 @@ type LeadershipStateDeputyRelationCellProps = { measureOverflow?: boolean } -const itemLabel = (item: RelationCellItem): string => - item.party ? `${item.label} (${item.party})` : item.label - -/** - * Normalized search labels, once per `options` array rather than per row: the - * RSC builds one array and every row of the table gets that same reference, so a - * `WeakMap` shares the work where a per-row `useMemo` would repeat it (same - * reasoning as `municipalityPortfolio`'s derivation cache). - */ -const normalizedLabelsByOptions = new WeakMap>() - -const normalizedOptionLabels = (options: readonly RelationCellOption[]): Map => { - const cached = normalizedLabelsByOptions.get(options) - if (cached) return cached - - const normalized = new Map( - options.map((option) => [option.id, normalizeSearchPhrase(option.searchLabel)] as const), - ) - normalizedLabelsByOptions.set(options, normalized) - return normalized -} - const DIRECTION_COPY: Record< LeadershipStateDeputyRelationDirection, { @@ -107,10 +76,10 @@ const DIRECTION_COPY: Record< } /** - * Bidirectional `leadership.stateDeputies` edge, on the same `RelationChipCell` - * that serves município portfolios (B37). Both directions write + * Bidirectional `leadership.stateDeputies` edge (B36), now a thin wrapper over + * the shared `RelationOptionCell` (extracted at B156). Both directions write * `leadershipId`/`stateDeputyId`/`assigned` through the same membership action; - * every chip here is a single item — no batch, no floor/ceiling. + * every chip is a single item — no batch, no floor/ceiling. */ export const LeadershipStateDeputyRelationCell = ({ direction, @@ -123,50 +92,6 @@ export const LeadershipStateDeputyRelationCell = ({ }: LeadershipStateDeputyRelationCellProps) => { const copy = DIRECTION_COPY[direction] - const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]) - // Additions resolve through `options` (the addable catalog), same asymmetry - // as the município cell's `optionById`: only what can still be added needs to - // be looked up there, and a removal always has its item in `items` already. - const optionItemById = useMemo( - () => new Map(options.map((option) => [option.id, option.item])), - [options], - ) - - const buildChips = useCallback( - (ids: number[]): RelationChip[] => { - const chips: RelationChip[] = [] - for (const id of ids) { - const item = itemById.get(id) ?? optionItemById.get(id) - if (!item) continue - chips.push({ key: String(id), label: itemLabel(item), href: item.href, ids: [id] }) - } - return chips - }, - [itemById, optionItemById], - ) - - const searchHits = useCallback( - (query: string, assignedIds: ReadonlySet): RelationSearchHit[] => { - // Normalize the query ONCE and read pre-normalized labels, instead of an - // NFD pass plus three Unicode-property regexes per candidate per - // keystroke — the cost `wordStartFilter`'s own doc comment warns about, - // and which matters here because `options` in the `fromStateDeputy` - // direction is the whole (unpaginated) leadership catalog. - const normalizedQuery = normalizeSearchPhrase(query) - const normalizedLabels = normalizedOptionLabels(options) - const hits: RelationSearchHit[] = [] - for (const option of options) { - if (assignedIds.has(option.id)) continue - if (!matchesNormalizedAtWordStart(normalizedLabels.get(option.id) ?? '', normalizedQuery)) { - continue - } - hits.push({ key: String(option.id), label: option.searchLabel, ids: [option.id] }) - } - return hits - }, - [options], - ) - const buildFormData = useCallback( (changedIds: number[], assigned: boolean) => { const itemId = changedIds[0] @@ -198,18 +123,17 @@ export const LeadershipStateDeputyRelationCell = ({ ) return ( - item.id)} - buildChips={buildChips} - searchHits={searchHits} + items={items} + options={options} buildFormData={buildFormData} commitAction={membershipAction} + copy={chipCellCopy} drawerTitle={copy.drawerTitle} triggerLabel={`Editar ${copy.triggerVerb} de ${ownerName}`} updateErrorMessage={copy.updateErrorMessage} - copy={chipCellCopy} measureOverflow={measureOverflow} /> ) diff --git a/src/components/campaign/shared/RelationChipCell.tsx b/src/components/campaign/shared/RelationChipCell.tsx index 44747d20..808b9e4a 100644 --- a/src/components/campaign/shared/RelationChipCell.tsx +++ b/src/components/campaign/shared/RelationChipCell.tsx @@ -157,6 +157,14 @@ type RelationChipCellProps = { buildCreateFormData?: (rawName: string) => FormData createLabel?: (rawName: string) => string createErrorMessage?: string + + // Read-only (B156) ───────────────────────────────────────────────────────── + /** + * Read-only rendering (B156): chips render linked but the cell carries no + * search input, remove affordance, or Drawer trigger — for an actor who may + * see the relation but not edit it (staff viewing `StateDeputy.advisors`). + */ + readOnly?: boolean } /** @@ -193,6 +201,7 @@ export const RelationChipCell = ({ buildCreateFormData, createLabel, createErrorMessage = updateErrorMessage, + readOnly = false, }: RelationChipCellProps) => { const [query, setQuery] = useState('') const [open, setOpen] = useState(false) @@ -732,7 +741,7 @@ export const RelationChipCell = ({ {chipBody(chip)} )} - {isPendingCreate ? null : ( + {isPendingCreate || readOnly ? null : (