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:
parent
b483e0cb32
commit
040c36bed7
9 changed files with 748 additions and 12 deletions
|
|
@ -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),
|
||||
|
|
|
|||
127
src/components/campaign/NucleusListOverview.tsx
Normal file
127
src/components/campaign/NucleusListOverview.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
137
src/utilities/nucleusListOverviewPageData.ts
Normal file
137
src/utilities/nucleusListOverviewPageData.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
81
src/utilities/nucleusListOverviewViewModels.ts
Normal file
81
src/utilities/nucleusListOverviewViewModels.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
254
tests/int/campaignNucleusListOverview.int.spec.ts
Normal file
254
tests/int/campaignNucleusListOverview.int.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
126
tests/unit/campaignNucleusListOverview.unit.spec.ts
Normal file
126
tests/unit/campaignNucleusListOverview.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue