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 : (