Primer commit — OPSV Dashboard de siniestralidad vial

This commit is contained in:
2026-04-29 13:39:09 -03:00
commit ca7b159657
67 changed files with 12246 additions and 0 deletions
@@ -0,0 +1,281 @@
/**
* ProteccionPersonas.jsx — v3 CORREGIDA
*/
const VERDE = '#2D7A4F'
const ROJO = '#C0392B'
const GRIS = '#4A4E6A'
const NARANJA = '#E8881A'
const CON_HABITACULO = new Set([
'Automóvil',
'Camioneta/Utilitario',
'Transporte De Pasajeros',
'Transporte De Carga',
])
const SIN_HABITACULO = new Set([
'Motocicleta',
'Peatón',
'Bicicleta',
'Tracción A Sangre',
'Tracción a Sangre',
])
function pct(n, base) {
return base > 0 ? `${((n / base) * 100).toFixed(1)}%` : '—'
}
function IndicadorChip({
titulo,
icon,
base,
baseLabel,
si,
no,
sd,
colorSi = VERDE,
}) {
return (
<div className="rounded-xl border border-opsv-border/70 bg-opsv-surface/5 p-4">
<div className="mb-2 flex items-start justify-between">
<div className="flex items-center gap-1.5">
{icon && <span className="text-base">{icon}</span>}
<span className="text-sm font-bold text-opsv-navy">
{titulo}
</span>
</div>
<div className="rounded-md bg-opsv-surface/60 px-2 py-[0.1rem] text-[0.78rem] text-opsv-muted">
base: {base.toLocaleString('es-AR')} {baseLabel}
</div>
</div>
{base > 0 ? (
<>
<div className="mb-2 flex h-5 overflow-hidden rounded bg-opsv-bg/40">
{si > 0 && (
<div
className="transition-[width] duration-300"
style={{
width: pct(si, base),
background: colorSi,
}}
title={`Sí: ${si}`}
/>
)}
{no > 0 && (
<div
className="transition-[width] duration-300"
style={{
width: pct(no, base),
background: ROJO,
}}
title={`No: ${no}`}
/>
)}
{sd > 0 && (
<div
className="opacity-60 transition-[width] duration-300"
style={{
width: pct(sd, base),
background: GRIS,
}}
title={`S/D: ${sd}`}
/>
)}
</div>
<div className="flex flex-wrap gap-4 text-[0.78rem]">
{[
{ label: 'Sí', v: si, color: colorSi },
{ label: 'No', v: no, color: ROJO },
{ label: 'Sin dato', v: sd, color: GRIS },
]
.filter((item) => item.v > 0)
.map((item) => (
<div
key={item.label}
className="flex items-baseline gap-1"
style={{ color: item.color }}
>
<strong
className="tabular-nums"
>
{item.v.toLocaleString('es-AR')}
</strong>
<span className="opacity-80">
{` ${item.label} (${pct(item.v, base)})`}
</span>
</div>
))}
</div>
</>
) : (
<div className="text-[0.78rem] italic text-opsv-muted">
Sin datos para esta categoría en el período seleccionado
</div>
)}
</div>
)
}
function SeccionAlcohol({ personas }) {
const total = personas.length
const pos = personas.filter((p) => p.prueba_alcohol === 'Positivo').length
const neg = personas.filter((p) => p.prueba_alcohol === 'Negativo').length
const nr = personas.filter((p) => p.prueba_alcohol === 'No se Realizó').length
const sd = total - pos - neg - nr
const realizadas = pos + neg
const items = [
{ label: 'Positivo', v: pos, color: ROJO },
{ label: 'Negativo', v: neg, color: VERDE },
{ label: 'No realizada', v: nr, color: NARANJA },
{ label: 'Sin dato', v: sd, color: GRIS },
].filter((i) => i.v > 0)
return (
<div className="rounded-xl border border-opsv-border/70 bg-opsv-surface/5 p-4">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-sm font-bold text-opsv-navy">
Prueba de alcohol
</span>
<span className="rounded-md bg-opsv-surface/60 px-2 py-[0.1rem] text-[0.78rem] text-opsv-muted">
base: {total.toLocaleString('es-AR')} personas totales
</span>
</div>
<div className="mb-2 flex flex-wrap gap-2">
{items.map((item) => (
<div
key={item.label}
className="flex-1 min-w-[80px] rounded-lg border-l-[3px] px-3 py-2"
style={{
borderColor: item.color,
background: `${item.color}18`,
}}
>
<div
className="text-lg font-extrabold tabular-nums"
style={{ color: item.color }}
>
{item.v.toLocaleString('es-AR')}
</div>
<div className="text-[0.78rem] text-opsv-muted">
{item.label}
<br />
<span className="opacity-70">
{pct(item.v, total)}
</span>
</div>
</div>
))}
</div>
{realizadas > 0 && (
<div className="mt-2 text-[0.75rem] text-opsv-muted">
Tasa de positividad sobre pruebas realizadas (
{realizadas.toLocaleString('es-AR')}
):
{' '}
<strong
style={{ color: pos > 0 ? ROJO : VERDE }}
>
{((pos / realizadas) * 100).toFixed(1)}%
</strong>
</div>
)}
</div>
)
}
export default function ProteccionPersonas({ personas, involucrados }) {
if (!personas?.length) return null
const tipoMap = new Map()
if (involucrados?.length) {
involucrados.forEach((i) => {
if (i.id_involucrado != null && i.tipo_involucrado) {
tipoMap.set(String(i.id_involucrado), i.tipo_involucrado)
}
})
}
const personasConTipo = personas.map((p) => ({
...p,
_tipo: tipoMap.get(String(p.id_involucrado)) || null,
}))
const motociclistas = personasConTipo.filter(
(p) => p._tipo === 'Motocicleta'
)
const cascaSi = motociclistas.filter((p) => p.casco === 'Si').length
const cascaNo = motociclistas.filter((p) => p.casco === 'No').length
const cascaSD = motociclistas.length - cascaSi - cascaNo
const enHabitaculo = personasConTipo.filter((p) => {
if (!p._tipo) return false
if (SIN_HABITACULO.has(p._tipo)) return false
if (CON_HABITACULO.has(p._tipo)) return true
return true
})
const cinSi = enHabitaculo.filter(
(p) => p.cinturon_seguridad === 'Si'
).length
const cinNo = enHabitaculo.filter(
(p) => p.cinturon_seguridad === 'No'
).length
const cinSD = enHabitaculo.length - cinSi - cinNo
const airSi = enHabitaculo.filter((p) => p.airbag === 'Si').length
const airNo = enHabitaculo.filter((p) => p.airbag === 'No').length
const airSD = enHabitaculo.length - airSi - airNo
const sinMapa = tipoMap.size === 0
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
{sinMapa && (
<div className="mb-4 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[0.76rem] text-amber-600">
No se recibieron datos de Involucrados los cálculos de casco/cinturón/airbag
no pueden segmentarse por tipo de vehículo. Verificá que el componente reciba
la prop <code>involucrados</code>.
</div>
)}
<div className="flex flex-col gap-3">
<IndicadorChip
titulo="Uso de casco"
base={motociclistas.length}
baseLabel="motociclistas"
si={cascaSi}
no={cascaNo}
sd={cascaSD}
/>
<IndicadorChip
titulo="Uso de cinturón"
base={enHabitaculo.length}
baseLabel="en vehículo c/habitáculo"
si={cinSi}
no={cinNo}
sd={cinSD}
/>
<IndicadorChip
titulo="Airbag activado"
base={enHabitaculo.length}
baseLabel="en vehículo c/habitáculo"
si={airSi}
no={airNo}
sd={airSD}
colorSi="#3A7EBF"
/>
<SeccionAlcohol personas={personas} />
</div>
</div>
)
}