TSX272 linhas10.849 bytes
apps/web/src/components/RankingTable.tsx
SHA-256
d0b99ce46db63e2601113378ae98e2d7efc6575c877b0b19471bea3a4377bd82
Somente leiturafonte-acf635076da2
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import type { IdsScore, SortKey, SortDir } from '@/lib/types'
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import {
StatusBadge,
ForaDeCompeticaoBadge,
MandatoEncerradoBadge,
} from './StatusBadge'
const PAGE_SIZE = 25
/** ISO (2026-04-01) → 04/2026, para o rodapé do mandato encerrado. */
function formatarData(iso: string): string {
const [ano, mes] = iso.split('-')
return mes ? `${mes}/${ano}` : iso
}
function idsClasses(v: number): { text: string; bg: string; border: string } {
if (v >= 75) return { text: 'text-[#14532d]', bg: 'bg-[#dcfce7]', border: 'border-[#86efac]' }
if (v >= 55) return { text: 'text-[#1e7d4a]', bg: 'bg-[#ecfdf5]', border: 'border-[#a7f3d0]' }
if (v >= 40) return { text: 'text-[#92742a]', bg: 'bg-[#fef9c3]', border: 'border-[#fde68a]' }
if (v >= 25) return { text: 'text-[#9b6608]', bg: 'bg-[#fff7ed]', border: 'border-[#fed7aa]' }
return { text: 'text-[#9b2c2c]', bg: 'bg-[#fef2f2]', border: 'border-[#fecaca]' }
}
interface Column {
key: SortKey
label: string
short?: string
numeric?: boolean
}
const COLUMNS: Column[] = [
{ key: 'nome', label: 'Senador' },
{ key: 'partido', label: 'Partido', short: 'Part.' },
{ key: 'uf', label: 'UF' },
{ key: 'idsTotal', label: 'IDS', numeric: true },
{ key: 'dimProdutividade', label: 'Produtividade', short: 'Prod.', numeric: true },
{ key: 'dimEfetividade', label: 'Efetividade', short: 'Efet.', numeric: true },
{ key: 'dimParticipacao', label: 'Participação', short: 'Part.', numeric: true },
{ key: 'dimFiscalizacao', label: 'Fiscalização', short: 'Fisc.', numeric: true },
{ key: 'dimCeap', label: 'CEAP', numeric: true },
{ key: 'dimTransparencia', label: 'Transparência', short: 'Transp.', numeric: true },
]
interface Props {
scores: IdsScore[]
sortKey: SortKey
sortDir: SortDir
onSort: (key: SortKey) => void
}
export default function RankingTable({ scores, sortKey, sortDir, onSort }: Props) {
const [page, setPage] = useState(1)
const totalPages = Math.ceil(scores.length / PAGE_SIZE)
const pageItems = scores.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
// Reset page when filters change result set length significantly
useEffect(() => {
setPage(1)
}, [scores.length, sortKey, sortDir])
function SortIcon({ col }: { col: SortKey }) {
if (sortKey !== col) return <ChevronsUpDown className="h-3 w-3 opacity-40" />
return sortDir === 'asc' ? (
<ChevronUp className="h-3 w-3 text-primary" />
) : (
<ChevronDown className="h-3 w-3 text-primary" />
)
}
return (
<div className="space-y-4">
<div className="overflow-x-auto rounded-sm border border-border bg-surface">
<table className="w-full text-sm">
<thead className="bg-panel">
<tr className="border-b border-border">
<th className="px-3 py-3 text-left text-muted font-semibold w-12 text-[11px] uppercase tracking-wider">
Pos.
</th>
{COLUMNS.map((col) => (
<th
key={col.key}
className={`px-3 py-3 font-semibold cursor-pointer select-none hover:bg-border/40 transition-colors group text-[11px] uppercase tracking-wider ${
col.numeric ? 'text-right' : 'text-left'
}`}
onClick={() => onSort(col.key)}
>
<span
className={`flex items-center gap-1 text-muted group-hover:text-primary ${
col.numeric ? 'justify-end' : ''
}`}
>
<span className="hidden md:inline">{col.label}</span>
<span className="md:hidden">{col.short ?? col.label}</span>
<SortIcon col={col.key} />
</span>
</th>
))}
</tr>
</thead>
<tbody>
{pageItems.map((s, i) => {
const rank = (page - 1) * PAGE_SIZE + i + 1
const cls = idsClasses(s.idsTotal)
return (
<tr
key={s.senadorCod}
className="border-b border-border last:border-b-0 hover:bg-panel transition-colors"
>
<td className="px-3 py-3 font-mono text-muted text-xs align-middle">
{rank}
</td>
<td className="px-3 py-3">
<Link
href={`/senador?codigo=${s.senadorCod}`}
className="flex items-center gap-3 group"
>
{s.fotoUrl ? (
<img
src={s.fotoUrl}
alt={s.nome}
className="w-8 h-8 rounded-full object-cover flex-shrink-0 border border-border"
/>
) : (
<div className="w-8 h-8 rounded-full bg-border flex-shrink-0" />
)}
<span className="flex flex-col">
<span className="font-medium text-ink group-hover:text-primary group-hover:underline underline-offset-2 transition-colors">
{s.nome}
</span>
{(s.status || s.emExercicio === false) && (
<span className="mt-0.5 flex flex-wrap items-center gap-1.5">
<StatusBadge status={s.status} />
<MandatoEncerradoBadge emExercicio={s.emExercicio} />
<ForaDeCompeticaoBadge
elegivel={s.elegivelPremio}
meses={s.mesesAtivos}
/>
{s.mesesAtivos != null && (
<span
className="text-[10px] text-muted font-mono"
title={s.confianca ? `Confiança ${s.confianca}` : undefined}
>
{s.mesesAtivos}m
</span>
)}
{s.emExercicio === false && s.dataFimExercicio && (
<span
className="text-[10px] text-muted font-mono"
title="Fim do exercício na Leg. 57"
>
até {formatarData(s.dataFimExercicio)}
</span>
)}
</span>
)}
</span>
</Link>
</td>
<td className="px-3 py-3 text-muted text-sm">{s.partido}</td>
<td className="px-3 py-3 text-muted text-sm">{s.uf}</td>
<td className="px-3 py-3 text-right">
<span
className={`inline-block font-mono font-bold text-sm px-2 py-0.5 rounded-sm border ${cls.text} ${cls.bg} ${cls.border}`}
>
{s.idsTotal}
</span>
</td>
{(
[
s.dimProdutividade,
s.dimEfetividade,
s.dimParticipacao,
s.dimFiscalizacao,
s.dimCeap,
s.dimTransparencia,
] as number[]
).map((v, di) => (
<td key={di} className="px-3 py-3">
<ScoreBar value={v} />
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted">
Exibindo <strong className="text-ink">{(page - 1) * PAGE_SIZE + 1}</strong>–
<strong className="text-ink">
{Math.min(page * PAGE_SIZE, scores.length)}
</strong>{' '}
de <strong className="text-ink">{scores.length}</strong> Senadores
</p>
<div className="flex gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="rounded-sm border border-border bg-surface px-3 py-1.5 text-sm text-muted hover:text-primary hover:border-primary/40 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Anterior
</button>
{Array.from({ length: Math.min(7, totalPages) }, (_, i) => {
const p =
totalPages <= 7
? i + 1
: page <= 4
? i + 1
: page >= totalPages - 3
? totalPages - 6 + i
: page - 3 + i
return (
<button
key={p}
onClick={() => setPage(p)}
className={`rounded-sm border px-3 py-1.5 text-sm transition-colors ${
p === page
? 'border-primary bg-primary text-white'
: 'border-border bg-surface text-muted hover:text-primary hover:border-primary/40'
}`}
>
{p}
</button>
)
})}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="rounded-sm border border-border bg-surface px-3 py-1.5 text-sm text-muted hover:text-primary hover:border-primary/40 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Próxima
</button>
</div>
</div>
)}
</div>
)
}
function ScoreBar({ value }: { value: number }) {
const color =
value >= 75
? '#1e7d4a'
: value >= 55
? '#65a30d'
: value >= 40
? '#b8860b'
: value >= 25
? '#c2410c'
: '#9b2c2c'
return (
<div className="flex items-center gap-2 justify-end">
<div className="h-1.5 w-16 rounded-full bg-border">
<div
className="h-1.5 rounded-full transition-all"
style={{ width: `${value}%`, background: color }}
/>
</div>
<span className="font-mono text-xs text-muted w-7 text-right">{value}</span>
</div>
)
}