TSX500 linhas19.078 bytes
apps/web/src/app/senador/page.tsx
SHA-256
38cf52e3ab589c2f71cff85d51badab1b4dda7cf30293e4b903e34ec1f821761
Somente leiturafonte-acf635076da2
'use client'
import { useEffect, useState, Suspense } from 'react'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import Link from 'next/link'
import { getSenador, getCeap } from '@/lib/api'
import type { IdsScore, CeapResponse, AbaSenador } from '@/lib/types'
import SenadorRadar from '@/components/SenadorRadar'
import CeapBarChart from '@/components/CeapBarChart'
import AbasMenu from '@/components/senador/AbasMenu'
import AbaAutorias from '@/components/senador/AbaAutorias'
import AbaRelatorias from '@/components/senador/AbaRelatorias'
import AbaVotacoes from '@/components/senador/AbaVotacoes'
import { AbaComissoes, AbaCargos } from '@/components/senador/AbaComissoesECargos'
import { AbaDiscursos, AbaApartes } from '@/components/senador/AbaDiscursos'
import AbaDespesas from '@/components/senador/AbaDespesas'
import AbaPerfil from '@/components/senador/AbaPerfil'
import ShareButtons from '@/components/ShareButtons'
function idsClass(v: number) {
if (v >= 75) return 'text-[#14532d]'
if (v >= 55) return 'text-[#1e7d4a]'
if (v >= 40) return 'text-[#92742a]'
if (v >= 25) return 'text-[#9b6608]'
return 'text-[#9b2c2c]'
}
function idsLabel(v: number) {
if (v >= 75) return 'Excelente'
if (v >= 55) return 'Bom'
if (v >= 40) return 'Médio'
if (v >= 25) return 'Abaixo da média'
return 'Crítico'
}
function DimBar({
label,
value,
description,
weight,
}: {
label: string
value: number
description: string
weight: string
}) {
const color =
value >= 75
? '#1e7d4a'
: value >= 55
? '#65a30d'
: value >= 40
? '#b8860b'
: value >= 25
? '#c2410c'
: '#9b2c2c'
return (
<div className="space-y-1.5 border-b border-border pb-3 last:border-b-0 last:pb-0">
<div className="flex justify-between items-baseline gap-3">
<div>
<div className="text-sm font-semibold text-ink">{label}</div>
<div className="text-xs text-muted">
{description} · peso {weight}
</div>
</div>
<span className="font-mono font-bold text-base text-ink">{value}</span>
</div>
<div className="h-1.5 rounded-full bg-border overflow-hidden">
<div
className="h-1.5 rounded-full transition-all"
style={{ width: `${value}%`, background: color }}
/>
</div>
</div>
)
}
const CEAP_ANOS = [2023, 2024, 2025, 2026]
function SenadorContent() {
const params = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const codigo = params.get('codigo') ?? ''
const aba = (params.get('aba') ?? 'resumo') as AbaSenador
const [data, setData] = useState<IdsScore | null>(null)
const [ceapPorAno, setCeapPorAno] = useState<Record<number, CeapResponse['meses']>>({})
const [ceapAno, setCeapAno] = useState(new Date().getFullYear())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
function setAba(a: AbaSenador) {
const qs = new URLSearchParams(params.toString())
qs.set('aba', a)
router.replace(`${pathname}?${qs.toString()}`, { scroll: false })
}
useEffect(() => {
if (!codigo) {
setError('Código do Senador não informado.')
setLoading(false)
return
}
setLoading(true)
Promise.all([
getSenador(codigo),
...CEAP_ANOS.map((ano) => getCeap(codigo, ano).catch(() => null)),
])
.then(([s, ...ceapResults]) => {
setData(s as IdsScore)
const porAno: Record<number, CeapResponse['meses']> = {}
CEAP_ANOS.forEach((ano, i) => {
const r = ceapResults[i] as CeapResponse | null
if (r?.meses?.length) porAno[ano] = r.meses
})
setCeapPorAno(porAno)
})
.catch(() => setError('Não foi possível carregar os dados deste Senador.'))
.finally(() => setLoading(false))
}, [codigo])
if (loading)
return (
<div className="py-20 flex items-center justify-center">
<div className="animate-spin h-6 w-6 border-2 border-primary rounded-full border-t-transparent" />
</div>
)
if (error || !data)
return (
<div className="rounded-sm border border-border bg-surface p-10 text-center">
<p className="font-serif text-lg font-semibold text-ink">
Senador não localizado
</p>
<p className="mt-2 text-sm text-muted">{error ?? 'Verifique o código.'}</p>
<Link
href="/"
className="mt-4 inline-block text-sm text-primary hover:underline"
>
← Voltar ao ranking
</Link>
</div>
)
return (
<div className="space-y-10 fade-in">
<Link
href="/"
className="text-sm text-muted hover:text-primary transition-colors inline-flex items-center gap-1"
>
← Voltar ao ranking completo
</Link>
{/* ============= HEADER PERFIL ============= */}
<header className="flex flex-col md:flex-row gap-8 md:items-center border-b border-border pb-8">
<div className="flex items-center gap-5 flex-1">
{data.fotoUrl ? (
<img
src={data.fotoUrl}
alt={data.nome}
className="w-24 h-24 rounded-sm object-cover border border-border flex-shrink-0"
/>
) : (
<div className="w-24 h-24 rounded-sm bg-border flex-shrink-0" />
)}
<div className="flex-1">
<p className="eyebrow">Perfil Parlamentar</p>
<h1 className="mt-1 font-serif text-3xl font-semibold text-ink leading-tight">
{data.nome}
</h1>
<p className="mt-2 text-sm text-muted">
<span className="font-medium text-ink">{data.partido}</span> ·{' '}
{data.uf}
{data.bloco && <span className="block text-xs mt-0.5">{data.bloco}</span>}
</p>
{data.dataInicioExercicio && (
<p className="text-xs text-muted mt-2">
Em exercício desde{' '}
{new Date(data.dataInicioExercicio).toLocaleDateString('pt-BR')}
{data.mesesAtivos !== undefined &&
` · ${data.mesesAtivos} meses ativos`}
</p>
)}
<div className="flex flex-wrap gap-2 mt-3">
{data.imovelFuncional && (
<span className="text-xs rounded-sm border border-border bg-panel px-2 py-1 text-muted">
Imóvel funcional
</span>
)}
{data.auxilioMoradia && (
<span className="text-xs rounded-sm border border-accent/30 bg-accent/10 px-2 py-1 text-accent">
Auxílio-moradia
</span>
)}
{data.cargosTitulos && data.cargosTitulos.length > 0 &&
data.cargosTitulos.slice(0, 3).map((cargo, i) => (
<span
key={i}
className="text-xs rounded-sm border border-primary/30 bg-primary/5 px-2 py-1 text-primary font-medium"
title={cargo}
>
{cargo.length > 50 ? cargo.slice(0, 47) + '…' : cargo}
</span>
))}
</div>
</div>
</div>
<div className="md:border-l md:border-border md:pl-8 text-center">
<p className="eyebrow">IDS</p>
<div className={`mt-1 font-serif text-6xl font-bold ${idsClass(data.idsTotal)}`}>
{data.idsTotal}
</div>
<p className="mt-1 text-sm text-muted">
{idsLabel(data.idsTotal)}
</p>
</div>
</header>
{/* ============= ABAS ============= */}
<AbasMenu active={aba} onChange={setAba} />
{aba === 'autorias' && <AbaAutorias codigo={codigo} />}
{aba === 'relatorias' && <AbaRelatorias codigo={codigo} />}
{aba === 'votacoes' && <AbaVotacoes codigo={codigo} />}
{aba === 'comissoes' && <AbaComissoes codigo={codigo} />}
{aba === 'cargos' && <AbaCargos codigo={codigo} />}
{aba === 'discursos' && <AbaDiscursos codigo={codigo} />}
{aba === 'apartes' && <AbaApartes codigo={codigo} />}
{aba === 'despesas' && <AbaDespesas codigo={codigo} />}
{aba === 'perfil' && <AbaPerfil codigo={codigo} />}
{aba === 'resumo' && (<>
{/* ============= INDICADORES BRUTOS ============= */}
<section>
<p className="eyebrow">Indicadores Brutos · 57ª Legislatura</p>
<h2 className="mt-1 font-serif text-2xl font-semibold text-ink">
Atividade parlamentar acumulada
</h2>
<p className="mt-2 text-sm text-muted max-w-2xl">
Valores absolutos registrados desde o início do mandato. Para o
cálculo do IDS, esses números são normalizados pelo número de meses
em exercício antes da padronização estatística.
</p>
<div className="mt-5 grid grid-cols-2 md:grid-cols-4 gap-px bg-border border border-border rounded-sm overflow-hidden">
<RawStat label="Autorias apresentadas" value={data.autoriasTotal} />
<RawStat label="Autorias aprovadas" value={data.autoriasAprovadas} />
<RawStat
label="Presença em votações (plenário)"
value={`${data.votacaoPresentes}/${data.votacoesTotal}`}
/>
<RawStat
label="Presença em votações (comissão)"
value={`${data.votacoesComissaoPresentes ?? 0}/${data.votacoesComissaoTotal ?? 0}`}
/>
<RawStat label="Relatorias assumidas" value={data.relatoriasTotal} />
<RawStat label="Discursos em plenário" value={data.discursosTotal} />
<RawStat label="Apartes" value={data.apartesTotal} />
<RawStat
label="Cargos de liderança"
value={data.cargosLideranca ?? 0}
/>
<RawStat
label="CEAP acumulada"
value={(data.ceapTotalAno || 0).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
maximumFractionDigits: 0,
})}
/>
<RawStat
label="% gasto em divulgação"
value={`${(data.pctDivulgacao ?? 0).toFixed(1)}%`}
/>
<RawStat
label="Escritórios de apoio"
value={data.escritoriosCount ?? 0}
/>
</div>
{(data.ceapDivulgacao || data.ceapEscritorio || data.ceapLocomocao) ? (
<div className="mt-6 card">
<h3 className="font-serif text-base font-semibold text-ink mb-3">
Composição da cota parlamentar (CEAP)
</h3>
<ul className="text-sm space-y-1">
{[
['Divulgação da atividade', data.ceapDivulgacao],
['Escritório (aluguel/material)', data.ceapEscritorio],
['Locomoção (passagens/combustível)', data.ceapLocomocao],
['Consultoria/Assessoria', data.ceapConsultoria],
['Outros', data.ceapOutros],
].filter(([, v]) => (v as number) > 0).map(([label, valor]) => (
<li key={label as string} className="flex justify-between border-b border-border/40 pb-1">
<span className="text-muted">{label}</span>
<span className="font-mono text-ink">
{(valor as number).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 })}
</span>
</li>
))}
</ul>
<p className="mt-3 text-xs text-muted">
Quando o gasto em “Divulgação” é desproporcional ao total, a dimensão CEAP é penalizada por indicar autopromoção em vez de atividade legislativa.
</p>
</div>
) : null}
</section>
{/* ============= DIMENSÕES IDS ============= */}
<section>
<p className="eyebrow">Decomposição do IDS</p>
<h2 className="mt-1 font-serif text-2xl font-semibold text-ink">
Desempenho por dimensão
</h2>
<p className="mt-2 text-sm text-muted max-w-2xl">
Cada dimensão é normalizada na escala 0–100 a partir da posição
relativa do Senador na distribuição do conjunto. Score 50 representa
o desempenho mediano do grupo avaliado.
</p>
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card">
<h3 className="font-serif text-lg font-semibold text-ink mb-4">
Perfil multidimensional
</h3>
<SenadorRadar
data={[
{ subject: 'Produtividade', value: data.dimProdutividade, fullMark: 100 },
{ subject: 'Efetividade', value: data.dimEfetividade, fullMark: 100 },
{ subject: 'Participação', value: data.dimParticipacao, fullMark: 100 },
{ subject: 'Fiscalização', value: data.dimFiscalizacao, fullMark: 100 },
{ subject: 'CEAP', value: data.dimCeap, fullMark: 100 },
{ subject: 'Transparência', value: data.dimTransparencia, fullMark: 100 },
]}
/>
</div>
<div className="card space-y-4">
<h3 className="font-serif text-lg font-semibold text-ink">
Pontuação detalhada
</h3>
<DimBar
label="Produtividade Legislativa"
value={data.dimProdutividade}
description="Pontos ponderados por tipo (PEC×3, PL×1.5, Req×0.4) por mês"
weight="20%"
/>
<DimBar
label="Efetividade"
value={data.dimEfetividade}
description="Status real: lei×3, aprovado SF×1.5, em tramitação×0.5"
weight="24%"
/>
<DimBar
label="Participação"
value={data.dimParticipacao}
description="Presença em votações nominais"
weight="20%"
/>
<DimBar
label="Fiscalização"
value={data.dimFiscalizacao}
description="Relatorias por mês, ajustada por cargos de liderança"
weight="13%"
/>
<DimBar
label="Eficiência CEAP"
value={data.dimCeap}
description="Gasto mensal (escala invertida)"
weight="11%"
/>
<DimBar
label="Transparência & Debate"
value={data.dimTransparencia}
description="Discursos + 0.5×apartes por mês"
weight="12%"
/>
</div>
</div>
</section>
{/* ============= CARGOS EXERCIDOS ============= */}
{data.cargosTitulos && data.cargosTitulos.length > 0 && (
<section>
<p className="eyebrow">Cargos Exercidos</p>
<h2 className="mt-1 font-serif text-2xl font-semibold text-ink">
Lideranças e funções ativas
</h2>
<p className="mt-2 text-sm text-muted max-w-2xl">
Cargos de liderança partidária, presidências de comissão e
assentos na Mesa Diretora atualmente exercidos pelo parlamentar.
Estes cargos são considerados no cálculo da dimensão
Fiscalização, com fator de ajuste para neutralizar viés estrutural
na distribuição de relatorias.
</p>
<ul className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-2">
{data.cargosTitulos.map((cargo, i) => (
<li
key={i}
className="text-sm rounded-sm border border-border bg-surface px-4 py-3 text-ink"
>
<span className="text-primary mr-2">▪</span>
{cargo}
</li>
))}
</ul>
</section>
)}
{/* ============= CEAP ============= */}
{Object.keys(ceapPorAno).length > 0 && (
<section>
<p className="eyebrow">Cota Parlamentar (CEAP)</p>
<h2 className="mt-1 font-serif text-2xl font-semibold text-ink">
Despesas mensais — {ceapAno}
</h2>
<p className="mt-2 text-sm text-muted max-w-2xl">
A Cota para o Exercício da Atividade Parlamentar dos Senadores
(CEAP) é o orçamento destinado a despesas relacionadas ao mandato.
Os valores abaixo refletem reembolsos efetivamente realizados.
</p>
<div className="mt-5 card">
<div className="flex gap-1 mb-4">
{CEAP_ANOS.filter((ano) => ceapPorAno[ano]?.length).map((ano) => (
<button
key={ano}
onClick={() => setCeapAno(ano)}
className={`px-3 py-1 text-sm rounded-sm border transition-colors ${
ceapAno === ano
? 'bg-primary text-white border-primary'
: 'border-border text-muted hover:border-primary hover:text-primary'
}`}
>
{ano}
</button>
))}
</div>
<CeapBarChart meses={ceapPorAno[ceapAno] ?? []} />
</div>
</section>
)}
</>)}
{/* ============= COMPARTILHAR ============= */}
<ShareButtons
variante="card"
titulo={`Compartilhe o desempenho de ${data.nome}`}
url={`https://observasenado.org/senador?codigo=${data.senadorCod}`}
texto={buildShareText(data)}
/>
</div>
)
}
function buildShareText(data: IdsScore): string {
const parts: string[] = []
const partidoUf = [data.partido, data.uf].filter(Boolean).join('/')
parts.push(`${data.nome}${partidoUf ? ` (${partidoUf})` : ''} — IDS ${data.idsTotal}`)
if (data.posicao && data.totalSenadores) {
parts.push(`${data.posicao}º de ${data.totalSenadores} no Observatório do Senado`)
} else {
parts.push('No Observatório do Senado')
}
parts.push('')
parts.push('Desempenho parlamentar avaliado em 6 dimensões com dados oficiais do Senado Federal.')
return parts.join('\n')
}
function RawStat({
label,
value,
}: {
label: string
value: number | string
}) {
return (
<div className="bg-surface px-4 py-4">
<p className="text-[11px] uppercase tracking-wider text-muted font-medium">
{label}
</p>
<p className="mt-1 font-serif text-2xl font-semibold text-ink">{value}</p>
</div>
)
}
export default function SenadorPage() {
return (
<Suspense
fallback={
<div className="py-20 flex items-center justify-center">
<div className="animate-spin h-6 w-6 border-2 border-primary rounded-full border-t-transparent" />
</div>
}
>
<SenadorContent />
</Suspense>
)
}