B156 — assessores por dobradinha (campo, coluna e seção; RelationOptionCell extraído)
This commit is contained in:
parent
8e434c14af
commit
6b8864debb
24 changed files with 14086 additions and 185 deletions
|
|
@ -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.
|
||||
|
|
|
|||
30
docs/plans/reconciliar-rationale-alocacao.md
Normal file
30
docs/plans/reconciliar-rationale-alocacao.md
Normal file
|
|
@ -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 <probe>` 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).
|
||||
|
|
@ -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<CampaignFormActionState> =>
|
||||
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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<CampaignPageShell>
|
||||
<SetCampaignPageChrome
|
||||
|
|
@ -119,6 +129,40 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai
|
|||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="state-deputy-advisors-title"
|
||||
className="flex flex-col gap-3 rounded-xl border p-4 lg:col-span-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCogIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 id="state-deputy-advisors-title" className="text-base font-medium">
|
||||
Assessores responsáveis
|
||||
</h2>
|
||||
<Badge variant="outline">{stateDeputy.advisors.length}</Badge>
|
||||
</div>
|
||||
<StateDeputyAdvisorRelationCell
|
||||
stateDeputyId={stateDeputy.id}
|
||||
stateDeputyName={stateDeputy.name}
|
||||
advisors={stateDeputy.advisors}
|
||||
options={
|
||||
canEditAdvisors
|
||||
? advisorOptions.map((option) => ({
|
||||
id: option.id,
|
||||
searchLabel: option.name,
|
||||
item: {
|
||||
id: option.id,
|
||||
label: option.name,
|
||||
href: `/campanha/assessores/${option.id}`,
|
||||
},
|
||||
}))
|
||||
: []
|
||||
}
|
||||
membershipAction={setStateDeputyAdvisorMembershipFormAction}
|
||||
readOnly={!canEditAdvisors}
|
||||
measureOverflow={false}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="state-deputy-edit-title" className="flex flex-col gap-3">
|
||||
|
|
|
|||
|
|
@ -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<CampaignFormActionState> =>
|
||||
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`
|
||||
|
|
|
|||
|
|
@ -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<number> | undefined,
|
||||
): Array<CampaignTableColumn<StateDeputyRowViewModel>> => [
|
||||
|
|
@ -174,6 +181,30 @@ const stateDeputyColumns = (
|
|||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'advisors',
|
||||
label: 'Assessores',
|
||||
head: canEditAdvisors ? (
|
||||
<CampaignTableHead description="Edite aqui: passe o mouse em um chip para remover, ou busque para adicionar. Quem responde por esta dobradinha.">
|
||||
Assessores
|
||||
</CampaignTableHead>
|
||||
) : (
|
||||
<CampaignTableHead description="Quem responde por esta dobradinha.">
|
||||
Assessores
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: 'max-w-72 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<StateDeputyAdvisorRelationCell
|
||||
stateDeputyId={row.id}
|
||||
stateDeputyName={row.name}
|
||||
advisors={row.advisors}
|
||||
options={canEditAdvisors ? advisorOptions : []}
|
||||
membershipAction={setStateDeputyAdvisorMembershipFormAction}
|
||||
readOnly={!canEditAdvisors}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<readonly RelationCellOption[], Map<number, string>>()
|
||||
|
||||
const normalizedOptionLabels = (options: readonly RelationCellOption[]): Map<number, string> => {
|
||||
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<number>): 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 (
|
||||
<RelationChipCell
|
||||
<RelationOptionCell
|
||||
ownerId={fixedId}
|
||||
ownerName={ownerName}
|
||||
ids={items.map((item) => 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}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
</Badge>
|
||||
)}
|
||||
{isPendingCreate ? null : (
|
||||
{isPendingCreate || readOnly ? null : (
|
||||
<button
|
||||
type="button"
|
||||
data-chip-remove
|
||||
|
|
@ -940,6 +949,7 @@ export const RelationChipCell = ({
|
|||
// Reached only on a fine pointer: the coarse overlay below covers the cell
|
||||
// and stops the click, so the pointer policy stays in the media queries.
|
||||
onClick={(event) => {
|
||||
if (readOnly) return
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('[data-chip-remove]') || target.closest('a')) return
|
||||
inputRef.current?.focus()
|
||||
|
|
@ -957,43 +967,52 @@ export const RelationChipCell = ({
|
|||
)}
|
||||
>
|
||||
{chips.length === 0 && !searching ? (
|
||||
<span className="px-1 text-sm text-muted-foreground pointer-fine:hidden">—</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-1 text-sm text-muted-foreground',
|
||||
!readOnly && 'pointer-fine:hidden',
|
||||
)}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
) : null}
|
||||
{visibleChips.map(inlineChip)}
|
||||
{expandToggle}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
role="combobox"
|
||||
aria-expanded={inlineSuggesting}
|
||||
// Only while expanded: the listbox lives inside the Popover, which
|
||||
// Radix unmounts on close, so an unconditional `aria-controls`
|
||||
// points at an id that is not in the document.
|
||||
aria-controls={inlineSuggesting ? listboxId('inline') : undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={inlineSuggesting ? activeOptionId : undefined}
|
||||
onChange={(event) => {
|
||||
setQuery(event.currentTarget.value)
|
||||
setFeedback(null)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
if (blurCloseTimeoutRef.current != null) {
|
||||
window.clearTimeout(blurCloseTimeoutRef.current)
|
||||
}
|
||||
blurCloseTimeoutRef.current = window.setTimeout(() => {
|
||||
blurCloseTimeoutRef.current = null
|
||||
if (!rootRef.current?.contains(document.activeElement)) {
|
||||
closeSuggestions()
|
||||
{readOnly ? null : (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
role="combobox"
|
||||
aria-expanded={inlineSuggesting}
|
||||
// Only while expanded: the listbox lives inside the Popover, which
|
||||
// Radix unmounts on close, so an unconditional `aria-controls`
|
||||
// points at an id that is not in the document.
|
||||
aria-controls={inlineSuggesting ? listboxId('inline') : undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={inlineSuggesting ? activeOptionId : undefined}
|
||||
onChange={(event) => {
|
||||
setQuery(event.currentTarget.value)
|
||||
setFeedback(null)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
if (blurCloseTimeoutRef.current != null) {
|
||||
window.clearTimeout(blurCloseTimeoutRef.current)
|
||||
}
|
||||
}, 120)
|
||||
}}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={chips.length ? 'Adicionar…' : copy.searchPlaceholder}
|
||||
aria-label={copy.searchLabel}
|
||||
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
|
||||
/>
|
||||
blurCloseTimeoutRef.current = window.setTimeout(() => {
|
||||
blurCloseTimeoutRef.current = null
|
||||
if (!rootRef.current?.contains(document.activeElement)) {
|
||||
closeSuggestions()
|
||||
}
|
||||
}, 120)
|
||||
}}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={chips.length ? 'Adicionar…' : copy.searchPlaceholder}
|
||||
aria-label={copy.searchLabel}
|
||||
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
{/* Portaled: the table's scroll container would clip an in-flow listbox. */}
|
||||
|
|
@ -1021,39 +1040,41 @@ export const RelationChipCell = ({
|
|||
* undoes the shared class's hover: this trigger is an invisible pane over
|
||||
* the whole cell, and tinting it would tint the chips it covers.
|
||||
*/}
|
||||
<CampaignCellEditOverlay
|
||||
variant="sheet"
|
||||
open={drawerOpen}
|
||||
// The container's trigger lets the click bubble to the cell root, which
|
||||
// opens the inline suggestions — harmless while the sheet is up, but it
|
||||
// would surface a portaled listbox on a touch device the moment the
|
||||
// sheet closed. Clearing the search on close settles both.
|
||||
//
|
||||
// Guarded on the sheet having actually been open: a closed Drawer still
|
||||
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
|
||||
// sweeps the layer stack, and unguarded this erased whatever the fine
|
||||
// pointer had typed into the inline input.
|
||||
onOpenChange={(next) => {
|
||||
setDrawerOpen(next)
|
||||
if (!next && drawerOpen) closeSuggestions()
|
||||
}}
|
||||
title={drawerTitle}
|
||||
description={ownerName}
|
||||
trigger={null}
|
||||
triggerLabel={resolvedTriggerLabel}
|
||||
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
|
||||
triggerBusy={isPending}
|
||||
sheetBodyClassName="gap-3"
|
||||
>
|
||||
{/*
|
||||
* Rendered unmounted, like the repo's other in-cell sheets: base-ui
|
||||
* keeps the popup mounted through a ~450 ms exit transition, so gating
|
||||
* this on `drawerOpen` animated an empty collapsing box out of view. The
|
||||
* allocation it saved was measured and is not a cost — the closed portal
|
||||
* emits no DOM, and the class string is a tailwind-merge cache hit.
|
||||
*/}
|
||||
<>{editorBody('drawer', drawerSuggesting)}</>
|
||||
</CampaignCellEditOverlay>
|
||||
{readOnly ? null : (
|
||||
<CampaignCellEditOverlay
|
||||
variant="sheet"
|
||||
open={drawerOpen}
|
||||
// The container's trigger lets the click bubble to the cell root, which
|
||||
// opens the inline suggestions — harmless while the sheet is up, but it
|
||||
// would surface a portaled listbox on a touch device the moment the
|
||||
// sheet closed. Clearing the search on close settles both.
|
||||
//
|
||||
// Guarded on the sheet having actually been open: a closed Drawer still
|
||||
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
|
||||
// sweeps the layer stack, and unguarded this erased whatever the fine
|
||||
// pointer had typed into the inline input.
|
||||
onOpenChange={(next) => {
|
||||
setDrawerOpen(next)
|
||||
if (!next && drawerOpen) closeSuggestions()
|
||||
}}
|
||||
title={drawerTitle}
|
||||
description={ownerName}
|
||||
trigger={null}
|
||||
triggerLabel={resolvedTriggerLabel}
|
||||
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
|
||||
triggerBusy={isPending}
|
||||
sheetBodyClassName="gap-3"
|
||||
>
|
||||
{/*
|
||||
* Rendered unmounted, like the repo's other in-cell sheets: base-ui
|
||||
* keeps the popup mounted through a ~450 ms exit transition, so gating
|
||||
* this on `drawerOpen` animated an empty collapsing box out of view. The
|
||||
* allocation it saved was measured and is not a cost — the closed portal
|
||||
* emits no DOM, and the class string is a tailwind-merge cache hit.
|
||||
*/}
|
||||
<>{editorBody('drawer', drawerSuggesting)}</>
|
||||
</CampaignCellEditOverlay>
|
||||
)}
|
||||
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{statusMessage}
|
||||
|
|
|
|||
154
src/components/campaign/shared/RelationOptionCell.tsx
Normal file
154
src/components/campaign/shared/RelationOptionCell.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import {
|
||||
RelationChipCell,
|
||||
type RelationChip,
|
||||
type RelationChipCellCopy,
|
||||
type RelationSearchHit,
|
||||
} from '@/components/campaign/shared/RelationChipCell'
|
||||
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
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
|
||||
}
|
||||
|
||||
type RelationOptionCellProps = {
|
||||
/** `null` in draft mode (a row that does not exist yet). */
|
||||
ownerId: number | null
|
||||
/** Whose relation this is — spoken in the aria-labels and the Drawer. */
|
||||
ownerName: string
|
||||
items: RelationCellItem[]
|
||||
options: RelationCellOption[]
|
||||
buildFormData: (changedIds: number[], assigned: boolean) => FormData
|
||||
commitAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
copy: RelationChipCellCopy
|
||||
drawerTitle: string
|
||||
triggerLabel: string
|
||||
updateErrorMessage: string
|
||||
/** Clamp rest chips to 3 rows + "Ver mais…". Default true; pass false when few chips are expected. */
|
||||
measureOverflow?: boolean
|
||||
/** Read-only rendering (B156): linked chips, no search/remove/Drawer affordances. */
|
||||
readOnly?: 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<readonly RelationCellOption[], Map<number, string>>()
|
||||
|
||||
const normalizedOptionLabels = (options: readonly RelationCellOption[]): Map<number, string> => {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic "items + options + membership action" specialization of
|
||||
* `RelationChipCell` (extracted at B156, the second use of the
|
||||
* leadership↔dobradinha shape): every chip is a single linked item from `items`
|
||||
* (additions resolve through `options`, so an optimistic add labels from the
|
||||
* addable catalog), search matches `options` at normalized word start, and the
|
||||
* write is one `changedId` delta through `commitAction`. All that differs
|
||||
* between relations — chips' href, the write's field names, copy — arrives as
|
||||
* props; nothing domain-specific lives here.
|
||||
*/
|
||||
export const RelationOptionCell = ({
|
||||
ownerId,
|
||||
ownerName,
|
||||
items,
|
||||
options,
|
||||
buildFormData,
|
||||
commitAction,
|
||||
copy,
|
||||
drawerTitle,
|
||||
triggerLabel,
|
||||
updateErrorMessage,
|
||||
measureOverflow = true,
|
||||
readOnly = false,
|
||||
}: RelationOptionCellProps) => {
|
||||
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<number>): 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.
|
||||
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],
|
||||
)
|
||||
|
||||
return (
|
||||
<RelationChipCell
|
||||
ownerId={ownerId}
|
||||
ownerName={ownerName}
|
||||
ids={items.map((item) => item.id)}
|
||||
buildChips={buildChips}
|
||||
searchHits={searchHits}
|
||||
buildFormData={buildFormData}
|
||||
commitAction={commitAction}
|
||||
drawerTitle={drawerTitle}
|
||||
triggerLabel={triggerLabel}
|
||||
updateErrorMessage={updateErrorMessage}
|
||||
copy={copy}
|
||||
measureOverflow={measureOverflow}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type { RelationChipCellCopy } from '@/components/campaign/shared/RelationChipCell'
|
||||
import {
|
||||
RelationOptionCell,
|
||||
type RelationCellOption,
|
||||
} from '@/components/campaign/shared/RelationOptionCell'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
type StateDeputyAdvisorRelationCellProps = {
|
||||
stateDeputyId: number
|
||||
stateDeputyName: string
|
||||
/** Assigned advisors, names resolved server-side (B156). */
|
||||
advisors: Array<{ id: number; name: string }>
|
||||
/** The addable staff catalog (`loadEligibleAdvisorOptions`). Empty when read-only. */
|
||||
options: RelationCellOption[]
|
||||
membershipAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
/**
|
||||
* Read-only for staff who may see but not assign (B156): linked chips lose
|
||||
* their href — `/campanha/assessores/[id]` is unrestricted-only, so a link
|
||||
* would send the read-only viewer into a redirect dead-end.
|
||||
*/
|
||||
readOnly?: boolean
|
||||
/** Clamp rest chips to 3 rows + "Ver mais…". Default true. */
|
||||
measureOverflow?: boolean
|
||||
}
|
||||
|
||||
type StateDeputyAdvisorCopy = RelationChipCellCopy & {
|
||||
drawerTitle: string
|
||||
updateErrorMessage: string
|
||||
}
|
||||
|
||||
const ADVISOR_COPY: StateDeputyAdvisorCopy = {
|
||||
drawerTitle: 'Assessores da dobradinha',
|
||||
searchPlaceholder: 'Buscar assessor…',
|
||||
searchLabel: 'Buscar assessor',
|
||||
suggestionsLabel: 'Sugestões de assessores',
|
||||
emptyDrawerMessage: 'Nenhum assessor vinculado.',
|
||||
savingMessage: 'Salvando assessores…',
|
||||
savedMessage: 'Assessores salvos.',
|
||||
removedMessage: (count: number) =>
|
||||
count === 1 ? 'Assessor removido.' : `${count} assessores removidos.`,
|
||||
updateErrorMessage: 'Não foi possível atualizar os assessores. Tente novamente.',
|
||||
}
|
||||
|
||||
/**
|
||||
* `StateDeputy.advisors` cell (B156) — the second thin wrapper over the shared
|
||||
* `RelationOptionCell` extracted at B156: chips link to
|
||||
* `/campanha/assessores/[id]` (unless read-only), search resolves the eligible
|
||||
* staff catalog, and the write sends `stateDeputyId`/`advisorId`/`assigned`
|
||||
* through the unrestricted-only membership action. Serves the "Assessores"
|
||||
* column of the list and the section of the detail page.
|
||||
*/
|
||||
export const StateDeputyAdvisorRelationCell = ({
|
||||
stateDeputyId,
|
||||
stateDeputyName,
|
||||
advisors,
|
||||
options,
|
||||
membershipAction,
|
||||
readOnly = false,
|
||||
measureOverflow = true,
|
||||
}: StateDeputyAdvisorRelationCellProps) => {
|
||||
const buildFormData = useCallback(
|
||||
(changedIds: number[], assigned: boolean) => {
|
||||
const formData = new FormData()
|
||||
formData.set('stateDeputyId', String(stateDeputyId))
|
||||
formData.set('advisorId', String(changedIds[0]))
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
return formData
|
||||
},
|
||||
[stateDeputyId],
|
||||
)
|
||||
|
||||
return (
|
||||
<RelationOptionCell
|
||||
ownerId={stateDeputyId}
|
||||
ownerName={stateDeputyName}
|
||||
items={advisors.map((advisor) => ({
|
||||
id: advisor.id,
|
||||
label: advisor.name,
|
||||
...(readOnly ? {} : { href: `/campanha/assessores/${advisor.id}` }),
|
||||
}))}
|
||||
options={options}
|
||||
buildFormData={buildFormData}
|
||||
commitAction={membershipAction}
|
||||
copy={ADVISOR_COPY}
|
||||
drawerTitle={ADVISOR_COPY.drawerTitle}
|
||||
triggerLabel={`Editar assessores de ${stateDeputyName}`}
|
||||
updateErrorMessage={ADVISOR_COPY.updateErrorMessage}
|
||||
measureOverflow={measureOverflow}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -88,9 +88,39 @@ export const municipalityStateDeputyCreateSchema = z
|
|||
message: 'O partido pode ter no máximo 32 caracteres.',
|
||||
})
|
||||
|
||||
/**
|
||||
* Cap for `StateDeputy.advisors` (B156) — mirrors `MAX_ADVISORS_PER_MUNICIPALITY`:
|
||||
* a dobradinha has a handful of responsible advisors, and every bounded
|
||||
* relation in `/campanha` carries a cap so `nextIdsAfterMembership` can refuse
|
||||
* with a spoken reason before the optimistic apply.
|
||||
*/
|
||||
export const MAX_ADVISORS_PER_STATE_DEPUTY = 10
|
||||
|
||||
export const STATE_DEPUTY_ADVISORS_CAP_MESSAGE = `Cada dobradinha aceita no máximo ${MAX_ADVISORS_PER_STATE_DEPUTY} assessores.`
|
||||
|
||||
export const STATE_DEPUTY_ADVISORS_UNRESTRICTED_MESSAGE =
|
||||
'Somente a coordenação geral ou o candidato gerencia assessores de dobradinhas.'
|
||||
|
||||
/**
|
||||
* Delta write for the "Assessores" column/section of a dobradinha (B156) —
|
||||
* one chip toggle on `StateDeputy.advisors`, the owning document (no batch:
|
||||
* the relation lives on the dobradinha itself).
|
||||
*/
|
||||
export const stateDeputyAdvisorMembershipSchema = z.object({
|
||||
stateDeputyId: positiveRelationshipId,
|
||||
advisorId: positiveRelationshipId,
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export const STATE_DEPUTY_ADVISOR_SAFE_MESSAGES = [
|
||||
STATE_DEPUTY_ADVISORS_UNRESTRICTED_MESSAGE,
|
||||
STATE_DEPUTY_ADVISORS_CAP_MESSAGE,
|
||||
] as const
|
||||
|
||||
export type StateDeputyCreateInput = z.input<typeof stateDeputyCreateSchema>
|
||||
export type StateDeputyUpdateInput = z.input<typeof stateDeputyUpdateSchema>
|
||||
export type StateDeputyMunicipalitiesBatchInput = z.input<
|
||||
typeof stateDeputyMunicipalitiesBatchSchema
|
||||
>
|
||||
export type MunicipalityStateDeputyCreateInput = z.input<typeof municipalityStateDeputyCreateSchema>
|
||||
export type StateDeputyAdvisorMembershipInput = z.input<typeof stateDeputyAdvisorMembershipSchema>
|
||||
|
|
|
|||
21
src/lib/stateDeputyAdvisorMembership.ts
Normal file
21
src/lib/stateDeputyAdvisorMembership.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { nextIdsAfterMembership } from '@/lib/relationMembershipDelta'
|
||||
import {
|
||||
MAX_ADVISORS_PER_STATE_DEPUTY,
|
||||
STATE_DEPUTY_ADVISORS_CAP_MESSAGE,
|
||||
} from '@/lib/schemas/stateDeputy'
|
||||
|
||||
/**
|
||||
* Returns the next advisor-id list after applying ONE membership change on
|
||||
* `StateDeputy.advisors`, or `null` when the dobradinha is already in the
|
||||
* desired state (no write needed) — the same bounded-delta contract as
|
||||
* `nextAdvisorIdsAfterMembership` (municipality) and the leadership twins.
|
||||
*/
|
||||
export const nextStateDeputyAdvisorIdsAfterMembership = (
|
||||
currentAdvisorIDs: readonly number[],
|
||||
advisorId: number,
|
||||
assigned: boolean,
|
||||
): number[] | null =>
|
||||
nextIdsAfterMembership(currentAdvisorIDs, advisorId, assigned, {
|
||||
max: MAX_ADVISORS_PER_STATE_DEPUTY,
|
||||
message: STATE_DEPUTY_ADVISORS_CAP_MESSAGE,
|
||||
})
|
||||
12895
src/migrations/20260804_061017_add_state_deputy_advisors.json
Normal file
12895
src/migrations/20260804_061017_add_state_deputy_advisors.json
Normal file
File diff suppressed because it is too large
Load diff
25
src/migrations/20260804_061017_add_state_deputy_advisors.ts
Normal file
25
src/migrations/20260804_061017_add_state_deputy_advisors.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "state_deputy_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"campaign_user_id" integer
|
||||
);
|
||||
|
||||
ALTER TABLE "state_deputy_rels" ADD CONSTRAINT "state_deputy_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."state_deputy"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "state_deputy_rels" ADD CONSTRAINT "state_deputy_rels_campaign_user_fk" FOREIGN KEY ("campaign_user_id") REFERENCES "public"."campaign_user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "state_deputy_rels_order_idx" ON "state_deputy_rels" USING btree ("order");
|
||||
CREATE INDEX "state_deputy_rels_parent_idx" ON "state_deputy_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "state_deputy_rels_path_idx" ON "state_deputy_rels" USING btree ("path");
|
||||
CREATE INDEX "state_deputy_rels_campaign_user_id_idx" ON "state_deputy_rels" USING btree ("campaign_user_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "state_deputy_rels" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "state_deputy_rels" CASCADE;`)
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ import * as migration_20260730_043306_simplify_leadership_fields from './2026073
|
|||
import * as migration_20260731_014319_add_campaign_notifications from './20260731_014319_add_campaign_notifications';
|
||||
import * as migration_20260801_062558_add_campaign_vote_summary_snapshot from './20260801_062558_add_campaign_vote_summary_snapshot';
|
||||
import * as migration_20260802_230000_strip_engagement_reversal_signals from './20260802_230000_strip_engagement_reversal_signals';
|
||||
import * as migration_20260804_061017_add_state_deputy_advisors from './20260804_061017_add_state_deputy_advisors';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -239,11 +240,16 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260801_062558_add_campaign_vote_summary_snapshot.up,
|
||||
down: migration_20260801_062558_add_campaign_vote_summary_snapshot.down,
|
||||
name: '20260801_062558_add_campaign_vote_summary_snapshot'
|
||||
name: '20260801_062558_add_campaign_vote_summary_snapshot',
|
||||
},
|
||||
{
|
||||
up: migration_20260802_230000_strip_engagement_reversal_signals.up,
|
||||
down: migration_20260802_230000_strip_engagement_reversal_signals.down,
|
||||
name: '20260802_230000_strip_engagement_reversal_signals',
|
||||
},
|
||||
{
|
||||
up: migration_20260804_061017_add_state_deputy_advisors.up,
|
||||
down: migration_20260804_061017_add_state_deputy_advisors.down,
|
||||
name: '20260804_061017_add_state_deputy_advisors'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -456,6 +456,7 @@ export interface StateDeputy {
|
|||
slug: string;
|
||||
party?: string | null;
|
||||
notes?: string | null;
|
||||
advisors?: (number | CampaignUser)[] | null;
|
||||
createdBy?: (number | null) | CampaignUser;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
|
|
@ -1630,6 +1631,7 @@ export interface StateDeputySelect<T extends boolean = true> {
|
|||
slug?: T;
|
||||
party?: T;
|
||||
notes?: T;
|
||||
advisors?: T;
|
||||
createdBy?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@
|
|||
// State deputies (dobradinhas)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { Access } from 'payload'
|
||||
import type { Access, FieldAccess } from 'payload'
|
||||
|
||||
import { getFreshCampaignUser, isCampaignStaff, isPayloadAdmin } from '@/utilities/access/shared'
|
||||
import {
|
||||
getFreshCampaignUser,
|
||||
isCampaignStaff,
|
||||
isCampaignUnrestricted,
|
||||
isPayloadAdmin,
|
||||
} from '@/utilities/access/shared'
|
||||
|
||||
export const canCreateStateDeputy: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
|
@ -21,3 +26,17 @@ export const canReadStateDeputy: Access = async ({ req }) => {
|
|||
export const canManageStateDeputy: Access = canReadStateDeputy
|
||||
|
||||
export const canDeleteStateDeputy: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
/**
|
||||
* Advisor assignment on a dobradinha is unrestricted staff (coordinator +
|
||||
* candidate) — the same policy as `Municipality.advisors` (B156). The write
|
||||
* path runs through the server action with `overrideAccess` after
|
||||
* `reloadUnrestrictedActor`; this guards direct `/admin` edits.
|
||||
*/
|
||||
export const canAssignStateDeputyAdvisors: FieldAccess = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
return isCampaignUnrestricted(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
export const canManageStateDeputyAdvisors: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
|
|
|||
|
|
@ -122,9 +122,11 @@ export {
|
|||
} from '@/utilities/access/elections'
|
||||
|
||||
export {
|
||||
canAssignStateDeputyAdvisors,
|
||||
canCreateStateDeputy,
|
||||
canDeleteStateDeputy,
|
||||
canManageStateDeputy,
|
||||
canManageStateDeputyAdvisors,
|
||||
canReadStateDeputy,
|
||||
} from '@/utilities/access/stateDeputies'
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { Payload } from 'payload'
|
|||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { populatedContactName } from '@/lib/relationship'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
|
||||
|
||||
/** Municipalities the actor may operate on (coordinator: all 436; advisor: administered). */
|
||||
export const loadMunicipalityOptions = async (
|
||||
|
|
@ -106,3 +107,65 @@ export const loadLeadershipOptions = async (
|
|||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
|
||||
}
|
||||
|
||||
export type CampaignUserSummary = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Names for a set of assigned advisor ids (B156) — the same catalog read the
|
||||
* municipios list does with `loadAdvisorSummaries`, but without the phone and
|
||||
* in the shared relation-options module (the dobradinhas cells need id+name
|
||||
* only). Honours `canReadCampaignUsers` and the eligibility filter, so a
|
||||
* stale id that fell out of staff is dropped — same fail-closed contract as
|
||||
* `loadAdvisorSummaries`.
|
||||
*/
|
||||
export const loadCampaignUserSummaries = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
ids: number[],
|
||||
): Promise<CampaignUserSummary[]> => {
|
||||
if (ids.length === 0) return []
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: {
|
||||
and: [{ id: { in: ids } }, eligibleCampaignStaffWhere],
|
||||
},
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: { name: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const byId = new Map(result.docs.map((doc) => [doc.id, { id: doc.id, name: doc.name }]))
|
||||
|
||||
return ids.flatMap((id) => {
|
||||
const summary = byId.get(id)
|
||||
return summary ? [summary] : []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The addable staff catalog for an advisor-relation cell (B156): every
|
||||
* eligible account (coordinator/advisor/candidate), sorted by name. The cell
|
||||
* resolves optimistic adds through it, same role as `getEligibleAdvisorOptions`
|
||||
* on the municipios list.
|
||||
*/
|
||||
export const loadEligibleAdvisorOptions = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
): Promise<RelationOption[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
sort: 'name',
|
||||
where: eligibleCampaignStaffWhere,
|
||||
select: { name: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
return result.docs.map(({ id, name }) => ({ id, name }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import 'server-only'
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { populatedContactName, relationshipId } from '@/lib/relationship'
|
||||
import { populatedContactName, relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import type { CampaignUser, StateDeputy } from '@/payload-types'
|
||||
import { loadCampaignUserSummaries } from '@/utilities/campaignRelationOptions'
|
||||
import {
|
||||
buildStateDeputyListWhere,
|
||||
NO_PARTY_FILTER_VALUE,
|
||||
|
|
@ -13,7 +14,7 @@ import {
|
|||
type StateDeputyListState,
|
||||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
type LeadershipRelationSummary = {
|
||||
type NamedRelationSummary = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
|
@ -24,7 +25,9 @@ export type StateDeputyRowViewModel = {
|
|||
slug: string
|
||||
party: string | null
|
||||
municipalityIDs: number[]
|
||||
leaderships: LeadershipRelationSummary[]
|
||||
leaderships: NamedRelationSummary[]
|
||||
/** B156 — the staff responsible for this dobradinha, names resolved. */
|
||||
advisors: NamedRelationSummary[]
|
||||
}
|
||||
|
||||
/** Values still reachable under the OTHER active filters (the Partido popover). */
|
||||
|
|
@ -126,7 +129,7 @@ export const loadStateDeputyListPageData = async (
|
|||
limit: stateDeputyPageSize,
|
||||
page: state.page,
|
||||
sort: resolveStateDeputyListPayloadSort(sort, dir),
|
||||
select: { name: true, slug: true, party: true },
|
||||
select: { name: true, slug: true, party: true, advisors: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
|
|
@ -134,7 +137,8 @@ export const loadStateDeputyListPageData = async (
|
|||
])
|
||||
|
||||
const stateDeputyIDs = result.docs.map((doc) => doc.id)
|
||||
const leadershipsByDeputy = new Map<number, LeadershipRelationSummary[]>()
|
||||
const leadershipsByDeputy = new Map<number, NamedRelationSummary[]>()
|
||||
const advisorsByDeputy = new Map<number, NamedRelationSummary[]>()
|
||||
|
||||
const municipalityIDsByDeputy =
|
||||
stateDeputyIDs.length > 0
|
||||
|
|
@ -142,6 +146,23 @@ export const loadStateDeputyListPageData = async (
|
|||
: new Map<number, number[]>()
|
||||
|
||||
if (stateDeputyIDs.length) {
|
||||
// B156 — one name lookup for every row's assigned advisors, same
|
||||
// aggregate-over-ids shape as the leaderships query below. The `advisors`
|
||||
// field is already on each doc (depth 0 → ids).
|
||||
const advisorIDs = [
|
||||
...new Set(result.docs.flatMap((doc) => uniqueRelationshipIds(doc.advisors))),
|
||||
]
|
||||
if (advisorIDs.length) {
|
||||
const summaries = await loadCampaignUserSummaries(payload, user, advisorIDs)
|
||||
const summaryById = new Map(summaries.map((summary) => [summary.id, summary]))
|
||||
for (const doc of result.docs) {
|
||||
const list = uniqueRelationshipIds(doc.advisors)
|
||||
.map((id) => summaryById.get(id))
|
||||
.filter((summary): summary is NamedRelationSummary => summary !== undefined)
|
||||
advisorsByDeputy.set(doc.id, list)
|
||||
}
|
||||
}
|
||||
|
||||
const leaderships = await payload.find({
|
||||
collection: 'leadership',
|
||||
where: { stateDeputies: { in: stateDeputyIDs } },
|
||||
|
|
@ -156,7 +177,7 @@ export const loadStateDeputyListPageData = async (
|
|||
})
|
||||
|
||||
for (const leadership of leaderships.docs) {
|
||||
const summary: LeadershipRelationSummary = {
|
||||
const summary: NamedRelationSummary = {
|
||||
id: leadership.id,
|
||||
name: populatedContactName(leadership.contact),
|
||||
}
|
||||
|
|
@ -184,6 +205,7 @@ export const loadStateDeputyListPageData = async (
|
|||
party: doc.party ?? null,
|
||||
municipalityIDs: municipalityIDsByDeputy.get(doc.id) ?? [],
|
||||
leaderships: leadershipsByDeputy.get(doc.id) ?? [],
|
||||
advisors: advisorsByDeputy.get(doc.id) ?? [],
|
||||
})),
|
||||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
|
|
@ -202,6 +224,8 @@ export type StateDeputyDetailViewModel = StateDeputySummary & {
|
|||
notes: string | null
|
||||
municipalities: Array<{ id: number; name: string; slug: string }>
|
||||
leaderships: Array<{ id: number; name: string }>
|
||||
/** B156 — staff responsible for this dobradinha. */
|
||||
advisors: Array<{ id: number; name: string }>
|
||||
}
|
||||
|
||||
export const loadStateDeputyDetail = async (
|
||||
|
|
@ -221,7 +245,7 @@ export const loadStateDeputyDetail = async (
|
|||
const stateDeputy = result.docs[0] as StateDeputy | undefined
|
||||
if (!stateDeputy) return null
|
||||
|
||||
const [municipalities, leaderships] = await Promise.all([
|
||||
const [municipalities, leaderships, advisorSummaries] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'municipality',
|
||||
where: { stateDeputies: { in: [stateDeputy.id] } },
|
||||
|
|
@ -243,6 +267,7 @@ export const loadStateDeputyDetail = async (
|
|||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
loadCampaignUserSummaries(payload, user, uniqueRelationshipIds(stateDeputy.advisors)),
|
||||
])
|
||||
|
||||
return {
|
||||
|
|
@ -260,6 +285,7 @@ export const loadStateDeputyDetail = async (
|
|||
id: leadership.id,
|
||||
name: populatedContactName(leadership.contact),
|
||||
})),
|
||||
advisors: advisorSummaries,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -277,6 +303,8 @@ export const loadStateDeputySummaries = async (
|
|||
pagination: false,
|
||||
sort: 'name',
|
||||
select: { name: true, slug: true, party: true },
|
||||
// Intentional admin bypass: id lookups for the home-search card, no
|
||||
// actor-scoping possible or needed (B52 precedent).
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
|
|
|
|||
161
tests/int/campaignStateDeputyAdvisorMembership.int.spec.ts
Normal file
161
tests/int/campaignStateDeputyAdvisorMembership.int.spec.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { setStateDeputyAdvisorMembershipRecord } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const advisorIds = (stateDeputy: { advisors?: (number | { id: number })[] | null }): number[] =>
|
||||
relationIds(stateDeputy.advisors)
|
||||
|
||||
describe('setStateDeputyAdvisorMembershipRecord (B156)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it.each(['coordinator', 'candidate'] as const)(
|
||||
'lets a %s assign and remove an advisor by delta',
|
||||
async (role) => {
|
||||
const fixtures = campaignFixtures()
|
||||
const actor = await fixtures.createCampaignUser(role)
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
|
||||
const { stateDeputySlug } = await setStateDeputyAdvisorMembershipRecord(payload, actor, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
advisorId: advisor.id,
|
||||
assigned: true,
|
||||
})
|
||||
expect(stateDeputySlug).toBe(stateDeputy.slug)
|
||||
|
||||
const assigned = await payload.findByID({
|
||||
collection: 'stateDeputy',
|
||||
id: stateDeputy.id,
|
||||
depth: 0,
|
||||
select: { advisors: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(advisorIds(assigned)).toContain(advisor.id)
|
||||
|
||||
await setStateDeputyAdvisorMembershipRecord(payload, actor, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
advisorId: advisor.id,
|
||||
assigned: false,
|
||||
})
|
||||
|
||||
const removed = await payload.findByID({
|
||||
collection: 'stateDeputy',
|
||||
id: stateDeputy.id,
|
||||
depth: 0,
|
||||
select: { advisors: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(advisorIds(removed)).not.toContain(advisor.id)
|
||||
},
|
||||
)
|
||||
|
||||
it('denies a plain advisor actor and a leader actor', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisorActor = await fixtures.createCampaignUser('advisor')
|
||||
const leaderActor = await fixtures.createCampaignUser('leader')
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
const target = await fixtures.createCampaignUser('coordinator')
|
||||
|
||||
await expect(
|
||||
setStateDeputyAdvisorMembershipRecord(payload, advisorActor, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
advisorId: target.id,
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow(/coordenação geral ou o candidato/i)
|
||||
|
||||
await expect(
|
||||
setStateDeputyAdvisorMembershipRecord(payload, leaderActor, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
advisorId: target.id,
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow(/coordenação geral ou o candidato/i)
|
||||
})
|
||||
|
||||
it('rejects a target account that is not eligible staff', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
const leaderTarget = await fixtures.createCampaignUser('leader')
|
||||
|
||||
await expect(
|
||||
setStateDeputyAdvisorMembershipRecord(payload, coordinator, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
advisorId: leaderTarget.id,
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow(/Coordenador Geral, Assessor ou Candidato/i)
|
||||
})
|
||||
|
||||
it('is idempotent (no-op does not re-write)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
|
||||
await setStateDeputyAdvisorMembershipRecord(payload, coordinator, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
advisorId: advisor.id,
|
||||
assigned: true,
|
||||
})
|
||||
|
||||
// No-op contract: no slug back (the caller then skips the revalidate).
|
||||
const { stateDeputySlug } = await setStateDeputyAdvisorMembershipRecord(payload, coordinator, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
advisorId: advisor.id,
|
||||
assigned: true,
|
||||
})
|
||||
expect(stateDeputySlug).toBeUndefined()
|
||||
|
||||
const after = await payload.findByID({
|
||||
collection: 'stateDeputy',
|
||||
id: stateDeputy.id,
|
||||
depth: 0,
|
||||
select: { advisors: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(advisorIds(after)).toEqual([advisor.id])
|
||||
})
|
||||
|
||||
it('rejects once the dobradinha is at the 10-advisor cap', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
await setStateDeputyAdvisorMembershipRecord(payload, coordinator, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
advisorId: advisor.id,
|
||||
assigned: true,
|
||||
})
|
||||
}
|
||||
|
||||
const eleventh = await fixtures.createCampaignUser('advisor')
|
||||
await expect(
|
||||
setStateDeputyAdvisorMembershipRecord(payload, coordinator, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
advisorId: eleventh.id,
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow(/no máximo 10 assessores/i)
|
||||
})
|
||||
})
|
||||
141
tests/unit/stateDeputyAdvisorRelationCell.unit.spec.ts
Normal file
141
tests/unit/stateDeputyAdvisorRelationCell.unit.spec.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { StateDeputyAdvisorRelationCell } from '@/components/campaign/stateDeputy/StateDeputyAdvisorRelationCell'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
/**
|
||||
* B156: pins what the advisor wrapper contributes over the shared
|
||||
* `RelationOptionCell` — the write's field names, the link target, the
|
||||
* read-only rendering for non-unrestricted staff, and the success feedback.
|
||||
*/
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
const OPTIONS = [
|
||||
{
|
||||
id: 10,
|
||||
searchLabel: 'Ana Bastos',
|
||||
item: { id: 10, label: 'Ana Bastos', href: '/campanha/assessores/10' },
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
searchLabel: 'Beto Lima',
|
||||
item: { id: 11, label: 'Beto Lima', href: '/campanha/assessores/11' },
|
||||
},
|
||||
]
|
||||
|
||||
const advisors = [{ id: 10, name: 'Ana Bastos' }]
|
||||
|
||||
const cell = (
|
||||
overrides: Partial<{
|
||||
advisors: Array<{ id: number; name: string }>
|
||||
readOnly: boolean
|
||||
membershipAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}> = {},
|
||||
) =>
|
||||
createElement(StateDeputyAdvisorRelationCell, {
|
||||
stateDeputyId: 7,
|
||||
stateDeputyName: 'Dobradinha Teste',
|
||||
advisors: overrides.advisors ?? advisors,
|
||||
options: OPTIONS,
|
||||
membershipAction: overrides.membershipAction ?? (async () => ({ status: 'success' as const })),
|
||||
readOnly: overrides.readOnly ?? false,
|
||||
measureOverflow: false,
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('state deputy advisor relation cell (B156)', () => {
|
||||
it('renders each assigned advisor as a linked chip', () => {
|
||||
render(cell())
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Ana Bastos' })
|
||||
expect(link.getAttribute('href')).toBe('/campanha/assessores/10')
|
||||
})
|
||||
|
||||
it('sends stateDeputyId/advisorId/assigned=false on removal', async () => {
|
||||
const membershipAction = vi.fn(
|
||||
async (_state: CampaignFormActionState, _formData: FormData) =>
|
||||
({ status: 'success' as const }) satisfies CampaignFormActionState,
|
||||
)
|
||||
render(cell({ membershipAction }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
|
||||
|
||||
await waitFor(() => expect(membershipAction).toHaveBeenCalledTimes(1))
|
||||
const formData = membershipAction.mock.calls[0]?.[1] as FormData
|
||||
expect(formData.get('stateDeputyId')).toBe('7')
|
||||
expect(formData.get('advisorId')).toBe('10')
|
||||
expect(formData.get('assigned')).toBe('false')
|
||||
})
|
||||
|
||||
it('adds a searched option and reports the live region as saved', async () => {
|
||||
render(cell())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Editar assessores de Dobradinha Teste' }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
const searchInput = within(dialog).getByRole('combobox', { name: 'Buscar assessor' })
|
||||
fireEvent.change(searchInput, { target: { value: 'Beto' } })
|
||||
|
||||
fireEvent.click(await within(dialog).findByRole('option', { name: /Beto Lima/ }))
|
||||
|
||||
await waitFor(() => expect(within(dialog).getByText('Beto Lima')).toBeTruthy())
|
||||
await waitFor(() => expect(screen.getByRole('status').textContent).toBe('Assessores salvos.'))
|
||||
})
|
||||
|
||||
it('readOnly renders chips with no edit affordances', () => {
|
||||
render(cell({ readOnly: true }))
|
||||
|
||||
// No link: `/campanha/assessores/[id]` is unrestricted-only, so a read-only
|
||||
// viewer must not be handed a dead-end redirect (B156 simplify fix).
|
||||
expect(screen.queryByRole('link', { name: 'Ana Bastos' })).toBeNull()
|
||||
expect(screen.getByText('Ana Bastos')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Remover Ana Bastos' })).toBeNull()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Editar assessores de Dobradinha Teste' }),
|
||||
).toBeNull()
|
||||
expect(screen.queryByRole('combobox')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the empty dash when there are no advisors yet', () => {
|
||||
render(cell({ advisors: [] }))
|
||||
expect(screen.getByText('—')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('undoes a failed removal without reverting a sibling change from the same render', async () => {
|
||||
const membershipAction = vi.fn(
|
||||
async (
|
||||
_state: CampaignFormActionState,
|
||||
_formData: FormData,
|
||||
): Promise<CampaignFormActionState> => ({
|
||||
message: 'Não foi possível atualizar os assessores. Tente novamente.',
|
||||
}),
|
||||
)
|
||||
render(cell({ membershipAction }))
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Ana Bastos')).toBeTruthy())
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue