mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
- helpers puros movidos utilities->lib: slug, phone, wordStartFilter, voteEstimate (9 inversoes lib->utilities zeradas) - chaves de consent em src/lib/campaignConsentKeys.ts (client-safe); campaignInvite/campaignConsent re-exportam; onda0ConsentTexts referencia as constantes - 'server-only' em 21 loaders Payload/cache (CLI segue com --conditions=react-server) - ciclo de tipos supporterListFilters <-> supporterUi quebrado (SupporterListFilterInput estrutural; sqlFilters idem) - votePledgeData dividido: votePledgeViews (puro, client-safe) + votePledgeData (loaders, server-only) - padrao contract-module - predicados de role client-safe em src/lib/campaignRoles.ts; access/shared deriva deles (fonte unica); nav.ts para de importar o barrel campaignAccess (casts 'as CampaignUser' eliminados)
534 lines
20 KiB
TypeScript
534 lines
20 KiB
TypeScript
import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
|
||
import Link from 'next/link'
|
||
|
||
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
|
||
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/CampaignTable'
|
||
import {
|
||
MissingAdvisorBadge,
|
||
MunicipalityAdvisorAvatarStack,
|
||
} from '@/components/campaign/MunicipalityAdvisorAvatarStack'
|
||
import { MunicipalityListAdvisorsControl } from '@/components/campaign/MunicipalityListAdvisorsControl'
|
||
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/MunicipalityListExpectedVotesControl'
|
||
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/MunicipalityListGoalCoverageCell'
|
||
import { MunicipalityListTrendControl } from '@/components/campaign/MunicipalityListTrendControl'
|
||
import { MunicipalitySortableHead } from '@/components/campaign/MunicipalitySortableHead'
|
||
import { Badge } from '@/components/ui/Badge'
|
||
import { Button, buttonVariants } from '@/components/ui/button'
|
||
import {
|
||
Empty,
|
||
EmptyContent,
|
||
EmptyDescription,
|
||
EmptyHeader,
|
||
EmptyMedia,
|
||
EmptyTitle,
|
||
} from '@/components/ui/Empty'
|
||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||
import { formatMunicipalityVoteRank, formatMunicipalityVoteShare } from '@/lib/municipalityVoteRank'
|
||
import { cn } from '@/lib/utils'
|
||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||
import {
|
||
formatMunicipalityConcentrationHint,
|
||
formatMunicipalityGeographyLabel,
|
||
municipalityKindLabels,
|
||
municipalityPriorityLabels,
|
||
} from '@/utilities/municipalityLabels'
|
||
import {
|
||
clearMunicipalityListFilters,
|
||
type MunicipalityFilterOption,
|
||
} from '@/utilities/municipalityListFilters'
|
||
import {
|
||
buildMunicipalityListHref,
|
||
formatMunicipalityListSortSummary,
|
||
resolveMunicipalityListSort,
|
||
type MunicipalityListState,
|
||
} from '@/utilities/municipalityListUrl'
|
||
import {
|
||
formatMunicipalitySignalAgeLabel,
|
||
isMunicipalitySignalCold,
|
||
MUNICIPALITY_COLD_SIGNAL_DAYS,
|
||
municipalitySignalAgeInDays,
|
||
} from '@/utilities/municipalitySignal'
|
||
import type {
|
||
EligibleAdvisorOption,
|
||
MunicipalityAdvisorSummary,
|
||
MunicipalityListViewModel,
|
||
} from '@/utilities/municipalityViewModels'
|
||
import {
|
||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||
voteEstimateScenarioLabels,
|
||
} from '@/lib/voteEstimate'
|
||
import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
|
||
|
||
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
|
||
|
||
type MunicipalityStaffFormAction = (
|
||
state: CampaignFormActionState,
|
||
formData: FormData,
|
||
) => Promise<CampaignFormActionState>
|
||
|
||
type MunicipalityColumnFilterOptions = {
|
||
/** Bare catalog slugs — labeled on the client (B16+ payload trim). */
|
||
name: readonly string[]
|
||
region: MunicipalityFilterOption[]
|
||
advisor: MunicipalityFilterOption[]
|
||
}
|
||
|
||
export type MunicipalityListProps = {
|
||
municipalities: MunicipalityListViewModel[]
|
||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
|
||
isStaffView: boolean
|
||
isCoordinator: boolean
|
||
advisorOptions: EligibleAdvisorOption[]
|
||
columnFilterOptions: MunicipalityColumnFilterOptions
|
||
trendFormAction: MunicipalityStaffFormAction
|
||
advisorsFormAction: MunicipalityStaffFormAction
|
||
state: MunicipalityListState
|
||
}
|
||
|
||
const VotePositionReadout = ({
|
||
position,
|
||
layout,
|
||
}: {
|
||
position: NonNullable<MunicipalityListViewModel['votePosition2022']>
|
||
layout: 'table' | 'card'
|
||
}) => {
|
||
const share = formatMunicipalityVoteShare(position.share)
|
||
const rank = formatMunicipalityVoteRank(position.rank)
|
||
const metaLine = `${formatElectionNumber(position.votes)} · ${rank}`
|
||
const ariaLabel = `${share} da votação estadual, ${formatElectionNumber(position.votes)} votos, ${rank} de ${formatElectionNumber(position.totalUnits)}`
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'flex flex-col gap-0.5 tabular-nums',
|
||
layout === 'table' ? 'items-end text-right' : 'text-sm',
|
||
)}
|
||
aria-label={ariaLabel}
|
||
>
|
||
<span className={cn('font-medium', layout === 'card' && 'text-foreground')}>{share}</span>
|
||
<span className="text-xs text-muted-foreground">{metaLine}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* E9 frescor: how long since anybody recorded anything here (staff update or
|
||
* leadership pledge).
|
||
*
|
||
* Cold reads in the warning amber, NOT destructive: early in the campaign
|
||
* most municípios are past the 21-day threshold, and painting them all red
|
||
* would drown the one state that really is an error in this list — a priority
|
||
* município with nobody answering for it. Same reason it stays text with an
|
||
* icon instead of a third badge: the row already carries the priority pill and,
|
||
* when it applies, the missing-advisor one.
|
||
*/
|
||
const SignalAgeReadout = ({
|
||
lastSignalAt,
|
||
layout,
|
||
}: {
|
||
lastSignalAt: string | null
|
||
layout: 'table' | 'card'
|
||
}) => {
|
||
const ageInDays = municipalitySignalAgeInDays(lastSignalAt)
|
||
const isCold = isMunicipalitySignalCold(ageInDays)
|
||
const ageLabel = formatMunicipalitySignalAgeLabel(ageInDays)
|
||
const dateLabel = lastSignalAt ? dateFormatter.format(new Date(lastSignalAt)) : null
|
||
|
||
return (
|
||
<div
|
||
data-signal={isCold ? 'cold' : 'fresh'}
|
||
className={cn('flex flex-col gap-0.5', layout === 'card' && 'text-sm')}
|
||
title={
|
||
isCold
|
||
? `Sem registro novo há ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias ou mais — atualização da equipe ou declaração de liderança.`
|
||
: undefined
|
||
}
|
||
>
|
||
<span
|
||
className={cn(
|
||
'inline-flex items-center gap-1 tabular-nums',
|
||
isCold ? 'font-medium text-estimate-pending-foreground' : 'text-foreground',
|
||
)}
|
||
>
|
||
{isCold ? <CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" /> : null}
|
||
{ageLabel}
|
||
</span>
|
||
{dateLabel ? (
|
||
<span className="text-xs text-muted-foreground tabular-nums">{dateLabel}</span>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Replaces only the rows: the filter header row and the overview stay put. */
|
||
const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState }) => (
|
||
<Empty className="min-h-56">
|
||
<EmptyHeader>
|
||
<EmptyMedia variant="icon">
|
||
<SearchXIcon aria-hidden="true" />
|
||
</EmptyMedia>
|
||
<EmptyTitle>Nenhum município encontrado</EmptyTitle>
|
||
<EmptyDescription>
|
||
Ajuste a busca ou os filtros. Você só vê municípios dentro do seu escopo.
|
||
</EmptyDescription>
|
||
</EmptyHeader>
|
||
<EmptyContent>
|
||
{/* Same contract as the filter bar's Limpar: drop filters, keep the sort. */}
|
||
<CampaignTransitionAnchor
|
||
href={buildMunicipalityListHref(clearMunicipalityListFilters(state), 1)}
|
||
replace
|
||
scroll={false}
|
||
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
|
||
>
|
||
Limpar busca e filtros
|
||
</CampaignTransitionAnchor>
|
||
</EmptyContent>
|
||
</Empty>
|
||
)
|
||
|
||
const advisorEntries = (
|
||
municipality: MunicipalityListViewModel,
|
||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
|
||
) =>
|
||
municipality.advisorIDs.flatMap((id) => {
|
||
const advisor = advisorNamesById.get(id)
|
||
return advisor ? [{ id: advisor.id, name: advisor.name }] : []
|
||
})
|
||
|
||
const advisorNames = (
|
||
municipality: MunicipalityListViewModel,
|
||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
|
||
): string[] => advisorEntries(municipality, advisorNamesById).map((advisor) => advisor.name)
|
||
|
||
/** The desktop table as column definitions (Pass 2 W1 list system). */
|
||
const municipalityListColumns = ({
|
||
state,
|
||
isStaffView,
|
||
isCoordinator,
|
||
columnFilterOptions,
|
||
advisorNamesById,
|
||
advisorOptions,
|
||
trendFormAction,
|
||
advisorsFormAction,
|
||
concentrationHint,
|
||
signalHint,
|
||
deficitHint,
|
||
}: MunicipalityListProps & {
|
||
concentrationHint: string
|
||
signalHint: string
|
||
deficitHint: string
|
||
}): Array<CampaignTableColumn<MunicipalityListViewModel>> => [
|
||
{
|
||
id: 'name',
|
||
mandatory: true,
|
||
head: (
|
||
<MunicipalitySortableHead
|
||
state={state}
|
||
sortKey="name"
|
||
filterParam="name"
|
||
filterOptions={columnFilterOptions.name}
|
||
showPriorityFilter={isStaffView}
|
||
>
|
||
Município
|
||
</MunicipalitySortableHead>
|
||
),
|
||
cellClassName: 'max-w-52 whitespace-normal',
|
||
cell: (municipality) => (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Link
|
||
href={`/campanha/municipios/${municipality.slug}`}
|
||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||
>
|
||
{municipality.name}
|
||
</Link>
|
||
{municipality.priority === 'alta' && isStaffView ? (
|
||
<Badge variant="destructive">{municipalityPriorityLabels.alta}</Badge>
|
||
) : null}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'region',
|
||
head: (
|
||
<MunicipalitySortableHead
|
||
state={state}
|
||
sortKey="region"
|
||
filterParam="region"
|
||
filterOptions={columnFilterOptions.region}
|
||
>
|
||
Território
|
||
</MunicipalitySortableHead>
|
||
),
|
||
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
|
||
cell: (municipality) => municipality.region,
|
||
},
|
||
{
|
||
id: 'kind',
|
||
head: (
|
||
<MunicipalitySortableHead state={state} sortKey="kind" filterParam="kind">
|
||
Tipo
|
||
</MunicipalitySortableHead>
|
||
),
|
||
cell: (municipality) => municipalityKindLabels[municipality.kind],
|
||
},
|
||
{
|
||
id: 'votos',
|
||
head: (
|
||
<MunicipalitySortableHead
|
||
state={state}
|
||
sortKey="votos"
|
||
align="right"
|
||
tooltip={concentrationHint}
|
||
/>
|
||
),
|
||
cellClassName: 'text-right',
|
||
cell: (municipality) =>
|
||
municipality.votePosition2022 ? (
|
||
<VotePositionReadout position={municipality.votePosition2022} layout="table" />
|
||
) : (
|
||
<span className="text-muted-foreground">—</span>
|
||
),
|
||
},
|
||
...(isStaffView
|
||
? ([
|
||
{
|
||
id: 'advisors',
|
||
head: (
|
||
<MunicipalitySortableHead
|
||
state={state}
|
||
sortKey="coverage"
|
||
filterParam="advisor"
|
||
filterOptions={columnFilterOptions.advisor}
|
||
>
|
||
Assessores
|
||
</MunicipalitySortableHead>
|
||
),
|
||
cell: (municipality) =>
|
||
isCoordinator ? (
|
||
<MunicipalityListAdvisorsControl
|
||
municipalityID={municipality.id}
|
||
municipalitySlug={municipality.slug}
|
||
currentAdvisorIDs={municipality.advisorIDs}
|
||
isPriority={municipality.priority === 'alta'}
|
||
advisorNamesById={advisorNamesById}
|
||
options={advisorOptions}
|
||
formAction={advisorsFormAction}
|
||
/>
|
||
) : (
|
||
<MunicipalityAdvisorAvatarStack
|
||
advisors={advisorEntries(municipality, advisorNamesById)}
|
||
isPriority={municipality.priority === 'alta'}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
id: 'trend',
|
||
head: (
|
||
<MunicipalitySortableHead state={state} sortKey="trend" filterParam="trend">
|
||
Tendência
|
||
</MunicipalitySortableHead>
|
||
),
|
||
cell: (municipality) => (
|
||
<MunicipalityListTrendControl
|
||
municipalityID={municipality.id}
|
||
municipalitySlug={municipality.slug}
|
||
status={municipality.politicalTrendStatus}
|
||
trendNote={municipality.politicalTrendNote}
|
||
formAction={trendFormAction}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
id: 'expectedVotes',
|
||
head: (
|
||
<MunicipalitySortableHead state={state} sortKey="expectedVotes" align="center">
|
||
Votos estimados
|
||
</MunicipalitySortableHead>
|
||
),
|
||
cellClassName: 'relative overflow-visible align-middle text-center',
|
||
cell: (municipality) => (
|
||
<div className="flex min-h-11 items-center justify-center">
|
||
<MunicipalityListExpectedVotesControl
|
||
municipalityID={municipality.id}
|
||
expectedVotes={municipality.expectedVotes}
|
||
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
|
||
/>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'lastSignal',
|
||
head: (
|
||
<MunicipalitySortableHead state={state} sortKey="frescor" tooltip={signalHint}>
|
||
Último sinal
|
||
</MunicipalitySortableHead>
|
||
),
|
||
cell: (municipality) => (
|
||
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="table" />
|
||
),
|
||
},
|
||
{
|
||
id: 'goalCoverage',
|
||
head: (
|
||
<MunicipalitySortableHead state={state} sortKey="deficit" tooltip={deficitHint}>
|
||
Cobertura da meta
|
||
</MunicipalitySortableHead>
|
||
),
|
||
cell: (municipality) => (
|
||
<MunicipalityListGoalCoverageCell
|
||
coverageByScenario={municipality.goalCoverageByScenario}
|
||
layout="compact"
|
||
/>
|
||
),
|
||
},
|
||
] satisfies Array<CampaignTableColumn<MunicipalityListViewModel>>)
|
||
: ([
|
||
{
|
||
id: 'lastUpdateAt',
|
||
head: (
|
||
<MunicipalitySortableHead state={state} sortKey="lastUpdateAt">
|
||
Última atualização
|
||
</MunicipalitySortableHead>
|
||
),
|
||
cell: (municipality) =>
|
||
municipality.lastUpdateAt
|
||
? dateFormatter.format(new Date(municipality.lastUpdateAt))
|
||
: 'Sem atualização',
|
||
},
|
||
] satisfies Array<CampaignTableColumn<MunicipalityListViewModel>>)),
|
||
]
|
||
|
||
export const MunicipalityList = (props: MunicipalityListProps) => {
|
||
const {
|
||
municipalities,
|
||
advisorNamesById,
|
||
isStaffView,
|
||
isCoordinator,
|
||
advisorOptions,
|
||
trendFormAction,
|
||
state,
|
||
} = props
|
||
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
|
||
const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir)
|
||
const concentrationHint = formatMunicipalityConcentrationHint()
|
||
const signalHint = `Última atualização da equipe ou declaração de liderança, o que for mais recente. Fica destacado a partir de ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias sem registro.`
|
||
// The scenario picker is client state, so the server can only order by one
|
||
// scenario — named here so the ordering never looks arbitrary.
|
||
const deficitHint = `Ordena pelo que falta para a meta (meta − comprometido) no cenário ${voteEstimateScenarioLabels[DEFAULT_VOTE_ESTIMATE_SCENARIO]}, independente do cenário selecionado acima.`
|
||
|
||
const columns = municipalityListColumns({ ...props, concentrationHint, signalHint, deficitHint })
|
||
|
||
return (
|
||
<>
|
||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||
{sortSummary}
|
||
</p>
|
||
|
||
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
|
||
{municipalities.length === 0 ? <MunicipalityListEmptyState state={state} /> : null}
|
||
{municipalities.map((municipality) => {
|
||
const names = advisorNames(municipality, advisorNamesById)
|
||
const position = municipality.votePosition2022
|
||
const isPriority = municipality.priority === 'alta'
|
||
return (
|
||
<article key={municipality.id} className="flex flex-col gap-3 rounded-xl border p-4">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="flex flex-col gap-1">
|
||
<h3 className="font-medium">{municipality.name}</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
{formatMunicipalityGeographyLabel(municipality)}
|
||
</p>
|
||
{position ? <VotePositionReadout position={position} layout="card" /> : null}
|
||
</div>
|
||
{isPriority && isStaffView ? (
|
||
<Badge variant="destructive">{municipalityPriorityLabels.alta}</Badge>
|
||
) : null}
|
||
</div>
|
||
{isStaffView ? (
|
||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||
<div>
|
||
<dt className="text-muted-foreground">Votos estimados</dt>
|
||
<dd className="flex justify-center">
|
||
<MunicipalityListExpectedVotesControl
|
||
municipalityID={municipality.id}
|
||
expectedVotes={municipality.expectedVotes}
|
||
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
|
||
/>
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-muted-foreground">Tendência</dt>
|
||
<dd>
|
||
<MunicipalityListTrendControl
|
||
municipalityID={municipality.id}
|
||
municipalitySlug={municipality.slug}
|
||
status={municipality.politicalTrendStatus}
|
||
trendNote={municipality.politicalTrendNote}
|
||
formAction={trendFormAction}
|
||
/>
|
||
</dd>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<dt className="text-muted-foreground">Cobertura da meta</dt>
|
||
<dd>
|
||
<MunicipalityListGoalCoverageCell
|
||
coverageByScenario={municipality.goalCoverageByScenario}
|
||
/>
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-muted-foreground">Último sinal</dt>
|
||
<dd>
|
||
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-muted-foreground">Assessores</dt>
|
||
<dd>
|
||
{isCoordinator ? (
|
||
<MunicipalityListAdvisorsControl
|
||
municipalityID={municipality.id}
|
||
municipalitySlug={municipality.slug}
|
||
currentAdvisorIDs={municipality.advisorIDs}
|
||
isPriority={isPriority}
|
||
advisorNamesById={advisorNamesById}
|
||
options={advisorOptions}
|
||
formAction={props.advisorsFormAction}
|
||
/>
|
||
) : names.length ? (
|
||
names.join(', ')
|
||
) : (
|
||
<MissingAdvisorBadge isPriority={isPriority} />
|
||
)}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
) : null}
|
||
<Button asChild variant="outline" className="min-h-11 w-full">
|
||
<Link href={`/campanha/municipios/${municipality.slug}`}>Abrir município</Link>
|
||
</Button>
|
||
</article>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* No inner scroller: the app shell's <main> scrolls, so the sticky header
|
||
resolves against it. A sticky <th> can't paint the row border, hence the
|
||
inset shadow standing in for it. */}
|
||
<CampaignTable
|
||
className="hidden overflow-visible md:block"
|
||
containerClassName="overflow-x-visible"
|
||
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
|
||
caption={
|
||
<>
|
||
{sortSummary}. Coluna 2022: {concentrationHint}
|
||
</>
|
||
}
|
||
columns={columns}
|
||
rows={municipalities}
|
||
rowKey={(municipality) => municipality.id}
|
||
empty={<MunicipalityListEmptyState state={state} />}
|
||
/>
|
||
</>
|
||
)
|
||
}
|