Card "Onde estou" no dashboard staff: point-in-polygon contra a malha municipal que o painel do mapa ja carrega, sem artefato de centroides, sem migration, sem collection, sem server action e sem Consent -- a posicao e lida por navigator.geolocation e casada no browser, nunca sai do aparelho. - src/lib/municipalityProximity.ts: haversine, ray casting, centroide ponderado por area e uniao discriminada (inScope | zoneCity | outOfScope | outsideBahia) - src/utilities/campaignGeolocation.ts: flag de sessao, leitura guardada de permissao e requestCurrentPosition com falha tipada - Salvador nao ganha ZE chutada: link para a lista filtrada, com href serializado no servidor (importar o serializador canonico no cliente custava 21 kB de First Load JS) - fallback fora da carteira ordenado por centroide, teto de 150 km - fix grosseiro (>10 km) mostra o resultado com ressalva de precisao /campanha First Load JS: 265 -> 268 kB.
207 lines
7.4 KiB
TypeScript
207 lines
7.4 KiB
TypeScript
import 'server-only'
|
||
|
||
import type { Payload } from 'payload'
|
||
|
||
import type { AccessibleMunicipality } from '@/lib/municipalityProximity'
|
||
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
|
||
import type { CampaignUser, VotePledge } from '@/payload-types'
|
||
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
|
||
import {
|
||
type DashboardPriorityMunicipality,
|
||
pickDashboardPriorityMunicipalities,
|
||
} from '@/utilities/dashboardPriorityMunicipalities'
|
||
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
|
||
import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipalityGoalAccount'
|
||
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
|
||
import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeViews'
|
||
|
||
export type StaffDashboardView = {
|
||
kind: 'staff'
|
||
role: 'coordinator' | 'advisor' | 'candidate'
|
||
municipalityCount: number
|
||
withAdvisorCount: number
|
||
highPriorityCount: number
|
||
staffVoteTotalByScenario: Record<VoteEstimateScenario, number>
|
||
declaredVotesTotal: number
|
||
pledgeCount: number
|
||
missingEstimateCount: number
|
||
/** E8 "conta da cadeira" — meta × comprometido, cenário central (dashboard não tem seletor de cenário). */
|
||
goalCoverage: MunicipalityGoalCoverage
|
||
missingEstimates: Array<{
|
||
pledgeID: number
|
||
municipalityName: string
|
||
municipalitySlug: string
|
||
contactName: string
|
||
declaredVotes: number
|
||
}>
|
||
priorityMunicipalities: DashboardPriorityMunicipality[]
|
||
/**
|
||
* B14 — every município the actor can open, for the geo shortcut to match a
|
||
* browser position against without sending the position anywhere. Minimal by
|
||
* design: three short strings per row, so the whole catalog stays a few KB of
|
||
* RSC payload.
|
||
*/
|
||
accessibleMunicipalities: AccessibleMunicipality[]
|
||
recentUpdates: Array<{
|
||
id: number
|
||
municipalityName: string
|
||
municipalitySlug: string
|
||
authorName: string
|
||
createdAt: string
|
||
}>
|
||
}
|
||
|
||
const resolveNames = async (
|
||
payload: Payload,
|
||
collection: 'municipality' | 'campaignUser' | 'leadership',
|
||
ids: number[],
|
||
select: Record<string, true>,
|
||
) => {
|
||
if (ids.length === 0) return new Map<number, Record<string, unknown>>()
|
||
const result = await payload.find({
|
||
collection,
|
||
where: { id: { in: ids } },
|
||
depth: collection === 'leadership' ? 1 : 0,
|
||
limit: 0,
|
||
pagination: false,
|
||
select,
|
||
overrideAccess: true,
|
||
})
|
||
return new Map(result.docs.map((doc) => [doc.id, doc as unknown as Record<string, unknown>]))
|
||
}
|
||
|
||
/**
|
||
* Staff dashboard data. Leaders never reach the dashboard — they are routed to
|
||
* the supporter contact tool before this is called (see `/campanha` page).
|
||
*/
|
||
export const getCampaignDashboardData = async (
|
||
payload: Payload,
|
||
user: CampaignUser,
|
||
): Promise<StaffDashboardView> => {
|
||
// Request-scoped shared load — the map bundle on the same page reuses it.
|
||
const scope = await loadMunicipalityScope(payload, user, {})
|
||
const municipalities = { docs: scope.municipalities }
|
||
const pledgeAggregates = scope.pledgeAggregates
|
||
const municipalityIDs = municipalities.docs.map((municipality) => municipality.id)
|
||
const municipalityById = new Map(
|
||
municipalities.docs.map((municipality) => [municipality.id, municipality]),
|
||
)
|
||
|
||
// Independent of each other — only depend on municipalityIDs.
|
||
const [missingEstimatePledges, recentUpdatesResult] = await Promise.all([
|
||
municipalityIDs.length
|
||
? payload.find({
|
||
collection: 'votePledge',
|
||
where: {
|
||
and: [
|
||
{ municipality: { in: municipalityIDs } },
|
||
{ 'estimatedVotes.pessimistic': { equals: null } },
|
||
{ 'estimatedVotes.central': { equals: null } },
|
||
{ 'estimatedVotes.optimistic': { equals: null } },
|
||
],
|
||
},
|
||
depth: 0,
|
||
limit: 5,
|
||
pagination: false,
|
||
sort: '-declaredVotes',
|
||
select: { municipality: true, leadership: true, declaredVotes: true },
|
||
overrideAccess: true,
|
||
})
|
||
: Promise.resolve({ docs: [] as VotePledge[] }),
|
||
municipalityIDs.length
|
||
? payload.find({
|
||
collection: 'municipalityUpdate',
|
||
where: { municipality: { in: municipalityIDs } },
|
||
depth: 0,
|
||
limit: 3,
|
||
pagination: false,
|
||
sort: '-createdAt',
|
||
select: { municipality: true, author: true, createdAt: true },
|
||
user,
|
||
overrideAccess: false,
|
||
})
|
||
: Promise.resolve({ docs: [] }),
|
||
])
|
||
|
||
const rollup = rollupMunicipalityStaffVotes(municipalities.docs, pledgeAggregates)
|
||
const { staffVoteTotalByScenario, declaredVotesTotal, pledgeCount, missingEstimateCount } = rollup
|
||
|
||
const goalCoverageBundle = await loadMunicipalityGoalCoverageBundle(
|
||
payload,
|
||
user,
|
||
municipalities.docs,
|
||
pledgeAggregates,
|
||
)
|
||
|
||
const leadershipIDs = [
|
||
...new Set(
|
||
missingEstimatePledges.docs.map((pledge) => requireRelationshipId(pledge.leadership)),
|
||
),
|
||
]
|
||
const authorIDs = [
|
||
...new Set(
|
||
recentUpdatesResult.docs
|
||
.map((update) => relationshipId(update.author))
|
||
.filter((id): id is number => id !== null),
|
||
),
|
||
]
|
||
|
||
const [leadershipsById, authorsById] = await Promise.all([
|
||
resolveNames(payload, 'leadership', leadershipIDs, { contact: true }),
|
||
resolveNames(payload, 'campaignUser', authorIDs, { name: true }),
|
||
])
|
||
|
||
const dashboardPriority = pickDashboardPriorityMunicipalities(
|
||
municipalities.docs,
|
||
goalCoverageBundle.coverageByMunicipalityID,
|
||
)
|
||
|
||
const accessibleMunicipalities: AccessibleMunicipality[] = municipalities.docs.map(
|
||
({ slug, name, ibgeCode }) => ({ slug, name, ibgeCode }),
|
||
)
|
||
|
||
return {
|
||
kind: 'staff',
|
||
role:
|
||
user.role === 'advisor' ? 'advisor' : user.role === 'candidate' ? 'candidate' : 'coordinator',
|
||
municipalityCount: municipalities.docs.length,
|
||
withAdvisorCount: municipalities.docs.filter(
|
||
(municipality) => (municipality.advisors ?? []).length > 0,
|
||
).length,
|
||
staffVoteTotalByScenario: { ...staffVoteTotalByScenario },
|
||
declaredVotesTotal,
|
||
pledgeCount,
|
||
missingEstimateCount,
|
||
goalCoverage: goalCoverageBundle.aggregateByScenario.central,
|
||
missingEstimates: missingEstimatePledges.docs.map((pledge) => {
|
||
const municipality = municipalityById.get(requireRelationshipId(pledge.municipality))
|
||
const leadership = leadershipsById.get(requireRelationshipId(pledge.leadership))
|
||
const contact = leadership?.contact
|
||
const contactName =
|
||
typeof contact === 'object' && contact !== null && 'name' in contact
|
||
? String((contact as { name?: string }).name ?? 'Contato')
|
||
: 'Contato'
|
||
return {
|
||
pledgeID: pledge.id,
|
||
municipalityName: municipality?.name ?? 'Município',
|
||
municipalitySlug: municipality?.slug ?? '',
|
||
contactName,
|
||
declaredVotes: pledge.declaredVotes,
|
||
}
|
||
}),
|
||
highPriorityCount: dashboardPriority.highPriorityCount,
|
||
priorityMunicipalities: dashboardPriority.municipalities,
|
||
accessibleMunicipalities,
|
||
recentUpdates: recentUpdatesResult.docs.map((update) => {
|
||
const municipality = municipalityById.get(relationshipId(update.municipality) ?? -1)
|
||
const author = authorsById.get(relationshipId(update.author) ?? -1)
|
||
return {
|
||
id: update.id,
|
||
municipalityName: municipality?.name ?? 'Município',
|
||
municipalitySlug: municipality?.slug ?? '',
|
||
authorName: String(author?.name ?? 'Usuário'),
|
||
createdAt: update.createdAt,
|
||
}
|
||
}),
|
||
}
|
||
}
|