teqo/src/utilities/municipalityViewModels.ts
Francisco Ignácio Fontoura Solla 0ef4ddac1d E10 /simplify: remove superficie morta, unifica o parser de enum repetivel e reusa o ranking A11
- campoVotes (sem leitor) e classification.fieldHeadroom (duplicava o fator field) removidos
- formatTerritorialClassWhy passa a ser mesmo a fonte unica do slice(0,2) e do fallback
  "sem serie", eliminando o sentinela de string vazia nas duas superficies de detalhe
- classe territorial vira um so componente com layout table|card, como os vizinhos
- parseExhaustiveEnumParam em campaignListUrl cobre trend e class (o includes era morto:
  allParamValues ja deduplica)
- bloco central caminha o computeVoteRankByYear memoizado do A11 em vez de reordenar
  os 435 slugs por conta propria
- comentarios corrigidos onde afirmavam o contrario do codigo (CampaignCellTooltip e a
  regra do sem_base por superficie)
2026-07-25 22:33:16 -03:00

262 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'server-only'
import type { Payload } from 'payload'
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
import {
toVoteEstimateScenarioViewModel,
type VoteEstimateScenario,
type VoteEstimateScenarioViewModel,
} from '@/lib/voteEstimate'
import type { CampaignUser, Municipality } from '@/payload-types'
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
import {
createEmptyGoalCoverageByScenario,
type MunicipalityGoalCoverage,
} from '@/utilities/goalCoverage'
import { type PoliticalTrendStatus } from '@/utilities/municipalityLabels'
import { resolveMunicipalityLastSignalAt } from '@/utilities/municipalitySignal'
import {
computeMunicipalityTerritorialClass,
type MunicipalityTerritorialClass,
type TerritorialFactor,
} from '@/utilities/municipalityTerritorialClass'
import { relationshipId } from '@/utilities/relationship'
import type { StateDeputySummary } from '@/utilities/stateDeputyData'
import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
export const municipalityListSelect = {
name: true,
slug: true,
kind: true,
city: true,
region: true,
ibgeCode: true,
zoneNumber: true,
advisors: true,
priority: true,
lastUpdateAt: true,
expectedVotes: true,
politicalTrend: {
status: true,
note: true,
},
} as const
export type MunicipalityPoliticalTrendViewModel = {
status: PoliticalTrendStatus | null
note: string | null
recordedByName: string | null
recordedAt: string | null
}
export type MunicipalityListViewModel = {
id: number
name: string
slug: string
kind: Municipality['kind']
city: string
region: string
ibgeCode: string
zoneNumber: number | null
advisorIDs: number[]
priority: 'alta' | 'normal'
lastUpdateAt: string | null
/**
* E9 frescor — `max(lastUpdateAt, pledges.lastPledgeAt)`, resolved once on
* the server so the "há N dias" in the cell always matches the `frescor`
* ordering.
*/
lastSignalAt: string | null
expectedVotes: VoteEstimateScenarioViewModel
politicalTrendStatus: PoliticalTrendStatus | null
politicalTrendNote: string | null
pledges: MunicipalityPledgeAggregate
votePosition2022: MunicipalityVoteRankEntry | null
/** E10 — classe operacional derivada do artefato TSE. */
territorialClass: MunicipalityTerritorialClass
/** Os fatores que produziram a classe: a célula nunca mostra o rótulo sozinho. */
territorialClassFactors: TerritorialFactor[]
/** E8 "conta da cadeira" — meta × comprometido por cenário; null fora da staff view. */
goalCoverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
}
export const toMunicipalityListViewModel = (
municipality: Municipality,
pledges: MunicipalityPledgeAggregate | undefined,
votePosition2022: MunicipalityListViewModel['votePosition2022'],
goalCoverageByScenario?: Record<VoteEstimateScenario, MunicipalityGoalCoverage>,
): MunicipalityListViewModel => {
const territorialClass = computeMunicipalityTerritorialClass(municipality.slug)
return {
id: municipality.id,
name: municipality.name,
slug: municipality.slug,
kind: municipality.kind,
city: municipality.city,
region: municipality.region,
ibgeCode: municipality.ibgeCode,
zoneNumber: municipality.zoneNumber ?? null,
advisorIDs: (municipality.advisors ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),
priority: municipality.priority === 'alta' ? 'alta' : 'normal',
lastUpdateAt: municipality.lastUpdateAt ?? null,
lastSignalAt: resolveMunicipalityLastSignalAt(
municipality.lastUpdateAt ?? null,
pledges?.lastPledgeAt ?? null,
),
expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes),
politicalTrendStatus: municipality.politicalTrend?.status ?? null,
politicalTrendNote: municipality.politicalTrend?.note ?? null,
pledges: pledges ?? createEmptyMunicipalityPledgeAggregate(),
votePosition2022,
territorialClass: territorialClass.class,
territorialClassFactors: territorialClass.factors,
goalCoverageByScenario: goalCoverageByScenario ?? createEmptyGoalCoverageByScenario(),
}
}
export type MunicipalityAdvisorSummary = {
id: number
name: string
phone: string | null
}
export type MunicipalityDetailViewModel = {
id: number
name: string
slug: string
kind: Municipality['kind']
city: string
region: string
ibgeCode: string
tseCityCode: string
zoneNumber: number | null
tseZones: number[]
advisorIDs: number[]
lastUpdateAt: string | null
/** Staff-only block — null for the leader view model. */
strategy: MunicipalityStrategyViewModel | null
}
/**
* The staff strategy block as the UI consumes it. Deliberately NOT derived
* from the zod input schema or payload-types: storage keeps `{ text }` rows
* and nullable scenario fields, the view flattens both.
*/
export type MunicipalityStrategyViewModel = {
priority: 'alta' | 'normal'
expectedVotes: VoteEstimateScenarioViewModel
politicalTrend: MunicipalityPoliticalTrendViewModel
strengths: string[]
risks: string[]
stateDeputyIDs: number[]
stateDeputies: StateDeputySummary[]
dobradinhaNotes: string | null
nextSteps: string | null
budgetNotes: string | null
}
export const toMunicipalityDetailViewModel = (
municipality: Municipality,
role: CampaignUser['role'],
tseZones: number[],
trendRecordedByName: string | null = null,
stateDeputies: StateDeputySummary[] = [],
): MunicipalityDetailViewModel => ({
id: municipality.id,
name: municipality.name,
slug: municipality.slug,
kind: municipality.kind,
city: municipality.city,
region: municipality.region,
ibgeCode: municipality.ibgeCode,
tseCityCode: municipality.tseCityCode,
zoneNumber: municipality.zoneNumber ?? null,
tseZones,
advisorIDs: (municipality.advisors ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),
lastUpdateAt: municipality.lastUpdateAt ?? null,
strategy:
role === 'leader'
? null
: {
priority: municipality.priority === 'alta' ? 'alta' : 'normal',
expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes),
politicalTrend: {
status: municipality.politicalTrend?.status ?? null,
note: municipality.politicalTrend?.note ?? null,
recordedByName: trendRecordedByName,
recordedAt: municipality.politicalTrend?.recordedAt ?? null,
},
strengths: (municipality.strengths ?? []).map((item) => item.text),
risks: (municipality.risks ?? []).map((item) => item.text),
stateDeputyIDs: (municipality.stateDeputies ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),
stateDeputies,
dobradinhaNotes: municipality.dobradinhaNotes ?? null,
nextSteps: municipality.nextSteps ?? null,
budgetNotes: municipality.budgetNotes ?? null,
},
})
export const loadAdvisorSummaries = async (
payload: Payload,
user: CampaignUser,
advisorIDs: number[],
): Promise<MunicipalityAdvisorSummary[]> => {
if (advisorIDs.length === 0) return []
const result = await payload.find({
collection: 'campaignUser',
where: {
and: [{ id: { in: advisorIDs } }, eligibleCampaignStaffWhere],
},
depth: 0,
pagination: false,
select: { name: true, phone: true },
user,
overrideAccess: false,
})
const advisorById = new Map(
result.docs.map(({ id, name, phone }) => [id, { id, name, phone: phone ?? null }]),
)
return advisorIDs.flatMap((id) => {
const advisor = advisorById.get(id)
return advisor ? [advisor] : []
})
}
export type EligibleAdvisorOption = Pick<CampaignUser, 'id' | 'name'> & {
isCurrent: boolean
}
export const getEligibleAdvisorOptions = async (
payload: Payload,
user: CampaignUser,
): Promise<EligibleAdvisorOption[]> => {
const result = await payload.find({
collection: 'campaignUser',
depth: 0,
pagination: false,
sort: 'name',
where: eligibleCampaignStaffWhere,
select: { name: true },
user,
overrideAccess: false,
})
return result.docs
.map(({ id, name }) => ({
id,
name,
isCurrent: id === user.id,
}))
.sort((left, right) => Number(right.isCurrent) - Number(left.isCurrent))
}