diff --git a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx index 47c9619f..dfa3cb76 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx @@ -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 ? : null} toNucleusListViewModel(nucleus as ElectoralNucleus), diff --git a/src/components/campaign/NucleusListOverview.tsx b/src/components/campaign/NucleusListOverview.tsx new file mode 100644 index 00000000..c80dcc73 --- /dev/null +++ b/src/components/campaign/NucleusListOverview.tsx @@ -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 +}) => ( +
+
+

+ Visão geral dos núcleos filtrados +

+

+ Mostrando agregados de{' '} + + {numberFormatter.format(view.totalFiltered)} + {' '} + {view.totalFiltered === 1 ? 'núcleo filtrado' : 'núcleos filtrados'} +

+
+ +
+ + + Estimativa de votos + + {numberFormatter.format(view.estimate.confirmedTotal)} + + + +
+
+ {view.estimate.confirmedPercent}% com estimativa confirmada + + {numberFormatter.format(view.estimate.confirmedCount)} confirmadas ·{' '} + {numberFormatter.format(view.estimate.unconfirmedCount)} pendentes + +
+ +
+ {view.estimate.pendingSuggestionsCount ? ( + + {numberFormatter.format(view.estimate.pendingSuggestionsCount)}{' '} + {view.estimate.pendingSuggestionsCount === 1 + ? 'sugestão pendente' + : 'sugestões pendentes'} + + ) : null} +
+
+ + + + Cobertura + {view.coverage.percent}% + + + +

+ {numberFormatter.format(view.coverage.coordinatedCount)} de{' '} + {numberFormatter.format(view.totalFiltered)} com coordenador +

+
+
+
+ + + + Últimas atualizações + + + {view.recentUpdates.length ? ( +
    + {view.recentUpdates.map((update) => ( +
  • +
    + {update.authorName} + + {nucleusUpdateKindLabels[update.kind]} + + + {relativeDateLabel(update.createdAt, now)} + +
    + + {update.nucleusName} + +
  • + ))} +
+ ) : ( +

Nenhuma atualização recente

+ )} +
+
+ + {/* Extension points for future roadmap domains (Eventos / Demandas): plug new cards here when those collections exist. */} +
+) diff --git a/src/components/campaign/NucleusUpdateFeed.tsx b/src/components/campaign/NucleusUpdateFeed.tsx index 3af42ed0..f435b23b 100644 --- a/src/components/campaign/NucleusUpdateFeed.tsx +++ b/src/components/campaign/NucleusUpdateFeed.tsx @@ -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 = { - semanal: 'Semanal', - urgente: 'Urgente', - nota: 'Nota', -} - const roleLabels: Record = { geral: 'Coordenação geral', coordenador: 'Coordenador', @@ -26,7 +21,7 @@ const roleLabels: Record = { 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
- {kindLabels[update.kind]} + {nucleusUpdateKindLabels[update.kind]} {relativeDate(update.createdAt, now)} diff --git a/src/utilities/nucleusListOverviewPageData.ts b/src/utilities/nucleusListOverviewPageData.ts new file mode 100644 index 00000000..088a382b --- /dev/null +++ b/src/utilities/nucleusListOverviewPageData.ts @@ -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 + 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, + user: CampaignUser, + state: NucleusListState, +): Promise => { + 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, + }) +} diff --git a/src/utilities/nucleusListOverviewViewModels.ts b/src/utilities/nucleusListOverviewViewModels.ts new file mode 100644 index 00000000..3d6d792e --- /dev/null +++ b/src/utilities/nucleusListOverviewViewModels.ts @@ -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 + 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, + } +} diff --git a/src/utilities/nucleusUpdateUi.ts b/src/utilities/nucleusUpdateUi.ts index 4f70ca8e..74b0e07f 100644 --- a/src/utilities/nucleusUpdateUi.ts +++ b/src/utilities/nucleusUpdateUi.ts @@ -11,6 +11,12 @@ import { export const nucleusUpdatePageSize = 10 +export const nucleusUpdateKindLabels: Record = { + semanal: 'Semanal', + urgente: 'Urgente', + nota: 'Nota', +} + export type NucleusUpdateListState = { kind?: NucleusUpdate['kind'] page: number diff --git a/tests/int/campaignNucleusListOverview.int.spec.ts b/tests/int/campaignNucleusListOverview.int.spec.ts new file mode 100644 index 00000000..d3fe167d --- /dev/null +++ b/tests/int/campaignNucleusListOverview.int.spec.ts @@ -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() + }) +}) diff --git a/tests/unit/campaignNucleusListOverview.unit.spec.ts b/tests/unit/campaignNucleusListOverview.unit.spec.ts new file mode 100644 index 00000000..658f5520 --- /dev/null +++ b/tests/unit/campaignNucleusListOverview.unit.spec.ts @@ -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') + }) +}) diff --git a/tests/unit/campaignNucleusPage.unit.spec.ts b/tests/unit/campaignNucleusPage.unit.spec.ts index 5fc6ea56..e8c81512 100644 --- a/tests/unit/campaignNucleusPage.unit.spec.ts +++ b/tests/unit/campaignNucleusPage.unit.spec.ts @@ -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()