Merge branch 'cursor/6b2ac7b2'
This commit is contained in:
commit
ba90a3b2dc
15 changed files with 624 additions and 280 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **B36 entregue em código (2026-07-26):** coluna "Lideranças" em `/campanha/dobradinhas` — chips nomeados (link `/campanha/liderancas/[id]`) + Popover+Command + auto-save por delta, reusando **sem alteração** a mutação/schema/lock do **B31 ✓** (`setLeadershipStateDeputyMembership`, `leadership-state-deputies:{id}`, `nextStateDeputyIdsAfterMembership`). **Auditoria do plano** (escrito antes do B31 as-built) corrigiu duas defasagens: (1) sem `RelationOptionsProvider`/Context — B31 já tinha trocado Context por columns factory no `/simplify`; (2) casca `dobradinhas/formActions.ts` própria (não import cross-rota de `liderancas/formActions`). **Extração paga aqui:** `LeadershipStateDeputiesCell` (domínio) → `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction: 'fromLeadership' | 'fromStateDeputy'`, `measureOverflow` default true — clamp 3 linhas + "Ver mais…" no lado do deputado; `liderancas` passa `false`). `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Gate: tsc/lint/format/knip/cycles/unit 530 + int (flakes de timeout re-rodados verdes)/build; Aikido 0 findings. Plano: [`chips-liderancas-lista-dobradinhas.md`](../../../docs/plans/chips-liderancas-lista-dobradinhas.md).
|
||||
- **B30 entregue em código (2026-07-26):** convite de completar cadastro por WhatsApp na lista `/campanha/liderancas` — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` existente (B28 ✓), ao lado do `MessageCircleIcon` de contato; Popover two-step (`Gerar convite` → Alert com âncora `wa.me` + copiar link) reusa `createCampaignInvite`/`kind: 'autopreenchimento'`; disable alinhado ao `whatsAppHrefForPhone` da mesma célula (`hasValidPhone`); sem migration/action/collection nova. Plano revisado pós-B28 (encaixe em `actions`, não coluna `inviteAction`). Gate: tsc/lint/format/cycles; 532 unit (+2) / 406 int; build não rodou sem `PAYLOAD_SECRET`; knip P3 pré-existente; Aikido 0 findings. Plano: [`convite-whatsapp-lista-liderancas.md`](../../../docs/plans/convite-whatsapp-lista-liderancas.md).
|
||||
- **B31 entregue em código (2026-07-26):** coluna "Dobradinhas" em `/campanha/liderancas` — chips clicáveis (`StateDeputyChips`, link para `/campanha/dobradinhas/<slug>`) com edição por Popover+Command (adicionar/remover sem "Salvar"), gravação **por delta** com lock (`leadership-state-deputies:{id}`), nunca replace do array inteiro (que `updateLeadershipInternalRecord` continua fazendo, inalterado, para o form completo da ficha). **Auditoria do plano antes de codar** achou 5 defasagens (ordem de colunas mudou com o B28 ✓; B27 ✓ já tinha entregue a mecânica Popover+Command+chips, então B31 é o 2º call site, não o 1º; o precedente de UI correto é duplo — mecânica de `MunicipalityListAdvisorsControl` (B27) + escrita por server-action-direta-do-cliente de `AdvisorMunicipalityCell`/`setAdvisorMunicipalityMembership` (B19), nenhum dos dois sozinho; `MAX_LEADERSHIP_STATE_DEPUTIES` era privado; `loadLeadershipDetail` fazia uma 2ª chamada redundante a `loadStateDeputySummaries`), nenhuma delas mudou a abordagem central do plano. **Fase 1:** `LeadershipRowViewModel.stateDeputyNames: string[]` → `stateDeputies: StateDeputySummary[]` (via `loadStateDeputySummaries`, já existente); `loadLeadershipDetail` parou de duplicar a chamada — deriva `stateDeputyIDs` de `row.stateDeputies`; `MAX_LEADERSHIP_STATE_DEPUTIES` exportado; novo `leadershipStateDeputyMembershipSchema`; novo helper puro `nextStateDeputyIdsAfterMembership` (`src/lib/leadershipStateDeputyMembership.ts`, espelha `nextAdvisorIdsAfterMembership` linha a linha); `loadStateDeputyOptions` ganhou `plainName`/`party`/`slug` no retorno (`StateDeputyRelationOption`) para a célula montar um chip otimista completo sem 2ª busca. **Fase 2:** `setLeadershipStateDeputyMembershipRecord`/`setLeadershipStateDeputyMembership` em `actions/leadership.ts` (transação + `acquireTextAdvisoryLocks` + `revalidatePath` em `/campanha/liderancas`, `/campanha/liderancas/[id]`, `/campanha/dobradinhas` e `/campanha/dobradinhas/<slug>` do deputado tocado); novo `liderancas/formActions.ts` (a rota da lista não tinha um ainda — só `nova/` e `[id]/` tinham). **Fase 3 (Impeccable craft→critique→polish):** catálogo uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory) + `LeadershipStateDeputiesCell` (chips removíveis + `Command`/`matchesAtWordStart`, `useTransition` chamando a form action diretamente — não `fetch` —, reversão otimista + `toast.error` em falha, `aria-live` de status); coluna nova entre Organizações e Acesso ao app. Sem "Ver mais…" (teto de schema 20, uso típico 0–3); sem filtro/sort por dobradinha (gatilho no B29, ainda não entregue). **`/simplify` (1ª rodada):** corrigido revert otimista por delta (não pelo snapshot inteiro — um toggle falho não apaga mais um toggle anterior já salvo), removido fallback silencioso na adição de chip, extraído `sameIdSet` compartilhado com `MunicipalityListAdvisorsControl` (B27) para `src/lib/sameIdSet.ts`, e o caminho no-op da action parou de pagar uma leitura + revalidação supérfluas. **`/simplify` (2ª rodada, pós-rebase):** o catálogo tinha nascido como `StateDeputyOptionsProvider` (Context) — trocado pela columns factory acima ao confirmar que `MunicipalityList.tsx`/`municipalityListColumns` já resolve o mesmo problema sem Context (1ª rodada tinha validado o Context por falta dessa evidência); `revalidatePath('/campanha/liderancas/[id]', 'page')` trocado pelo id concreto (o template dinâmico invalidava toda ficha de liderança a cada toggle); non-null assertion em `optionById.get(...)!` movida para dentro do `setCurrent` updater. Adiado (documentado no plano): passar o `slug` do deputado pelo form em vez do `findByID` extra na action — fica para o 3º call site. Gate verde (tsc, lint --max-warnings=0, format:check, knip — P3 pré-existente, unit 530/530, int 406/406, build); Aikido 0 achados. Plano: [`dobradinhas-lista-liderancas.md`](../../../docs/plans/dobradinhas-lista-liderancas.md).
|
||||
- **B25 entregue em código (2026-07-26):** link do território na lista de `/campanha/municipios` — coluna "Território" e card mobile usam `TerritoryLink` → `/campanha/territorios#ti-<slug>`; helper compartilhado `territoryAnchor.ts`; destino com `id` nas linhas parent de `TerritoryList` via `CampaignTable.rowId`; sem migration/action. Gate: tsc/lint/format/knip/check:cycles; unit+int verdes (1 int flake por timeout em suíte cheia, pré-existente); `pnpm build` não rodou neste worktree (falta `PAYLOAD_SECRET` no ambiente). Plano: [`link-territorio-lista-municipios.md`](../../../docs/plans/link-territorio-lista-municipios.md).
|
||||
|
|
|
|||
|
|
@ -1,12 +1,23 @@
|
|||
# Chips de lideranças na lista de dobradinhas (espelho da coluna Municípios de Assessores)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B36**)
|
||||
Impeccable: B — encaixe na coluna "Lideranças" de `/campanha/dobradinhas`, reusando o padrão visual já em produção em `/campanha/assessores` (**B19 ✓**) e a mutação desenhada pelo **B31** para o lado inverso da mesma relação
|
||||
Appetite: ~0,5–0,75 dia se **B31** já tiver aterrissado (só a direção nova + o loader da lista); ~1–1,5 dia se **B36** entrar primeiro (paga a extração do componente/ação compartilhados que o B31 passaria a consumir)
|
||||
Status: entregue (2026-07-26)
|
||||
Atualizado em: 2026-07-26
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B36** ✓)
|
||||
Impeccable: B — encaixe na coluna "Lideranças" de `/campanha/dobradinhas`, reusando o padrão visual já em produção em `/campanha/assessores` (**B19 ✓**) e a mutação entregue pelo **B31 ✓** para o lado inverso da mesma relação
|
||||
Appetite: ~0,75–1d (B31 já aterrissado; paga a extração da célula compartilhada que o B31 deixou pendente)
|
||||
Responsável: —
|
||||
|
||||
## Revisão na entrega (2026-07-26)
|
||||
|
||||
Auditoria pré-implementação contra o B31 as-built:
|
||||
|
||||
1. **Context → columns factory.** O plano pedia `RelationOptionsProvider`; o B31 `/simplify` rodada 2 já tinha deletado o Context em favor de `leadershipColumns(options)`. Entrega seguiu o precedente: `stateDeputyColumns(..., leadershipOptions)` + `toLeadershipRelationOptions`.
|
||||
2. **Casca por rota.** `setLeadershipStateDeputyMembershipFormAction` ganhou cópia fina em `dobradinhas/formActions.ts` (não importa de `liderancas/` — `formActions` são cascas por rota no codebase-map). Mutação/schema/lock inalterados.
|
||||
3. **Célula compartilhada.** `LeadershipStateDeputiesCell` deletado; nasce `shared/LeadershipStateDeputyRelationCell` com `direction` + `measureOverflow` (default true; lado liderança passa `false`). Tipos de UI: `RelationCellItem` / `RelationCellOption`.
|
||||
4. **Loader.** `leadershipCount` → `leaderships: {id,name}[]` em `StateDeputyRowViewModel`; `loadLeadershipOptions` em `campaignRelationOptions.ts`.
|
||||
|
||||
As-built bate com as Decisões travadas de mutação/reuso; só o packaging de UI/catalogo divergiu do rascunho de 25/07, alinhado ao B31 real.
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 3 **Edit where you see**, 4 **Auto-save, no Save button**, 8 Feel the action; anti-goals "spreadsheet mode" e "always-mounted inputs on every row") / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · precedentes de interação [`AdvisorMunicipalityCell.tsx`](../../src/components/campaign/advisor/AdvisorMunicipalityCell.tsx) (B19 ✓ — chips removíveis + typeahead + otimista + `ResizeObserver`/"Ver mais…") e o desenho ainda não implementado do **B31** ([`dobradinhas-lista-liderancas.md`](dobradinhas-lista-liderancas.md) — chips + Popover + `Command`, sem "Ver mais…", para a mesma relação `leadership.stateDeputies` na direção oposta) · shells `CampaignTable` / `CampaignPageShell` · regras `.cursor/rules/campanha-edit-where-you-see.mdc` e `campanha-action-feedback.mdc`.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -7,7 +7,12 @@ import {
|
|||
setAdvisorMunicipalityMembership,
|
||||
updateAdvisorProfile,
|
||||
} from '@/app/(campaign)/campanha/actions/advisor'
|
||||
import { nullableFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
|
||||
import {
|
||||
nullableFormText,
|
||||
requiredFormBoolean,
|
||||
requiredFormText,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import { ADVISOR_ACTION_SAFE_MESSAGES } from '@/lib/schemas/advisor'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
|
|
@ -65,11 +70,10 @@ export const setAdvisorMunicipalityMembershipFormAction = async (
|
|||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const assignedRaw = requiredFormText(formData, 'assigned')
|
||||
await setAdvisorMunicipalityMembership({
|
||||
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
|
||||
municipalityId: requiredRelationshipFormValue(formData, 'municipalityId'),
|
||||
assigned: assignedRaw === 'true',
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Carteira atualizada.' }
|
||||
},
|
||||
|
|
@ -87,11 +91,10 @@ export const setAdvisorMunicipalitiesBatchFormAction = async (
|
|||
if (municipalityIds.length === 0) {
|
||||
throw new Error(EMPTY_BATCH_MESSAGE)
|
||||
}
|
||||
const assignedRaw = requiredFormText(formData, 'assigned')
|
||||
await setAdvisorMunicipalitiesBatch({
|
||||
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
|
||||
municipalityIds,
|
||||
assigned: assignedRaw === 'true',
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Carteira atualizada.' }
|
||||
},
|
||||
|
|
|
|||
32
src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts
Normal file
32
src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
'use server'
|
||||
|
||||
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import { requiredFormBoolean, requiredRelationshipFormValue } from '@/lib/formData'
|
||||
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
|
||||
const safeMessages = [
|
||||
'Somente a coordenação e a assessoria podem gerenciar lideranças.',
|
||||
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
|
||||
] as const
|
||||
|
||||
/** One chip toggle in the "Lideranças" column of `/campanha/dobradinhas` (B36). */
|
||||
export const setLeadershipStateDeputyMembershipFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setLeadershipStateDeputyMembership({
|
||||
leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'),
|
||||
stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Lideranças atualizadas.' }
|
||||
},
|
||||
safeMessages,
|
||||
genericMessage: 'Não foi possível atualizar as lideranças. Tente novamente.',
|
||||
})
|
||||
|
|
@ -15,6 +15,10 @@ import {
|
|||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import {
|
||||
LeadershipStateDeputyRelationCell,
|
||||
type RelationCellOption,
|
||||
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters'
|
||||
import { StateDeputySortableHead } from '@/components/campaign/stateDeputy/StateDeputySortableHead'
|
||||
|
|
@ -30,6 +34,7 @@ import {
|
|||
import { cn } from '@/lib/utils'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { loadLeadershipOptions } from '@/utilities/campaignRelationOptions'
|
||||
import {
|
||||
loadStateDeputyListPageData,
|
||||
type StateDeputyRowViewModel,
|
||||
|
|
@ -46,6 +51,8 @@ import {
|
|||
type StateDeputyListState,
|
||||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
import { setLeadershipStateDeputyMembershipFormAction } from './formActions'
|
||||
|
||||
type StateDeputiesPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
|
@ -79,6 +86,7 @@ const stateDeputyColumns = (
|
|||
state: StateDeputyListState,
|
||||
partyFilterOptions: StateDeputyFilterOption[],
|
||||
hasNoPartyOption: boolean,
|
||||
leadershipOptions: RelationCellOption[],
|
||||
): Array<CampaignTableColumn<StateDeputyRowViewModel>> => [
|
||||
{
|
||||
id: 'name',
|
||||
|
|
@ -121,8 +129,20 @@ const stateDeputyColumns = (
|
|||
{
|
||||
id: 'leaderships',
|
||||
head: <CampaignTableHead>Lideranças</CampaignTableHead>,
|
||||
cellClassName: 'tabular-nums',
|
||||
cell: (row) => row.leadershipCount,
|
||||
cellClassName: 'max-w-72 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<LeadershipStateDeputyRelationCell
|
||||
direction="fromStateDeputy"
|
||||
fixedId={row.id}
|
||||
items={row.leaderships.map((leadership) => ({
|
||||
id: leadership.id,
|
||||
label: leadership.name,
|
||||
href: `/campanha/liderancas/${leadership.id}`,
|
||||
}))}
|
||||
options={leadershipOptions}
|
||||
membershipAction={setLeadershipStateDeputyMembershipFormAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -135,11 +155,10 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
if (!user) redirect('/campanha/login')
|
||||
if (!isCampaignStaff(user)) redirect('/campanha')
|
||||
|
||||
const { rows, totalDocs, totalPages, filterFacets } = await loadStateDeputyListPageData(
|
||||
payload,
|
||||
user,
|
||||
canonicalUrl.state,
|
||||
)
|
||||
const [{ rows, totalDocs, totalPages, filterFacets }, leadershipOptions] = await Promise.all([
|
||||
loadStateDeputyListPageData(payload, user, canonicalUrl.state),
|
||||
loadLeadershipOptions(payload, user),
|
||||
])
|
||||
const resolvedUrl = resolveStateDeputyListUrl(rawSearchParams, totalPages)
|
||||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
const { state } = resolvedUrl
|
||||
|
|
@ -147,6 +166,20 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
const { sort, dir } = resolveStateDeputyListSort(state)
|
||||
const sortSummary = formatStateDeputyListSortSummary(sort, dir)
|
||||
const partyFilterOptions = filterFacets.parties.map((party) => ({ value: party, label: party }))
|
||||
const columns = stateDeputyColumns(
|
||||
state,
|
||||
partyFilterOptions,
|
||||
filterFacets.hasNoParty,
|
||||
leadershipOptions.map((option) => ({
|
||||
id: option.id,
|
||||
searchLabel: option.name,
|
||||
item: {
|
||||
id: option.id,
|
||||
label: option.name,
|
||||
href: `/campanha/liderancas/${option.id}`,
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
|
|
@ -154,8 +187,8 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Dobradinhas</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Deputados estaduais com quem a campanha dobra — vincule a municípios e lideranças nas
|
||||
fichas correspondentes.
|
||||
Deputados estaduais com quem a campanha dobra — vincule lideranças na própria lista e
|
||||
municípios nas fichas correspondentes.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild className="min-h-11">
|
||||
|
|
@ -179,7 +212,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
</p>
|
||||
<CampaignTable
|
||||
caption={`${sortSummary}. Deputados estaduais com quem a campanha dobra.`}
|
||||
columns={stateDeputyColumns(state, partyFilterOptions, filterFacets.hasNoParty)}
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(row) => row.id}
|
||||
empty={<StateDeputyListEmptyState state={state} />}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use server'
|
||||
|
||||
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
|
||||
import { requiredFormBoolean, requiredRelationshipFormValue } from '@/lib/formData'
|
||||
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
|
|
@ -20,11 +20,10 @@ export const setLeadershipStateDeputyMembershipFormAction = async (
|
|||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const assignedRaw = requiredFormText(formData, 'assigned')
|
||||
await setLeadershipStateDeputyMembership({
|
||||
leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'),
|
||||
stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
|
||||
assigned: assignedRaw === 'true',
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Dobradinhas atualizadas.' }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { redirect } from 'next/navigation'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
|
||||
import { LeadershipStateDeputiesCell } from '@/components/campaign/leadership/LeadershipStateDeputiesCell'
|
||||
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
|
||||
import { CampaignCopyableCell } from '@/components/campaign/shared/CampaignCopyableCell'
|
||||
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
|
||||
|
|
@ -20,16 +19,17 @@ import {
|
|||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import {
|
||||
LeadershipStateDeputyRelationCell,
|
||||
type RelationCellOption,
|
||||
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import {
|
||||
loadStateDeputyOptions,
|
||||
type StateDeputyRelationOption,
|
||||
} from '@/utilities/campaignRelationOptions'
|
||||
import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
|
||||
import {
|
||||
buildLeadershipListHref,
|
||||
loadLeadershipListPageData,
|
||||
|
|
@ -44,7 +44,7 @@ type LeadershipsPageProps = {
|
|||
}
|
||||
|
||||
const leadershipColumns = (
|
||||
stateDeputyOptions: StateDeputyRelationOption[],
|
||||
stateDeputyOptions: RelationCellOption[],
|
||||
): Array<CampaignTableColumn<LeadershipRowViewModel>> => [
|
||||
{
|
||||
id: 'name',
|
||||
|
|
@ -99,11 +99,18 @@ const leadershipColumns = (
|
|||
head: <CampaignTableHead>Dobradinhas</CampaignTableHead>,
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<LeadershipStateDeputiesCell
|
||||
leadershipId={row.id}
|
||||
stateDeputies={row.stateDeputies}
|
||||
<LeadershipStateDeputyRelationCell
|
||||
direction="fromLeadership"
|
||||
fixedId={row.id}
|
||||
items={row.stateDeputies.map((deputy) => ({
|
||||
id: deputy.id,
|
||||
label: deputy.name,
|
||||
href: `/campanha/dobradinhas/${deputy.slug}`,
|
||||
...(deputy.party ? { party: deputy.party } : {}),
|
||||
}))}
|
||||
options={stateDeputyOptions}
|
||||
membershipAction={setLeadershipStateDeputyMembershipFormAction}
|
||||
measureOverflow={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
@ -173,7 +180,18 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
loadLeadershipListPageData(payload, user, state),
|
||||
loadStateDeputyOptions(payload, user),
|
||||
])
|
||||
const columns = leadershipColumns(stateDeputyOptions)
|
||||
const columns = leadershipColumns(
|
||||
stateDeputyOptions.map((option) => ({
|
||||
id: option.id,
|
||||
searchLabel: option.name,
|
||||
item: {
|
||||
id: option.id,
|
||||
label: option.plainName,
|
||||
href: `/campanha/dobradinhas/${option.slug}`,
|
||||
...(option.party ? { party: option.party } : {}),
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
|
|
|
|||
|
|
@ -1,222 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { PencilIcon, XIcon } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyChips'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { matchesAtWordStart } from '@/lib/wordStartFilter'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
|
||||
import type { StateDeputySummary } from '@/utilities/stateDeputyData'
|
||||
|
||||
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar as dobradinhas. Tente novamente.'
|
||||
|
||||
type LeadershipStateDeputiesCellProps = {
|
||||
leadershipId: number
|
||||
stateDeputies: StateDeputySummary[]
|
||||
options: StateDeputyRelationOption[]
|
||||
membershipAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
/**
|
||||
* "Dobradinhas" column cell (B31) — read chips reuse `StateDeputyChips`
|
||||
* (same look as the leadership ficha / município strategy card); a separate
|
||||
* pencil affordance opens the Popover+Command editor, mirroring the
|
||||
* mechanics of `MunicipalityListAdvisorsControl` (B27) but writing through a
|
||||
* server action + `useTransition` like `AdvisorMunicipalityCell` (B19)
|
||||
* instead of a JSON endpoint. The catalog arrives as a prop, computed once
|
||||
* by the page's column factory (mirrors `municipalityListColumns`) instead
|
||||
* of a table-scoped context — one static catalog, one place that builds it.
|
||||
*/
|
||||
export const LeadershipStateDeputiesCell = ({
|
||||
leadershipId,
|
||||
stateDeputies,
|
||||
options,
|
||||
membershipAction,
|
||||
}: LeadershipStateDeputiesCellProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [current, setCurrent] = useState<StateDeputySummary[]>(stateDeputies)
|
||||
const [query, setQuery] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const lastPropsRef = useRef(stateDeputies)
|
||||
|
||||
// Adopt server props only when they change from outside (navigation / RSC
|
||||
// refresh) — an in-flight delta's optimistic state must not be clobbered by
|
||||
// a prop identical in content to what we already had.
|
||||
useEffect(() => {
|
||||
const propIds = stateDeputies.map((deputy) => deputy.id)
|
||||
if (
|
||||
sameIdSet(
|
||||
propIds,
|
||||
lastPropsRef.current.map((deputy) => deputy.id),
|
||||
)
|
||||
)
|
||||
return
|
||||
lastPropsRef.current = stateDeputies
|
||||
setCurrent(stateDeputies)
|
||||
}, [stateDeputies])
|
||||
|
||||
const currentIds = useMemo(() => new Set(current.map((deputy) => deputy.id)), [current])
|
||||
const optionById = useMemo(() => new Map(options.map((option) => [option.id, option])), [options])
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
options.filter(
|
||||
(option) => !currentIds.has(option.id) && matchesAtWordStart(option.name, query),
|
||||
),
|
||||
[options, currentIds, query],
|
||||
)
|
||||
|
||||
const toggle = (stateDeputyId: number, assigned: boolean) => {
|
||||
// The removed summary is captured up front (not re-derived from the
|
||||
// catalog) so a failed removal can be undone even for a deputy that has
|
||||
// since dropped out of `options` — same asymmetry as the `optionById`
|
||||
// lookup below, which only needs to resolve *additions*.
|
||||
const removedSummary = assigned
|
||||
? undefined
|
||||
: current.find((deputy) => deputy.id === stateDeputyId)
|
||||
if (assigned && !optionById.has(stateDeputyId)) return
|
||||
|
||||
setErrorMessage(null)
|
||||
setCurrent((previous) => {
|
||||
if (assigned) {
|
||||
if (previous.some((deputy) => deputy.id === stateDeputyId)) return previous
|
||||
const option = optionById.get(stateDeputyId)
|
||||
if (!option) return previous
|
||||
return [
|
||||
...previous,
|
||||
{ id: stateDeputyId, name: option.plainName, slug: option.slug, party: option.party },
|
||||
]
|
||||
}
|
||||
return previous.filter((deputy) => deputy.id !== stateDeputyId)
|
||||
})
|
||||
setQuery('')
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set('leadershipId', String(leadershipId))
|
||||
formData.set('stateDeputyId', String(stateDeputyId))
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await membershipAction({}, formData)
|
||||
if (result.status === 'success') return
|
||||
// Undo only this delta — not the whole cell back to `lastPropsRef` —
|
||||
// so a failed toggle can't wipe an earlier toggle from the same batch
|
||||
// that already saved successfully.
|
||||
setCurrent((previous) =>
|
||||
assigned
|
||||
? previous.filter((deputy) => deputy.id !== stateDeputyId)
|
||||
: removedSummary
|
||||
? [...previous, removedSummary]
|
||||
: previous,
|
||||
)
|
||||
const message = result.message ?? SAVE_ERROR_MESSAGE
|
||||
setErrorMessage(message)
|
||||
toast.error(message)
|
||||
})
|
||||
}
|
||||
|
||||
const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando dobradinhas.' : ''
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{current.length > 0 ? (
|
||||
<StateDeputyChips deputies={current} />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Editar dobradinhas"
|
||||
className={cn(
|
||||
'inline-flex min-h-11 min-w-11 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
open ? 'bg-muted/60 text-foreground' : undefined,
|
||||
)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-0">
|
||||
<div className="relative flex flex-col gap-2 p-3 pb-0">
|
||||
<p className="text-sm font-medium">Atribuir dobradinhas</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deputados estaduais em dobradinha com esta liderança.
|
||||
</p>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className="absolute top-3 right-3 size-3.5 text-muted-foreground"
|
||||
aria-label="Salvando dobradinhas"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{current.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
|
||||
{current.map((deputy) => (
|
||||
<button
|
||||
key={deputy.id}
|
||||
type="button"
|
||||
aria-label={`Remover ${deputy.name}`}
|
||||
onClick={() => toggle(deputy.id, false)}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
|
||||
>
|
||||
<span className="truncate">
|
||||
{deputy.name}
|
||||
{deputy.party ? ` (${deputy.party})` : ''}
|
||||
</span>
|
||||
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Command shouldFilter={false} className="mt-1">
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Buscar deputado estadual…"
|
||||
aria-label="Buscar deputado estadual"
|
||||
/>
|
||||
<CommandList>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado.
|
||||
</p>
|
||||
) : (
|
||||
filteredOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`state-deputy-${option.id}`}
|
||||
onSelect={() => toggle(option.id, true)}
|
||||
>
|
||||
<span className="truncate">{option.name}</span>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{statusMessage}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
'use client'
|
||||
|
||||
import { PencilIcon, XIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { matchesAtWordStart } 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 LeadershipStateDeputyRelationDirection = 'fromLeadership' | 'fromStateDeputy'
|
||||
|
||||
type LeadershipStateDeputyRelationCellProps = {
|
||||
direction: LeadershipStateDeputyRelationDirection
|
||||
fixedId: number
|
||||
items: RelationCellItem[]
|
||||
options: RelationCellOption[]
|
||||
membershipAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
/** Clamp rest chips to 3 rows + "Ver mais…". Default true; pass false when few chips are expected. */
|
||||
measureOverflow?: boolean
|
||||
}
|
||||
|
||||
const COLLAPSED_CHIP_ROWS = 3
|
||||
/** Matches `gap-1` on the rest chip row. */
|
||||
const CHIP_GAP_PX = 4
|
||||
|
||||
const COPY = {
|
||||
fromLeadership: {
|
||||
editLabel: 'Editar dobradinhas',
|
||||
title: 'Atribuir dobradinhas',
|
||||
description: 'Deputados estaduais em dobradinha com esta liderança.',
|
||||
searchPlaceholder: 'Buscar deputado estadual…',
|
||||
searchAriaLabel: 'Buscar deputado estadual',
|
||||
saving: 'Salvando dobradinhas.',
|
||||
savingAria: 'Salvando dobradinhas',
|
||||
saveError: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
|
||||
},
|
||||
fromStateDeputy: {
|
||||
editLabel: 'Editar lideranças',
|
||||
title: 'Atribuir lideranças',
|
||||
description: 'Lideranças em dobradinha com este deputado estadual.',
|
||||
searchPlaceholder: 'Buscar liderança…',
|
||||
searchAriaLabel: 'Buscar liderança',
|
||||
saving: 'Salvando lideranças.',
|
||||
savingAria: 'Salvando lideranças',
|
||||
saveError: 'Não foi possível atualizar as lideranças. Tente novamente.',
|
||||
},
|
||||
} as const
|
||||
|
||||
const itemLabel = (item: RelationCellItem): string =>
|
||||
item.party ? `${item.label} (${item.party})` : item.label
|
||||
|
||||
/**
|
||||
* Bidirectional cell for the `leadership.stateDeputies` edge.
|
||||
* Both directions write `leadershipId`/`stateDeputyId`/`assigned` through the
|
||||
* same membership action.
|
||||
*/
|
||||
export const LeadershipStateDeputyRelationCell = ({
|
||||
direction,
|
||||
fixedId,
|
||||
items,
|
||||
options,
|
||||
membershipAction,
|
||||
measureOverflow = true,
|
||||
}: LeadershipStateDeputyRelationCellProps) => {
|
||||
const copy = COPY[direction]
|
||||
const [open, setOpen] = useState(false)
|
||||
const [current, setCurrent] = useState<RelationCellItem[]>(items)
|
||||
const [query, setQuery] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const lastPropsRef = useRef(items)
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [collapsedHeight, setCollapsedHeight] = useState<number | null>(null)
|
||||
/** `null` means "render every chip so the next effect can measure them". */
|
||||
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
|
||||
const [measureToken, setMeasureToken] = useState(0)
|
||||
const lastRowWidth = useRef(0)
|
||||
const chipRowRef = useRef<HTMLDivElement>(null)
|
||||
const editRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Adopt server props only when they change from outside (navigation / RSC
|
||||
// refresh) — an in-flight delta's optimistic state must not be clobbered by
|
||||
// a prop identical in content to what we already had.
|
||||
useEffect(() => {
|
||||
const propIds = items.map((item) => item.id)
|
||||
if (
|
||||
sameIdSet(
|
||||
propIds,
|
||||
lastPropsRef.current.map((item) => item.id),
|
||||
)
|
||||
)
|
||||
return
|
||||
lastPropsRef.current = items
|
||||
setCurrent(items)
|
||||
}, [items])
|
||||
|
||||
const currentIds = useMemo(() => new Set(current.map((item) => item.id)), [current])
|
||||
const optionById = useMemo(() => new Map(options.map((option) => [option.id, option])), [options])
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!open) return []
|
||||
return options.filter(
|
||||
(option) => !currentIds.has(option.id) && matchesAtWordStart(option.searchLabel, query),
|
||||
)
|
||||
}, [open, options, currentIds, query])
|
||||
|
||||
const invalidateMeasurement = useCallback(() => {
|
||||
setVisibleChipCount(null)
|
||||
setMeasureToken((token) => token + 1)
|
||||
}, [])
|
||||
|
||||
const chipsKey = current.map((item) => item.id).join('|')
|
||||
useEffect(() => {
|
||||
if (!measureOverflow) return
|
||||
invalidateMeasurement()
|
||||
}, [chipsKey, measureOverflow, invalidateMeasurement])
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureOverflow) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
lastRowWidth.current = row.clientWidth
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (row.clientWidth === lastRowWidth.current) return
|
||||
lastRowWidth.current = row.clientWidth
|
||||
invalidateMeasurement()
|
||||
})
|
||||
observer.observe(row)
|
||||
return () => observer.disconnect()
|
||||
}, [measureOverflow, invalidateMeasurement])
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureOverflow || visibleChipCount !== null) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
|
||||
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-relation-chip]')]
|
||||
if (chipElements.length === 0) {
|
||||
setVisibleChipCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
|
||||
const firstChip = chipRects[0]
|
||||
if (!firstChip) {
|
||||
setVisibleChipCount(0)
|
||||
return
|
||||
}
|
||||
const chipHeight = firstChip.height
|
||||
setCollapsedHeight(chipHeight * COLLAPSED_CHIP_ROWS + CHIP_GAP_PX * (COLLAPSED_CHIP_ROWS - 1))
|
||||
|
||||
const lastVisibleTop = firstChip.top + (chipHeight + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
|
||||
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
|
||||
|
||||
// Reserve room on the last visible line for "Ver mais…" (and the sibling pencil).
|
||||
const toggleWidth =
|
||||
row.querySelector<HTMLElement>('[data-relation-toggle]')?.getBoundingClientRect().width ?? 0
|
||||
const pencilWidth = editRef.current?.getBoundingClientRect().width ?? 0
|
||||
const trailingWidth = toggleWidth + CHIP_GAP_PX + pencilWidth
|
||||
while (fitting > 0 && fitting < chipElements.length) {
|
||||
const trailing = chipRects[fitting - 1]
|
||||
if (!trailing || trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
|
||||
fitting -= 1
|
||||
}
|
||||
|
||||
setVisibleChipCount(fitting)
|
||||
}, [measureOverflow, measureToken, visibleChipCount])
|
||||
|
||||
const toggle = (itemId: number, assigned: boolean) => {
|
||||
// The removed summary is captured up front (not re-derived from the
|
||||
// catalog) so a failed removal can be undone even for an item that has
|
||||
// since dropped out of `options` — same asymmetry as the `optionById`
|
||||
// lookup below, which only needs to resolve *additions*.
|
||||
const removedItem = assigned ? undefined : current.find((item) => item.id === itemId)
|
||||
if (assigned && !optionById.has(itemId)) return
|
||||
|
||||
setErrorMessage(null)
|
||||
setCurrent((previous) => {
|
||||
if (assigned) {
|
||||
if (previous.some((item) => item.id === itemId)) return previous
|
||||
const option = optionById.get(itemId)
|
||||
if (!option) return previous
|
||||
return [...previous, option.item]
|
||||
}
|
||||
return previous.filter((item) => item.id !== itemId)
|
||||
})
|
||||
setQuery('')
|
||||
|
||||
const formData = new FormData()
|
||||
if (direction === 'fromLeadership') {
|
||||
formData.set('leadershipId', String(fixedId))
|
||||
formData.set('stateDeputyId', String(itemId))
|
||||
} else {
|
||||
formData.set('leadershipId', String(itemId))
|
||||
formData.set('stateDeputyId', String(fixedId))
|
||||
}
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await membershipAction({}, formData)
|
||||
if (result.status === 'success') return
|
||||
// Undo only this delta — not the whole cell back to `lastPropsRef` —
|
||||
// so a failed toggle can't wipe an earlier toggle from the same batch
|
||||
// that already saved successfully.
|
||||
setCurrent((previous) =>
|
||||
assigned
|
||||
? previous.filter((item) => item.id !== itemId)
|
||||
: removedItem
|
||||
? [...previous, removedItem]
|
||||
: previous,
|
||||
)
|
||||
const message = result.message ?? copy.saveError
|
||||
setErrorMessage(message)
|
||||
toast.error(message)
|
||||
})
|
||||
}
|
||||
|
||||
const statusMessage = errorMessage ?? (isPending ? copy.saving : '')
|
||||
|
||||
const showAllChips = !measureOverflow || expanded
|
||||
const measuring = measureOverflow && visibleChipCount === null
|
||||
const hasHiddenChips =
|
||||
measureOverflow && visibleChipCount !== null && visibleChipCount < current.length
|
||||
const visibleItems = (() => {
|
||||
if (!measureOverflow || showAllChips || measuring || visibleChipCount === null) return current
|
||||
return current.slice(0, visibleChipCount)
|
||||
})()
|
||||
/**
|
||||
* Only the pre-measurement render needs a height cap; once the chip list is
|
||||
* trimmed the row is three lines tall on its own.
|
||||
*/
|
||||
const clamping = measuring && !showAllChips
|
||||
const chipRowStyle =
|
||||
clamping && collapsedHeight !== null ? { maxHeight: collapsedHeight } : undefined
|
||||
|
||||
// Mounted while measuring so width is reserved; hidden until overflow is known.
|
||||
const expandToggle =
|
||||
measureOverflow && (measuring || hasHiddenChips) ? (
|
||||
<button
|
||||
type="button"
|
||||
data-relation-toggle
|
||||
tabIndex={measuring ? -1 : undefined}
|
||||
aria-hidden={measuring || undefined}
|
||||
className={cn(
|
||||
'px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
|
||||
measuring && 'invisible pointer-events-none',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setExpanded((previous) => !previous)
|
||||
}}
|
||||
>
|
||||
{showAllChips ? 'Ver menos' : 'Ver mais…'}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div
|
||||
ref={chipRowRef}
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-1',
|
||||
measureOverflow && current.length > 0 ? 'min-w-0 flex-1' : undefined,
|
||||
clamping && 'overflow-hidden',
|
||||
)}
|
||||
style={chipRowStyle}
|
||||
>
|
||||
{current.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
) : (
|
||||
visibleItems.map((item) => (
|
||||
<Badge key={item.id} variant="outline" asChild>
|
||||
<Link href={item.href} data-relation-chip className="max-w-full truncate">
|
||||
{itemLabel(item)}
|
||||
</Link>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{expandToggle}
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
ref={editRef}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={copy.editLabel}
|
||||
className={cn(
|
||||
'inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
open ? 'bg-muted/60 text-foreground' : undefined,
|
||||
)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-0">
|
||||
<div className="relative flex flex-col gap-2 p-3 pb-0">
|
||||
<p className="text-sm font-medium">{copy.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{copy.description}</p>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className="absolute top-3 right-3 size-3.5 text-muted-foreground"
|
||||
aria-label={copy.savingAria}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{current.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
|
||||
{current.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-label={`Remover ${item.label}`}
|
||||
onClick={() => toggle(item.id, false)}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
|
||||
>
|
||||
<span className="truncate">{itemLabel(item)}</span>
|
||||
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Command shouldFilter={false} className="mt-1">
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder={copy.searchPlaceholder}
|
||||
aria-label={copy.searchAriaLabel}
|
||||
/>
|
||||
<CommandList>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado.
|
||||
</p>
|
||||
) : (
|
||||
filteredOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`${direction}-${option.id}`}
|
||||
onSelect={() => toggle(option.id, true)}
|
||||
>
|
||||
<span className="truncate">{option.searchLabel}</span>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{statusMessage}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -32,6 +32,14 @@ export const requiredFormText = (formData: FormData, field: string): string => {
|
|||
return value
|
||||
}
|
||||
|
||||
/** Membership toggles send `'true'` / `'false'` as form text. */
|
||||
export const requiredFormBoolean = (formData: FormData, field: string): boolean => {
|
||||
const value = requiredFormText(formData, field)
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
throw new FormDataBoundaryError(field, 'Valor inválido.')
|
||||
}
|
||||
|
||||
export const requiredFormSecret = (formData: FormData, field: string): string => {
|
||||
const entry = formEntry(formData, field)
|
||||
const value = entry.present ? entry.value : ''
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Payload } from 'payload'
|
|||
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
import { populatedContactName, relationshipId } from '@/utilities/relationship'
|
||||
|
||||
export type ActivityRelationOption = RelationOption & {
|
||||
municipalityId: number
|
||||
|
|
@ -105,3 +105,30 @@ export const loadStateDeputyOptions = async (
|
|||
slug: stateDeputy.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Leadership catalog for typeahead cells (B36). Access of `canReadLeadership`
|
||||
* already scopes an advisor to leaderships of administered municipalities —
|
||||
* no manual intersection with `getAccessibleLeadershipIds`.
|
||||
*/
|
||||
export const loadLeadershipOptions = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
): Promise<RelationOption[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'leadership',
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { contact: true },
|
||||
where: {},
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
return result.docs
|
||||
.map((leadership) => ({
|
||||
id: leadership.id,
|
||||
name: populatedContactName(leadership.contact),
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ export const relationshipId = (value: unknown): number | null => {
|
|||
return isPopulatedRelationship(value) ? value.id : null
|
||||
}
|
||||
|
||||
/** Display name from a populated `Contact` relation (or `fallback` when unpopulated). */
|
||||
export const populatedContactName = (contact: unknown, fallback = 'Contato'): string => {
|
||||
if (typeof contact !== 'object' || contact === null || !('name' in contact)) return fallback
|
||||
const { name } = contact
|
||||
return typeof name === 'string' && name.length > 0 ? name : fallback
|
||||
}
|
||||
|
||||
export const requireRelationshipId = (
|
||||
value: unknown,
|
||||
message = 'Relacionamento inválido.',
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import 'server-only'
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { CampaignUser, Leadership, StateDeputy } from '@/payload-types'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
import type { CampaignUser, StateDeputy } from '@/payload-types'
|
||||
import { populatedContactName, relationshipId } from '@/utilities/relationship'
|
||||
import {
|
||||
buildStateDeputyListWhere,
|
||||
NO_PARTY_FILTER_VALUE,
|
||||
|
|
@ -13,13 +13,18 @@ import {
|
|||
type StateDeputyListState,
|
||||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
type LeadershipRelationSummary = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type StateDeputyRowViewModel = {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
party: string | null
|
||||
municipalityCount: number
|
||||
leadershipCount: number
|
||||
leaderships: LeadershipRelationSummary[]
|
||||
}
|
||||
|
||||
/** Values still reachable under the OTHER active filters (the Partido popover). */
|
||||
|
|
@ -94,7 +99,7 @@ export const loadStateDeputyListPageData = async (
|
|||
|
||||
const stateDeputyIDs = result.docs.map((doc) => doc.id)
|
||||
const municipalityCounts = new Map<number, number>()
|
||||
const leadershipCounts = new Map<number, number>()
|
||||
const leadershipsByDeputy = new Map<number, LeadershipRelationSummary[]>()
|
||||
|
||||
if (stateDeputyIDs.length) {
|
||||
const [municipalities, leaderships] = await Promise.all([
|
||||
|
|
@ -110,11 +115,14 @@ export const loadStateDeputyListPageData = async (
|
|||
payload.find({
|
||||
collection: 'leadership',
|
||||
where: { stateDeputies: { in: stateDeputyIDs } },
|
||||
depth: 0,
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { stateDeputies: true },
|
||||
overrideAccess: true,
|
||||
select: { stateDeputies: true, contact: true },
|
||||
// Names ship in the list cell — honour canReadLeadership (unlike
|
||||
// municipalityCounts above, which stays aggregate-only).
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
])
|
||||
|
||||
|
|
@ -128,13 +136,24 @@ export const loadStateDeputyListPageData = async (
|
|||
}
|
||||
|
||||
for (const leadership of leaderships.docs) {
|
||||
const summary: LeadershipRelationSummary = {
|
||||
id: leadership.id,
|
||||
name: populatedContactName(leadership.contact),
|
||||
}
|
||||
for (const deputy of leadership.stateDeputies ?? []) {
|
||||
const id = relationshipId(deputy)
|
||||
if (id !== null && stateDeputyIDs.includes(id)) {
|
||||
leadershipCounts.set(id, (leadershipCounts.get(id) ?? 0) + 1)
|
||||
const list = leadershipsByDeputy.get(id) ?? []
|
||||
list.push(summary)
|
||||
leadershipsByDeputy.set(id, list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, list] of leadershipsByDeputy) {
|
||||
list.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
|
||||
leadershipsByDeputy.set(id, list)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -144,7 +163,7 @@ export const loadStateDeputyListPageData = async (
|
|||
slug: doc.slug,
|
||||
party: doc.party ?? null,
|
||||
municipalityCount: municipalityCounts.get(doc.id) ?? 0,
|
||||
leadershipCount: leadershipCounts.get(doc.id) ?? 0,
|
||||
leaderships: leadershipsByDeputy.get(doc.id) ?? [],
|
||||
})),
|
||||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
|
|
@ -217,16 +236,10 @@ export const loadStateDeputyDetail = async (
|
|||
name: municipality.name,
|
||||
slug: municipality.slug,
|
||||
})),
|
||||
leaderships: leaderships.docs.map((leadership) => {
|
||||
const contact = leadership.contact as Leadership['contact']
|
||||
return {
|
||||
id: leadership.id,
|
||||
name:
|
||||
typeof contact === 'object' && contact !== null && 'name' in contact
|
||||
? String(contact.name)
|
||||
: 'Contato',
|
||||
}
|
||||
}),
|
||||
leaderships: leaderships.docs.map((leadership) => ({
|
||||
id: leadership.id,
|
||||
name: populatedContactName(leadership.contact),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -132,7 +132,36 @@ describe('loadStateDeputyListPageData', () => {
|
|||
slug: stateDeputy.slug,
|
||||
party: 'PT',
|
||||
municipalityCount: 0,
|
||||
leadershipCount: 0,
|
||||
leaderships: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves named leaderships linked to the deputy (not a bare count)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const stateDeputy = await fixtures.createStateDeputy({ party: 'PT' })
|
||||
const contact = await fixtures.createContact()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
municipalities: [municipality.id],
|
||||
})
|
||||
await payload.update({
|
||||
collection: 'leadership',
|
||||
id: leadership.id,
|
||||
data: { stateDeputies: [stateDeputy.id] },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const result = await loadStateDeputyListPageData(payload, coordinator, {
|
||||
page: 1,
|
||||
q: stateDeputy.name,
|
||||
})
|
||||
|
||||
expect(result.rows[0]).toMatchObject({
|
||||
slug: stateDeputy.slug,
|
||||
leaderships: [{ id: leadership.id, name: contact.name }],
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue