Add filtered overview panel to the nuclei list page.

Give coordinators aggregate vote estimates, coverage, and recent updates for the current list filters without leaving /campanha/nucleos.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-18 17:10:57 -03:00
parent b483e0cb32
commit 040c36bed7
9 changed files with 748 additions and 12 deletions

View file

@ -8,6 +8,7 @@ import { getPayload } from 'payload'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { NucleusFilters } from '@/components/campaign/NucleusFilters'
import { NucleusList } from '@/components/campaign/NucleusList'
import { NucleusListOverview } from '@/components/campaign/NucleusListOverview'
import { NucleusPagination } from '@/components/campaign/NucleusPagination'
import { Button } from '@/components/ui/button'
import {
@ -19,6 +20,7 @@ import {
EmptyTitle,
} from '@/components/ui/Empty'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadNucleusListOverviewData } from '@/utilities/nucleusListOverviewPageData'
import { loadNucleusListPageData } from '@/utilities/nucleusPageData'
import {
buildNucleusFiltersKey,
@ -40,7 +42,11 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) {
if (!user) return null
const { result, scope } = await loadNucleusListPageData(payload, user, rawSearchParams)
const now = new Date()
const [{ result, scope }, overview] = await Promise.all([
loadNucleusListPageData(payload, user, rawSearchParams),
loadNucleusListOverviewData(payload, user, canonicalUrl.state),
])
const resolvedUrl = resolveNucleusListUrl(rawSearchParams, result.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
@ -72,6 +78,7 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) {
{result.docs.length ? (
<>
{overview ? <NucleusListOverview view={overview} now={now} /> : null}
<NucleusList
nuclei={result.docs.map((nucleus) =>
toNucleusListViewModel(nucleus as ElectoralNucleus),

View file

@ -0,0 +1,127 @@
import Link from 'next/link'
import { Badge } from '@/components/ui/Badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/Progress'
import type { NucleusListOverviewViewModel } from '@/utilities/nucleusListOverviewViewModels'
import { nucleusUpdateKindLabels } from '@/utilities/nucleusUpdateUi'
const numberFormatter = new Intl.NumberFormat('pt-BR')
const relativeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' })
const relativeDateLabel = (value: string, now: Date): string => {
const days = Math.floor((new Date(value).getTime() - now.getTime()) / (24 * 60 * 60 * 1000))
return relativeFormatter.format(days, 'day')
}
export const NucleusListOverview = ({
view,
now,
}: {
view: NucleusListOverviewViewModel
now: Date
}) => (
<section aria-labelledby="nucleus-list-overview" className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<h2 id="nucleus-list-overview" className="sr-only">
Visão geral dos núcleos filtrados
</h2>
<p className="text-sm text-muted-foreground">
Mostrando agregados de{' '}
<strong className="font-medium text-foreground tabular-nums">
{numberFormatter.format(view.totalFiltered)}
</strong>{' '}
{view.totalFiltered === 1 ? 'núcleo filtrado' : 'núcleos filtrados'}
</p>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Estimativa de votos</CardDescription>
<CardTitle className="text-3xl tabular-nums">
{numberFormatter.format(view.estimate.confirmedTotal)}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>{view.estimate.confirmedPercent}% com estimativa confirmada</span>
<span className="text-muted-foreground">
{numberFormatter.format(view.estimate.confirmedCount)} confirmadas ·{' '}
{numberFormatter.format(view.estimate.unconfirmedCount)} pendentes
</span>
</div>
<Progress
value={view.estimate.confirmedPercent}
aria-label={`Estimativa confirmada: ${view.estimate.confirmedPercent}%`}
/>
</div>
{view.estimate.pendingSuggestionsCount ? (
<Badge variant="estimate-pending" className="w-fit">
{numberFormatter.format(view.estimate.pendingSuggestionsCount)}{' '}
{view.estimate.pendingSuggestionsCount === 1
? 'sugestão pendente'
: 'sugestões pendentes'}
</Badge>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>Cobertura</CardDescription>
<CardTitle className="text-3xl tabular-nums">{view.coverage.percent}%</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Progress
value={view.coverage.percent}
aria-label={`Cobertura com coordenador: ${view.coverage.percent}%`}
/>
<p className="text-sm text-muted-foreground">
{numberFormatter.format(view.coverage.coordinatedCount)} de{' '}
{numberFormatter.format(view.totalFiltered)} com coordenador
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Últimas atualizações</CardTitle>
</CardHeader>
<CardContent>
{view.recentUpdates.length ? (
<ul className="flex flex-col gap-3">
{view.recentUpdates.map((update) => (
<li
key={update.id}
className="flex flex-col gap-1 border-b pb-3 last:border-b-0 last:pb-0"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{update.authorName}</span>
<Badge variant={update.kind === 'urgente' ? 'destructive' : 'secondary'}>
{nucleusUpdateKindLabels[update.kind]}
</Badge>
<span className="text-sm text-muted-foreground">
{relativeDateLabel(update.createdAt, now)}
</span>
</div>
<Link
href={`/campanha/nucleos/${update.nucleusSlug}?tab=updates`}
className="text-sm text-primary underline-offset-4 hover:underline"
>
{update.nucleusName}
</Link>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nenhuma atualização recente</p>
)}
</CardContent>
</Card>
{/* Extension points for future roadmap domains (Eventos / Demandas): plug new cards here when those collections exist. */}
</section>
)

View file

@ -8,16 +8,11 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/
import type { NucleusUpdatesPageData } from '@/utilities/nucleusUpdatePageData'
import {
buildNucleusUpdateHref,
nucleusUpdateKindLabels,
type NucleusUpdateListState,
type NucleusUpdateViewModel,
} from '@/utilities/nucleusUpdateUi'
const kindLabels: Record<NucleusUpdateViewModel['kind'], string> = {
semanal: 'Semanal',
urgente: 'Urgente',
nota: 'Nota',
}
const roleLabels: Record<NucleusUpdateViewModel['authorRole'], string> = {
geral: 'Coordenação geral',
coordenador: 'Coordenador',
@ -26,7 +21,7 @@ const roleLabels: Record<NucleusUpdateViewModel['authorRole'], string> = {
const updateFilters: Array<{ label: string; state: NucleusUpdateListState }> = [
{ label: 'Todas', state: { page: 1 } },
...Object.entries(kindLabels).map(([kind, label]) => ({
...Object.entries(nucleusUpdateKindLabels).map(([kind, label]) => ({
label,
state: { kind: kind as NucleusUpdateListState['kind'], page: 1 },
})),
@ -45,7 +40,7 @@ const UpdateCard = ({ update, now }: { update: NucleusUpdateViewModel; now: stri
<CardHeader>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={update.kind === 'urgente' ? 'destructive' : 'secondary'}>
{kindLabels[update.kind]}
{nucleusUpdateKindLabels[update.kind]}
</Badge>
<span className="text-sm text-muted-foreground">
{relativeDate(update.createdAt, now)}

View file

@ -0,0 +1,137 @@
import type { Payload } from 'payload'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import {
buildNucleusListOverviewViewModel,
nucleusListOverviewPreviewLimit,
type NucleusListOverviewNucleusRecord,
type NucleusListOverviewUpdateRecord,
type NucleusListOverviewViewModel,
} from '@/utilities/nucleusListOverviewViewModels'
import { buildNucleusListWhere, type NucleusListState } from '@/utilities/nucleusUi'
import { requireRelationshipId } from '@/utilities/relationship'
const overviewStaffNucleusSelect = {
slug: true,
name: true,
coordinators: true,
confirmedVoteEstimate: true,
proposedVoteEstimate: true,
} as const
const overviewLeadershipNucleusSelect = {
slug: true,
name: true,
coordinators: true,
confirmedVoteEstimate: true,
} as const
const overviewUpdateSelect = {
author: true,
kind: true,
createdAt: true,
nucleus: true,
} as const
type RawOverviewNucleus = {
id: number
slug: string
name: string
coordinators?: Array<number | { id: number }>
confirmedVoteEstimate?: number | null
proposedVoteEstimate?: number | null
}
type RawOverviewUpdate = {
id: number
author: number | { id: number }
kind: NucleusUpdate['kind']
createdAt: string
nucleus: number | { id: number }
}
export const loadNucleusListOverviewData = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
state: NucleusListState,
): Promise<NucleusListOverviewViewModel | null> => {
const nucleusResult = await payload.find({
collection: 'electoralNucleus',
where: buildNucleusListWhere(state),
depth: 0,
pagination: false,
select:
user.role === 'lideranca' ? overviewLeadershipNucleusSelect : overviewStaffNucleusSelect,
user,
overrideAccess: false,
})
const rawNuclei = nucleusResult.docs as unknown as RawOverviewNucleus[]
if (rawNuclei.length === 0) return null
const nuclei: NucleusListOverviewNucleusRecord[] = rawNuclei.map((nucleus) => ({
id: nucleus.id,
slug: nucleus.slug,
name: nucleus.name,
coordinators: nucleus.coordinators ?? [],
confirmedVoteEstimate: nucleus.confirmedVoteEstimate ?? null,
proposedVoteEstimate: nucleus.proposedVoteEstimate ?? null,
}))
const nucleiById = new Map(nuclei.map((nucleus) => [nucleus.id, nucleus]))
const updateResult = await payload.find({
collection: 'nucleusUpdate',
where: { nucleus: { in: nuclei.map(({ id }) => id) } },
depth: 0,
limit: nucleusListOverviewPreviewLimit,
page: 1,
sort: '-createdAt',
select: overviewUpdateSelect,
user,
overrideAccess: false,
})
const updates = updateResult.docs as unknown as RawOverviewUpdate[]
const authorIds = [...new Set(updates.map(({ author }) => requireRelationshipId(author)))]
const authors =
user.role === 'lideranca'
? [user]
: authorIds.length === 0
? []
: (
await payload.find({
collection: 'campaignUser',
where: { id: { in: authorIds } },
depth: 0,
pagination: false,
select: { name: true, role: true },
user,
overrideAccess: false,
})
).docs
const authorsById = new Map(authors.map((author) => [author.id, author.name]))
const recentUpdates: NucleusListOverviewUpdateRecord[] = updates.flatMap((update) => {
const authorName = authorsById.get(requireRelationshipId(update.author))
const nucleus = nucleiById.get(requireRelationshipId(update.nucleus))
if (!authorName || !nucleus) return []
return [
{
id: update.id,
nucleusSlug: nucleus.slug,
nucleusName: nucleus.name,
authorName,
kind: update.kind,
createdAt: update.createdAt,
},
]
})
return buildNucleusListOverviewViewModel({
nuclei,
recentUpdates,
role: user.role,
})
}

View file

@ -0,0 +1,81 @@
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
export const nucleusListOverviewPreviewLimit = 3
export type NucleusListOverviewNucleusRecord = {
id: number
slug: string
name: string
coordinators: Array<number | { id: number }>
confirmedVoteEstimate: number | null
proposedVoteEstimate: number | null
}
export type NucleusListOverviewUpdateRecord = {
id: number
nucleusSlug: string
nucleusName: string
authorName: string
kind: NucleusUpdate['kind']
createdAt: string
}
export type NucleusListOverviewViewModel = {
totalFiltered: number
estimate: {
confirmedTotal: number
confirmedCount: number
confirmedPercent: number
unconfirmedCount: number
pendingSuggestionsCount?: number
}
coverage: {
coordinatedCount: number
percent: number
}
recentUpdates: NucleusListOverviewUpdateRecord[]
}
const percentage = (part: number, total: number): number =>
total === 0 ? 0 : Math.round((part / total) * 100)
export const buildNucleusListOverviewViewModel = ({
nuclei,
recentUpdates,
role,
}: {
nuclei: NucleusListOverviewNucleusRecord[]
recentUpdates: NucleusListOverviewUpdateRecord[]
role: CampaignUser['role']
}): NucleusListOverviewViewModel => {
const totalFiltered = nuclei.length
const confirmedCount = nuclei.filter(
({ confirmedVoteEstimate }) => confirmedVoteEstimate !== null,
).length
const coordinatedCount = nuclei.filter(({ coordinators }) => coordinators.length > 0).length
return {
totalFiltered,
estimate: {
confirmedTotal: nuclei.reduce(
(sum, nucleus) => sum + (nucleus.confirmedVoteEstimate ?? 0),
0,
),
confirmedCount,
confirmedPercent: percentage(confirmedCount, totalFiltered),
unconfirmedCount: totalFiltered - confirmedCount,
...(role === 'lideranca'
? {}
: {
pendingSuggestionsCount: nuclei.filter(
({ proposedVoteEstimate }) => proposedVoteEstimate !== null,
).length,
}),
},
coverage: {
coordinatedCount,
percent: percentage(coordinatedCount, totalFiltered),
},
recentUpdates,
}
}

View file

@ -11,6 +11,12 @@ import {
export const nucleusUpdatePageSize = 10
export const nucleusUpdateKindLabels: Record<NucleusUpdate['kind'], string> = {
semanal: 'Semanal',
urgente: 'Urgente',
nota: 'Nota',
}
export type NucleusUpdateListState = {
kind?: NucleusUpdate['kind']
page: number

View file

@ -0,0 +1,254 @@
// @vitest-environment node
import { beforeAll, describe, expect, it } from 'vitest'
import { getPayload, type Payload } from 'payload'
import config from '@/payload.config'
import { loadNucleusListOverviewData } from '@/utilities/nucleusListOverviewPageData'
import { parseNucleusListParams } from '@/utilities/nucleusUi'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('campaign nucleus list overview page data', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('aggregates the full filtered set for geral and previews recent updates', async () => {
const fixtures = campaignFixtures()
const general = await fixtures.createCampaignUser('geral', {
name: fixtures.value('Geral overview'),
email: `${fixtures.value('geral-overview')}@example.com`,
password: fixtures.value('password'),
})
const author = await fixtures.createCampaignUser('coordenador', {
name: fixtures.value('Autor overview'),
email: `${fixtures.value('autor-overview')}@example.com`,
password: fixtures.value('password'),
})
const filteredA = await fixtures.createNucleus({
name: fixtures.value('Overview A'),
region: 'Velho Chico',
city: 'Bom Jesus da Lapa',
coordinators: [author.id],
organizationKind: 'territorial',
})
const filteredB = await fixtures.createNucleus({
name: fixtures.value('Overview B'),
region: 'Velho Chico',
city: 'Bom Jesus da Lapa',
organizationKind: 'territorial',
})
const outside = await fixtures.createNucleus({
name: fixtures.value('Overview fora'),
region: 'Metropolitano de Salvador',
city: 'Salvador',
organizationKind: 'territorial',
})
await payload.update({
collection: 'electoralNucleus',
id: filteredA.id,
data: {
confirmedVoteEstimate: 1000,
proposedVoteEstimate: 1100,
},
depth: 0,
})
await payload.update({
collection: 'electoralNucleus',
id: filteredB.id,
data: {
proposedVoteEstimate: 400,
},
depth: 0,
})
await payload.update({
collection: 'electoralNucleus',
id: outside.id,
data: {
confirmedVoteEstimate: 9999,
proposedVoteEstimate: 8888,
},
depth: 0,
})
await fixtures.createNucleusUpdate({
nucleus: filteredA.id,
author: author.id,
kind: 'semanal',
worked: 'Campo',
failed: 'Nada',
needs: 'Apoio',
})
await fixtures.createNucleusUpdate({
nucleus: outside.id,
author: general.id,
kind: 'urgente',
body: 'Fora do filtro',
})
const view = await loadNucleusListOverviewData(
payload,
general,
parseNucleusListParams({ region: 'Velho Chico' }),
)
expect(view).not.toBeNull()
if (!view) throw new Error('Expected overview view model')
expect(view.totalFiltered).toBe(2)
expect(view.estimate.confirmedTotal).toBe(1000)
expect(view.estimate.confirmedCount).toBe(1)
expect(view.estimate.pendingSuggestionsCount).toBe(2)
expect(view.coverage.coordinatedCount).toBe(1)
expect(view.recentUpdates).toHaveLength(1)
expect(view.recentUpdates[0]?.nucleusSlug).toBe(filteredA.slug)
expect(view.recentUpdates[0]?.authorName).toBe(author.name)
expect(view.recentUpdates[0]?.kind).toBe('semanal')
})
it('scopes coordenador to assigned nuclei only', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordenador', {
name: fixtures.value('Coord overview'),
email: `${fixtures.value('coord-overview')}@example.com`,
password: fixtures.value('password'),
})
const otherCoordinator = await fixtures.createCampaignUser('coordenador', {
name: fixtures.value('Outro coord'),
email: `${fixtures.value('outro-coord')}@example.com`,
password: fixtures.value('password'),
})
const assigned = await fixtures.createNucleus({
name: fixtures.value('Atribuído overview'),
region: 'Metropolitano de Salvador',
city: 'Salvador',
coordinators: [coordinator.id],
organizationKind: 'territorial',
})
const other = await fixtures.createNucleus({
name: fixtures.value('Não atribuído'),
region: 'Metropolitano de Salvador',
city: 'Salvador',
coordinators: [otherCoordinator.id],
organizationKind: 'territorial',
})
await payload.update({
collection: 'electoralNucleus',
id: assigned.id,
data: { confirmedVoteEstimate: 700 },
depth: 0,
})
await payload.update({
collection: 'electoralNucleus',
id: other.id,
data: { confirmedVoteEstimate: 5000, proposedVoteEstimate: 6000 },
depth: 0,
})
const view = await loadNucleusListOverviewData(payload, coordinator, parseNucleusListParams({}))
expect(view).not.toBeNull()
if (!view) throw new Error('Expected overview view model')
expect(view.totalFiltered).toBe(1)
expect(view.estimate.confirmedTotal).toBe(700)
expect(view.estimate.pendingSuggestionsCount).toBe(0)
})
it('shows lideranca only own updates and omits pending suggestions', async () => {
const fixtures = campaignFixtures()
const general = await fixtures.createCampaignUser('geral', {
name: fixtures.value('Geral lideranca overview'),
email: `${fixtures.value('geral-lideranca-overview')}@example.com`,
password: fixtures.value('password'),
})
const leader = await fixtures.createCampaignUser('lideranca', {
name: fixtures.value('Liderança overview'),
})
const staffAuthor = await fixtures.createCampaignUser('coordenador', {
name: fixtures.value('Staff autor'),
email: `${fixtures.value('staff-autor')}@example.com`,
password: fixtures.value('password'),
})
const nucleus = await fixtures.createNucleus({
name: fixtures.value('Núcleo liderança'),
region: 'Metropolitano de Salvador',
city: 'Salvador',
coordinators: [staffAuthor.id],
organizationKind: 'territorial',
})
const contact = await fixtures.createContact({
name: fixtures.value('Contato liderança'),
})
await fixtures.createLeadership({
contact,
nucleus: nucleus.id,
user: leader.id,
supportStatus: 'engajado',
createdBy: general.id,
})
await payload.update({
collection: 'electoralNucleus',
id: nucleus.id,
data: {
confirmedVoteEstimate: 450,
proposedVoteEstimate: 500,
},
depth: 0,
})
await fixtures.createNucleusUpdate({
nucleus: nucleus.id,
author: leader.id,
kind: 'nota',
body: 'Meu reporte',
})
await fixtures.createNucleusUpdate({
nucleus: nucleus.id,
author: staffAuthor.id,
kind: 'urgente',
body: 'Reporte do staff',
})
const view = await loadNucleusListOverviewData(payload, leader, parseNucleusListParams({}))
expect(view).not.toBeNull()
if (!view) throw new Error('Expected overview view model')
expect(view.totalFiltered).toBe(1)
expect(view.estimate.confirmedTotal).toBe(450)
expect(view.estimate).not.toHaveProperty('pendingSuggestionsCount')
expect(view.recentUpdates).toHaveLength(1)
expect(view.recentUpdates[0]?.authorName).toBe(leader.name)
expect(view.recentUpdates[0]?.kind).toBe('nota')
})
it('returns null when the filtered set is empty', async () => {
const fixtures = campaignFixtures()
const general = await fixtures.createCampaignUser('geral', {
name: fixtures.value('Geral vazio'),
email: `${fixtures.value('geral-vazio')}@example.com`,
password: fixtures.value('password'),
})
const view = await loadNucleusListOverviewData(
payload,
general,
parseNucleusListParams({ q: fixtures.value('nucleo-inexistente-overview') }),
)
expect(view).toBeNull()
})
})

View file

@ -0,0 +1,126 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { NucleusListOverview } from '@/components/campaign/NucleusListOverview'
import { buildNucleusListOverviewViewModel } from '@/utilities/nucleusListOverviewViewModels'
const now = new Date('2026-07-18T15:00:00.000Z')
const nucleus = ({
id,
coordinators = [],
confirmedVoteEstimate = null,
proposedVoteEstimate = null,
}: {
id: number
coordinators?: number[]
confirmedVoteEstimate?: number | null
proposedVoteEstimate?: number | null
}) => ({
id,
slug: `nucleo-${id}`,
name: `Núcleo ${id}`,
coordinators,
confirmedVoteEstimate,
proposedVoteEstimate,
})
describe('buildNucleusListOverviewViewModel', () => {
it('aggregates confirmed estimates, coverage, and pending suggestions for staff', () => {
const view = buildNucleusListOverviewViewModel({
role: 'geral',
nuclei: [
nucleus({ id: 1, coordinators: [10], confirmedVoteEstimate: 1000 }),
nucleus({
id: 2,
coordinators: [11],
confirmedVoteEstimate: 500,
proposedVoteEstimate: 600,
}),
nucleus({ id: 3, proposedVoteEstimate: 200 }),
nucleus({ id: 4 }),
],
recentUpdates: [],
})
expect(view.totalFiltered).toBe(4)
expect(view.estimate).toEqual({
confirmedTotal: 1500,
confirmedCount: 2,
confirmedPercent: 50,
unconfirmedCount: 2,
pendingSuggestionsCount: 2,
})
expect(view.coverage).toEqual({
coordinatedCount: 2,
percent: 50,
})
})
it('omits pending suggestions for lideranca', () => {
const view = buildNucleusListOverviewViewModel({
role: 'lideranca',
nuclei: [
nucleus({ id: 1, coordinators: [10], confirmedVoteEstimate: 800 }),
nucleus({ id: 2, proposedVoteEstimate: 400 }),
],
recentUpdates: [],
})
expect(view.estimate).not.toHaveProperty('pendingSuggestionsCount')
expect(view.estimate.confirmedTotal).toBe(800)
expect(view.estimate.confirmedPercent).toBe(50)
})
})
describe('NucleusListOverview', () => {
it('renders estimate, coverage, and update preview without a suggestions badge for lideranca', () => {
const view = buildNucleusListOverviewViewModel({
role: 'lideranca',
nuclei: [
nucleus({ id: 1, coordinators: [3], confirmedVoteEstimate: 1200 }),
nucleus({ id: 2 }),
],
recentUpdates: [
{
id: 21,
nucleusSlug: 'nucleo-1',
nucleusName: 'Núcleo 1',
authorName: 'Maria',
kind: 'semanal',
createdAt: '2026-07-17T12:00:00.000Z',
},
],
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
expect(html).toContain('Mostrando agregados de')
expect(html).toContain('1.200')
expect(html).toContain('50% com estimativa confirmada')
expect(html).toContain('1 de 2 com coordenador')
expect(html).toContain('Últimas atualizações')
expect(html).toContain('Maria')
expect(html).toContain('Semanal')
expect(html).toContain('/campanha/nucleos/nucleo-1?tab=updates')
expect(html).not.toContain('sugestão pendente')
expect(html).not.toContain('sugestões pendentes')
})
it('renders the pending suggestions badge for staff when proposals exist', () => {
const view = buildNucleusListOverviewViewModel({
role: 'geral',
nuclei: [
nucleus({ id: 1, proposedVoteEstimate: 300 }),
nucleus({ id: 2, proposedVoteEstimate: 400 }),
],
recentUpdates: [],
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
expect(html).toContain('2 sugestões pendentes')
expect(html).toContain('Nenhuma atualização recente')
})
})

View file

@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
getCampaignUser: vi.fn(),
getPayload: vi.fn(),
loadNucleusListPageData: vi.fn(),
loadNucleusListOverviewData: vi.fn(),
redirect: vi.fn((href: string) => {
throw new Error(`redirect:${href}`)
}),
@ -18,6 +19,9 @@ vi.mock('@/utilities/campaignAuth', () => ({
vi.mock('@/utilities/nucleusPageData', () => ({
loadNucleusListPageData: mocks.loadNucleusListPageData,
}))
vi.mock('@/utilities/nucleusListOverviewPageData', () => ({
loadNucleusListOverviewData: mocks.loadNucleusListOverviewData,
}))
import NucleiPage from '@/app/(campaign)/campanha/(app)/nucleos/page'
@ -26,6 +30,7 @@ describe('campaign nucleus server page canonical URL', () => {
vi.clearAllMocks()
mocks.getCampaignUser.mockResolvedValue({ id: 9, role: 'coordenador' })
mocks.getPayload.mockResolvedValue({})
mocks.loadNucleusListOverviewData.mockResolvedValue(null)
})
it('redirects a noncanonical query before loading Payload data', async () => {
@ -37,9 +42,7 @@ describe('campaign nucleus server page canonical URL', () => {
obsoleteFilter: 'legacy',
}),
}),
).rejects.toThrow(
'redirect:/campanha/nucleos?region=Chapada+Diamantina&city=Mucug%C3%AA',
)
).rejects.toThrow('redirect:/campanha/nucleos?region=Chapada+Diamantina&city=Mucug%C3%AA')
expect(mocks.getCampaignUser).not.toHaveBeenCalled()
expect(mocks.getPayload).not.toHaveBeenCalled()