Primer commit — OPSV Dashboard de siniestralidad vial
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import { useMemo } from 'react'
|
||||
import { calcularKPIs } from '../utils/calculos'
|
||||
import SerieHistorica, {
|
||||
SERIE_HISTORICA,
|
||||
getPoblacionAnual,
|
||||
} from '../components/charts/SerieHistorica'
|
||||
import PorLocalidad from '../components/charts/PorLocalidad'
|
||||
import PorTipoSiniestro from '../components/charts/PorTipoSiniestro'
|
||||
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
||||
import KPICard from '../components/ui/KPICard'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { useChartTheme } from '../hooks/useChartTheme'
|
||||
import { COLOR } from '../utils/colores'
|
||||
|
||||
function calcularPorcentaje(base, actual) {
|
||||
if (!base || base === 0) return '0%'
|
||||
const diff = actual - base
|
||||
const sign = diff >= 0 ? '▲' : '▼'
|
||||
return `${sign} ${Math.abs(((diff / base) * 100).toFixed(1))}%`
|
||||
}
|
||||
|
||||
export default function SecHistorica({ siniestros, year }) {
|
||||
const yearNum = Number(year)
|
||||
const kpis = calcularKPIs(siniestros)
|
||||
|
||||
// Hook se usa DENTRO del componente
|
||||
const { tickColor, gridColor, tooltipBg, tooltipBorder, tooltipLabel } =
|
||||
useChartTheme()
|
||||
|
||||
const serieComparativa = useMemo(() => {
|
||||
const base = [...SERIE_HISTORICA]
|
||||
const yaExiste = base.some((row) => row.ano === yearNum)
|
||||
|
||||
if (!yaExiste && kpis.total > 0) {
|
||||
const pob = getPoblacionAnual(yearNum)
|
||||
const tasa = Number(((kpis.victimas / pob) * 100000).toFixed(2))
|
||||
|
||||
return [
|
||||
...base,
|
||||
{
|
||||
ano: yearNum,
|
||||
siniestros: kpis.total,
|
||||
victimas: kpis.victimas,
|
||||
tasa,
|
||||
},
|
||||
].sort((a, b) => a.ano - b.ano)
|
||||
}
|
||||
|
||||
return base
|
||||
}, [yearNum, kpis.total, kpis.victimas])
|
||||
|
||||
const yearActualData = useMemo(
|
||||
() => serieComparativa.find((row) => row.ano === yearNum) ?? null,
|
||||
[serieComparativa, yearNum],
|
||||
)
|
||||
|
||||
const prevYearData = useMemo(
|
||||
() => serieComparativa.find((row) => row.ano === yearNum - 1) ?? null,
|
||||
[serieComparativa, yearNum],
|
||||
)
|
||||
|
||||
const victimasPorAno = useMemo(
|
||||
() =>
|
||||
serieComparativa.map((row) => ({ ano: row.ano, victimas: row.victimas })),
|
||||
[serieComparativa],
|
||||
)
|
||||
|
||||
const dataTabla = serieComparativa
|
||||
|
||||
const serieParaExtremos = useMemo(() => {
|
||||
const base = [...SERIE_HISTORICA]
|
||||
|
||||
const existe2025 = base.some((row) => row.ano === 2025)
|
||||
if (!existe2025) {
|
||||
base.push({
|
||||
ano: 2025,
|
||||
siniestros: 0,
|
||||
victimas: 21,
|
||||
tasa: 6.27,
|
||||
})
|
||||
}
|
||||
|
||||
return base
|
||||
.filter((row) => row.ano !== 2020 && row.victimas != null)
|
||||
.sort((a, b) => a.ano - b.ano)
|
||||
}, [])
|
||||
|
||||
const victimasActual = yearActualData?.victimas ?? kpis.victimas ?? null
|
||||
|
||||
const comparativoVictimas =
|
||||
prevYearData?.victimas != null && victimasActual != null
|
||||
? calcularPorcentaje(prevYearData.victimas, victimasActual)
|
||||
: '—'
|
||||
|
||||
const serieVictimasValidas = serieComparativa.filter(
|
||||
(row) => row.ano !== 2020 && row.victimas != null,
|
||||
)
|
||||
|
||||
const maxEntry =
|
||||
serieParaExtremos.length > 0
|
||||
? serieParaExtremos.reduce(
|
||||
(max, row) => (max == null || row.victimas > max.victimas ? row : max),
|
||||
null,
|
||||
)
|
||||
: null
|
||||
const maxHistorico = maxEntry?.victimas ?? null
|
||||
const maxAno = maxEntry?.ano ?? null
|
||||
|
||||
const minEntry =
|
||||
serieParaExtremos.length > 0
|
||||
? serieParaExtremos.reduce(
|
||||
(min, row) => (min == null || row.victimas < min.victimas ? row : min),
|
||||
null,
|
||||
)
|
||||
: null
|
||||
const minHistorico = minEntry?.victimas ?? null
|
||||
const minAno = minEntry?.ano ?? null
|
||||
|
||||
const serieOrdenada = [...serieVictimasValidas].sort((a, b) => a.ano - b.ano)
|
||||
const ultimos10 = serieOrdenada.filter((row) => row.ano <= yearNum).slice(-10)
|
||||
|
||||
const promedio10 =
|
||||
ultimos10.length > 0
|
||||
? (
|
||||
ultimos10.reduce((acc, row) => acc + row.victimas, 0) / ultimos10.length
|
||||
).toFixed(1)
|
||||
: null
|
||||
|
||||
const rango10Desde = ultimos10[0]?.ano ?? null
|
||||
const rango10Hasta = ultimos10[ultimos10.length - 1]?.ano ?? null
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* KPIs históricas */}
|
||||
<div data-pdf-block className="grid gap-4 xl:grid-cols-5">
|
||||
<KPICard
|
||||
label={
|
||||
yearActualData
|
||||
? `Víctimas fatales (${yearActualData.ano})`
|
||||
: 'Víctimas fatales'
|
||||
}
|
||||
value={victimasActual ?? '—'}
|
||||
color={COLOR.red}
|
||||
/>
|
||||
<KPICard
|
||||
label={
|
||||
prevYearData && yearActualData
|
||||
? `Variación vs. año anterior (${prevYearData.ano}–${yearActualData.ano})`
|
||||
: 'Variación vs. año anterior'
|
||||
}
|
||||
value={comparativoVictimas}
|
||||
color={COLOR.gold}
|
||||
/>
|
||||
<KPICard
|
||||
label={
|
||||
maxAno
|
||||
? `Máximo histórico de víctimas (${maxAno})`
|
||||
: 'Máximo histórico de víctimas'
|
||||
}
|
||||
value={maxHistorico ?? '—'}
|
||||
color={COLOR.navy}
|
||||
/>
|
||||
<KPICard
|
||||
label={
|
||||
minAno
|
||||
? `Mínimo histórico de víctimas (${minAno})`
|
||||
: 'Mínimo histórico de víctimas'
|
||||
}
|
||||
value={minHistorico ?? '—'}
|
||||
color={COLOR.blue}
|
||||
/>
|
||||
<KPICard
|
||||
label={
|
||||
rango10Desde && rango10Hasta
|
||||
? `Promedio últimos 10 años (${rango10Desde}–${rango10Hasta})`
|
||||
: 'Promedio últimos 10 años'
|
||||
}
|
||||
value={promedio10 ?? '—'}
|
||||
color={COLOR.green}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gráfico de tasa + barras de víctimas */}
|
||||
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]">
|
||||
<SerieHistorica
|
||||
year={yearNum}
|
||||
siniestrosActual={kpis.total}
|
||||
victimasActual={kpis.victimas}
|
||||
/>
|
||||
<div data-pdf-block className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
||||
<div className="mb-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">
|
||||
Víctimas fatales
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl font-black text-opsv-navy">
|
||||
Evolución anual
|
||||
</h3>
|
||||
</div>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={victimasPorAno}
|
||||
margin={{ top: 10, right: 20, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke={gridColor}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="ano"
|
||||
tick={{ fill: tickColor, fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: tickColor, fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: tooltipBg,
|
||||
border: `1px solid ${tooltipBorder}`,
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
labelStyle={{ color: tooltipLabel, fontWeight: 700 }}
|
||||
itemStyle={{ color: tickColor }}
|
||||
/>
|
||||
<Bar dataKey="victimas" fill={COLOR.red} radius={[8, 8, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Distribución por tipo, franja y localidad */}
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<PorTipoSiniestro siniestros={siniestros} />
|
||||
<FranjaHoraria siniestros={siniestros} />
|
||||
<PorLocalidad siniestros={siniestros} />
|
||||
</div>
|
||||
|
||||
{/* Tabla histórica */}
|
||||
<div data-pdf-block className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
||||
<div className="mb-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">
|
||||
Tabla histórica
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl font-black text-opsv-navy">
|
||||
Histórico de siniestros
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="min-w-full text-left text-sm"
|
||||
style={{ borderCollapse: 'collapse' }}
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b-2 border-opsv-border">
|
||||
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">
|
||||
Año
|
||||
</th>
|
||||
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">
|
||||
Siniestros
|
||||
</th>
|
||||
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">
|
||||
Víctimas
|
||||
</th>
|
||||
<th className="pb-3 font-bold uppercase tracking-wider text-opsv-navy">
|
||||
Tasa
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataTabla.map((row) => (
|
||||
<tr
|
||||
key={row.ano}
|
||||
className={`border-b border-opsv-border/60 transition-colors hover:bg-opsv-bg ${
|
||||
row.ano === yearNum &&
|
||||
!SERIE_HISTORICA.find((r) => r.ano === yearNum)
|
||||
? 'bg-blue-500/5 font-semibold'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 pr-6 font-medium text-opsv-navy">
|
||||
{row.ano}
|
||||
</td>
|
||||
<td className="py-3 pr-6 text-opsv-text">
|
||||
{row.siniestros}
|
||||
</td>
|
||||
<td className="py-3 pr-6 text-opsv-text">
|
||||
{row.victimas ?? '—'}
|
||||
</td>
|
||||
<td className="py-3 text-opsv-text">{row.tasa ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user