Adds electoral nuclei, leaderships, updates, and WhatsApp invite flows behind campaign auth, plus the consolidated schema migration that Vercel applies on deploy.
250 lines
7 KiB
TypeScript
250 lines
7 KiB
TypeScript
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
|
|
import type { SupportStatus } from '@/lib/schemas/leadership'
|
|
|
|
export type CoordinatorSummary = {
|
|
id: number
|
|
name: string
|
|
phone: string | null
|
|
}
|
|
|
|
export type DashboardNucleusRecord = {
|
|
id: number
|
|
name: string
|
|
slug: string
|
|
region: ElectoralNucleus['region']
|
|
city: string | null
|
|
neighborhood: string | null
|
|
locality: string | null
|
|
organizationKind: ElectoralNucleus['organizationKind']
|
|
organizationLabel: string | null
|
|
coordinators: CoordinatorSummary[]
|
|
tseZones: number[]
|
|
confirmedVoteEstimate: number | null
|
|
proposedVoteEstimate: number | null
|
|
lastUpdateAt: string | null
|
|
}
|
|
|
|
export type DashboardLeadershipRecord = {
|
|
nucleusId: number
|
|
supportStatus: SupportStatus
|
|
}
|
|
|
|
export type DashboardQueueItem = {
|
|
id: number
|
|
slug: string
|
|
name: string
|
|
territory: string
|
|
tseZones: number[]
|
|
confirmedVoteEstimate: number | null
|
|
proposedVoteEstimate: number | null
|
|
lastUpdateAt: string | null
|
|
}
|
|
|
|
export type GeneralDashboardViewModel = {
|
|
role: 'geral'
|
|
kpis: {
|
|
activeNuclei: number
|
|
coordinatorCoveragePercent: number
|
|
confirmedVoteEstimateTotal: number
|
|
confirmedEstimatePercent: number
|
|
updatesThisWeek: number
|
|
}
|
|
supportCounts: Record<SupportStatus, number>
|
|
queues: {
|
|
withoutCoordinator: DashboardQueueItem[]
|
|
withoutRecentUpdate: DashboardQueueItem[]
|
|
pendingEstimate: DashboardQueueItem[]
|
|
}
|
|
}
|
|
|
|
export type CoordinatorDashboardCard = {
|
|
id: number
|
|
slug: string
|
|
name: string
|
|
territory: string
|
|
organization: string
|
|
tseZones: number[]
|
|
confirmedVoteEstimate: number | null
|
|
hasPendingEstimate: boolean
|
|
lastUpdateAt: string | null
|
|
isUpdateOverdue: boolean
|
|
leadershipCounts: {
|
|
engaged: number
|
|
toApproach: number
|
|
disputed: number
|
|
}
|
|
}
|
|
|
|
export type LeadershipDashboardCard = {
|
|
id: number
|
|
slug: string
|
|
name: string
|
|
territory: string
|
|
organization: string
|
|
tseZones: number[]
|
|
confirmedVoteEstimate: number | null
|
|
coordinators: Array<{
|
|
name: string
|
|
phone: string | null
|
|
}>
|
|
}
|
|
|
|
export type ScopedDashboardViewModel =
|
|
| {
|
|
role: 'coordenador'
|
|
cards: CoordinatorDashboardCard[]
|
|
}
|
|
| {
|
|
role: 'lideranca'
|
|
cards: LeadershipDashboardCard[]
|
|
}
|
|
|
|
const percentage = (part: number, total: number): number =>
|
|
total === 0 ? 0 : Math.round((part / total) * 100)
|
|
|
|
const isOverdue = (lastUpdateAt: string | null, now: Date): boolean =>
|
|
!lastUpdateAt || now.getTime() - new Date(lastUpdateAt).getTime() > 7 * 24 * 60 * 60 * 1000
|
|
|
|
const territoryLabel = ({ neighborhood, locality, city, region }: DashboardNucleusRecord): string =>
|
|
[neighborhood, locality, city, region].filter(Boolean).join(' · ') || 'Território não informado'
|
|
|
|
const organizationLabel = ({
|
|
organizationKind,
|
|
organizationLabel: customLabel,
|
|
}: DashboardNucleusRecord): string => customLabel ?? organizationKind
|
|
|
|
const toQueueItem = (nucleus: DashboardNucleusRecord): DashboardQueueItem => ({
|
|
id: nucleus.id,
|
|
slug: nucleus.slug,
|
|
name: nucleus.name,
|
|
territory: territoryLabel(nucleus),
|
|
tseZones: nucleus.tseZones,
|
|
confirmedVoteEstimate: nucleus.confirmedVoteEstimate,
|
|
proposedVoteEstimate: nucleus.proposedVoteEstimate,
|
|
lastUpdateAt: nucleus.lastUpdateAt,
|
|
})
|
|
|
|
const aggregateLeaderships = (leaderships: DashboardLeadershipRecord[]) => {
|
|
const supportCounts: GeneralDashboardViewModel['supportCounts'] = {
|
|
engajado: 0,
|
|
a_abordar: 0,
|
|
em_disputa: 0,
|
|
negativo: 0,
|
|
}
|
|
const countsByNucleus = new Map<number, CoordinatorDashboardCard['leadershipCounts']>()
|
|
const nucleusCountKeys: Record<
|
|
SupportStatus,
|
|
keyof CoordinatorDashboardCard['leadershipCounts'] | null
|
|
> = {
|
|
engajado: 'engaged',
|
|
a_abordar: 'toApproach',
|
|
em_disputa: 'disputed',
|
|
negativo: null,
|
|
}
|
|
|
|
for (const leadership of leaderships) {
|
|
supportCounts[leadership.supportStatus] += 1
|
|
const counts = countsByNucleus.get(leadership.nucleusId) ?? {
|
|
engaged: 0,
|
|
toApproach: 0,
|
|
disputed: 0,
|
|
}
|
|
const countKey = nucleusCountKeys[leadership.supportStatus]
|
|
if (countKey !== null) counts[countKey] += 1
|
|
countsByNucleus.set(leadership.nucleusId, counts)
|
|
}
|
|
|
|
return { supportCounts, countsByNucleus }
|
|
}
|
|
|
|
const coordinatorContacts = (
|
|
coordinators: DashboardNucleusRecord['coordinators'],
|
|
): LeadershipDashboardCard['coordinators'] =>
|
|
coordinators.map(({ name, phone }) => ({ name, phone }))
|
|
|
|
export const buildGeneralDashboardViewModel = (
|
|
nuclei: DashboardNucleusRecord[],
|
|
leaderships: DashboardLeadershipRecord[],
|
|
updatesThisWeek: number,
|
|
now: Date,
|
|
): GeneralDashboardViewModel => {
|
|
const coordinatedCount = nuclei.filter(({ coordinators }) => coordinators.length > 0).length
|
|
const confirmedCount = nuclei.filter(
|
|
({ confirmedVoteEstimate }) => confirmedVoteEstimate !== null,
|
|
).length
|
|
const { supportCounts } = aggregateLeaderships(leaderships)
|
|
|
|
return {
|
|
role: 'geral',
|
|
kpis: {
|
|
activeNuclei: nuclei.length,
|
|
coordinatorCoveragePercent: percentage(coordinatedCount, nuclei.length),
|
|
confirmedVoteEstimateTotal: nuclei.reduce(
|
|
(sum, nucleus) => sum + (nucleus.confirmedVoteEstimate ?? 0),
|
|
0,
|
|
),
|
|
confirmedEstimatePercent: percentage(confirmedCount, nuclei.length),
|
|
updatesThisWeek,
|
|
},
|
|
supportCounts,
|
|
queues: {
|
|
withoutCoordinator: nuclei
|
|
.filter(({ coordinators }) => coordinators.length === 0)
|
|
.map(toQueueItem),
|
|
withoutRecentUpdate: nuclei
|
|
.filter(({ lastUpdateAt }) => isOverdue(lastUpdateAt, now))
|
|
.map(toQueueItem),
|
|
pendingEstimate: nuclei
|
|
.filter(
|
|
({ confirmedVoteEstimate, proposedVoteEstimate }) =>
|
|
confirmedVoteEstimate === null || proposedVoteEstimate !== null,
|
|
)
|
|
.map(toQueueItem),
|
|
},
|
|
}
|
|
}
|
|
|
|
export const buildScopedDashboardViewModel = (
|
|
role: Exclude<CampaignUser['role'], 'geral'>,
|
|
nuclei: DashboardNucleusRecord[],
|
|
leaderships: DashboardLeadershipRecord[],
|
|
now: Date,
|
|
): ScopedDashboardViewModel => {
|
|
if (role === 'lideranca') {
|
|
return {
|
|
role,
|
|
cards: nuclei.map((nucleus) => ({
|
|
id: nucleus.id,
|
|
slug: nucleus.slug,
|
|
name: nucleus.name,
|
|
territory: territoryLabel(nucleus),
|
|
organization: organizationLabel(nucleus),
|
|
tseZones: nucleus.tseZones,
|
|
confirmedVoteEstimate: nucleus.confirmedVoteEstimate,
|
|
coordinators: coordinatorContacts(nucleus.coordinators),
|
|
})),
|
|
}
|
|
}
|
|
const { countsByNucleus } = aggregateLeaderships(leaderships)
|
|
|
|
return {
|
|
role,
|
|
cards: nuclei.map((nucleus) => ({
|
|
id: nucleus.id,
|
|
slug: nucleus.slug,
|
|
name: nucleus.name,
|
|
territory: territoryLabel(nucleus),
|
|
organization: organizationLabel(nucleus),
|
|
tseZones: nucleus.tseZones,
|
|
confirmedVoteEstimate: nucleus.confirmedVoteEstimate,
|
|
hasPendingEstimate: nucleus.proposedVoteEstimate !== null,
|
|
lastUpdateAt: nucleus.lastUpdateAt,
|
|
isUpdateOverdue: isOverdue(nucleus.lastUpdateAt, now),
|
|
leadershipCounts: countsByNucleus.get(nucleus.id) ?? {
|
|
engaged: 0,
|
|
toApproach: 0,
|
|
disputed: 0,
|
|
},
|
|
})),
|
|
}
|
|
}
|