281 lines
8.2 KiB
React
281 lines
8.2 KiB
React
/**
|
|
* 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>
|
|
)
|
|
} |