Primer commit — OPSV Dashboard de siniestralidad vial
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user