feat: múltiples mejoras y correcciones
- Agrega SecMapa con visualización geográfica de siniestros - Actualiza exportación a PDF con nuevas secciones - Corrige colores de interfaz (modo claro/oscuro) - Corrige cálculo de víctimas fatales en SecSintesis usando campo 'fallecidos' - Corrige serie histórica para reflejar año filtrado correctamente
This commit is contained in:
Generated
+65
@@ -12,9 +12,14 @@
|
|||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"html2canvas-pro": "^2.0.2",
|
"html2canvas-pro": "^2.0.2",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet-image": "^0.4.0",
|
||||||
|
"leaflet.heat": "^0.2.0",
|
||||||
|
"leaflet.markercluster": "^1.5.3",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"recharts": "^3.8.1"
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
@@ -603,6 +608,17 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.11.2",
|
"version": "2.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
@@ -1845,6 +1861,12 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-queue": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-queue/-/d3-queue-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-ejbdHqZYEmk9ns/ljSbEcD6VRiuNwAkZMdFf6rsUb3vHROK5iMFd8xewDQnUVr6m/ba2BG63KmR/LySfsluxbg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/d3-scale": {
|
"node_modules/d3-scale": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
@@ -2624,6 +2646,35 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/leaflet-image": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet-image/-/leaflet-image-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-J/vLCHiYNXlcQ/SZbHhj/VF5k3thxTryWijoqMO9sB20KV7hlMNUZDgxcDzXnfjk4hcYcFfGbveVc1tyQ9FgYw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-queue": "2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/leaflet.heat": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
|
||||||
|
},
|
||||||
|
"node_modules/leaflet.markercluster": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -3219,6 +3270,20 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
|||||||
@@ -14,9 +14,14 @@
|
|||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"html2canvas-pro": "^2.0.2",
|
"html2canvas-pro": "^2.0.2",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet-image": "^0.4.0",
|
||||||
|
"leaflet.heat": "^0.2.0",
|
||||||
|
"leaflet.markercluster": "^1.5.3",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"recharts": "^3.8.1"
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
|
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
|
||||||
import { calcularKPIs } from '../../utils/calculos'
|
import { calcularKPIs } from '../../utils/calculos'
|
||||||
import { COLOR } from '../../utils/colores'
|
import { COLOR } from '../../utils/colores'
|
||||||
|
|
||||||
@@ -8,26 +8,41 @@ const sectors = [
|
|||||||
{ key: 'sinLes', label: 'Sin Lesiones', color: COLOR.sinLes },
|
{ key: 'sinLes', label: 'Sin Lesiones', color: COLOR.sinLes },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Tooltip personalizado igual al estilo de los otros gráficos
|
||||||
|
function CustomTooltip({ active, payload }) {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
const { name, value, color, pct } = payload[0].payload
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-opsv-border bg-opsv-surface px-4 py-3 shadow-md">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-opsv-muted">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full" style={{ background: color }} />
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-black text-opsv-navy">
|
||||||
|
{value}
|
||||||
|
<span className="ml-2 text-sm font-semibold text-opsv-muted">{pct}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function DonutGravedad({ siniestros }) {
|
export default function DonutGravedad({ siniestros }) {
|
||||||
const kpis = calcularKPIs(siniestros)
|
const kpis = calcularKPIs(siniestros)
|
||||||
|
const total = kpis.total || 1
|
||||||
|
|
||||||
const data = sectors.map((sector) => ({
|
const data = sectors.map((sector) => ({
|
||||||
name: sector.label,
|
name: sector.label,
|
||||||
value: kpis[sector.key],
|
value: kpis[sector.key],
|
||||||
color: sector.color,
|
color: sector.color,
|
||||||
|
pct: `${((kpis[sector.key] / total) * 100).toFixed(1)}%`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
<>
|
||||||
<div className="mb-6 flex items-center justify-between gap-3">
|
<div className="relative h-[280px] w-full">
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">Gravedad por categoría</p>
|
|
||||||
<h3 className="mt-2 text-xl font-black text-opsv-navy">Distribución de siniestros</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative h-[320px] w-full">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Pie
|
<Pie
|
||||||
data={data}
|
data={data}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
@@ -51,7 +66,8 @@ export default function DonutGravedad({ siniestros }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
{/* Leyenda con porcentajes */}
|
||||||
|
<div className="mt-6 grid gap-3 sm:grid-cols-3">
|
||||||
{data.map((item) => (
|
{data.map((item) => (
|
||||||
<div key={item.name} className="rounded-3xl bg-opsv-bg p-4">
|
<div key={item.name} className="rounded-3xl bg-opsv-bg p-4">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-opsv-muted">
|
<div className="flex items-center gap-2 text-sm font-semibold text-opsv-muted">
|
||||||
@@ -59,9 +75,10 @@ export default function DonutGravedad({ siniestros }) {
|
|||||||
{item.name}
|
{item.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-3xl font-black text-opsv-navy">{item.value}</div>
|
<div className="mt-3 text-3xl font-black text-opsv-navy">{item.value}</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-opsv-muted">{item.pct}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import 'leaflet.markercluster/dist/leaflet.markercluster.js'
|
||||||
|
import 'leaflet.heat/dist/leaflet-heat.js'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const GRAVEDAD = {
|
||||||
|
fatal: { color: '#C44228', label: 'Fatal' },
|
||||||
|
lesionado: { color: '#E8881A', label: 'Lesionado' },
|
||||||
|
sinlesiones: { color: '#6B7280', label: 'Sin lesiones' },
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const BBOX_SANTA_CRUZ = {
|
||||||
|
latMin: -52.4,
|
||||||
|
latMax: -46.0,
|
||||||
|
lngMin: -73.6,
|
||||||
|
lngMax: -65.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function dentroDeRango(lat, lng) {
|
||||||
|
return (
|
||||||
|
lat >= BBOX_SANTA_CRUZ.latMin &&
|
||||||
|
lat <= BBOX_SANTA_CRUZ.latMax &&
|
||||||
|
lng >= BBOX_SANTA_CRUZ.lngMin &&
|
||||||
|
lng <= BBOX_SANTA_CRUZ.lngMax
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getGravedad(s) {
|
||||||
|
const fatales = Number(s.cantidad_fallecidos ?? s.fallecidos ?? 0)
|
||||||
|
const lesion = Number(s.cantidad_lesionados ?? s.heridos ?? 0)
|
||||||
|
if (fatales > 0) return 'fatal'
|
||||||
|
if (lesion > 0) return 'lesionado'
|
||||||
|
return 'sinlesiones'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function formatFecha(s) {
|
||||||
|
const dia = s.dia ? String(s.dia).padStart(2, '0') : '??'
|
||||||
|
const mes = s.mes ? String(s.mes).padStart(2, '0') : '??'
|
||||||
|
const ano = s.ano ?? s.year ?? '????'
|
||||||
|
return `${dia}/${mes}/${ano}`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const DARK_FILTER = 'brightness(0.55) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)'
|
||||||
|
|
||||||
|
|
||||||
|
export default function MapaSiniestros({ siniestros, siniestrosMapa, vista, darkMode }) {
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const mapRef = useRef(null)
|
||||||
|
const tileRef = useRef(null)
|
||||||
|
const clusterRef = useRef(null)
|
||||||
|
const heatRef = useRef(null)
|
||||||
|
const pointsRef = useRef(null)
|
||||||
|
const [mapaListo, setMapaListo] = useState(false)
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || mapRef.current) return
|
||||||
|
|
||||||
|
const map = L.map(containerRef.current, {
|
||||||
|
center: [-50.0, -69.0],
|
||||||
|
zoom: 6,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
tileRef.current = L.tileLayer(
|
||||||
|
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
{ attribution: '© OpenStreetMap contributors', maxZoom: 19 }
|
||||||
|
).addTo(map)
|
||||||
|
|
||||||
|
mapRef.current = map
|
||||||
|
setMapaListo(true)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.remove()
|
||||||
|
mapRef.current = null
|
||||||
|
tileRef.current = null
|
||||||
|
setMapaListo(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = tileRef.current?.getContainer()
|
||||||
|
if (el) el.style.filter = darkMode ? DARK_FILTER : ''
|
||||||
|
}, [darkMode])
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return
|
||||||
|
|
||||||
|
if (clusterRef.current) { mapRef.current.removeLayer(clusterRef.current); clusterRef.current = null }
|
||||||
|
if (heatRef.current) { mapRef.current.removeLayer(heatRef.current); heatRef.current = null }
|
||||||
|
if (pointsRef.current) { mapRef.current.removeLayer(pointsRef.current); pointsRef.current = null }
|
||||||
|
|
||||||
|
const fuenteMapa = siniestrosMapa ?? []
|
||||||
|
|
||||||
|
const validos = fuenteMapa.filter((s) => {
|
||||||
|
const lat = Number(s.latitud_norm)
|
||||||
|
const lng = Number(s.longitud_norm)
|
||||||
|
return (
|
||||||
|
s.latitud_norm != null &&
|
||||||
|
s.longitud_norm != null &&
|
||||||
|
!isNaN(lat) &&
|
||||||
|
!isNaN(lng) &&
|
||||||
|
dentroDeRango(lat, lng)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (validos.length === 0) return
|
||||||
|
|
||||||
|
const datosCompletos = Object.fromEntries(
|
||||||
|
(siniestros ?? []).map((s) => [s.id_feu, s])
|
||||||
|
)
|
||||||
|
|
||||||
|
const latlngs = validos.map((s) => [
|
||||||
|
Number(s.latitud_norm),
|
||||||
|
Number(s.longitud_norm),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (vista === 'marcadores') {
|
||||||
|
const group = L.layerGroup()
|
||||||
|
|
||||||
|
validos.forEach((s) => {
|
||||||
|
const dato = datosCompletos[s.id_feu] ?? s
|
||||||
|
const grav = getGravedad(dato)
|
||||||
|
const color = GRAVEDAD[grav].color
|
||||||
|
|
||||||
|
const marker = L.circleMarker(
|
||||||
|
[Number(s.latitud_norm), Number(s.longitud_norm)],
|
||||||
|
{ radius: 6, fillColor: color, color: '#ffffff', weight: 2, opacity: 1, fillOpacity: 0.9 }
|
||||||
|
)
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div style="font-family:sans-serif;min-width:180px;line-height:1.5">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
|
||||||
|
<div style="width:10px;height:10px;border-radius:50%;background:${color};flex-shrink:0"></div>
|
||||||
|
<strong style="font-size:13px">${GRAVEDAD[grav].label}</strong>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:#555">
|
||||||
|
<div><b>Fecha:</b> ${formatFecha(dato)}</div>
|
||||||
|
<div><b>Tipo:</b> ${dato.tipo_siniestro_unico || dato.tipo_siniestro || 'Sin dato'}</div>
|
||||||
|
<div><b>Localidad:</b> ${dato.localidad || 'Sin dato'}</div>
|
||||||
|
<div><b>Departamento:</b> ${dato.departamento || 'Sin dato'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`, { maxWidth: 240 })
|
||||||
|
|
||||||
|
group.addLayer(marker)
|
||||||
|
})
|
||||||
|
|
||||||
|
pointsRef.current = group
|
||||||
|
mapRef.current.addLayer(group)
|
||||||
|
|
||||||
|
if (latlngs.length === 1) {
|
||||||
|
mapRef.current.setView(latlngs[0], 12)
|
||||||
|
} else {
|
||||||
|
mapRef.current.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] })
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const points = validos.map((s) => {
|
||||||
|
const dato = datosCompletos[s.id_feu] ?? s
|
||||||
|
const grav = getGravedad(dato)
|
||||||
|
const intensity = grav === 'fatal' ? 1.0 : grav === 'lesionado' ? 0.5 : 0.2
|
||||||
|
return [Number(s.latitud_norm), Number(s.longitud_norm), intensity]
|
||||||
|
})
|
||||||
|
|
||||||
|
heatRef.current = L.heatLayer(points, {
|
||||||
|
radius: 25,
|
||||||
|
blur: 20,
|
||||||
|
maxZoom: 12,
|
||||||
|
gradient: { 0.2: '#80B0DE', 0.5: '#E8881A', 0.8: '#C44228', 1.0: '#7B0000' },
|
||||||
|
}).addTo(mapRef.current)
|
||||||
|
|
||||||
|
if (latlngs.length === 1) {
|
||||||
|
mapRef.current.setView(latlngs[0], 12)
|
||||||
|
} else {
|
||||||
|
mapRef.current.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [siniestros, siniestrosMapa, vista, mapaListo])
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full rounded-[20px] overflow-hidden"
|
||||||
|
style={{ height: '520px', zIndex: 0 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ export default function PorLocalidad({ siniestros, tipo = 'todas' }) {
|
|||||||
|
|
||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
|
allowDecimals={false}
|
||||||
tick={{ fill: tickColor, fontSize: 12 }}
|
tick={{ fill: tickColor, fontSize: 12 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ export default function PorTipoSiniestro({ siniestros }) {
|
|||||||
data={data}
|
data={data}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
nameKey="name"
|
nameKey="name"
|
||||||
cx="60%"
|
cx="100%"
|
||||||
cy="50%"
|
cy="30%"
|
||||||
outerRadius={95}
|
outerRadius={95}
|
||||||
innerRadius={58}
|
innerRadius={58}
|
||||||
paddingAngle={4}
|
paddingAngle={4}
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ export const SERIE_HISTORICA = [
|
|||||||
{ ano: 2019, siniestros: 1178, victimas: 31, tasa: 8.64 },
|
{ ano: 2019, siniestros: 1178, victimas: 31, tasa: 8.64 },
|
||||||
// 2020 excluido
|
// 2020 excluido
|
||||||
{ ano: 2021, siniestros: 1043, victimas: 24, tasa: 6.40 },
|
{ ano: 2021, siniestros: 1043, victimas: 24, tasa: 6.40 },
|
||||||
{ ano: 2022, siniestros: 1134, victimas: 26, tasa: 7.80 },
|
{ ano: 2022, siniestros: 1134, victimas: 27, tasa: 7.80 },
|
||||||
{ ano: 2023, siniestros: 1198, victimas: 26, tasa: 7.71 },
|
{ ano: 2023, siniestros: 1198, victimas: 25, tasa: 7.71 },
|
||||||
{ ano: 2024, siniestros: 1238, victimas: 24, tasa: 7.12 },
|
{ ano: 2024, siniestros: 1238, victimas: 26, tasa: 7.12 },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ─── Tooltip ──────────────────────────
|
// ─── Tooltip ──────────────────────────
|
||||||
@@ -151,17 +151,7 @@ export default function SerieHistorica({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
<div 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">
|
|
||||||
Serie histórica
|
|
||||||
</p>
|
|
||||||
<h3 className="mt-2 text-xl font-black text-opsv-navy">
|
|
||||||
Tasa de mortalidad vial
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 text-sm text-opsv-muted">
|
|
||||||
Víctimas fatales cada 100.000 habitantes. Provincia de Santa Cruz, {primerAnio}–{ultimoAnio}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-[300px]">
|
<div className="h-[300px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
@@ -228,12 +218,12 @@ export default function SerieHistorica({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-1">
|
<div className="mt-4 space-y-1">
|
||||||
<p className="text-xs text-opsv-muted">
|
<p className="text-sm text-opsv-muted">
|
||||||
Tasas 2013–2024: informes anuales OPSV Santa Cruz. Tasas 2025 en adelante:
|
Tasas 2013–2024: informes anuales OPSV Santa Cruz. Tasas 2025 en adelante:
|
||||||
cálculo propio sobre proyecciones INDEC base Censo Nacional de Población,
|
cálculo propio sobre proyecciones INDEC base Censo Nacional de Población,
|
||||||
Hogares y Viviendas 2022.
|
Hogares y Viviendas 2022.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-amber-600/80">
|
<p className="text-sm text-amber-600/80">
|
||||||
<span className="font-semibold">Nota:</span> el año 2020 no se incluye en la
|
<span className="font-semibold">Nota:</span> el año 2020 no se incluye en la
|
||||||
serie histórica. Las restricciones de circulación por la pandemia de COVID-19
|
serie histórica. Las restricciones de circulación por la pandemia de COVID-19
|
||||||
generaron una reducción atípica de la movilidad vehicular, por lo que los datos
|
generaron una reducción atípica de la movilidad vehicular, por lo que los datos
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ export default function SiniestrosPorMes({ siniestros }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
||||||
|
|
||||||
<div className="h-[320px]">
|
<div className="h-[320px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={data} margin={{ top: 10, right: 0, left: -10, bottom: 0 }}>
|
<BarChart data={data} margin={{ top: 10, right: 0, left: -10, bottom: 0 }}>
|
||||||
@@ -99,19 +98,76 @@ export default function SiniestrosPorMes({ siniestros }) {
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
tick={{ fill: tickColor, fontSize: 12 }}
|
tick={{ fill: tickColor, fontSize: 12 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
content={({ active, payload, label }) => {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
|
||||||
|
// Filtramos las series cuyo valor es 0
|
||||||
|
const visibles = payload.filter((item) => Number(item.value) > 0)
|
||||||
|
if (!visibles.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
background: tooltipBg,
|
background: tooltipBg,
|
||||||
border: `1px solid ${tooltipBorder}`,
|
border: `1px solid ${tooltipBorder}`,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
|
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
|
||||||
|
padding: '10px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: tooltipLabel,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visibles.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: tickColor,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: item.color,
|
||||||
|
display: 'inline-block',
|
||||||
}}
|
}}
|
||||||
labelStyle={{ color: tooltipLabel, fontWeight: 700 }}
|
|
||||||
/>
|
/>
|
||||||
|
<span>{item.name || item.dataKey}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontWeight: 700, color: tooltipLabel }}>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Bar dataKey="fatales" stackId="a" fill={COLOR.fatales} radius={[8, 8, 0, 0]} />
|
<Bar dataKey="fatales" stackId="a" fill={COLOR.fatales} radius={[8, 8, 0, 0]} />
|
||||||
<Bar dataKey="conLes" stackId="a" fill={COLOR.conLes} radius={[8, 8, 0, 0]} />
|
<Bar dataKey="conLes" stackId="a" fill={COLOR.conLes} radius={[8, 8, 0, 0]} />
|
||||||
<Bar dataKey="sinLes" stackId="a" fill={COLOR.sinLes} radius={[8, 8, 0, 0]} />
|
<Bar dataKey="sinLes" stackId="a" fill={COLOR.sinLes} radius={[8, 8, 0, 0]} />
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
FileText,
|
FileText,
|
||||||
Sun,
|
Sun,
|
||||||
|
Map, // ← NUEVO
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
|
||||||
const SECCIONES = [
|
const SECCIONES = [
|
||||||
{ id: 'resumen', label: 'Resumen General', icon: LayoutDashboard },
|
{ id: 'resumen', label: 'Resumen General', icon: LayoutDashboard },
|
||||||
{ id: 'historica', label: 'Serie Histórica Provincial', icon: TrendingUp },
|
{ id: 'historica', label: 'Serie Histórica Provincial', icon: TrendingUp },
|
||||||
@@ -15,10 +17,11 @@ const SECCIONES = [
|
|||||||
{ id: 'lesionados', label: 'Con Lesionados', icon: Activity },
|
{ id: 'lesionados', label: 'Con Lesionados', icon: Activity },
|
||||||
{ id: 'sinlesiones',label: 'Sin Lesiones', icon: ShieldCheck },
|
{ id: 'sinlesiones',label: 'Sin Lesiones', icon: ShieldCheck },
|
||||||
{ id: 'sintesis', label: 'Síntesis', icon: FileText },
|
{ id: 'sintesis', label: 'Síntesis', icon: FileText },
|
||||||
|
{ id: 'mapa', label: 'Mapa de Siniestros', icon: Map }, // ← NUEVO
|
||||||
{ id: 'veranovivo', label: 'Campañas Verano Vivo', icon: Sun },
|
{ id: 'veranovivo', label: 'Campañas Verano Vivo', icon: Sun },
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
seccion,
|
seccion,
|
||||||
setSeccion,
|
setSeccion,
|
||||||
@@ -33,22 +36,22 @@ export default function Sidebar({
|
|||||||
<img
|
<img
|
||||||
src="/logo-opsv.png"
|
src="/logo-opsv.png"
|
||||||
alt="Observatorio Provincial de Seguridad Vial"
|
alt="Observatorio Provincial de Seguridad Vial"
|
||||||
className="h-12 w-12 rounded-xl object-contain bg-white/5 p-1"
|
className="h-25 w-25 rounded-xl object-contain bg-white/5 p-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-3xl font-black tracking-tight">OPSV</div>
|
<div className="text-3xl font-black tracking-tight text-center">OPSV</div>
|
||||||
|
|
||||||
<div className="mt-1 text-sm leading-5 text-slate-200">
|
<div className="mt-1 text-sm leading-5 text-slate-200 text-center">
|
||||||
Observatorio Provincial
|
Observatorio Provincial
|
||||||
<br />
|
<br />
|
||||||
de Seguridad Vial
|
de Seguridad Vial
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 text-[11px] uppercase tracking-[0.25em] text-opsv-blue text-center">
|
<div className="mt-2 text-[13px] uppercase tracking-[0.25em] text-opsv-blue text-center">
|
||||||
APSV
|
APSV
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-[11px] uppercase tracking-[0.25em] text-opsv-blue">
|
<div className="mt-2 text-[13px] uppercase tracking-[0.25em] text-opsv-blue text-center">
|
||||||
Ministerio de Seguridad · Santa Cruz
|
Ministerio de Seguridad · Santa Cruz
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// src/components/layout/Topbar.jsx
|
// src/components/layout/Topbar.jsx
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { CalendarRange, ChevronDown, X } from 'lucide-react'
|
import { CalendarRange, ChevronDown, X } from 'lucide-react'
|
||||||
import { Building2, MapPin } from 'lucide-react'
|
import { Building2, MapPin, Map } from 'lucide-react'
|
||||||
import ThemeToggle from '../ui/ThemeToggle'
|
import ThemeToggle from '../ui/ThemeToggle'
|
||||||
import FilterSelect from '../ui/FilterSelect'
|
import FilterSelect from '../ui/FilterSelect'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const TITULOS = {
|
const TITULOS = {
|
||||||
resumen: { title: 'Resumen General', subtitle: 'Indicadores principales del año seleccionado' },
|
resumen: { title: 'Resumen General', subtitle: 'Indicadores principales del año seleccionado' },
|
||||||
historica: { title: 'Serie Histórica Provincial', subtitle: 'Tasas y tendencias calculadas sobre el total de la provincia · sin filtro geográfico' },
|
historica: { title: 'Serie Histórica Provincial', subtitle: 'Tasas y tendencias calculadas sobre el total de la provincia · sin filtro geográfico' },
|
||||||
@@ -16,9 +17,11 @@ const TITULOS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const AÑOS = [2026, 2025, 2024, 2023, 2022, 2021]
|
const AÑOS = [2026, 2025, 2024, 2023, 2022, 2021]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const MESES = [
|
const MESES = [
|
||||||
{ value: 1, label: 'Enero' },
|
{ value: 1, label: 'Enero' },
|
||||||
{ value: 2, label: 'Febrero' },
|
{ value: 2, label: 'Febrero' },
|
||||||
@@ -35,11 +38,13 @@ const MESES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function periodoToValue(parte) {
|
function periodoToValue(parte) {
|
||||||
if (!parte?.mes || !parte?.ano) return ''
|
if (!parte?.mes || !parte?.ano) return ''
|
||||||
return `${parte.ano}-${String(parte.mes).padStart(2, '0')}`
|
return `${parte.ano}-${String(parte.mes).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function valueToPeriodo(value) {
|
function valueToPeriodo(value) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
const [ano, mes] = value.split('-').map(Number)
|
const [ano, mes] = value.split('-').map(Number)
|
||||||
@@ -47,11 +52,13 @@ function valueToPeriodo(value) {
|
|||||||
return { ano, mes }
|
return { ano, mes }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function periodoToNumber(parte) {
|
function periodoToNumber(parte) {
|
||||||
if (!parte?.ano || !parte?.mes) return null
|
if (!parte?.ano || !parte?.mes) return null
|
||||||
return parte.ano * 100 + parte.mes
|
return parte.ano * 100 + parte.mes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function formatPeriodo(periodo) {
|
function formatPeriodo(periodo) {
|
||||||
if (!periodo?.desde && !periodo?.hasta) return 'Filtro por fecha'
|
if (!periodo?.desde && !periodo?.hasta) return 'Filtro por fecha'
|
||||||
|
|
||||||
@@ -67,7 +74,7 @@ function formatPeriodo(periodo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Clases del botón de período (sigue siendo nativo, no usa FilterSelect)
|
|
||||||
const pillBase = 'flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm font-medium transition'
|
const pillBase = 'flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm font-medium transition'
|
||||||
const pillInactive =
|
const pillInactive =
|
||||||
'border-opsv-border dark:border-slate-600 bg-white dark:bg-slate-800 ' +
|
'border-opsv-border dark:border-slate-600 bg-white dark:bg-slate-800 ' +
|
||||||
@@ -78,6 +85,7 @@ const pillActive =
|
|||||||
'dark:border-sky-400 dark:bg-sky-500/15 dark:text-white'
|
'dark:border-sky-400 dark:bg-sky-500/15 dark:text-white'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function Topbar({
|
export default function Topbar({
|
||||||
seccion,
|
seccion,
|
||||||
year,
|
year,
|
||||||
@@ -91,6 +99,9 @@ export default function Topbar({
|
|||||||
localidadFiltro,
|
localidadFiltro,
|
||||||
setLocalidadFiltro,
|
setLocalidadFiltro,
|
||||||
localidadesDisponibles,
|
localidadesDisponibles,
|
||||||
|
zonaFiltro,
|
||||||
|
setZonaFiltro,
|
||||||
|
zonasDisponibles,
|
||||||
onExportarPdf,
|
onExportarPdf,
|
||||||
}) {
|
}) {
|
||||||
const { title, subtitle } = TITULOS[seccion] || TITULOS.resumen
|
const { title, subtitle } = TITULOS[seccion] || TITULOS.resumen
|
||||||
@@ -152,18 +163,24 @@ export default function Topbar({
|
|||||||
setOpenFiltro(false)
|
setOpenFiltro(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex flex-col gap-4 border-b border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-900 px-6 py-5">
|
<header className="flex flex-col gap-3 border-b border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-900 px-6 py-5">
|
||||||
|
|
||||||
|
{/* ── Fila 1: título + filtros de datos ───────────────────────────── */}
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
|
||||||
|
{/* Título */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.35em] text-opsv-muted dark:text-slate-400">OPSV</p>
|
<p className="text-sm uppercase tracking-[0.35em] text-opsv-muted dark:text-slate-400">OPSV</p>
|
||||||
<h1 className="mt-2 text-3xl font-black text-opsv-navy dark:text-white">{title}</h1>
|
<h1 className="mt-2 text-3xl font-black !text-white">{title}</h1>
|
||||||
<p className="mt-1 text-sm text-opsv-muted dark:text-slate-400">{subtitle}</p>
|
<p className="mt-1 text-base !text-white/70">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filtros de datos */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|
||||||
{/* ── Selector de año ── */}
|
{/* Selector de año */}
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
value={String(year)}
|
value={String(year)}
|
||||||
onChange={(v) => setYear(Number(v))}
|
onChange={(v) => setYear(Number(v))}
|
||||||
@@ -171,7 +188,7 @@ export default function Topbar({
|
|||||||
placeholder="Año"
|
placeholder="Año"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Filtro por período ── sin cambios, botón nativo ── */}
|
{/* Filtro por período */}
|
||||||
<div className="relative" ref={panelRef}>
|
<div className="relative" ref={panelRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -245,8 +262,8 @@ export default function Topbar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Filtro por departamento (oculto en Verano Vivo) ── */}
|
{/* Filtro por departamento */}
|
||||||
{seccion !== 'veranovivo' !== 'historica'&& (
|
{seccion !== 'veranovivo' && seccion !== 'historica' && (
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
icon={Building2}
|
icon={Building2}
|
||||||
value={departamentoFiltro}
|
value={departamentoFiltro}
|
||||||
@@ -256,8 +273,8 @@ export default function Topbar({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Filtro por localidad (oculto en Verano Vivo, deshabilitado sin depto.) ── */}
|
{/* Filtro por localidad */}
|
||||||
{seccion !== 'veranovivo' !== 'historica' && (
|
{seccion !== 'veranovivo' && seccion !== 'historica' && (
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
icon={MapPin}
|
icon={MapPin}
|
||||||
value={localidadFiltro}
|
value={localidadFiltro}
|
||||||
@@ -269,25 +286,47 @@ export default function Topbar({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Filtro por zona */}
|
||||||
|
{seccion !== 'veranovivo' && seccion !== 'historica' && (
|
||||||
|
<FilterSelect
|
||||||
|
icon={Map}
|
||||||
|
value={zonaFiltro}
|
||||||
|
onChange={setZonaFiltro}
|
||||||
|
options={(zonasDisponibles ?? []).map((z) => ({ value: z, label: z }))}
|
||||||
|
placeholder="Todas las zonas"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Descargar PDF ── */}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Fila 2: contador (centro) + acciones (derecha) ──────────────── */}
|
||||||
|
<div className="flex items-center justify-end gap-3"> {/* ← todo a la derecha */}
|
||||||
|
|
||||||
|
{/* Contador — tipografía más grande, destacado */}
|
||||||
|
<div className="flex-1 text-center"> {/* ← ocupa el espacio del medio y centra */}
|
||||||
|
<span className="text-2xl font-black text-opsv-navy dark:text-white tabular-nums">
|
||||||
|
{(siniestrosCount ?? 0).toLocaleString('es-AR')}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-sm font-medium text-opsv-muted dark:text-slate-400">
|
||||||
|
siniestros
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Descargar PDF */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onExportarPdf}
|
onClick={onExportarPdf}
|
||||||
className="rounded-2xl bg-opsv-navy dark:bg-slate-700 px-4 py-3 text-sm font-semibold text-white transition hover:bg-opsv-navy-dark dark:hover:bg-slate-600"
|
className="rounded-2xl bg-opsv-navy dark:bg-slate-700 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-opsv-navy-dark dark:hover:bg-slate-600"
|
||||||
>
|
>
|
||||||
Descargar PDF
|
Descargar PDF
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Modo claro/oscuro */}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
{/* ── Contador ── */}
|
|
||||||
<div className="rounded-2xl border border-opsv-border dark:border-slate-700 bg-opsv-surface dark:bg-slate-800 px-4 py-3 text-sm font-medium text-opsv-navy dark:text-slate-200">
|
|
||||||
{siniestrosCount ?? 0} siniestros cargados
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// src/components/ui/ChartCard.jsx
|
|
||||||
const HEIGHTS = {
|
const HEIGHTS = {
|
||||||
sm: 'h-[300px]',
|
sm: 'h-[300px]',
|
||||||
md: 'h-[360px]',
|
md: 'h-[360px]',
|
||||||
@@ -15,10 +14,13 @@ export default function ChartCard({
|
|||||||
className = '',
|
className = '',
|
||||||
contentClassName = '',
|
contentClassName = '',
|
||||||
}) {
|
}) {
|
||||||
|
const isFixed = height !== 'auto'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
data-pdf-block // ← única línea nueva
|
data-pdf-block
|
||||||
className={`rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm overflow-hidden ${className}`}
|
className={`rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm ${className}`}
|
||||||
|
// ← overflow-hidden ELIMINADO del section
|
||||||
>
|
>
|
||||||
{(kicker || title || subtitle) && (
|
{(kicker || title || subtitle) && (
|
||||||
<header className="mb-5">
|
<header className="mb-5">
|
||||||
@@ -39,7 +41,12 @@ export default function ChartCard({
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
<div className={`min-w-0 w-full ${HEIGHTS[height]} ${contentClassName}`}>
|
{/* overflow-hidden solo en el div del contenido cuando altura es fija */}
|
||||||
|
<div
|
||||||
|
className={`min-w-0 w-full ${HEIGHTS[height]} ${
|
||||||
|
isFixed ? 'overflow-hidden' : ''
|
||||||
|
} ${contentClassName}`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,35 +4,24 @@ export default function KPICard({
|
|||||||
color,
|
color,
|
||||||
unit,
|
unit,
|
||||||
variation,
|
variation,
|
||||||
centered = false,
|
|
||||||
}) {
|
}) {
|
||||||
const formattedValue =
|
const formattedValue =
|
||||||
typeof value === 'number' ? value.toLocaleString('es-AR') : value
|
typeof value === 'number' ? value.toLocaleString('es-AR') : value
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
||||||
<div
|
<div className="flex flex-col items-center text-center gap-4">
|
||||||
className={`flex items-start justify-between gap-4 ${
|
<div className="flex flex-col items-center">
|
||||||
centered ? 'flex-col items-center text-center' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={centered ? 'flex flex-col items-center' : ''}>
|
|
||||||
<div
|
<div
|
||||||
className="text-3xl font-black text-opsv-navy"
|
className="text-3xl font-black text-opsv-navy"
|
||||||
style={color ? { color } : undefined}
|
style={color ? { color } : undefined}
|
||||||
>
|
>
|
||||||
{formattedValue}
|
{formattedValue}
|
||||||
{unit ? (
|
{unit ? (
|
||||||
<span className="text-base font-semibold text-opsv-muted">
|
<span className="text-base font-semibold text-opsv-muted"> {unit}</span>
|
||||||
{' '}
|
|
||||||
{unit}
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-opsv-muted">{label}</p>
|
||||||
<p className="mt-3 text-sm text-opsv-muted">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{variation ? (
|
{variation ? (
|
||||||
|
|||||||
+17
-13
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { supabasePublic } from '../lib/supabase'
|
import { supabasePublic } from '../lib/supabase'
|
||||||
|
|
||||||
|
|
||||||
function estaEnPeriodo(item, periodo) {
|
function estaEnPeriodo(item, periodo) {
|
||||||
if (!periodo?.desde || !periodo?.hasta) return true
|
if (!periodo?.desde || !periodo?.hasta) return true
|
||||||
|
|
||||||
@@ -14,10 +15,12 @@ function estaEnPeriodo(item, periodo) {
|
|||||||
return actualValor >= desdeValor && actualValor <= hastaValor
|
return actualValor >= desdeValor && actualValor <= hastaValor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useData(year = null, periodo = { desde: null, hasta: null }) {
|
export function useData(year = null, periodo = { desde: null, hasta: null }) {
|
||||||
const [siniestros, setSiniestros] = useState([])
|
const [siniestros, setSiniestros] = useState([])
|
||||||
const [involucrados, setInvolucrados] = useState([])
|
const [involucrados, setInvolucrados] = useState([])
|
||||||
const [personas, setPersonas] = useState([])
|
const [personas, setPersonas] = useState([])
|
||||||
|
const [siniestrosMapa, setSiniestrosMapa] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
@@ -34,6 +37,9 @@ export function useData(year = null, periodo = { desde: null, hasta: null }) {
|
|||||||
let qS = supabasePublic.from('siniestros').select('*')
|
let qS = supabasePublic.from('siniestros').select('*')
|
||||||
let qI = supabasePublic.from('Involucrados').select('*')
|
let qI = supabasePublic.from('Involucrados').select('*')
|
||||||
let qP = supabasePublic.from('Personas').select('*')
|
let qP = supabasePublic.from('Personas').select('*')
|
||||||
|
let qM = supabasePublic
|
||||||
|
.from('siniestros_mapa')
|
||||||
|
.select('id_feu, ano, mes, latitud_norm, longitud_norm')
|
||||||
|
|
||||||
if (periodoActivo) {
|
if (periodoActivo) {
|
||||||
const anoDesde = periodoActual.desde.ano
|
const anoDesde = periodoActual.desde.ano
|
||||||
@@ -42,39 +48,37 @@ export function useData(year = null, periodo = { desde: null, hasta: null }) {
|
|||||||
qS = qS.gte('ano', anoDesde).lte('ano', anoHasta)
|
qS = qS.gte('ano', anoDesde).lte('ano', anoHasta)
|
||||||
qI = qI.gte('ano', anoDesde).lte('ano', anoHasta)
|
qI = qI.gte('ano', anoDesde).lte('ano', anoHasta)
|
||||||
qP = qP.gte('ano', anoDesde).lte('ano', anoHasta)
|
qP = qP.gte('ano', anoDesde).lte('ano', anoHasta)
|
||||||
|
qM = qM.gte('ano', anoDesde).lte('ano', anoHasta)
|
||||||
} else if (year) {
|
} else if (year) {
|
||||||
qS = qS.eq('ano', year)
|
qS = qS.eq('ano', year)
|
||||||
qI = qI.eq('ano', year)
|
qI = qI.eq('ano', year)
|
||||||
qP = qP.eq('ano', year)
|
qP = qP.eq('ano', year)
|
||||||
|
qM = qM.eq('ano', year)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [resS, resI, resP] = await Promise.all([qS, qI, qP])
|
const [resS, resI, resP, resM] = await Promise.all([qS, qI, qP, qM])
|
||||||
|
|
||||||
if (resS.error) throw resS.error
|
if (resS.error) throw resS.error
|
||||||
if (resI.error) throw resI.error
|
if (resI.error) throw resI.error
|
||||||
if (resP.error) throw resP.error
|
if (resP.error) throw resP.error
|
||||||
|
if (resM.error) throw resM.error
|
||||||
|
|
||||||
let dataS = resS.data || []
|
let dataS = resS.data || []
|
||||||
let dataI = resI.data || []
|
let dataI = resI.data || []
|
||||||
let dataP = resP.data || []
|
let dataP = resP.data || []
|
||||||
|
let dataM = resM.data || []
|
||||||
|
|
||||||
if (periodoActivo) {
|
if (periodoActivo) {
|
||||||
dataS = dataS.filter(item => estaEnPeriodo(item, periodoActual))
|
dataS = dataS.filter((item) => estaEnPeriodo(item, periodoActual))
|
||||||
dataI = dataI.filter(item => estaEnPeriodo(item, periodoActual))
|
dataI = dataI.filter((item) => estaEnPeriodo(item, periodoActual))
|
||||||
dataP = dataP.filter(item => estaEnPeriodo(item, periodoActual))
|
dataP = dataP.filter((item) => estaEnPeriodo(item, periodoActual))
|
||||||
|
dataM = dataM.filter((item) => estaEnPeriodo(item, periodoActual))
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('useData fetch', {
|
|
||||||
year,
|
|
||||||
periodo: periodoActual,
|
|
||||||
siniestros: dataS.length,
|
|
||||||
involucrados: dataI.length,
|
|
||||||
personas: dataP.length,
|
|
||||||
})
|
|
||||||
|
|
||||||
setSiniestros(dataS)
|
setSiniestros(dataS)
|
||||||
setInvolucrados(dataI)
|
setInvolucrados(dataI)
|
||||||
setPersonas(dataP)
|
setPersonas(dataP)
|
||||||
|
setSiniestrosMapa(dataM)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err?.message || String(err))
|
setError(err?.message || String(err))
|
||||||
console.error('useData error', err)
|
console.error('useData error', err)
|
||||||
@@ -87,5 +91,5 @@ export function useData(year = null, periodo = { desde: null, hasta: null }) {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
return { siniestros, involucrados, personas, loading, error, refetch: fetchData }
|
return { siniestros, involucrados, personas, siniestrosMapa, loading, error, refetch: fetchData }
|
||||||
}
|
}
|
||||||
+12
-1
@@ -1,4 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import 'leaflet/dist/leaflet.css';
|
||||||
|
@import 'leaflet.markercluster/dist/MarkerCluster.css';
|
||||||
|
@import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* Colores de marca */
|
/* Colores de marca */
|
||||||
@@ -30,7 +33,7 @@ html.dark {
|
|||||||
--color-opsv-text: #e2e8f0;
|
--color-opsv-text: #e2e8f0;
|
||||||
--color-opsv-muted: #94a3b8;
|
--color-opsv-muted: #94a3b8;
|
||||||
--color-opsv-faint: #64748b;
|
--color-opsv-faint: #64748b;
|
||||||
--color-opsv-navy: #a8b4ff;
|
--color-opsv-navy: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -57,6 +60,14 @@ html.dark {
|
|||||||
color: theme(--color-opsv-navy);
|
color: theme(--color-opsv-navy);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
html.dark h1,
|
||||||
|
html.dark h2,
|
||||||
|
html.dark h3,
|
||||||
|
html.dark h4,
|
||||||
|
html.dark h5,
|
||||||
|
html.dark h6 {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
/* ── Override de colores modernos para captura html2canvas ── */
|
/* ── Override de colores modernos para captura html2canvas ── */
|
||||||
[data-pdf-render="true"],
|
[data-pdf-render="true"],
|
||||||
[data-pdf-render="true"] * {
|
[data-pdf-render="true"] * {
|
||||||
|
|||||||
+96
-15
@@ -14,10 +14,12 @@ import SecLesionados from './SecLesionados'
|
|||||||
import SecSinLesiones from './SecSinLesiones'
|
import SecSinLesiones from './SecSinLesiones'
|
||||||
import SecSintesis from './SecSintesis'
|
import SecSintesis from './SecSintesis'
|
||||||
import SecVeranoVivo from './SecVeranoVivo'
|
import SecVeranoVivo from './SecVeranoVivo'
|
||||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
import SecMapa from './SecMapa' // ← NUEVO
|
||||||
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import PdfExportModal from '../components/ui/PdfExportModal'
|
import PdfExportModal from '../components/ui/PdfExportModal'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function SectionPlaceholder({ title, description }) {
|
function SectionPlaceholder({ title, description }) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-3xl border border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm">
|
<section className="rounded-3xl border border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm">
|
||||||
@@ -31,6 +33,7 @@ function SectionPlaceholder({ title, description }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function AdminFooter() {
|
function AdminFooter() {
|
||||||
const { user, isAdmin } = useAuth()
|
const { user, isAdmin } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -52,16 +55,19 @@ function AdminFooter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
// ── Estados ──────────────────────────────────────────────────────────────
|
||||||
const [seccion, setSeccion] = useState('resumen')
|
const [seccion, setSeccion] = useState('resumen')
|
||||||
const [year, setYear] = useState(2025)
|
const [year, setYear] = useState(2025)
|
||||||
const [periodo, setPeriodo] = useState({ desde: null, hasta: null })
|
const [periodo, setPeriodo] = useState({ desde: null, hasta: null })
|
||||||
const [departamentoFiltro, setDepartamentoFiltro] = useState('')
|
const [departamentoFiltro, setDepartamentoFiltro] = useState('')
|
||||||
const [localidadFiltro, setLocalidadFiltro] = useState('')
|
const [localidadFiltro, setLocalidadFiltro] = useState('')
|
||||||
|
const [zonaFiltro, setZonaFiltro] = useState('')
|
||||||
const [modalPdf, setModalPdf] = useState(false)
|
const [modalPdf, setModalPdf] = useState(false)
|
||||||
|
|
||||||
// Hook principal — año seleccionado por el usuario
|
// ── Hook principal — año seleccionado por el usuario ─────────────────────
|
||||||
const { siniestros, personas, involucrados, loading, error } = useData(year, periodo)
|
const { siniestros, personas, involucrados, siniestrosMapa, loading, error } = useData(year, periodo)
|
||||||
|
|
||||||
// ── Departamentos disponibles para el año/período activo ─────────────────
|
// ── Departamentos disponibles para el año/período activo ─────────────────
|
||||||
const departamentosDisponibles = useMemo(() => {
|
const departamentosDisponibles = useMemo(() => {
|
||||||
@@ -78,10 +84,18 @@ export default function Dashboard() {
|
|||||||
return [...new Set(base.map(s => s.localidad).filter(Boolean))].sort()
|
return [...new Set(base.map(s => s.localidad).filter(Boolean))].sort()
|
||||||
}, [siniestros, departamentoFiltro])
|
}, [siniestros, departamentoFiltro])
|
||||||
|
|
||||||
|
// ── Zonas disponibles ─────────────────────────────────────────────────────
|
||||||
|
const zonasDisponibles = useMemo(() => {
|
||||||
|
return [...new Set(
|
||||||
|
(siniestros ?? []).map(s => s.zona_ocurrencia).filter(Boolean)
|
||||||
|
)].sort()
|
||||||
|
}, [siniestros])
|
||||||
|
|
||||||
// ── Reset en cascada: año/período → limpia todo ──────────────────────────
|
// ── Reset en cascada: año/período → limpia todo ──────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDepartamentoFiltro('')
|
setDepartamentoFiltro('')
|
||||||
setLocalidadFiltro('')
|
setLocalidadFiltro('')
|
||||||
|
setZonaFiltro('')
|
||||||
}, [year, periodo])
|
}, [year, periodo])
|
||||||
|
|
||||||
// ── Reset en cascada: departamento → limpia localidad ───────────────────
|
// ── Reset en cascada: departamento → limpia localidad ───────────────────
|
||||||
@@ -89,25 +103,37 @@ export default function Dashboard() {
|
|||||||
setLocalidadFiltro('')
|
setLocalidadFiltro('')
|
||||||
}, [departamentoFiltro])
|
}, [departamentoFiltro])
|
||||||
|
|
||||||
// ── Array final con ambos filtros aplicados ──────────────────────────────
|
// ── Array final con todos los filtros aplicados ──────────────────────────
|
||||||
const siniestrosFiltrados = useMemo(() => {
|
const siniestrosFiltrados = useMemo(() => {
|
||||||
let result = siniestros ?? []
|
let result = siniestros ?? []
|
||||||
if (departamentoFiltro) result = result.filter(s => s.departamento === departamentoFiltro)
|
if (departamentoFiltro) result = result.filter(s => s.departamento === departamentoFiltro)
|
||||||
if (localidadFiltro) result = result.filter(s => s.localidad === localidadFiltro)
|
if (localidadFiltro) result = result.filter(s => s.localidad === localidadFiltro)
|
||||||
|
if (zonaFiltro) result = result.filter(s => s.zona_ocurrencia === zonaFiltro)
|
||||||
return result
|
return result
|
||||||
}, [siniestros, departamentoFiltro, localidadFiltro])
|
}, [siniestros, departamentoFiltro, localidadFiltro, zonaFiltro])
|
||||||
|
|
||||||
// ── Verano Vivo: datos históricos de campañas anteriores ─────────────────
|
// ── Víctimas fatales del período filtrado (igual que SecFatales) ──────────
|
||||||
|
const victimasFatalesFiltradas = useMemo(
|
||||||
|
() => siniestrosFiltrados.reduce(
|
||||||
|
(acc, s) => acc + (Number(s.fallecidos) || 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
[siniestrosFiltrados]
|
||||||
|
)
|
||||||
|
// ── Verano Vivo: carga fija de todos los años, independiente del filtro ──
|
||||||
const { siniestros: sinVV2022 } = useData(2022, { desde: null, hasta: null })
|
const { siniestros: sinVV2022 } = useData(2022, { desde: null, hasta: null })
|
||||||
const { siniestros: sinVV2023 } = useData(2023, { desde: null, hasta: null })
|
const { siniestros: sinVV2023 } = useData(2023, { desde: null, hasta: null })
|
||||||
const { siniestros: sinVV2024 } = useData(2024, { desde: null, hasta: null })
|
const { siniestros: sinVV2024 } = useData(2024, { desde: null, hasta: null })
|
||||||
|
const { siniestros: sinVV2025 } = useData(2025, { desde: null, hasta: null })
|
||||||
|
const { siniestros: sinVV2026 } = useData(2026, { desde: null, hasta: null })
|
||||||
|
|
||||||
const siniestrosVV = useMemo(() => {
|
const siniestrosVV = useMemo(() => {
|
||||||
const all = [
|
const all = [
|
||||||
...(sinVV2022 ?? []),
|
...(sinVV2022 ?? []),
|
||||||
...(sinVV2023 ?? []),
|
...(sinVV2023 ?? []),
|
||||||
...(sinVV2024 ?? []),
|
...(sinVV2024 ?? []),
|
||||||
...(siniestros ?? []),
|
...(sinVV2025 ?? []),
|
||||||
|
...(sinVV2026 ?? []),
|
||||||
]
|
]
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
return all.filter(s => {
|
return all.filter(s => {
|
||||||
@@ -117,9 +143,35 @@ export default function Dashboard() {
|
|||||||
seen.add(id)
|
seen.add(id)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}, [sinVV2022, sinVV2023, sinVV2024, siniestros])
|
}, [sinVV2022, sinVV2023, sinVV2024, sinVV2025, sinVV2026])
|
||||||
|
|
||||||
|
// ── Personas VV — carga fija independiente del filtro ────────────────────
|
||||||
|
const { personas: persVV2022 } = useData(2022, { desde: null, hasta: null })
|
||||||
|
const { personas: persVV2023 } = useData(2023, { desde: null, hasta: null })
|
||||||
|
const { personas: persVV2024 } = useData(2024, { desde: null, hasta: null })
|
||||||
|
const { personas: persVV2025 } = useData(2025, { desde: null, hasta: null })
|
||||||
|
const { personas: persVV2026 } = useData(2026, { desde: null, hasta: null })
|
||||||
|
|
||||||
|
const personasVV = useMemo(() => {
|
||||||
|
const all = [
|
||||||
|
...(persVV2022 ?? []),
|
||||||
|
...(persVV2023 ?? []),
|
||||||
|
...(persVV2024 ?? []),
|
||||||
|
...(persVV2025 ?? []),
|
||||||
|
...(persVV2026 ?? []),
|
||||||
|
]
|
||||||
|
const seen = new Set()
|
||||||
|
return all.filter(p => {
|
||||||
|
const id = p.id_feu
|
||||||
|
if (id == null) return true
|
||||||
|
if (seen.has(id)) return false
|
||||||
|
seen.add(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [persVV2022, persVV2023, persVV2024, persVV2025, persVV2026])
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen overflow-hidden bg-opsv-bg">
|
<div className="flex min-h-screen overflow-hidden bg-opsv-bg">
|
||||||
<Sidebar seccion={seccion} setSeccion={setSeccion} year={year} />
|
<Sidebar seccion={seccion} setSeccion={setSeccion} year={year} />
|
||||||
@@ -138,6 +190,9 @@ export default function Dashboard() {
|
|||||||
localidadFiltro={localidadFiltro}
|
localidadFiltro={localidadFiltro}
|
||||||
setLocalidadFiltro={setLocalidadFiltro}
|
setLocalidadFiltro={setLocalidadFiltro}
|
||||||
localidadesDisponibles={localidadesDisponibles}
|
localidadesDisponibles={localidadesDisponibles}
|
||||||
|
zonaFiltro={zonaFiltro}
|
||||||
|
setZonaFiltro={setZonaFiltro}
|
||||||
|
zonasDisponibles={zonasDisponibles}
|
||||||
onExportarPdf={() => setModalPdf(true)}
|
onExportarPdf={() => setModalPdf(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -146,7 +201,12 @@ export default function Dashboard() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
) : seccion === 'resumen' ? (
|
) : seccion === 'resumen' ? (
|
||||||
<SecResumen siniestros={siniestrosFiltrados} periodo={periodo} />
|
<SecResumen
|
||||||
|
siniestros={siniestrosFiltrados}
|
||||||
|
personas={personas}
|
||||||
|
involucrados={involucrados}
|
||||||
|
periodo={periodo}
|
||||||
|
/>
|
||||||
) : seccion === 'historica' ? (
|
) : seccion === 'historica' ? (
|
||||||
<SecHistorica siniestros={siniestros} year={year} />
|
<SecHistorica siniestros={siniestros} year={year} />
|
||||||
) : seccion === 'fatales' ? (
|
) : seccion === 'fatales' ? (
|
||||||
@@ -156,9 +216,19 @@ export default function Dashboard() {
|
|||||||
) : seccion === 'sinlesiones' ? (
|
) : seccion === 'sinlesiones' ? (
|
||||||
<SecSinLesiones siniestros={siniestrosFiltrados} involucrados={involucrados} periodo={periodo} />
|
<SecSinLesiones siniestros={siniestrosFiltrados} involucrados={involucrados} periodo={periodo} />
|
||||||
) : seccion === 'sintesis' ? (
|
) : seccion === 'sintesis' ? (
|
||||||
<SecSintesis siniestros={siniestrosFiltrados} personas={personas} involucrados={involucrados} periodo={periodo} />
|
<SecSintesis
|
||||||
|
siniestros={siniestrosFiltrados}
|
||||||
|
personas={personas}
|
||||||
|
involucrados={involucrados}
|
||||||
|
year={year}
|
||||||
|
victimasActual ={victimasFatalesFiltradas}
|
||||||
|
siniestrosActual={siniestrosFiltrados.length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
) : seccion === 'mapa' ? (
|
||||||
|
<SecMapa siniestros={siniestrosFiltrados} siniestrosMapa={siniestrosMapa}/>
|
||||||
) : seccion === 'veranovivo' ? (
|
) : seccion === 'veranovivo' ? (
|
||||||
<SecVeranoVivo siniestros={siniestrosVV} />
|
<SecVeranoVivo siniestros={siniestrosVV} personas={personasVV} />
|
||||||
) : (
|
) : (
|
||||||
<SectionPlaceholder
|
<SectionPlaceholder
|
||||||
title={seccion}
|
title={seccion}
|
||||||
@@ -167,6 +237,7 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
<AdminFooter />
|
<AdminFooter />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* ── Contenedor oculto para captura PDF ─────────────────────────── */}
|
{/* ── Contenedor oculto para captura PDF ─────────────────────────── */}
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -181,7 +252,7 @@ export default function Dashboard() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div id="pdf-section-resumen" className="p-6 bg-opsv-bg">
|
<div id="pdf-section-resumen" className="p-6 bg-opsv-bg">
|
||||||
<SecResumen siniestros={siniestrosFiltrados} periodo={periodo} />
|
<SecResumen siniestros={siniestrosFiltrados} personas={personas} involucrados={involucrados} periodo={periodo} />
|
||||||
</div>
|
</div>
|
||||||
<div id="pdf-section-historica" className="p-6 bg-opsv-bg">
|
<div id="pdf-section-historica" className="p-6 bg-opsv-bg">
|
||||||
<SecHistorica siniestros={siniestros} year={year} />
|
<SecHistorica siniestros={siniestros} year={year} />
|
||||||
@@ -196,10 +267,21 @@ export default function Dashboard() {
|
|||||||
<SecSinLesiones siniestros={siniestrosFiltrados} involucrados={involucrados} periodo={periodo} />
|
<SecSinLesiones siniestros={siniestrosFiltrados} involucrados={involucrados} periodo={periodo} />
|
||||||
</div>
|
</div>
|
||||||
<div id="pdf-section-sintesis" className="p-6 bg-opsv-bg">
|
<div id="pdf-section-sintesis" className="p-6 bg-opsv-bg">
|
||||||
<SecSintesis siniestros={siniestrosFiltrados} personas={personas} involucrados={involucrados} periodo={periodo} />
|
<SecSintesis
|
||||||
|
siniestros={siniestrosFiltrados}
|
||||||
|
personas={personas}
|
||||||
|
involucrados={involucrados}
|
||||||
|
year={year}
|
||||||
|
victimasActual={victimasFatalesFiltradas}
|
||||||
|
siniestrosActual={siniestrosFiltrados.length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div id="pdf-section-mapa" className="p-6 bg-opsv-bg">
|
||||||
|
<SecMapa siniestros={siniestrosFiltrados} siniestrosMapa={siniestrosMapa} />
|
||||||
</div>
|
</div>
|
||||||
<div id="pdf-section-veranovivo" className="p-6 bg-opsv-bg">
|
<div id="pdf-section-veranovivo" className="p-6 bg-opsv-bg">
|
||||||
<SecVeranoVivo siniestros={siniestrosVV} />
|
<SecVeranoVivo siniestros={siniestrosVV} personas={personasVV} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* ────────────────────────────────────────────────────────────────── */}
|
{/* ────────────────────────────────────────────────────────────────── */}
|
||||||
@@ -216,4 +298,3 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
+15
-13
@@ -5,9 +5,9 @@ import PorTipoSiniestro from '../components/charts/PorTipoSiniestro'
|
|||||||
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
||||||
import PorLocalidad from '../components/charts/PorLocalidad'
|
import PorLocalidad from '../components/charts/PorLocalidad'
|
||||||
import PerfilVictimas from '../components/charts/PerfilVictimas'
|
import PerfilVictimas from '../components/charts/PerfilVictimas'
|
||||||
import ProteccionPersonas from '../components/charts/ProteccionPersonas'
|
|
||||||
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
||||||
import ChartCard from '../components/ui/ChartCard'
|
import ChartCard from '../components/ui/ChartCard'
|
||||||
|
import { filtrarPersonasPorSiniestros } from '../utils/calculos'
|
||||||
|
|
||||||
const SECTION_COLORS = {
|
const SECTION_COLORS = {
|
||||||
total: '#252C61',
|
total: '#252C61',
|
||||||
@@ -31,8 +31,15 @@ export default function SecFatales({ siniestros, personas, involucrados }) {
|
|||||||
const diarios = total ? (total / 365).toFixed(1) : '0.0'
|
const diarios = total ? (total / 365).toFixed(1) : '0.0'
|
||||||
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
||||||
|
|
||||||
|
// ← NUEVO: personas acotadas a los siniestros fatales filtrados
|
||||||
|
const personasFiltradas = useMemo(
|
||||||
|
() => filtrarPersonasPorSiniestros(personas, filtrarFatales),
|
||||||
|
[personas, filtrarFatales],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|
||||||
{/* KPIs */}
|
{/* KPIs */}
|
||||||
<div data-pdf-block className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div data-pdf-block className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<KPICard label="Siniestros fatales" value={total} color={SECTION_COLORS.fatales} />
|
<KPICard label="Siniestros fatales" value={total} color={SECTION_COLORS.fatales} />
|
||||||
@@ -65,8 +72,8 @@ export default function SecFatales({ siniestros, personas, involucrados }) {
|
|||||||
{/* Franja horaria */}
|
{/* Franja horaria */}
|
||||||
<ChartCard
|
<ChartCard
|
||||||
kicker="Condiciones temporales"
|
kicker="Condiciones temporales"
|
||||||
title="Franja horaria de los siniestros fatales"
|
title="Franja horaria de los siniestros fatales según zona de ocurrencia"
|
||||||
subtitle="Cantidad de siniestros fatales según la franja horaria en que ocurrieron."
|
subtitle="Cantidad de siniestros fatales según la franja horaria y la zona en que ocurrieron."
|
||||||
height="md"
|
height="md"
|
||||||
>
|
>
|
||||||
<FranjaHoraria siniestros={filtrarFatales} />
|
<FranjaHoraria siniestros={filtrarFatales} />
|
||||||
@@ -100,18 +107,13 @@ export default function SecFatales({ siniestros, personas, involucrados }) {
|
|||||||
subtitle="Distribución de las víctimas fatales según edad, género y Tipo de Vehículo."
|
subtitle="Distribución de las víctimas fatales según edad, género y Tipo de Vehículo."
|
||||||
height="lg"
|
height="lg"
|
||||||
>
|
>
|
||||||
<PerfilVictimas personas={personas} involucrados={involucrados} soloFatales={true} />
|
<PerfilVictimas
|
||||||
|
personas={personasFiltradas}
|
||||||
|
involucrados={involucrados}
|
||||||
|
soloFatales={true}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{/* Seguridad pasiva */}
|
|
||||||
<ChartCard
|
|
||||||
kicker="Seguridad pasiva"
|
|
||||||
title="Uso de elementos de protección"
|
|
||||||
subtitle="Uso de cinturón de seguridad, casco y otros elementos de protección en los siniestros fatales. (Bases ajustadas por tipo de vehículo)"
|
|
||||||
height="auto"
|
|
||||||
>
|
|
||||||
<ProteccionPersonas personas={personas} involucrados={involucrados} />
|
|
||||||
</ChartCard>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+81
-127
@@ -8,6 +8,7 @@ import PorLocalidad from '../components/charts/PorLocalidad'
|
|||||||
import PorTipoSiniestro from '../components/charts/PorTipoSiniestro'
|
import PorTipoSiniestro from '../components/charts/PorTipoSiniestro'
|
||||||
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
||||||
import KPICard from '../components/ui/KPICard'
|
import KPICard from '../components/ui/KPICard'
|
||||||
|
import ChartCard from '../components/ui/ChartCard'
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
@@ -31,29 +32,20 @@ export default function SecHistorica({ siniestros, year }) {
|
|||||||
const yearNum = Number(year)
|
const yearNum = Number(year)
|
||||||
const kpis = calcularKPIs(siniestros)
|
const kpis = calcularKPIs(siniestros)
|
||||||
|
|
||||||
// Hook se usa DENTRO del componente
|
|
||||||
const { tickColor, gridColor, tooltipBg, tooltipBorder, tooltipLabel } =
|
const { tickColor, gridColor, tooltipBg, tooltipBorder, tooltipLabel } =
|
||||||
useChartTheme()
|
useChartTheme()
|
||||||
|
|
||||||
const serieComparativa = useMemo(() => {
|
const serieComparativa = useMemo(() => {
|
||||||
const base = [...SERIE_HISTORICA]
|
const base = [...SERIE_HISTORICA]
|
||||||
const yaExiste = base.some((row) => row.ano === yearNum)
|
const yaExiste = base.some((row) => row.ano === yearNum)
|
||||||
|
|
||||||
if (!yaExiste && kpis.total > 0) {
|
if (!yaExiste && kpis.total > 0) {
|
||||||
const pob = getPoblacionAnual(yearNum)
|
const pob = getPoblacionAnual(yearNum)
|
||||||
const tasa = Number(((kpis.victimas / pob) * 100000).toFixed(2))
|
const tasa = Number(((kpis.victimas / pob) * 100000).toFixed(2))
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...base,
|
...base,
|
||||||
{
|
{ ano: yearNum, siniestros: kpis.total, victimas: kpis.victimas, tasa },
|
||||||
ano: yearNum,
|
|
||||||
siniestros: kpis.total,
|
|
||||||
victimas: kpis.victimas,
|
|
||||||
tasa,
|
|
||||||
},
|
|
||||||
].sort((a, b) => a.ano - b.ano)
|
].sort((a, b) => a.ano - b.ano)
|
||||||
}
|
}
|
||||||
|
|
||||||
return base
|
return base
|
||||||
}, [yearNum, kpis.total, kpis.victimas])
|
}, [yearNum, kpis.total, kpis.victimas])
|
||||||
|
|
||||||
@@ -68,8 +60,7 @@ export default function SecHistorica({ siniestros, year }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const victimasPorAno = useMemo(
|
const victimasPorAno = useMemo(
|
||||||
() =>
|
() => serieComparativa.map((row) => ({ ano: row.ano, victimas: row.victimas })),
|
||||||
serieComparativa.map((row) => ({ ano: row.ano, victimas: row.victimas })),
|
|
||||||
[serieComparativa],
|
[serieComparativa],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -77,20 +68,9 @@ export default function SecHistorica({ siniestros, year }) {
|
|||||||
|
|
||||||
const serieParaExtremos = useMemo(() => {
|
const serieParaExtremos = useMemo(() => {
|
||||||
const base = [...SERIE_HISTORICA]
|
const base = [...SERIE_HISTORICA]
|
||||||
|
|
||||||
const existe2025 = base.some((row) => row.ano === 2025)
|
const existe2025 = base.some((row) => row.ano === 2025)
|
||||||
if (!existe2025) {
|
if (!existe2025) base.push({ ano: 2025, siniestros: 0, victimas: 21, tasa: 6.27 })
|
||||||
base.push({
|
return base.filter((row) => row.ano !== 2020 && row.victimas != null).sort((a, b) => a.ano - b.ano)
|
||||||
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 victimasActual = yearActualData?.victimas ?? kpis.victimas ?? null
|
||||||
@@ -104,22 +84,14 @@ export default function SecHistorica({ siniestros, year }) {
|
|||||||
(row) => row.ano !== 2020 && row.victimas != null,
|
(row) => row.ano !== 2020 && row.victimas != null,
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxEntry =
|
const maxEntry = serieParaExtremos.length > 0
|
||||||
serieParaExtremos.length > 0
|
? serieParaExtremos.reduce((max, row) => (max == null || row.victimas > max.victimas ? row : max), null)
|
||||||
? serieParaExtremos.reduce(
|
|
||||||
(max, row) => (max == null || row.victimas > max.victimas ? row : max),
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
: null
|
: null
|
||||||
const maxHistorico = maxEntry?.victimas ?? null
|
const maxHistorico = maxEntry?.victimas ?? null
|
||||||
const maxAno = maxEntry?.ano ?? null
|
const maxAno = maxEntry?.ano ?? null
|
||||||
|
|
||||||
const minEntry =
|
const minEntry = serieParaExtremos.length > 0
|
||||||
serieParaExtremos.length > 0
|
? serieParaExtremos.reduce((min, row) => (min == null || row.victimas < min.victimas ? row : min), null)
|
||||||
? serieParaExtremos.reduce(
|
|
||||||
(min, row) => (min == null || row.victimas < min.victimas ? row : min),
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
: null
|
: null
|
||||||
const minHistorico = minEntry?.victimas ?? null
|
const minHistorico = minEntry?.victimas ?? null
|
||||||
const minAno = minEntry?.ano ?? null
|
const minAno = minEntry?.ano ?? null
|
||||||
@@ -127,11 +99,8 @@ export default function SecHistorica({ siniestros, year }) {
|
|||||||
const serieOrdenada = [...serieVictimasValidas].sort((a, b) => a.ano - b.ano)
|
const serieOrdenada = [...serieVictimasValidas].sort((a, b) => a.ano - b.ano)
|
||||||
const ultimos10 = serieOrdenada.filter((row) => row.ano <= yearNum).slice(-10)
|
const ultimos10 = serieOrdenada.filter((row) => row.ano <= yearNum).slice(-10)
|
||||||
|
|
||||||
const promedio10 =
|
const promedio10 = ultimos10.length > 0
|
||||||
ultimos10.length > 0
|
? (ultimos10.reduce((acc, row) => acc + row.victimas, 0) / ultimos10.length).toFixed(1)
|
||||||
? (
|
|
||||||
ultimos10.reduce((acc, row) => acc + row.victimas, 0) / ultimos10.length
|
|
||||||
).toFixed(1)
|
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const rango10Desde = ultimos10[0]?.ano ?? null
|
const rango10Desde = ultimos10[0]?.ano ?? null
|
||||||
@@ -139,93 +108,63 @@ export default function SecHistorica({ siniestros, year }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* KPIs históricas */}
|
|
||||||
|
{/* KPIs */}
|
||||||
<div data-pdf-block className="grid gap-4 xl:grid-cols-5">
|
<div data-pdf-block className="grid gap-4 xl:grid-cols-5">
|
||||||
<KPICard
|
<KPICard
|
||||||
label={
|
label={yearActualData ? `Víctimas fatales (${yearActualData.ano})` : 'Víctimas fatales'}
|
||||||
yearActualData
|
|
||||||
? `Víctimas fatales (${yearActualData.ano})`
|
|
||||||
: 'Víctimas fatales'
|
|
||||||
}
|
|
||||||
value={victimasActual ?? '—'}
|
value={victimasActual ?? '—'}
|
||||||
color={COLOR.red}
|
color={COLOR.red}
|
||||||
/>
|
/>
|
||||||
<KPICard
|
<KPICard
|
||||||
label={
|
label={prevYearData && yearActualData ? `Variación vs. año anterior (${prevYearData.ano}–${yearActualData.ano})` : 'Variación vs. año anterior'}
|
||||||
prevYearData && yearActualData
|
|
||||||
? `Variación vs. año anterior (${prevYearData.ano}–${yearActualData.ano})`
|
|
||||||
: 'Variación vs. año anterior'
|
|
||||||
}
|
|
||||||
value={comparativoVictimas}
|
value={comparativoVictimas}
|
||||||
color={COLOR.gold}
|
color={COLOR.gold}
|
||||||
/>
|
/>
|
||||||
<KPICard
|
<KPICard
|
||||||
label={
|
label={maxAno ? `Máximo histórico de víctimas (${maxAno})` : 'Máximo histórico de víctimas'}
|
||||||
maxAno
|
|
||||||
? `Máximo histórico de víctimas (${maxAno})`
|
|
||||||
: 'Máximo histórico de víctimas'
|
|
||||||
}
|
|
||||||
value={maxHistorico ?? '—'}
|
value={maxHistorico ?? '—'}
|
||||||
color={COLOR.navy}
|
color={COLOR.navy}
|
||||||
/>
|
/>
|
||||||
<KPICard
|
<KPICard
|
||||||
label={
|
label={minAno ? `Mínimo histórico de víctimas (${minAno})` : 'Mínimo histórico de víctimas'}
|
||||||
minAno
|
|
||||||
? `Mínimo histórico de víctimas (${minAno})`
|
|
||||||
: 'Mínimo histórico de víctimas'
|
|
||||||
}
|
|
||||||
value={minHistorico ?? '—'}
|
value={minHistorico ?? '—'}
|
||||||
color={COLOR.blue}
|
color={COLOR.blue}
|
||||||
/>
|
/>
|
||||||
<KPICard
|
<KPICard
|
||||||
label={
|
label={rango10Desde && rango10Hasta ? `Promedio últimos 10 años (${rango10Desde}–${rango10Hasta})` : 'Promedio últimos 10 años'}
|
||||||
rango10Desde && rango10Hasta
|
|
||||||
? `Promedio últimos 10 años (${rango10Desde}–${rango10Hasta})`
|
|
||||||
: 'Promedio últimos 10 años'
|
|
||||||
}
|
|
||||||
value={promedio10 ?? '—'}
|
value={promedio10 ?? '—'}
|
||||||
color={COLOR.green}
|
color={COLOR.green}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gráfico de tasa + barras de víctimas */}
|
{/* Serie histórica + barras de víctimas */}
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]">
|
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr] items-start">
|
||||||
|
<ChartCard
|
||||||
|
kicker="Análisis histórico"
|
||||||
|
title="Evolución de la tasa de siniestralidad"
|
||||||
|
subtitle="Serie histórica de siniestros y tasa de víctimas fatales cada 100.000 habitantes."
|
||||||
|
height="lg"
|
||||||
|
>
|
||||||
<SerieHistorica
|
<SerieHistorica
|
||||||
year={yearNum}
|
year={yearNum}
|
||||||
siniestrosActual={kpis.total}
|
siniestrosActual={kpis.total}
|
||||||
victimasActual={kpis.victimas}
|
victimasActual={kpis.victimas}
|
||||||
/>
|
/>
|
||||||
<div data-pdf-block className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
</ChartCard>
|
||||||
<div className="mb-6">
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">
|
<ChartCard
|
||||||
Víctimas fatales
|
kicker="Víctimas fatales"
|
||||||
</p>
|
title="Evolución anual"
|
||||||
<h3 className="mt-2 text-xl font-black text-opsv-navy">
|
subtitle="Cantidad de víctimas fatales por año en el periodo histórico registrado."
|
||||||
Evolución anual
|
height="lg"
|
||||||
</h3>
|
>
|
||||||
</div>
|
|
||||||
<div className="h-[300px]">
|
<div className="h-[300px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart
|
<BarChart data={victimasPorAno} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
|
||||||
data={victimasPorAno}
|
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
|
||||||
margin={{ top: 10, right: 20, left: 0, bottom: 0 }}
|
<XAxis dataKey="ano" tick={{ fill: tickColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||||
>
|
<YAxis tick={{ fill: tickColor, fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||||
<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
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
background: tooltipBg,
|
background: tooltipBg,
|
||||||
@@ -239,15 +178,47 @@ export default function SecHistorica({ siniestros, year }) {
|
|||||||
<Bar dataKey="victimas" fill={COLOR.red} radius={[8, 8, 0, 0]} />
|
<Bar dataKey="victimas" fill={COLOR.red} radius={[8, 8, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
<div className="mt-4 space-y-1">
|
||||||
|
|
||||||
|
<p className="text-sm text-amber-600/80">
|
||||||
|
<span className="font-semibold">Nota:</span> el año 2020 no se incluye en la
|
||||||
|
serie histórica. Las restricciones de circulación por la pandemia de COVID-19
|
||||||
|
generaron una reducción atípica de la movilidad vehicular, por lo que los datos
|
||||||
|
no son comparables con el resto de la serie.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Distribución por tipo, franja y localidad */}
|
{/* Tipo, franja y localidad */}
|
||||||
<div className="grid gap-6 xl:grid-cols-3">
|
<div className="grid gap-6 xl:grid-cols-3">
|
||||||
|
<ChartCard
|
||||||
|
kicker="Características"
|
||||||
|
title="Por tipo de siniestro"
|
||||||
|
subtitle="Distribución según el tipo de evento vial registrado en el periodo."
|
||||||
|
height="md"
|
||||||
|
>
|
||||||
<PorTipoSiniestro siniestros={siniestros} />
|
<PorTipoSiniestro siniestros={siniestros} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
kicker="Condiciones temporales"
|
||||||
|
title="Franja horaria"
|
||||||
|
subtitle="Cantidad de siniestros según la franja horaria en que ocurrieron."
|
||||||
|
height="md"
|
||||||
|
>
|
||||||
<FranjaHoraria siniestros={siniestros} />
|
<FranjaHoraria siniestros={siniestros} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
kicker="Distribución territorial"
|
||||||
|
title="Top 10 localidades"
|
||||||
|
subtitle="Las 10 localidades con mayor cantidad de siniestros en el periodo seleccionado."
|
||||||
|
height="md"
|
||||||
|
>
|
||||||
<PorLocalidad siniestros={siniestros} />
|
<PorLocalidad siniestros={siniestros} />
|
||||||
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabla histórica */}
|
{/* Tabla histórica */}
|
||||||
@@ -261,24 +232,13 @@ export default function SecHistorica({ siniestros, year }) {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table
|
<table className="min-w-full text-left text-sm" style={{ borderCollapse: 'collapse' }}>
|
||||||
className="min-w-full text-left text-sm"
|
|
||||||
style={{ borderCollapse: 'collapse' }}
|
|
||||||
>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b-2 border-opsv-border">
|
<tr className="border-b-2 border-opsv-border">
|
||||||
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">
|
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">Año</th>
|
||||||
Año
|
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">Siniestros</th>
|
||||||
</th>
|
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">Víctimas</th>
|
||||||
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">
|
<th className="pb-3 font-bold uppercase tracking-wider text-opsv-navy">Tasa</th>
|
||||||
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -286,21 +246,14 @@ export default function SecHistorica({ siniestros, year }) {
|
|||||||
<tr
|
<tr
|
||||||
key={row.ano}
|
key={row.ano}
|
||||||
className={`border-b border-opsv-border/60 transition-colors hover:bg-opsv-bg ${
|
className={`border-b border-opsv-border/60 transition-colors hover:bg-opsv-bg ${
|
||||||
row.ano === yearNum &&
|
row.ano === yearNum && !SERIE_HISTORICA.find((r) => r.ano === yearNum)
|
||||||
!SERIE_HISTORICA.find((r) => r.ano === yearNum)
|
|
||||||
? 'bg-blue-500/5 font-semibold'
|
? 'bg-blue-500/5 font-semibold'
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="py-3 pr-6 font-medium text-opsv-navy">
|
<td className="py-3 pr-6 font-medium text-opsv-navy">{row.ano}</td>
|
||||||
{row.ano}
|
<td className="py-3 pr-6 text-opsv-text">{row.siniestros}</td>
|
||||||
</td>
|
<td className="py-3 pr-6 text-opsv-text">{row.victimas ?? '—'}</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>
|
<td className="py-3 text-opsv-text">{row.tasa ?? '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -308,6 +261,7 @@ export default function SecHistorica({ siniestros, year }) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+96
-26
@@ -5,9 +5,10 @@ import PorTipoSiniestro from '../components/charts/PorTipoSiniestro'
|
|||||||
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
||||||
import PorLocalidad from '../components/charts/PorLocalidad'
|
import PorLocalidad from '../components/charts/PorLocalidad'
|
||||||
import PerfilVictimas from '../components/charts/PerfilVictimas'
|
import PerfilVictimas from '../components/charts/PerfilVictimas'
|
||||||
import ProteccionPersonas from '../components/charts/ProteccionPersonas'
|
|
||||||
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
||||||
import ChartCard from '../components/ui/ChartCard'
|
import ChartCard from '../components/ui/ChartCard'
|
||||||
|
import { calcularLesionadosPorGravedad, filtrarPersonasPorSiniestros } from '../utils/calculos'
|
||||||
|
|
||||||
|
|
||||||
export default function SecLesionados({ siniestros, personas, involucrados }) {
|
export default function SecLesionados({ siniestros, personas, involucrados }) {
|
||||||
const filtrarLesionados = useMemo(
|
const filtrarLesionados = useMemo(
|
||||||
@@ -21,24 +22,99 @@ export default function SecLesionados({ siniestros, personas, involucrados }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const total = filtrarLesionados.length
|
const total = filtrarLesionados.length
|
||||||
const lesionados = filtrarLesionados.reduce(
|
|
||||||
(acc, s) => acc + Number((s.cantidad_lesionados ?? s.heridos) || 0),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
const diarios = total ? (total / 365).toFixed(1) : '0.0'
|
const diarios = total ? (total / 365).toFixed(1) : '0.0'
|
||||||
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
||||||
|
|
||||||
|
const { graves, leves } = useMemo(
|
||||||
|
() => calcularLesionadosPorGravedad(filtrarLesionados, personas),
|
||||||
|
[filtrarLesionados, personas],
|
||||||
|
)
|
||||||
|
const totalPersonas = graves + leves
|
||||||
|
const pctGraves = totalPersonas ? Math.round((graves / totalPersonas) * 100) : 0
|
||||||
|
const pctLeves = totalPersonas ? Math.round((leves / totalPersonas) * 100) : 0
|
||||||
|
|
||||||
|
const personasFiltradas = useMemo(
|
||||||
|
() => filtrarPersonasPorSiniestros(personas, filtrarLesionados),
|
||||||
|
[personas, filtrarLesionados],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* KPIs */}
|
|
||||||
|
{/* ── KPIs ── */}
|
||||||
<div data-pdf-block className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div data-pdf-block className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<KPICard label="Siniestros con lesionados" value={total} color="#CD9F2B" />
|
<KPICard label="Siniestros con lesionados" value={total} color="#CD9F2B" />
|
||||||
<KPICard label="Total lesionados" value={lesionados} color="#E8881A" />
|
<KPICard label="Heridos graves" value={graves} color="#C44228" />
|
||||||
<KPICard label="Promedio diario" value={diarios} unit="x día" color="#80B0DE" />
|
<KPICard label="Heridos leves" value={leves} color="#E8881A" />
|
||||||
<KPICard label="% sobre total" value={pct} color="#337C58" />
|
<KPICard label="% sobre total siniestros" value={pct} color="#337C58" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Evolución y tipo de siniestro */}
|
{/* ── Bloque desglose graves / leves ── */}
|
||||||
|
<div data-pdf-block className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-muted">
|
||||||
|
Personas lesionadas · gravedad
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 text-xl font-black text-opsv-navy dark:text-white">
|
||||||
|
Proporción de Heridos graves y heridos leves
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{totalPersonas === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-opsv-muted">Sin datos de gravedad para el periodo seleccionado.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Barra proporcional */}
|
||||||
|
<div className="mt-6 flex h-5 w-full overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-[11px] font-bold text-white transition-all"
|
||||||
|
style={{ width: `${pctGraves}%`, backgroundColor: '#C44228' }}
|
||||||
|
>
|
||||||
|
{pctGraves >= 10 ? `${pctGraves}%` : ''}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-[11px] font-bold text-white transition-all"
|
||||||
|
style={{ width: `${pctLeves}%`, backgroundColor: '#E8881A' }}
|
||||||
|
>
|
||||||
|
{pctLeves >= 10 ? `${pctLeves}%` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tarjetas de detalle */}
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
|
||||||
|
{/* Heridos graves */}
|
||||||
|
<div className="flex items-center gap-4 rounded-2xl bg-white dark:bg-opsv-bg border border-opsv-border dark:border-slate-700 p-5">
|
||||||
|
<div className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: '#C44228' }} />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-black text-opsv-text">{graves}</p>
|
||||||
|
<p className="text-sm font-semibold text-opsv-muted">
|
||||||
|
Heridos graves — {pctGraves}% del total
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-opsv-muted">
|
||||||
|
Personas con lesiones de gravedad que requirieron atención médica de mayor complejidad.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heridos leves */}
|
||||||
|
<div className="flex items-center gap-4 rounded-2xl bg-white dark:bg-opsv-bg border border-opsv-border dark:border-slate-700 p-5">
|
||||||
|
<div className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: '#E8881A' }} />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-black text-opsv-text">{leves}</p>
|
||||||
|
<p className="text-sm font-semibold text-opsv-muted">
|
||||||
|
Heridos leves — {pctLeves}% del total
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-opsv-muted">
|
||||||
|
Personas con lesiones de menor gravedad atendidas en el lugar o con derivación ambulatoria.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Evolución y tipo de siniestro ── */}
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.6fr_1fr] items-start">
|
<div className="grid gap-6 xl:grid-cols-[1.6fr_1fr] items-start">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
kicker="Evolución temporal"
|
kicker="Evolución temporal"
|
||||||
@@ -59,17 +135,17 @@ export default function SecLesionados({ siniestros, personas, involucrados }) {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Franja horaria */}
|
{/* ── Franja horaria ── */}
|
||||||
<ChartCard
|
<ChartCard
|
||||||
kicker="Condiciones temporales"
|
kicker="Condiciones temporales"
|
||||||
title="Franja horaria de los siniestros con lesionados"
|
title="Franja horaria de los siniestros con lesionados según zona de ocurrencia"
|
||||||
subtitle="Cantidad de siniestros con lesionados según la franja horaria del día en que ocurrieron."
|
subtitle="Cantidad de siniestros con lesionados según la franja horaria y la zona en que ocurrieron."
|
||||||
height="md"
|
height="md"
|
||||||
>
|
>
|
||||||
<FranjaHoraria siniestros={filtrarLesionados} />
|
<FranjaHoraria siniestros={filtrarLesionados} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{/* Distribución territorial */}
|
{/* ── Distribución territorial ── */}
|
||||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_2.1fr] items-start">
|
<div className="grid gap-6 xl:grid-cols-[0.9fr_2.1fr] items-start">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
kicker="Distribución territorial"
|
kicker="Distribución territorial"
|
||||||
@@ -90,25 +166,19 @@ export default function SecLesionados({ siniestros, personas, involucrados }) {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Perfil de víctimas */}
|
{/* ── Perfil de víctimas ── */}
|
||||||
<ChartCard
|
<ChartCard
|
||||||
kicker="Perfil de víctimas"
|
kicker="Perfil de víctimas"
|
||||||
title="Características de las personas lesionadas"
|
title="Características de las personas lesionadas"
|
||||||
subtitle="Distribución de las personas lesionadas según edad, género y Tipo de Vehículo."
|
subtitle="Distribución de las personas lesionadas según edad, género y tipo de vehículo."
|
||||||
height="lg"
|
height="lg"
|
||||||
>
|
>
|
||||||
<PerfilVictimas personas={personas} involucrados={involucrados} />
|
<PerfilVictimas
|
||||||
|
personas={personasFiltradas}
|
||||||
|
involucrados={involucrados}
|
||||||
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{/* Seguridad pasiva */}
|
|
||||||
<ChartCard
|
|
||||||
kicker="Seguridad pasiva"
|
|
||||||
title="Uso de elementos de protección"
|
|
||||||
subtitle="Uso de cinturón de seguridad, casco y otros elementos de protección en los siniestros con lesionados."
|
|
||||||
height="auto"
|
|
||||||
>
|
|
||||||
<ProteccionPersonas personas={personas} involucrados={involucrados} />
|
|
||||||
</ChartCard>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { useMemo, useState, useEffect } from 'react'
|
||||||
|
import ChartCard from '../components/ui/ChartCard'
|
||||||
|
import KPICard from '../components/ui/KPICard'
|
||||||
|
import MapaSiniestros from '../components/charts/MapaSiniestros'
|
||||||
|
|
||||||
|
|
||||||
|
const FILTROS_GRAVEDAD = [
|
||||||
|
{ value: 'todos', label: 'Todos' },
|
||||||
|
{ value: 'fatal', label: 'Fatales' },
|
||||||
|
{ value: 'lesionado', label: 'Lesionados' },
|
||||||
|
{ value: 'sinlesiones', label: 'Sin lesiones' },
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
function getGravedad(s) {
|
||||||
|
const fatales = Number(s.cantidad_fallecidos ?? s.fallecidos ?? 0)
|
||||||
|
const lesion = Number(s.cantidad_lesionados ?? s.heridos ?? 0)
|
||||||
|
if (fatales > 0) return 'fatal'
|
||||||
|
if (lesion > 0) return 'lesionado'
|
||||||
|
return 'sinlesiones'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function SecMapa({ siniestros, siniestrosMapa }) {
|
||||||
|
const [vista, setVista] = useState('marcadores')
|
||||||
|
const [gravedad, setGravedad] = useState('todos')
|
||||||
|
const [darkMode, setDarkMode] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () =>
|
||||||
|
setDarkMode(document.documentElement.classList.contains('dark'))
|
||||||
|
check()
|
||||||
|
const observer = new MutationObserver(check)
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class'],
|
||||||
|
})
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { conCoords, sinCoords, pctCobertura } = useMemo(() => {
|
||||||
|
const fuente = siniestrosMapa ?? []
|
||||||
|
const conCoords = fuente.filter(
|
||||||
|
(s) =>
|
||||||
|
s.latitud_norm != null &&
|
||||||
|
s.longitud_norm != null &&
|
||||||
|
!isNaN(Number(s.latitud_norm)) &&
|
||||||
|
!isNaN(Number(s.longitud_norm)) &&
|
||||||
|
Math.abs(Number(s.latitud_norm)) <= 90 &&
|
||||||
|
Math.abs(Number(s.longitud_norm)) <= 180
|
||||||
|
).length
|
||||||
|
const sinCoords = siniestros.length - conCoords
|
||||||
|
const pctCobertura = siniestros.length
|
||||||
|
? Math.round((conCoords / siniestros.length) * 100)
|
||||||
|
: 0
|
||||||
|
return { conCoords, sinCoords, pctCobertura }
|
||||||
|
}, [siniestros, siniestrosMapa])
|
||||||
|
|
||||||
|
const siniestrosFiltrados = useMemo(() => {
|
||||||
|
if (gravedad === 'todos') return siniestros
|
||||||
|
return siniestros.filter((s) => getGravedad(s) === gravedad)
|
||||||
|
}, [siniestros, gravedad])
|
||||||
|
|
||||||
|
const siniestrosMapaFiltrados = useMemo(() => {
|
||||||
|
if (gravedad === 'todos') return siniestrosMapa ?? []
|
||||||
|
const idsValidos = new Set(siniestrosFiltrados.map((s) => s.id_feu))
|
||||||
|
return (siniestrosMapa ?? []).filter((s) => idsValidos.has(s.id_feu))
|
||||||
|
}, [siniestrosMapa, siniestrosFiltrados, gravedad])
|
||||||
|
|
||||||
|
const btnBase = 'px-4 py-2 rounded-2xl text-sm font-semibold transition'
|
||||||
|
const btnActive = 'bg-opsv-navy dark:bg-slate-600 text-white'
|
||||||
|
const btnInactive = 'bg-opsv-bg dark:bg-slate-800 text-opsv-muted dark:text-slate-400 border border-opsv-border dark:border-slate-700 hover:text-opsv-navy dark:hover:text-white'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* ── KPIs de cobertura ── */}
|
||||||
|
<div data-pdf-block className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<KPICard label="Siniestros georreferenciados" value={conCoords} color="#252C61" />
|
||||||
|
<KPICard label="Sin coordenadas" value={sinCoords} color="#6B7280" />
|
||||||
|
<KPICard label="Cobertura" value={`${pctCobertura}%`} color="#337C58" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Aviso si hay siniestros sin coords ── */}
|
||||||
|
{sinCoords > 0 && (
|
||||||
|
<div className="rounded-2xl border border-amber-200 bg-amber-50 dark:border-amber-900/40 dark:bg-amber-950/20 px-4 py-3 text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
⚠️ <strong>{sinCoords} siniestro{sinCoords > 1 ? 's' : ''}</strong> no {sinCoords > 1 ? 'tienen' : 'tiene'} coordenadas registradas y no {sinCoords > 1 ? 'aparecen' : 'aparece'} en el mapa.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Mapa ── */}
|
||||||
|
<div data-pdf-block>
|
||||||
|
<ChartCard
|
||||||
|
kicker="Distribución geográfica"
|
||||||
|
title="Mapa de siniestros viales"
|
||||||
|
subtitle="Ubicación georreferenciada de los siniestros del período seleccionado. Los colores indican la gravedad del evento."
|
||||||
|
height="auto"
|
||||||
|
>
|
||||||
|
{/* Controles */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
|
||||||
|
{/* Toggle vista */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-opsv-muted dark:text-slate-400">
|
||||||
|
Vista
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[
|
||||||
|
{ value: 'marcadores', label: 'Marcadores' },
|
||||||
|
{ value: 'calor', label: 'Mapa de calor' },
|
||||||
|
].map((op) => (
|
||||||
|
<button
|
||||||
|
key={op.value}
|
||||||
|
onClick={() => setVista(op.value)}
|
||||||
|
className={`${btnBase} ${vista === op.value ? btnActive : btnInactive}`}
|
||||||
|
>
|
||||||
|
{op.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtro gravedad */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-opsv-muted dark:text-slate-400">
|
||||||
|
Gravedad
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{FILTROS_GRAVEDAD.map((op) => (
|
||||||
|
<button
|
||||||
|
key={op.value}
|
||||||
|
onClick={() => setGravedad(op.value)}
|
||||||
|
className={`${btnBase} ${gravedad === op.value ? btnActive : btnInactive}`}
|
||||||
|
>
|
||||||
|
{op.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leyenda */}
|
||||||
|
{vista === 'marcadores' && (
|
||||||
|
<div className="mb-3 flex flex-wrap gap-4">
|
||||||
|
{[
|
||||||
|
{ color: '#C44228', label: 'Fatal' },
|
||||||
|
{ color: '#E8881A', label: 'Lesionado' },
|
||||||
|
{ color: '#6B7280', label: 'Sin lesiones' },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-full border-2 border-white shadow-sm"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-opsv-muted dark:text-slate-400">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mapa */}
|
||||||
|
<MapaSiniestros
|
||||||
|
siniestros={siniestrosFiltrados}
|
||||||
|
siniestrosMapa={siniestrosMapaFiltrados}
|
||||||
|
vista={vista}
|
||||||
|
darkMode={darkMode}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+70
-23
@@ -1,45 +1,82 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import KPICard from '../components/ui/KPICard'
|
import KPICard from '../components/ui/KPICard'
|
||||||
import SiniestrosPorMes from '../components/charts/SiniestrosPorMes'
|
import SiniestrosPorMes from '../components/charts/SiniestrosPorMes'
|
||||||
import DonutGravedad from '../components/charts/DonutGravedad'
|
import DonutGravedad from '../components/charts/DonutGravedad'
|
||||||
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
||||||
import {
|
import ProteccionPersonas from '../components/charts/ProteccionPersonas'
|
||||||
calcularKPIs,
|
import ChartCard from '../components/ui/ChartCard'
|
||||||
calcularTablaComparativa,
|
import { calcularKPIs, calcularTablaComparativa, calcularLesionadosPorGravedad, filtrarPersonasPorSiniestros } from '../utils/calculos'
|
||||||
POBLACION_DEPTO,
|
|
||||||
} from '../utils/calculos'
|
|
||||||
|
|
||||||
export default function SecResumen({ siniestros }) {
|
export default function SecResumen({ siniestros, personas, involucrados }) {
|
||||||
const kpis = calcularKPIs(siniestros)
|
const kpis = useMemo(() => calcularKPIs(siniestros), [siniestros])
|
||||||
const tablaData = calcularTablaComparativa(siniestros)
|
const tablaData = useMemo(() => calcularTablaComparativa(siniestros), [siniestros])
|
||||||
|
const lesionados = useMemo(
|
||||||
console.log(
|
() => calcularLesionadosPorGravedad(siniestros, personas),
|
||||||
'Departamentos en BD:',
|
[siniestros, personas]
|
||||||
[...new Set(siniestros.map((s) => s.departamento))]
|
|
||||||
)
|
)
|
||||||
console.log(
|
const personasFiltradas = useMemo(
|
||||||
'SecResumen recibe siniestros:',
|
() => filtrarPersonasPorSiniestros(personas, siniestros),
|
||||||
siniestros.length,
|
[personas, siniestros]
|
||||||
siniestros[0]?.ano,
|
|
||||||
siniestros[0]?.mes
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div data-pdf-block className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
|
||||||
|
{/* ── Fila 1: KPIs de siniestros ── */}
|
||||||
|
<div data-pdf-block className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-muted">
|
||||||
|
Siniestros
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<KPICard label="Total siniestros" value={kpis.total} color="#252C61" />
|
<KPICard label="Total siniestros" value={kpis.total} color="#252C61" />
|
||||||
<KPICard label="Siniestros fatales" value={kpis.fatales} color="#C44228" />
|
<KPICard label="Siniestros fatales" value={kpis.fatales} color="#C44228" />
|
||||||
<KPICard label="Con lesionados" value={kpis.conLes} color="#CD9F2B" />
|
<KPICard label="Con lesionados" value={kpis.conLes} color="#CD9F2B" />
|
||||||
<KPICard label="Sin lesiones" value={kpis.sinLes} color="#337C58" />
|
<KPICard label="Sin lesiones" value={kpis.sinLes} color="#337C58" />
|
||||||
<KPICard label="Victimas fatales" value={kpis.victimas} color="#922B21" />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.7fr_1fr]">
|
{/* ── Fila 2: KPIs de personas ── */}
|
||||||
|
<div data-pdf-block className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-muted">
|
||||||
|
Personas involucradas
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<KPICard label="Víctimas fatales" value={kpis.victimas} color="#922B21" />
|
||||||
|
<KPICard label="Heridos graves" value={lesionados.graves} color="#C44228" badge="personas" />
|
||||||
|
<KPICard label="Heridos leves" value={lesionados.leves} color="#CD9F2B" badge="personas" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Evolución y gravedad */}
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.7fr_1fr] items-start">
|
||||||
|
<ChartCard
|
||||||
|
kicker="Evolución temporal"
|
||||||
|
title="Siniestros por mes"
|
||||||
|
subtitle="Cantidad total de siniestros viales agregados por mes del periodo seleccionado."
|
||||||
|
height="lg"
|
||||||
|
>
|
||||||
<SiniestrosPorMes siniestros={siniestros} />
|
<SiniestrosPorMes siniestros={siniestros} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
kicker="Gravedad"
|
||||||
|
title="Distribución por gravedad"
|
||||||
|
subtitle="Proporción de siniestros según su nivel de gravedad: fatales, con lesionados y sin lesiones."
|
||||||
|
height="auto"
|
||||||
|
>
|
||||||
<DonutGravedad siniestros={siniestros} />
|
<DonutGravedad siniestros={siniestros} />
|
||||||
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_2.1fr]">
|
{/* Zona y tabla comparativa */}
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[0.9fr_2.1fr] items-start">
|
||||||
|
<ChartCard
|
||||||
|
kicker="Distribución territorial"
|
||||||
|
title="Zona de ocurrencia"
|
||||||
|
subtitle="Proporción de siniestros ocurridos en ámbitos urbanos y rurales."
|
||||||
|
height="sm"
|
||||||
|
>
|
||||||
<ZonaOcurrencia siniestros={siniestros} />
|
<ZonaOcurrencia siniestros={siniestros} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
<div data-pdf-block className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
<div data-pdf-block className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -74,7 +111,6 @@ export default function SecResumen({ siniestros }) {
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{tablaData.map((row, i) => (
|
{tablaData.map((row, i) => (
|
||||||
<tr
|
<tr
|
||||||
@@ -101,6 +137,17 @@ export default function SecResumen({ siniestros }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Seguridad pasiva */}
|
||||||
|
<ChartCard
|
||||||
|
kicker="Seguridad pasiva"
|
||||||
|
title="Uso de elementos de protección"
|
||||||
|
subtitle="Uso de cinturón de seguridad, casco y otros elementos de protección en el total de personas involucradas. (Bases ajustadas por tipo de vehículo)"
|
||||||
|
height="auto"
|
||||||
|
>
|
||||||
|
<ProteccionPersonas personas={personas} involucrados={involucrados} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import TipoInvolucrado from '../components/charts/TipoInvolucrado'
|
|||||||
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
||||||
import ChartCard from '../components/ui/ChartCard'
|
import ChartCard from '../components/ui/ChartCard'
|
||||||
|
|
||||||
|
|
||||||
export default function SecSinLesiones({ siniestros, involucrados }) {
|
export default function SecSinLesiones({ siniestros, involucrados }) {
|
||||||
const filtrarSinLesiones = useMemo(
|
const filtrarSinLesiones = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -24,8 +25,15 @@ export default function SecSinLesiones({ siniestros, involucrados }) {
|
|||||||
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
||||||
const ilesos = filtrarSinLesiones.reduce((acc, s) => acc + Number(s.ilesos || 0), 0)
|
const ilesos = filtrarSinLesiones.reduce((acc, s) => acc + Number(s.ilesos || 0), 0)
|
||||||
|
|
||||||
|
// ← NUEVO: involucrados acotados a los siniestros sin lesiones filtrados
|
||||||
|
const involucradosFiltrados = useMemo(() => {
|
||||||
|
const ids = new Set(filtrarSinLesiones.map(s => s.id_feu).filter(Boolean))
|
||||||
|
return (involucrados ?? []).filter(i => ids.has(i.id_feu))
|
||||||
|
}, [involucrados, filtrarSinLesiones])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|
||||||
{/* KPIs */}
|
{/* KPIs */}
|
||||||
<div data-pdf-block className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div data-pdf-block className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<KPICard label="Siniestros sin lesiones" value={total} color="#337C58" />
|
<KPICard label="Siniestros sin lesiones" value={total} color="#337C58" />
|
||||||
@@ -57,8 +65,8 @@ export default function SecSinLesiones({ siniestros, involucrados }) {
|
|||||||
{/* Franja horaria */}
|
{/* Franja horaria */}
|
||||||
<ChartCard
|
<ChartCard
|
||||||
kicker="Condiciones temporales"
|
kicker="Condiciones temporales"
|
||||||
title="Franja horaria de los siniestros sin lesiones"
|
title="Franja horaria de los siniestros sin lesiones según zona de ocurrencia"
|
||||||
subtitle="Cantidad de siniestros sin lesiones según la franja horaria del día en que ocurrieron y la zona."
|
subtitle="Cantidad de siniestros sin lesiones según la franja horaria y la zona en que ocurrieron."
|
||||||
height="auto"
|
height="auto"
|
||||||
>
|
>
|
||||||
<FranjaHoraria siniestros={filtrarSinLesiones} />
|
<FranjaHoraria siniestros={filtrarSinLesiones} />
|
||||||
@@ -93,8 +101,9 @@ export default function SecSinLesiones({ siniestros, involucrados }) {
|
|||||||
height="auto"
|
height="auto"
|
||||||
className="mx-auto w-full max-w-4xl"
|
className="mx-auto w-full max-w-4xl"
|
||||||
>
|
>
|
||||||
<TipoInvolucrado involucrados={involucrados} />
|
<TipoInvolucrado involucrados={involucradosFiltrados} /> {/* ← NUEVO */}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+99
-46
@@ -1,16 +1,15 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { calcularSintesis } from '../utils/calculos'
|
import { calcularSintesis, calcularLesionadosPorGravedad } from '../utils/calculos'
|
||||||
import { SERIE_HISTORICA } from '../components/charts/SerieHistorica'
|
import { SERIE_HISTORICA } from '../components/charts/SerieHistorica'
|
||||||
import ChartCard from '../components/ui/ChartCard'
|
import ChartCard from '../components/ui/ChartCard'
|
||||||
import { COLOR } from '../utils/colores'
|
import { COLOR } from '../utils/colores'
|
||||||
|
|
||||||
|
|
||||||
// ── Ficha compacta vertical ───────────────────────────────────────────────────
|
// ── Ficha compacta vertical ───────────────────────────────────────────────────
|
||||||
function Ficha({ kicker, title, color, destacado, datos }) {
|
function Ficha({ kicker, title, color, destacado, datos }) {
|
||||||
return (
|
return (
|
||||||
<ChartCard kicker={kicker} title={title} height="auto" contentClassName="min-h-0">
|
<ChartCard kicker={kicker} title={title} height="auto" contentClassName="min-h-0">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|
||||||
{/* Dato destacado */}
|
|
||||||
<div
|
<div
|
||||||
className="flex flex-col justify-center rounded-[14px] px-4 py-4"
|
className="flex flex-col justify-center rounded-[14px] px-4 py-4"
|
||||||
style={{ background: `${color}12` }}
|
style={{ background: `${color}12` }}
|
||||||
@@ -22,8 +21,6 @@ function Ficha({ kicker, title, color, destacado, datos }) {
|
|||||||
{destacado.valor ?? '—'}
|
{destacado.valor ?? '—'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grilla de datos secundarios */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{datos.map(({ label, valor }) => (
|
{datos.map(({ label, valor }) => (
|
||||||
<div key={label} className="rounded-[12px] bg-opsv-bg px-3 py-2.5">
|
<div key={label} className="rounded-[12px] bg-opsv-bg px-3 py-2.5">
|
||||||
@@ -32,36 +29,65 @@ function Ficha({ kicker, title, color, destacado, datos }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Componente principal ──────────────────────────────────────────────────────
|
// ── Componente principal ──────────────────────────────────────────────────────
|
||||||
export default function SecSintesis({ siniestros, personas, involucrados }) {
|
export default function SecSintesis({ siniestros, personas, involucrados, year, victimasActual, siniestrosActual }) {
|
||||||
const s = useMemo(
|
const s = useMemo(
|
||||||
() => calcularSintesis(siniestros, personas, involucrados),
|
() => calcularSintesis(siniestros, personas, involucrados),
|
||||||
[siniestros, personas, involucrados],
|
[siniestros, personas, involucrados],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Serie válida: excluye 2020, agrega 2025 si no está, ordena por año
|
const lesionados = useMemo(
|
||||||
|
() => calcularLesionadosPorGravedad(siniestros, personas),
|
||||||
|
[siniestros, personas],
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── SERIE HISTÓRICA: completa, con el año actual actualizado ─────────────
|
||||||
const serieValida = useMemo(() => {
|
const serieValida = useMemo(() => {
|
||||||
const base = [...SERIE_HISTORICA]
|
const base = [...SERIE_HISTORICA]
|
||||||
const existe2025 = base.some((r) => r.ano === 2025)
|
|
||||||
if (!existe2025) {
|
if (year && victimasActual != null) {
|
||||||
base.push({ ano: 2025, siniestros: 0, victimas: 21, tasa: 6.27 })
|
const idx = base.findIndex((r) => r.ano === year)
|
||||||
|
if (idx >= 0) {
|
||||||
|
// Año ya existe en la serie (ej: 2024) → actualizarlo con datos reales
|
||||||
|
base[idx] = {
|
||||||
|
...base[idx],
|
||||||
|
siniestros: siniestrosActual ?? base[idx].siniestros,
|
||||||
|
victimas: victimasActual,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Año nuevo (ej: 2025 en adelante) → agregarlo
|
||||||
|
base.push({
|
||||||
|
ano: year,
|
||||||
|
siniestros: siniestrosActual ?? 0,
|
||||||
|
victimas: victimasActual,
|
||||||
|
tasa: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return base
|
return base
|
||||||
.filter((r) => r.ano !== 2020 && r.victimas != null)
|
.filter((r) => r.ano !== 2020 && r.victimas != null)
|
||||||
.sort((a, b) => a.ano - b.ano)
|
.sort((a, b) => a.ano - b.ano)
|
||||||
}, [])
|
}, [year, victimasActual, siniestrosActual])
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Derivados de la serie ─────────────────────────────────────────────────
|
||||||
const anoMaxVictimas = serieValida.reduce((a, b) => (a.victimas > b.victimas ? a : b))
|
const anoMaxVictimas = serieValida.reduce((a, b) => (a.victimas > b.victimas ? a : b))
|
||||||
const anoMinVictimas = serieValida.reduce((a, b) => (a.victimas < b.victimas ? a : b))
|
const anoMinVictimas = serieValida.reduce((a, b) => (a.victimas < b.victimas ? a : b))
|
||||||
|
|
||||||
const ultimoAno = serieValida[serieValida.length - 1]
|
// ultimoAno = el año seleccionado (o el último de la serie si no hay filtro)
|
||||||
const penultimoAno = serieValida[serieValida.length - 2]
|
const ultimoAno = year
|
||||||
|
? (serieValida.find((r) => r.ano === year) ?? serieValida[serieValida.length - 1])
|
||||||
|
: serieValida[serieValida.length - 1]
|
||||||
|
|
||||||
|
// penultimoAno = el año inmediatamente anterior al seleccionado en la serie
|
||||||
|
const penultimoAno = serieValida[serieValida.indexOf(ultimoAno) - 1] ?? null
|
||||||
|
|
||||||
const tendencia =
|
const tendencia =
|
||||||
ultimoAno && penultimoAno
|
ultimoAno && penultimoAno
|
||||||
@@ -71,26 +97,28 @@ export default function SecSintesis({ siniestros, personas, involucrados }) {
|
|||||||
const tendenciaColor = tendencia.includes('▲') ? COLOR.fatales : COLOR.green
|
const tendenciaColor = tendencia.includes('▲') ? COLOR.fatales : COLOR.green
|
||||||
|
|
||||||
const pctVariacion =
|
const pctVariacion =
|
||||||
ultimoAno && penultimoAno
|
ultimoAno && penultimoAno && penultimoAno.victimas > 0
|
||||||
? (((ultimoAno.victimas - penultimoAno.victimas) / penultimoAno.victimas) * 100).toFixed(1)
|
? (((ultimoAno.victimas - penultimoAno.victimas) / penultimoAno.victimas) * 100).toFixed(1)
|
||||||
: null
|
: null
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const fichas = [
|
const fichas = [
|
||||||
{ kicker: 'Análisis por tipo', title: 'Siniestros fatales', color: COLOR.fatales, bloque: s.fatalesBloque },
|
{ kicker: 'Análisis por tipo', title: 'Siniestros fatales', color: COLOR.fatales, bloque: s.fatalesBloque,
|
||||||
{ kicker: 'Análisis por tipo', title: 'Con lesionados', color: COLOR.conLes, bloque: s.conLesBloque },
|
extraDatos: [] },
|
||||||
{ kicker: 'Análisis por tipo', title: 'Sin lesiones', color: COLOR.sinLes, bloque: s.sinLesBloque },
|
{ kicker: 'Análisis por tipo', title: 'Con lesionados', color: COLOR.conLes, bloque: s.conLesBloque,
|
||||||
|
extraDatos: [
|
||||||
|
{ label: 'Heridos graves', valor: lesionados.graves },
|
||||||
|
{ label: 'Heridos leves', valor: lesionados.leves },
|
||||||
|
]},
|
||||||
|
{ kicker: 'Análisis por tipo', title: 'Sin lesiones', color: COLOR.sinLes, bloque: s.sinLesBloque,
|
||||||
|
extraDatos: [] },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* ── Siniestralidad general ── */}
|
{/* ── Siniestralidad general ── */}
|
||||||
<ChartCard
|
<ChartCard kicker="Resumen general" title="Siniestralidad del período" height="auto" contentClassName="min-h-0">
|
||||||
kicker="Resumen general"
|
|
||||||
title="Siniestralidad del período"
|
|
||||||
height="auto"
|
|
||||||
contentClassName="min-h-0"
|
|
||||||
>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-5">
|
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-5">
|
||||||
{[
|
{[
|
||||||
{ label: 'Total siniestros', valor: s.kpis.total, color: COLOR.navy },
|
{ label: 'Total siniestros', valor: s.kpis.total, color: COLOR.navy },
|
||||||
@@ -107,36 +135,60 @@ export default function SecSintesis({ siniestros, personas, involucrados }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Desglose graves/leves ── */}
|
||||||
|
{(lesionados.graves > 0 || lesionados.leves > 0) && (() => {
|
||||||
|
const total = lesionados.graves + lesionados.leves
|
||||||
|
const pctG = total ? Math.round((lesionados.graves / total) * 100) : 0
|
||||||
|
const pctL = total ? Math.round((lesionados.leves / total) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-[14px] border border-opsv-border bg-opsv-surface px-4 py-3">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.3em] text-opsv-muted">
|
||||||
|
Desglose de personas lesionadas
|
||||||
|
</p>
|
||||||
|
<div className="flex h-4 w-full overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-[10px] font-bold text-white"
|
||||||
|
style={{ width: `${pctG}%`, backgroundColor: COLOR.fatales }}
|
||||||
|
>
|
||||||
|
{pctG >= 12 ? `${pctG}%` : ''}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-[10px] font-bold text-white"
|
||||||
|
style={{ width: `${pctL}%`, backgroundColor: COLOR.conLes }}
|
||||||
|
>
|
||||||
|
{pctL >= 12 ? `${pctL}%` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-4 text-xs text-opsv-muted">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: COLOR.fatales }} />
|
||||||
|
<strong className="text-opsv-navy">{lesionados.graves}</strong> heridos graves ({pctG}%)
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: COLOR.conLes }} />
|
||||||
|
<strong className="text-opsv-navy">{lesionados.leves}</strong> heridos leves ({pctL}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{/* ── Contexto histórico ── */}
|
{/* ── Contexto histórico ── */}
|
||||||
<ChartCard
|
<ChartCard kicker="Serie histórica" title="Evolución de víctimas fatales" subtitle="Excluye 2020 por restricciones de movilidad COVID-19" height="auto" contentClassName="min-h-0">
|
||||||
kicker="Serie histórica"
|
|
||||||
title="Evolución de víctimas fatales"
|
|
||||||
subtitle="Excluye 2020 por restricciones de movilidad COVID-19"
|
|
||||||
height="auto"
|
|
||||||
contentClassName="min-h-0"
|
|
||||||
>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
||||||
<p className="text-3xl font-black text-opsv-navy">{anoMaxVictimas.ano}</p>
|
<p className="text-3xl font-black text-opsv-navy">{anoMaxVictimas.ano}</p>
|
||||||
<p className="mt-1 text-sm text-opsv-muted">
|
<p className="mt-1 text-sm text-opsv-muted">Año más crítico ({anoMaxVictimas.victimas} víctimas)</p>
|
||||||
Año más crítico ({anoMaxVictimas.victimas} víctimas)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
||||||
<p className="text-3xl font-black text-opsv-navy">{anoMinVictimas.ano}</p>
|
<p className="text-3xl font-black text-opsv-navy">{anoMinVictimas.ano}</p>
|
||||||
<p className="mt-1 text-sm text-opsv-muted">
|
<p className="mt-1 text-sm text-opsv-muted">Año más bajo ({anoMinVictimas.victimas} víctimas)</p>
|
||||||
Año más bajo ({anoMinVictimas.victimas} víctimas)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
||||||
<p className="text-3xl font-black text-opsv-navy">
|
<p className="text-3xl font-black text-opsv-navy">{penultimoAno?.victimas ?? '—'}</p>
|
||||||
{penultimoAno?.victimas ?? '—'}
|
<p className="mt-1 text-sm text-opsv-muted">Víctimas {penultimoAno?.ano ?? '—'}</p>
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-opsv-muted">
|
|
||||||
Víctimas {penultimoAno?.ano ?? '—'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
||||||
<p className="text-3xl font-black" style={{ color: tendenciaColor }}>
|
<p className="text-3xl font-black" style={{ color: tendenciaColor }}>
|
||||||
@@ -146,15 +198,15 @@ export default function SecSintesis({ siniestros, personas, involucrados }) {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-opsv-muted">
|
<p className="mt-1 text-sm text-opsv-muted">
|
||||||
Tendencia {penultimoAno?.ano}→{ultimoAno?.ano}
|
Tendencia {penultimoAno?.ano ?? '—'}→{ultimoAno?.ano ?? '—'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{/* ── Fichas por tipo: 3 columnas en desktop ── */}
|
{/* ── Fichas por tipo ── */}
|
||||||
<div className="grid gap-6 xl:grid-cols-3">
|
<div className="grid gap-6 xl:grid-cols-3">
|
||||||
{fichas.map(({ kicker, title, color, bloque }) => (
|
{fichas.map(({ kicker, title, color, bloque, extraDatos }) => (
|
||||||
<Ficha
|
<Ficha
|
||||||
key={title}
|
key={title}
|
||||||
kicker={kicker}
|
kicker={kicker}
|
||||||
@@ -169,6 +221,7 @@ export default function SecSintesis({ siniestros, personas, involucrados }) {
|
|||||||
{ label: 'Género predominante', valor: bloque.genero },
|
{ label: 'Género predominante', valor: bloque.genero },
|
||||||
{ label: 'Rango etario más afectado', valor: bloque.rangoEtario },
|
{ label: 'Rango etario más afectado', valor: bloque.rangoEtario },
|
||||||
{ label: 'Tipo de involucrado', valor: bloque.tipoInvolucrado },
|
{ label: 'Tipo de involucrado', valor: bloque.tipoInvolucrado },
|
||||||
|
...extraDatos,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+218
-165
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
SecVeranoVivo
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||||
@@ -9,10 +7,11 @@ import {
|
|||||||
import ChartCard from '../components/ui/ChartCard'
|
import ChartCard from '../components/ui/ChartCard'
|
||||||
import { COLOR } from '../utils/colores'
|
import { COLOR } from '../utils/colores'
|
||||||
import {
|
import {
|
||||||
HISTORICO_VERANO_VIVO, PROMEDIO_HISTORICO_VV, CAMPANAS_VV,
|
CAMPANAS_VV,
|
||||||
filtrarCampanaVV, kpisVV, ruralUrbanoPorCampana,
|
filtrarCampanaVV, kpisVV, ruralUrbanoPorCampana,
|
||||||
distribucionMensualVV, rankingRutas, rankingLocalidades,
|
distribucionMensualVV, rankingRutas, rankingLocalidades,
|
||||||
tiposSiniestroVV,
|
tiposSiniestroVV, generarHistoricoVV, calcularPromedioHistoricoVV,
|
||||||
|
calcularLesionadosPorGravedad,
|
||||||
} from '../utils/calculos'
|
} from '../utils/calculos'
|
||||||
|
|
||||||
|
|
||||||
@@ -46,10 +45,11 @@ const CustomPieLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, pct }) =>
|
|||||||
return (
|
return (
|
||||||
<text
|
<text
|
||||||
x={x} y={y}
|
x={x} y={y}
|
||||||
fill="white"
|
fill="currentColor"
|
||||||
|
className="text-opsv-navy dark:text-white"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
fontSize={12}
|
fontSize={13}
|
||||||
fontWeight="700"
|
fontWeight="700"
|
||||||
>
|
>
|
||||||
{`${pct.toFixed(0)}%`}
|
{`${pct.toFixed(0)}%`}
|
||||||
@@ -59,109 +59,92 @@ const CustomPieLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, pct }) =>
|
|||||||
|
|
||||||
|
|
||||||
// ── INSIGHTS ────────────────────────────────────────────────
|
// ── INSIGHTS ────────────────────────────────────────────────
|
||||||
function calcularInsights(campanaActual, kpis, graficoC, graficoD, graficoF) {
|
function calcularInsights(campanaActual, kpis, graficoC, graficoD, graficoF, promedioHistorico, historicoVV, lesionados) {
|
||||||
const insights = []
|
const insights = []
|
||||||
|
|
||||||
// 1. Fatales vs promedio histórico
|
|
||||||
const fatalesActual = kpis.fatales
|
const fatalesActual = kpis.fatales
|
||||||
const diff = fatalesActual - PROMEDIO_HISTORICO_VV
|
const diff = fatalesActual - promedioHistorico
|
||||||
|
|
||||||
if (fatalesActual === 0) {
|
if (fatalesActual === 0) {
|
||||||
insights.push({
|
insights.push({ tipo: 'logro', texto: `La campaña ${campanaActual.label} finalizó sin víctimas fatales en rutas y caminos.` })
|
||||||
tipo: 'logro',
|
|
||||||
texto: `La campaña ${campanaActual.label} finalizó sin víctimas fatales en rutas y caminos.`,
|
|
||||||
})
|
|
||||||
} else if (diff < 0) {
|
} else if (diff < 0) {
|
||||||
insights.push({
|
insights.push({ tipo: 'logro', texto: `Con ${fatalesActual} víctima${fatalesActual !== 1 ? 's' : ''} fatal${fatalesActual !== 1 ? 'es' : ''} en ruta, la campaña se mantuvo por debajo del promedio histórico de ${promedioHistorico}.` })
|
||||||
tipo: 'logro',
|
|
||||||
texto: `Con ${fatalesActual} víctima${fatalesActual !== 1 ? 's' : ''} fatal${fatalesActual !== 1 ? 'es' : ''} en ruta, la campaña se mantuvo por debajo del promedio histórico de ${PROMEDIO_HISTORICO_VV}.`,
|
|
||||||
})
|
|
||||||
} else if (diff === 0) {
|
} else if (diff === 0) {
|
||||||
insights.push({
|
insights.push({ tipo: 'neutro', texto: `La campaña registró ${fatalesActual} víctimas fatales en ruta, igual al promedio histórico de ${promedioHistorico}.` })
|
||||||
tipo: 'neutro',
|
|
||||||
texto: `La campaña registró ${fatalesActual} víctimas fatales en ruta, igual al promedio histórico de ${PROMEDIO_HISTORICO_VV}.`,
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
insights.push({
|
insights.push({ tipo: 'alerta', texto: `La campaña registró ${fatalesActual} víctimas fatales en ruta, superando el promedio histórico de ${promedioHistorico}.` })
|
||||||
tipo: 'alerta',
|
|
||||||
texto: `La campaña registró ${fatalesActual} víctimas fatales en ruta, superando el promedio histórico de ${PROMEDIO_HISTORICO_VV}.`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Reducción vs 2015/16 (pico inicial)
|
if (!historicoVV || historicoVV.length === 0) return insights
|
||||||
const fatalesPico = HISTORICO_VERANO_VIVO[0].fatales
|
const fatalesPico = historicoVV[0].fatales
|
||||||
if (fatalesActual < fatalesPico) {
|
if (fatalesActual < fatalesPico) {
|
||||||
const reduccion = Math.round(((fatalesPico - fatalesActual) / fatalesPico) * 100)
|
const reduccion = Math.round(((fatalesPico - fatalesActual) / fatalesPico) * 100)
|
||||||
insights.push({
|
insights.push({ tipo: 'logro', texto: `Reducción del ${reduccion}% respecto a la primera campaña Verano Vivo (${historicoVV[0].campaña}), que registró ${fatalesPico} víctimas fatales en ruta.` })
|
||||||
tipo: 'logro',
|
|
||||||
texto: `Reducción del ${reduccion}% respecto a la primera campaña Verano Vivo (${HISTORICO_VERANO_VIVO[0].campaña}), que registró ${fatalesPico} víctimas fatales en ruta.`,
|
|
||||||
})
|
|
||||||
} else if (fatalesActual === fatalesPico) {
|
} else if (fatalesActual === fatalesPico) {
|
||||||
insights.push({
|
insights.push({ tipo: 'alerta', texto: `Los fatales en ruta igualaron el máximo histórico de la primera campaña (${historicoVV[0].campaña}: ${fatalesPico} víctimas).` })
|
||||||
tipo: 'alerta',
|
|
||||||
texto: `Los fatales en ruta igualaron el máximo histórico de la primera campaña (${HISTORICO_VERANO_VIVO[0].campaña}: ${fatalesPico} víctimas).`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Tendencia últimas 3 campañas
|
if (historicoVV.length >= 3) {
|
||||||
if (HISTORICO_VERANO_VIVO.length >= 3) {
|
const ultimas = historicoVV.slice(-3)
|
||||||
const ultimas = HISTORICO_VERANO_VIVO.slice(-3)
|
|
||||||
const [a, b, c] = ultimas.map(x => x.fatales)
|
const [a, b, c] = ultimas.map(x => x.fatales)
|
||||||
if (c <= b && b <= a && c < a) {
|
if (c <= b && b <= a && c < a) {
|
||||||
insights.push({
|
insights.push({ tipo: 'logro', texto: `Tendencia a la baja: los últimos 3 períodos registraron ${a}, ${b} y ${c} víctimas fatales en ruta respectivamente.` })
|
||||||
tipo: 'logro',
|
|
||||||
texto: `Tendencia a la baja: los últimos 3 períodos registraron ${a}, ${b} y ${c} víctimas fatales en ruta respectivamente.`,
|
|
||||||
})
|
|
||||||
} else if (c >= b && b >= a && c > a) {
|
} else if (c >= b && b >= a && c > a) {
|
||||||
insights.push({
|
insights.push({ tipo: 'alerta', texto: `Tendencia al alza: los últimos 3 períodos registraron ${a}, ${b} y ${c} víctimas fatales en ruta respectivamente.` })
|
||||||
tipo: 'alerta',
|
|
||||||
texto: `Tendencia al alza: los últimos 3 períodos registraron ${a}, ${b} y ${c} víctimas fatales en ruta respectivamente.`,
|
|
||||||
})
|
|
||||||
} else if (a === b && b === c) {
|
} else if (a === b && b === c) {
|
||||||
insights.push({
|
insights.push({ tipo: 'neutro', texto: `Los últimos 3 períodos mantuvieron estable la cantidad de víctimas fatales en ruta: ${c} por campaña.` })
|
||||||
tipo: 'neutro',
|
|
||||||
texto: `Los últimos 3 períodos mantuvieron estable la cantidad de víctimas fatales en ruta: ${c} por campaña.`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Meses sin siniestros fatales en ruta
|
|
||||||
const NOMBRES_MESES = { 12: 'Diciembre', 1: 'Enero', 2: 'Febrero', 3: 'Marzo' }
|
const NOMBRES_MESES = { 12: 'Diciembre', 1: 'Enero', 2: 'Febrero', 3: 'Marzo' }
|
||||||
const mesesSinFatales = graficoC
|
const mesesSinFatales = graficoC
|
||||||
.filter(m => m.fatales === 0)
|
.filter(m => m.fatales === 0)
|
||||||
.map(m => Object.values(NOMBRES_MESES).find(n => n.startsWith(m.mes)) || m.mes)
|
.map(m => Object.values(NOMBRES_MESES).find(n => n.startsWith(m.mes)) || m.mes)
|
||||||
|
|
||||||
if (mesesSinFatales.length === 4) {
|
if (mesesSinFatales.length === 4) {
|
||||||
insights.push({
|
insights.push({ tipo: 'logro', texto: 'Todos los meses de la campaña transcurrieron sin víctimas fatales en rutas y caminos.' })
|
||||||
tipo: 'logro',
|
|
||||||
texto: 'Todos los meses de la campaña transcurrieron sin víctimas fatales en rutas y caminos.',
|
|
||||||
})
|
|
||||||
} else if (mesesSinFatales.length > 0) {
|
} else if (mesesSinFatales.length > 0) {
|
||||||
const listaMeses = mesesSinFatales.length === 1
|
const listaMeses = mesesSinFatales.length === 1
|
||||||
? mesesSinFatales[0]
|
? mesesSinFatales[0]
|
||||||
: mesesSinFatales.slice(0, -1).join(', ') + ' y ' + mesesSinFatales.at(-1)
|
: mesesSinFatales.slice(0, -1).join(', ') + ' y ' + mesesSinFatales.at(-1)
|
||||||
insights.push({
|
insights.push({ tipo: 'logro', texto: `${listaMeses} transcurrió${mesesSinFatales.length > 1 ? 'ron' : ''} sin víctimas fatales en rutas y caminos.` })
|
||||||
tipo: 'logro',
|
|
||||||
texto: `${listaMeses} transcurrió${mesesSinFatales.length > 1 ? 'ron' : ''} sin víctimas fatales en rutas y caminos.`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Tipo más frecuente en ruta
|
|
||||||
if (graficoF.length > 0) {
|
if (graficoF.length > 0) {
|
||||||
const top = graficoF[0]
|
const top = graficoF[0]
|
||||||
insights.push({
|
insights.push({ tipo: 'dato', texto: `El tipo de siniestro más frecuente en rutas fue "${top.name}" (${top.pct.toFixed(0)}% de los casos).` })
|
||||||
tipo: 'dato',
|
|
||||||
texto: `El tipo de siniestro más frecuente en rutas fue "${top.name}" (${top.pct.toFixed(0)}% de los casos).`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Ruta con más siniestros
|
|
||||||
if (graficoD.length > 0) {
|
if (graficoD.length > 0) {
|
||||||
const topRuta = graficoD[0]
|
const rutaMasGrave = [...graficoD].sort(
|
||||||
|
(a, b) => (b.fatales - a.fatales) || (b.conLes - a.conLes) || (b.total - a.total)
|
||||||
|
)[0]
|
||||||
|
const tipoVia = (rutaMasGrave.via_publica || '').trim()
|
||||||
|
const nombreVia = (rutaMasGrave.nombre_via || '').trim()
|
||||||
|
const nombreRuta = `${tipoVia} ${nombreVia}`.trim() || rutaMasGrave.ruta || 'la ruta identificada'
|
||||||
|
if (rutaMasGrave.fatales > 0) {
|
||||||
|
insights.push({ tipo: 'alerta', texto: `La ruta de mayor gravedad fue la ${nombreRuta}, con ${rutaMasGrave.fatales} siniestro${rutaMasGrave.fatales !== 1 ? 's' : ''} fatal${rutaMasGrave.fatales !== 1 ? 'es' : ''}${rutaMasGrave.conLes > 0 ? ` y ${rutaMasGrave.conLes} con lesionados` : ''}.` })
|
||||||
|
} else if (rutaMasGrave.conLes > 0) {
|
||||||
|
insights.push({ tipo: 'dato', texto: `La ruta de mayor gravedad fue la ${nombreRuta}, con ${rutaMasGrave.conLes} siniestro${rutaMasGrave.conLes !== 1 ? 's' : ''} con lesionados.` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NUEVO: insight sobre heridos graves ──
|
||||||
|
if (lesionados) {
|
||||||
|
const { graves, leves } = lesionados
|
||||||
|
const totalLes = graves + leves
|
||||||
|
if (totalLes > 0 && graves > 0) {
|
||||||
|
const pctGraves = Math.round((graves / totalLes) * 100)
|
||||||
insights.push({
|
insights.push({
|
||||||
tipo: 'dato',
|
tipo: pctGraves >= 40 ? 'alerta' : 'dato',
|
||||||
texto: `La Ruta ${topRuta.ruta} concentró la mayor cantidad de siniestros con ${topRuta.total} evento${topRuta.total !== 1 ? 's' : ''}.`,
|
texto: `De las ${totalLes} personas lesionadas en ruta, ${graves} (${pctGraves}%) fueron heridos graves y ${leves} heridos leves.`,
|
||||||
})
|
})
|
||||||
|
} else if (totalLes > 0 && graves === 0) {
|
||||||
|
insights.push({
|
||||||
|
tipo: 'logro',
|
||||||
|
texto: `Las ${leves} personas lesionadas en ruta durante la campaña resultaron con heridas leves, sin heridos graves registrados.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return insights
|
return insights
|
||||||
@@ -192,18 +175,11 @@ function BloqueInsights({ insights, campanaLabel }) {
|
|||||||
{insights.map((insight, i) => {
|
{insights.map((insight, i) => {
|
||||||
const cfg = INSIGHT_CONFIG[insight.tipo]
|
const cfg = INSIGHT_CONFIG[insight.tipo]
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={i} className={`flex gap-3 rounded-2xl border p-4 ${cfg.bg} ${cfg.border}`}>
|
||||||
key={i}
|
|
||||||
className={`flex gap-3 rounded-2xl border p-4 ${cfg.bg} ${cfg.border}`}
|
|
||||||
>
|
|
||||||
<span className="mt-0.5 text-lg leading-none">{cfg.icon}</span>
|
<span className="mt-0.5 text-lg leading-none">{cfg.icon}</span>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className={`text-sm font-bold uppercase tracking-widest ${cfg.labelColor}`}>
|
<span className={`text-sm font-bold uppercase tracking-widest ${cfg.labelColor}`}>{cfg.label}</span>
|
||||||
{cfg.label}
|
<p className="text-[16px] leading-relaxed text-slate-900 dark:text-slate-100 font-medium">{insight.texto}</p>
|
||||||
</span>
|
|
||||||
<p className="text-[16px] leading-relaxed text-slate-900 dark:text-slate-100 font-medium">
|
|
||||||
{insight.texto}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -251,14 +227,9 @@ function CustomTooltip({ active, payload, label }) {
|
|||||||
function BarHorizontalStacked({ data, nameKey }) {
|
function BarHorizontalStacked({ data, nameKey }) {
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart
|
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 16, left: 30, bottom: 4 }} barCategoryGap="30%">
|
||||||
data={data}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{ top: 4, right: 16, left: 8, bottom: 4 }}
|
|
||||||
barCategoryGap="30%"
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||||
<XAxis type="number" tick={{ fontSize: 11, fill: '#4A5568' }} />
|
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||||
<YAxis type="category" dataKey={nameKey} width={130} tick={{ fontSize: 11, fill: '#4A5568' }} />
|
<YAxis type="category" dataKey={nameKey} width={130} tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
@@ -271,8 +242,73 @@ function BarHorizontalStacked({ data, nameKey }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Bloque de lesionados por gravedad ───────────────────────
|
||||||
|
function BloqueLesionadosVV({ graves, leves, campanaLabel }) {
|
||||||
|
const total = graves + leves
|
||||||
|
if (total === 0) return null
|
||||||
|
const pctGraves = Math.round((graves / total) * 100)
|
||||||
|
const pctLeves = Math.round((leves / total) * 100)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-pdf-block className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-muted">
|
||||||
|
Personas lesionadas en ruta · {campanaLabel}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 text-xl font-black text-opsv-navy dark:text-white">
|
||||||
|
Heridos graves vs. heridos leves
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Barra proporcional */}
|
||||||
|
<div className="mt-6 flex h-5 w-full overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-[11px] font-bold text-white transition-all"
|
||||||
|
style={{ width: `${pctGraves}%`, backgroundColor: COLOR.fatales }}
|
||||||
|
>
|
||||||
|
{pctGraves >= 10 ? `${pctGraves}%` : ''}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-[11px] font-bold text-white transition-all"
|
||||||
|
style={{ width: `${pctLeves}%`, backgroundColor: COLOR.conLes }}
|
||||||
|
>
|
||||||
|
{pctLeves >= 10 ? `${pctLeves}%` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tarjetas */}
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex items-center gap-4 rounded-2xl bg-opsv-bg p-5">
|
||||||
|
<div className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: COLOR.fatales }} />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-black text-opsv-text">{graves}</p>
|
||||||
|
<p className="text-sm font-semibold text-opsv-muted">
|
||||||
|
Heridos graves — {pctGraves}% del total
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-opsv-muted">
|
||||||
|
Personas con lesiones de gravedad registradas en rutas y caminos provinciales.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 rounded-2xl bg-opsv-bg p-5">
|
||||||
|
<div className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: COLOR.conLes }} />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-black text-opsv-text">{leves}</p>
|
||||||
|
<p className="text-sm font-semibold text-opsv-muted">
|
||||||
|
Heridos leves — {pctLeves}% del total
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-opsv-muted">
|
||||||
|
Personas con lesiones de menor gravedad registradas en rutas y caminos provinciales.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Componente principal ────────────────────────────────────
|
// ── Componente principal ────────────────────────────────────
|
||||||
export default function SecVeranoVivo({ siniestros }) {
|
export default function SecVeranoVivo({ siniestros, personas }) {
|
||||||
const [campanaIdx, setCampanaIdx] = useState(CAMPANAS_VV.length - 1)
|
const [campanaIdx, setCampanaIdx] = useState(CAMPANAS_VV.length - 1)
|
||||||
const campanaActual = CAMPANAS_VV[campanaIdx]
|
const campanaActual = CAMPANAS_VV[campanaIdx]
|
||||||
|
|
||||||
@@ -284,7 +320,8 @@ export default function SecVeranoVivo({ siniestros }) {
|
|||||||
() => CAMPANAS_VV.map(c => ({ label: c.label, datos: filtrarCampanaVV(siniestros, c.anoDesde) })),
|
() => CAMPANAS_VV.map(c => ({ label: c.label, datos: filtrarCampanaVV(siniestros, c.anoDesde) })),
|
||||||
[siniestros]
|
[siniestros]
|
||||||
)
|
)
|
||||||
|
const historicoVV = useMemo(() => generarHistoricoVV(siniestros), [siniestros])
|
||||||
|
const promedioHistoricoVV = useMemo(() => calcularPromedioHistoricoVV(siniestros), [siniestros])
|
||||||
const kpis = useMemo(() => kpisVV(datosActual), [datosActual])
|
const kpis = useMemo(() => kpisVV(datosActual), [datosActual])
|
||||||
const graficoB = useMemo(() => ruralUrbanoPorCampana(datosPorCampana), [datosPorCampana])
|
const graficoB = useMemo(() => ruralUrbanoPorCampana(datosPorCampana), [datosPorCampana])
|
||||||
const graficoCRural = useMemo(() => distribucionMensualVV(datosActual, 'rural'), [datosActual])
|
const graficoCRural = useMemo(() => distribucionMensualVV(datosActual, 'rural'), [datosActual])
|
||||||
@@ -293,23 +330,47 @@ export default function SecVeranoVivo({ siniestros }) {
|
|||||||
const graficoE = useMemo(() => rankingLocalidades(datosActual), [datosActual])
|
const graficoE = useMemo(() => rankingLocalidades(datosActual), [datosActual])
|
||||||
const graficoF = useMemo(() => agruparConUmbral(tiposSiniestroVV(datosActual), 10), [datosActual])
|
const graficoF = useMemo(() => agruparConUmbral(tiposSiniestroVV(datosActual), 10), [datosActual])
|
||||||
|
|
||||||
// ✅ graficoCRural (no graficoC) — datos de ruta para los insights
|
// ── NUEVO: lesionados por gravedad filtrados por campaña activa ──
|
||||||
|
const lesionadosVV = useMemo(
|
||||||
|
() => calcularLesionadosPorGravedad(datosActual, personas),
|
||||||
|
[datosActual, personas]
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxComparativoZona = useMemo(() => {
|
||||||
|
const maximo = Math.max(0, ...graficoB.flatMap((d) => [d.ruralFatal ?? 0, d.ruralLes ?? 0, d.urbanaFatal ?? 0, d.urbanaLes ?? 0]))
|
||||||
|
return Math.max(5, Math.ceil(maximo / 5) * 5)
|
||||||
|
}, [graficoB])
|
||||||
|
|
||||||
|
const maxMensualZona = useMemo(() => {
|
||||||
|
const maximo = Math.max(
|
||||||
|
0,
|
||||||
|
...graficoCRural.map((d) => (d.fatales ?? 0) + (d.conLes ?? 0) + (d.sinLes ?? 0)),
|
||||||
|
...graficoCUrbano.map((d) => (d.fatales ?? 0) + (d.conLes ?? 0) + (d.sinLes ?? 0))
|
||||||
|
)
|
||||||
|
return Math.max(5, Math.ceil(maximo / 5) * 5)
|
||||||
|
}, [graficoCRural, graficoCUrbano])
|
||||||
|
|
||||||
|
const graficoDConEtiqueta = useMemo(
|
||||||
|
() => graficoD.map((item) => ({ ...item, viaCompleta: `${item.via_publica || 'Ruta'} ${item.nombre_via || ''}`.trim() })),
|
||||||
|
[graficoD]
|
||||||
|
)
|
||||||
|
|
||||||
const insights = useMemo(
|
const insights = useMemo(
|
||||||
() => calcularInsights(campanaActual, kpis, graficoCRural, graficoD, graficoF),
|
() => calcularInsights(campanaActual, kpis, graficoCRural, graficoD, graficoF, promedioHistoricoVV, historicoVV, lesionadosVV),
|
||||||
[campanaActual, kpis, graficoCRural, graficoD, graficoF]
|
[campanaActual, kpis, graficoCRural, graficoD, graficoF, promedioHistoricoVV, historicoVV, lesionadosVV]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* Encabezado + selector de campaña */}
|
{/* Encabezado + selector */}
|
||||||
<div data-pdf-block className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div data-pdf-block className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-muted">Campaña</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-muted">Campaña</p>
|
||||||
<h2 className="mt-1 text-2xl font-black text-opsv-navy dark:text-white">Verano Vivo</h2>
|
<h2 className="mt-1 text-2xl font-black text-opsv-navy dark:text-white">Verano Vivo</h2>
|
||||||
<p className="text-sm text-slate-500">20 de diciembre al 20 de marzo</p>
|
<p className="text-sm text-slate-500">20 de diciembre al 20 de marzo</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{CAMPANAS_VV.map((c, i) => (
|
{CAMPANAS_VV.map((c, i) => (
|
||||||
<button
|
<button
|
||||||
key={c.label}
|
key={c.label}
|
||||||
@@ -326,20 +387,27 @@ export default function SecVeranoVivo({ siniestros }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPIs */}
|
{/* KPIs — siniestros en ruta */}
|
||||||
<div data-pdf-block>
|
<div data-pdf-block>
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.3em] text-opsv-muted">
|
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.3em] text-opsv-muted">
|
||||||
Rutas y caminos de la provincia · {campanaActual.label}
|
Rutas y caminos de la provincia · {campanaActual.label}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<KpiVV label="Total siniestros en ruta" value={kpis.total} color={COLOR.navy} />
|
<KpiVV label="Total siniestros en ruta" value={kpis.total} color={COLOR.navy} />
|
||||||
<KpiVV label="Fatales en Ruta" value={kpis.fatales} color={COLOR.fatales} />
|
<KpiVV label="Fatales en ruta" value={kpis.fatales} color={COLOR.fatales} />
|
||||||
<KpiVV label="Con lesiones en Ruta" value={kpis.conLes} color={COLOR.conLes} />
|
<KpiVV label="Con lesiones en ruta" value={kpis.conLes} color={COLOR.conLes} />
|
||||||
<KpiVV label="Sin lesiones en Ruta" value={kpis.sinLes} color={COLOR.sinLes} />
|
<KpiVV label="Sin lesiones en ruta" value={kpis.sinLes} color={COLOR.sinLes} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gráfico A — Serie histórica · ancho completo */}
|
{/* ── NUEVO: bloque lesionados por gravedad ── */}
|
||||||
|
<BloqueLesionadosVV
|
||||||
|
graves={lesionadosVV.graves}
|
||||||
|
leves={lesionadosVV.leves}
|
||||||
|
campanaLabel={campanaActual.label}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gráfico A — Serie histórica */}
|
||||||
<ChartCard
|
<ChartCard
|
||||||
kicker="Serie histórica"
|
kicker="Serie histórica"
|
||||||
title="Evolución de siniestros fatales en ruta"
|
title="Evolución de siniestros fatales en ruta"
|
||||||
@@ -347,16 +415,16 @@ export default function SecVeranoVivo({ siniestros }) {
|
|||||||
height="md"
|
height="md"
|
||||||
>
|
>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={HISTORICO_VERANO_VIVO} margin={{ top: 16, right: 24, left: 0, bottom: 8 }}>
|
<LineChart data={historicoVV} margin={{ top: 16, right: 24, left: 0, bottom: 8 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
<XAxis dataKey="campaña" tick={{ fontSize: 11, fill: '#4A5568' }} />
|
<XAxis dataKey="campaña" tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||||
<YAxis tick={{ fontSize: 11, fill: '#4A5568' }} domain={[0, 14]} />
|
<YAxis allowDecimals={false} tick={{ fontSize: 11, fill: '#4A5568' }} domain={[0, 14]} />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={PROMEDIO_HISTORICO_VV}
|
y={promedioHistoricoVV}
|
||||||
stroke={COLOR.conLes}
|
stroke={COLOR.conLes}
|
||||||
strokeDasharray="6 3"
|
strokeDasharray="6 3"
|
||||||
label={{ value: `Promedio ${PROMEDIO_HISTORICO_VV}`, position: 'insideTopRight', fontSize: 11, fill: COLOR.conLes }}
|
label={{ value: `Promedio ${promedioHistoricoVV}`, position: 'insideTopRight', fontSize: 11, fill: COLOR.conLes }}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
@@ -371,41 +439,45 @@ export default function SecVeranoVivo({ siniestros }) {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{/* Gráfico B — Comparación histórica · ancho completo */}
|
{/* Gráfico B — Comparación histórica por zona */}
|
||||||
<ChartCard
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
kicker="Comparación entre campañas"
|
<ChartCard kicker="Comparación entre campañas" title="Rutas y caminos" subtitle="Evolución histórica de siniestros fatales y con lesiones en zona rural." height="lg">
|
||||||
title="Siniestros por zona y severidad"
|
|
||||||
subtitle="Incluye siniestros en rutas/caminos y en zonas urbanas."
|
|
||||||
height="lg"
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={graficoB} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="25%" barGap={2}>
|
<BarChart data={graficoB} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="25%" barGap={2}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
<XAxis dataKey="campaña" tick={{ fontSize: 11, fill: '#4A5568' }} />
|
<XAxis dataKey="campaña" tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||||
<YAxis tick={{ fontSize: 11, fill: '#4A5568' }} />
|
<YAxis allowDecimals={false} domain={[0, maxComparativoZona]} tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
<Bar dataKey="ruralFatal" name="Ruta — fatal" fill={COLOR.fatales} radius={[3,3,0,0]} />
|
<Bar dataKey="ruralFatal" name="Fatales" fill={COLOR.fatales} radius={[3,3,0,0]} />
|
||||||
<Bar dataKey="urbanaFatal" name="Urbano — fatal" fill={COLOR.orange} radius={[3,3,0,0]} />
|
<Bar dataKey="ruralLes" name="Con lesiones" fill={COLOR.conLes} radius={[3,3,0,0]} />
|
||||||
<Bar dataKey="ruralLes" name="Ruta — con lesiones" fill={COLOR.navy} radius={[3,3,0,0]} />
|
|
||||||
<Bar dataKey="urbanaLes" name="Urbano — con lesiones" fill={COLOR.blue} radius={[3,3,0,0]} />
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{/* Gráficos C — Siniestros por mes separados por zona · grid 2 col */}
|
<ChartCard kicker="Comparación entre campañas" title="Ejido urbano" subtitle="Evolución histórica de siniestros fatales y con lesiones en zona urbana." height="lg">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={graficoB} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="25%" barGap={2}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
|
<XAxis dataKey="campaña" tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||||
|
<YAxis allowDecimals={false} domain={[0, maxComparativoZona]} tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
<Bar dataKey="urbanaFatal" name="Fatales" fill={COLOR.orange} radius={[3,3,0,0]} />
|
||||||
|
<Bar dataKey="urbanaLes" name="Con lesiones" fill={COLOR.blue} radius={[3,3,0,0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gráficos C — Siniestros por mes por zona */}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<ChartCard
|
<ChartCard kicker={`Rutas y caminos · ${campanaActual.label}`} title="Siniestros por mes en ruta" subtitle="Solo siniestros ocurridos en rutas y caminos provinciales." height="lg">
|
||||||
kicker={`Rutas y caminos · ${campanaActual.label}`}
|
|
||||||
title="Siniestros por mes en ruta"
|
|
||||||
subtitle="Solo siniestros ocurridos en rutas y caminos provinciales."
|
|
||||||
height="lg"
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={graficoCRural} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="35%">
|
<BarChart data={graficoCRural} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="35%">
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
<XAxis dataKey="mes" tick={{ fontSize: 12, fill: '#4A5568' }} />
|
<XAxis dataKey="mes" tick={{ fontSize: 12, fill: '#4A5568' }} />
|
||||||
<YAxis tick={{ fontSize: 11, fill: '#4A5568' }} />
|
<YAxis allowDecimals={false} domain={[0, maxMensualZona]} tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
<Bar dataKey="fatales" name="Fatales" fill={COLOR.fatales} stackId="a" />
|
<Bar dataKey="fatales" name="Fatales" fill={COLOR.fatales} stackId="a" />
|
||||||
@@ -415,17 +487,12 @@ export default function SecVeranoVivo({ siniestros }) {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard kicker={`Zona urbana · ${campanaActual.label}`} title="Siniestros por mes en ejido urbano" subtitle="Solo siniestros ocurridos en ejidos urbanos de la provincia." height="lg">
|
||||||
kicker={`Zona urbana · ${campanaActual.label}`}
|
|
||||||
title="Siniestros por mes en ejido urbano"
|
|
||||||
subtitle="Solo siniestros ocurridos en ejidos urbanos de la provincia."
|
|
||||||
height="lg"
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={graficoCUrbano} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="35%">
|
<BarChart data={graficoCUrbano} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="35%">
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
<XAxis dataKey="mes" tick={{ fontSize: 12, fill: '#4A5568' }} />
|
<XAxis dataKey="mes" tick={{ fontSize: 12, fill: '#4A5568' }} />
|
||||||
<YAxis tick={{ fontSize: 11, fill: '#4A5568' }} />
|
<YAxis allowDecimals={false} domain={[0, maxMensualZona]} tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
<Bar dataKey="fatales" name="Fatales" fill={COLOR.fatales} stackId="a" />
|
<Bar dataKey="fatales" name="Fatales" fill={COLOR.fatales} stackId="a" />
|
||||||
@@ -436,26 +503,16 @@ export default function SecVeranoVivo({ siniestros }) {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gráficos D y E — Rutas y Localidades · grid 2 col */}
|
{/* Gráficos D y E — Rutas y Localidades */}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<ChartCard
|
<ChartCard kicker={`Rutas y caminos · ${campanaActual.label}`} title="Rutas con más siniestros" subtitle="Siniestros ocurridos en Rutas de la Provincia." height="lg">
|
||||||
kicker={`Rutas y caminos · ${campanaActual.label}`}
|
|
||||||
title="Rutas con más siniestros"
|
|
||||||
subtitle="Siniestros ocurridos en Rutas de la Provincia."
|
|
||||||
height="lg"
|
|
||||||
>
|
|
||||||
{graficoD.length === 0
|
{graficoD.length === 0
|
||||||
? <p className="text-sm text-opsv-muted text-center pt-8">Sin datos para esta campaña</p>
|
? <p className="text-sm text-opsv-muted text-center pt-8">Sin datos para esta campaña</p>
|
||||||
: <BarHorizontalStacked data={graficoD} nameKey="ruta" />
|
: <BarHorizontalStacked data={graficoDConEtiqueta} nameKey="viaCompleta" />
|
||||||
}
|
}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard kicker={`Zona urbana · ${campanaActual.label}`} title="Localidades con más siniestros" subtitle="Siniestros ocurridos en Ejidos Urbanos." height="lg">
|
||||||
kicker={`Zona urbana · ${campanaActual.label}`}
|
|
||||||
title="Localidades con más siniestros"
|
|
||||||
subtitle="Siniestros ocurridos en Ejidos Urbanos."
|
|
||||||
height="lg"
|
|
||||||
>
|
|
||||||
{graficoE.length === 0
|
{graficoE.length === 0
|
||||||
? <p className="text-sm text-opsv-muted text-center pt-8">Sin datos para esta campaña</p>
|
? <p className="text-sm text-opsv-muted text-center pt-8">Sin datos para esta campaña</p>
|
||||||
: <BarHorizontalStacked data={graficoE} nameKey="localidad" />
|
: <BarHorizontalStacked data={graficoE} nameKey="localidad" />
|
||||||
@@ -463,41 +520,37 @@ export default function SecVeranoVivo({ siniestros }) {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gráfico F — Donut tipos · ancho completo */}
|
{/* Gráfico F — Donut tipos */}
|
||||||
<ChartCard
|
<ChartCard kicker={`Solo rutas y caminos · ${campanaActual.label}`} title="Tipo de siniestro en rutas y caminos" subtitle="Solo siniestros en zona rural / ruta. Excluye siniestros en zona urbana." height="auto">
|
||||||
kicker={`Solo rutas y caminos · ${campanaActual.label}`}
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.5fr_1fr] lg:items-start">
|
||||||
title="Tipo de siniestro en rutas y caminos"
|
<div className="h-[380px] w-full">
|
||||||
subtitle="Solo siniestros en zona rural / ruta. Excluye siniestros en zona urbana."
|
|
||||||
height="sm"
|
|
||||||
>
|
|
||||||
<div className="h-[320px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie data={graficoF} dataKey="value" nameKey="name" cx="50%" cy="52%" outerRadius={125} innerRadius={72} paddingAngle={4} labelLine={false} label={CustomPieLabel}>
|
||||||
data={graficoF}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="name"
|
|
||||||
cx="60%" cy="50%"
|
|
||||||
outerRadius={95} innerRadius={58}
|
|
||||||
paddingAngle={4}
|
|
||||||
labelLine={false}
|
|
||||||
label={CustomPieLabel}
|
|
||||||
>
|
|
||||||
{graficoF.map((entry, index) => (
|
{graficoF.map((entry, index) => (
|
||||||
<Cell key={entry.name} fill={COLORS_F[index % COLORS_F.length]} />
|
<Cell key={entry.name} fill={COLORS_F[index % COLORS_F.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Legend
|
|
||||||
layout="vertical" verticalAlign="bottom" align="right" iconType="circle"
|
|
||||||
formatter={(value) => <span className="text-sm text-opsv-text">{value}</span>}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 pt-1 lg:pt-2">
|
||||||
|
{graficoF.map((item, index) => (
|
||||||
|
<div key={item.name} className="rounded-2xl bg-opsv-bg p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-opsv-muted">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: COLORS_F[index % COLORS_F.length] }} />
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-lg font-black text-opsv-text">{item.value}</div>
|
||||||
|
<div className="text-xs text-opsv-muted">{item.pct.toFixed(0)}% del total</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{/* Bloque de insights · ancho completo */}
|
{/* Bloque de insights */}
|
||||||
<BloqueInsights insights={insights} campanaLabel={campanaActual.label} />
|
<BloqueInsights insights={insights} campanaLabel={campanaActual.label} />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+77
-41
@@ -57,6 +57,29 @@ export function calcularKPIs(siniestros) {
|
|||||||
return { total, fatales, conLes, sinLes, victimas, lesion }
|
return { total, fatales, conLes, sinLes, victimas, lesion }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Desagrega lesionados graves y leves cruzando con tabla personas ──────────
|
||||||
|
export function calcularLesionadosPorGravedad(siniestros, personas) {
|
||||||
|
const ids = new Set(siniestros.map(s => s.id_feu))
|
||||||
|
return (personas ?? [])
|
||||||
|
.filter(p => ids.has(p.id_feu))
|
||||||
|
.reduce(
|
||||||
|
(acc, p) => {
|
||||||
|
const estado = (p.estado_ocupante_final ?? '').trim()
|
||||||
|
if (estado === 'Herido Grave') acc.graves++
|
||||||
|
if (estado === 'Herido Leve') acc.leves++
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ graves: 0, leves: 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filtra personas que corresponden a un conjunto de siniestros (join id_feu)
|
||||||
|
export function filtrarPersonasPorSiniestros(personas, siniestros) { // ← NUEVO
|
||||||
|
const ids = new Set((siniestros ?? []).map(s => s.id_feu).filter(Boolean))
|
||||||
|
return (personas ?? []).filter(p => ids.has(p.id_feu))
|
||||||
|
}
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function evolucionMensual(siniestros) {
|
export function evolucionMensual(siniestros) {
|
||||||
const mapa = {}
|
const mapa = {}
|
||||||
MESES.forEach((m) => { mapa[m] = { mes: m.slice(0, 3), fatales: 0, conLes: 0, sinLes: 0, total: 0 } })
|
MESES.forEach((m) => { mapa[m] = { mes: m.slice(0, 3), fatales: 0, conLes: 0, sinLes: 0, total: 0 } })
|
||||||
@@ -186,10 +209,8 @@ export function proteccionPasiva(personas, involucrados) {
|
|||||||
|
|
||||||
const cascoBase = motoristas.length
|
const cascoBase = motoristas.length
|
||||||
const cascoUso = motoristas.filter((p) => hasCasco(p)).length
|
const cascoUso = motoristas.filter((p) => hasCasco(p)).length
|
||||||
|
|
||||||
const cinBase = habitaculo.length
|
const cinBase = habitaculo.length
|
||||||
const cinUso = habitaculo.filter((p) => hasCinturon(p)).length
|
const cinUso = habitaculo.filter((p) => hasCinturon(p)).length
|
||||||
|
|
||||||
const airbagBase = habitaculo.length
|
const airbagBase = habitaculo.length
|
||||||
const airbagUso = habitaculo.filter((p) => hasAirbag(p)).length
|
const airbagUso = habitaculo.filter((p) => hasAirbag(p)).length
|
||||||
|
|
||||||
@@ -283,7 +304,7 @@ export function calcularRangoEtario(personas) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SÍNTESIS ──────────────────────────────────────────────────
|
// ── SÍNTESIS ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function topEntry(obj) {
|
function topEntry(obj) {
|
||||||
return Object.entries(obj).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'Sin dato'
|
return Object.entries(obj).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'Sin dato'
|
||||||
@@ -388,26 +409,12 @@ export function calcularSintesis(siniestros, personas = [], involucrados = []) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── VERANO VIVO ───────────────────────────────────────────────
|
// ── VERANO VIVO ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const HISTORICO_VERANO_VIVO = [
|
|
||||||
{ campaña: '2015/16', fatales: 11 },
|
|
||||||
{ campaña: '2016/17', fatales: 8 },
|
|
||||||
{ campaña: '2017/18', fatales: 2 },
|
|
||||||
{ campaña: '2018/19', fatales: 6 },
|
|
||||||
{ campaña: '2019/20', fatales: 5 },
|
|
||||||
// 2020/21 excluida — restricciones COVID
|
|
||||||
{ campaña: '2021/22', fatales: 4 },
|
|
||||||
{ campaña: '2022/23', fatales: 3 },
|
|
||||||
{ campaña: '2023/24', fatales: 3 },
|
|
||||||
{ campaña: '2024/25', fatales: 3 },
|
|
||||||
]
|
|
||||||
export const PROMEDIO_HISTORICO_VV = 5.2
|
|
||||||
|
|
||||||
export const CAMPANAS_VV = [
|
export const CAMPANAS_VV = [
|
||||||
{ label: '2022/23', anoDesde: 2022, anoHasta: 2023 },
|
{ label: '2022/23', anoDesde: 2022, anoHasta: 2023 },
|
||||||
{ label: '2023/24', anoDesde: 2023, anoHasta: 2024 },
|
{ label: '2023/24', anoDesde: 2023, anoHasta: 2024 },
|
||||||
{ label: '2024/25', anoDesde: 2024, anoHasta: 2025 },
|
{ label: '2024/25', anoDesde: 2024, anoHasta: 2025 },
|
||||||
|
{ label: '2025/26', anoDesde: 2025, anoHasta: 2026 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function filtrarCampanaVV(siniestros, anoDesde) {
|
export function filtrarCampanaVV(siniestros, anoDesde) {
|
||||||
@@ -422,10 +429,36 @@ export function filtrarCampanaVV(siniestros, anoDesde) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HISTORICO_PREVIO_VV = [
|
||||||
|
{ campaña: '2015/16', fatales: 11 },
|
||||||
|
{ campaña: '2016/17', fatales: 8 },
|
||||||
|
{ campaña: '2017/18', fatales: 2 },
|
||||||
|
{ campaña: '2018/19', fatales: 6 },
|
||||||
|
{ campaña: '2019/20', fatales: 5 },
|
||||||
|
// 2020/21 excluida — restricciones COVID
|
||||||
|
{ campaña: '2021/22', fatales: 4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const HISTORICO_VERANO_VIVO = HISTORICO_PREVIO_VV
|
||||||
|
|
||||||
|
export function generarHistoricoVV(siniestros) {
|
||||||
|
const desdeSupabase = CAMPANAS_VV.map(({ label, anoDesde }) => {
|
||||||
|
const datos = filtrarCampanaVV(siniestros, anoDesde)
|
||||||
|
const rural = datos.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural'))
|
||||||
|
const fatales = rural.filter(s => getCantidadFallecidos(s) > 0).length
|
||||||
|
return { campaña: label, fatales }
|
||||||
|
})
|
||||||
|
return [...HISTORICO_PREVIO_VV, ...desdeSupabase]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcularPromedioHistoricoVV(siniestros) {
|
||||||
|
const historico = generarHistoricoVV(siniestros)
|
||||||
|
const suma = historico.reduce((acc, c) => acc + c.fatales, 0)
|
||||||
|
return Math.round((suma / historico.length) * 10) / 10
|
||||||
|
}
|
||||||
|
|
||||||
export function kpisVV(siniestros) {
|
export function kpisVV(siniestros) {
|
||||||
const rural = siniestros.filter(s =>
|
const rural = siniestros.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural'))
|
||||||
(s.zona_ocurrencia || '').toLowerCase().includes('rural')
|
|
||||||
)
|
|
||||||
const total = rural.length
|
const total = rural.length
|
||||||
const fatales = rural.filter(s => getCantidadFallecidos(s) > 0).length
|
const fatales = rural.filter(s => getCantidadFallecidos(s) > 0).length
|
||||||
const conLes = rural.filter(s => getCantidadFallecidos(s) === 0 && getCantidadLesionados(s) > 0).length
|
const conLes = rural.filter(s => getCantidadFallecidos(s) === 0 && getCantidadLesionados(s) > 0).length
|
||||||
@@ -449,16 +482,6 @@ export function ruralUrbanoPorCampana(siniestrosPorCampana) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const MESES_VV = [
|
|
||||||
{ key: 12, label: 'Dic' },
|
|
||||||
{ key: 1, label: 'Ene' },
|
|
||||||
{ key: 2, label: 'Feb' },
|
|
||||||
{ key: 3, label: 'Mar' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ANTES — distribucionMensualVV(siniestros)
|
|
||||||
// AHORA — acepta zona opcional: 'rural' | 'urbano' | undefined (todos)
|
|
||||||
|
|
||||||
const esUrbano = (s) =>
|
const esUrbano = (s) =>
|
||||||
(s.zona || s.zona_ocurrencia || '').toLowerCase().includes('urban')
|
(s.zona || s.zona_ocurrencia || '').toLowerCase().includes('urban')
|
||||||
|
|
||||||
@@ -491,18 +514,32 @@ export function distribucionMensualVV(siniestros, zona) {
|
|||||||
|
|
||||||
export function rankingRutas(siniestros, topN = 8) {
|
export function rankingRutas(siniestros, topN = 8) {
|
||||||
const map = {}
|
const map = {}
|
||||||
|
|
||||||
siniestros
|
siniestros
|
||||||
.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural'))
|
.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural'))
|
||||||
.forEach(s => {
|
.forEach(s => {
|
||||||
const nombre = (s.nombre_via || '').trim()
|
const nombre_via = (s.nombre_via || '').trim()
|
||||||
const tipo = (s.via_publica || '').trim()
|
const via_publica = (s.via_publica || '').trim()
|
||||||
const ruta = nombre || tipo || 'Sin datos'
|
const key = `${via_publica}__${nombre_via}` || 'Sin datos'
|
||||||
if (!map[ruta]) map[ruta] = { ruta, tipo, fatales: 0, conLes: 0, sinLes: 0, total: 0 }
|
|
||||||
if (getCantidadFallecidos(s) > 0) map[ruta].fatales++
|
if (!map[key]) {
|
||||||
else if (getCantidadLesionados(s) > 0) map[ruta].conLes++
|
map[key] = {
|
||||||
else map[ruta].sinLes++
|
ruta: nombre_via || via_publica || 'Sin datos',
|
||||||
map[ruta].total++
|
nombre_via,
|
||||||
|
via_publica,
|
||||||
|
fatales: 0,
|
||||||
|
conLes: 0,
|
||||||
|
sinLes: 0,
|
||||||
|
total: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getCantidadFallecidos(s) > 0) map[key].fatales++
|
||||||
|
else if (getCantidadLesionados(s) > 0) map[key].conLes++
|
||||||
|
else map[key].sinLes++
|
||||||
|
map[key].total++
|
||||||
})
|
})
|
||||||
|
|
||||||
return Object.values(map)
|
return Object.values(map)
|
||||||
.sort((a, b) => b.total - a.total)
|
.sort((a, b) => b.total - a.total)
|
||||||
.slice(0, topN)
|
.slice(0, topN)
|
||||||
@@ -526,7 +563,6 @@ export function rankingLocalidades(siniestros, topN = 8) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ FIX: eliminados logs temporales y normalización redundante
|
// ✅ FIX: eliminados logs temporales y normalización redundante
|
||||||
// (getTipoSiniestro ya aplica sentence case + trim + normalize)
|
|
||||||
export function tiposSiniestroVV(siniestros) {
|
export function tiposSiniestroVV(siniestros) {
|
||||||
const map = {}
|
const map = {}
|
||||||
siniestros
|
siniestros
|
||||||
|
|||||||
+123
-25
@@ -1,16 +1,18 @@
|
|||||||
import html2canvas from 'html2canvas-pro'
|
import html2canvas from 'html2canvas-pro'
|
||||||
import jsPDF from 'jspdf'
|
import jsPDF from 'jspdf'
|
||||||
|
|
||||||
|
|
||||||
export const SECCIONES_EXPORTABLES = [
|
export const SECCIONES_EXPORTABLES = [
|
||||||
{ id: 'resumen', label: 'Resumen General' },
|
{ id: 'resumen', label: 'Resumen General', subtitulo: 'Indicadores y estadísticas generales del período' },
|
||||||
{ id: 'historica', label: 'Serie Histórica Provincial' },
|
{ id: 'historica', label: 'Serie Histórica Provincial', subtitulo: 'Evolución de la siniestralidad vial a lo largo del tiempo' },
|
||||||
{ id: 'fatales', label: 'Siniestros Fatales' },
|
{ id: 'fatales', label: 'Siniestros Fatales', subtitulo: 'Análisis detallado de siniestros con víctimas fatales' },
|
||||||
{ id: 'lesionados', label: 'Con Lesionados' },
|
{ id: 'lesionados', label: 'Con Lesionados', subtitulo: 'Siniestros con personas lesionadas sin fallecidos' },
|
||||||
{ id: 'sinlesiones', label: 'Sin Lesiones' },
|
{ id: 'sinlesiones', label: 'Sin Lesiones', subtitulo: 'Siniestros con daños materiales sin víctimas' },
|
||||||
{ id: 'sintesis', label: 'Síntesis' },
|
{ id: 'sintesis', label: 'Síntesis', subtitulo: 'Resumen ejecutivo y conclusiones del período' },
|
||||||
{ id: 'veranovivo', label: 'Verano Vivo' },
|
{ id: 'veranovivo', label: 'Verano Vivo', subtitulo: 'Estadísticas del operativo estival' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
function loadImage(src) {
|
function loadImage(src) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
@@ -21,6 +23,7 @@ function loadImage(src) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function drawHeaderFooter(pdf, secLabel, year, pageNum, W, H) {
|
function drawHeaderFooter(pdf, secLabel, year, pageNum, W, H) {
|
||||||
pdf.setFillColor(37, 44, 97)
|
pdf.setFillColor(37, 44, 97)
|
||||||
pdf.rect(0, 0, W, 13, 'F')
|
pdf.rect(0, 0, W, 13, 'F')
|
||||||
@@ -38,7 +41,73 @@ function drawHeaderFooter(pdf, secLabel, year, pageNum, W, H) {
|
|||||||
pdf.text(`Página ${pageNum}`, W - 10, H - 2.5, { align: 'right' })
|
pdf.text(`Página ${pageNum}`, W - 10, H - 2.5, { align: 'right' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Captura un elemento individual ───────────────────────────────────────────
|
|
||||||
|
// ── Carátula de sección ───────────────────────────────────────────────────────
|
||||||
|
function drawCaratulaSeccion(pdf, secLabel, subtitulo, year, logoImg, W, H) {
|
||||||
|
pdf.setFillColor(37, 44, 97)
|
||||||
|
pdf.rect(0, 0, W, H, 'F')
|
||||||
|
|
||||||
|
// Línea decorativa superior
|
||||||
|
pdf.setDrawColor(128, 176, 222)
|
||||||
|
pdf.setLineWidth(0.8)
|
||||||
|
pdf.line(W / 2 - 40, H / 2 - 22, W / 2 + 40, H / 2 - 22)
|
||||||
|
|
||||||
|
// Label año
|
||||||
|
pdf.setTextColor(128, 176, 222)
|
||||||
|
pdf.setFont('helvetica', 'normal')
|
||||||
|
pdf.setFontSize(10)
|
||||||
|
pdf.text(`INFORME ${year}`, W / 2, H / 2 - 14, { align: 'center' })
|
||||||
|
|
||||||
|
// Título de sección
|
||||||
|
pdf.setTextColor(255, 255, 255)
|
||||||
|
pdf.setFont('helvetica', 'bold')
|
||||||
|
pdf.setFontSize(28)
|
||||||
|
pdf.text(secLabel.toUpperCase(), W / 2, H / 2 + 4, { align: 'center' })
|
||||||
|
|
||||||
|
// Subtítulo
|
||||||
|
if (subtitulo) {
|
||||||
|
pdf.setTextColor(180, 195, 230)
|
||||||
|
pdf.setFont('helvetica', 'normal')
|
||||||
|
pdf.setFontSize(11)
|
||||||
|
pdf.text(subtitulo, W / 2, H / 2 + 18, { align: 'center' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Línea decorativa inferior
|
||||||
|
pdf.setDrawColor(128, 176, 222)
|
||||||
|
pdf.setLineWidth(0.8)
|
||||||
|
pdf.line(W / 2 - 40, H / 2 + 26, W / 2 + 40, H / 2 + 26)
|
||||||
|
|
||||||
|
// ── Logo + texto al pie ──────────────────────────────────────────────────
|
||||||
|
const pieY = H - 14
|
||||||
|
if (logoImg) {
|
||||||
|
const logoH = 8
|
||||||
|
const logoW = (logoImg.width / logoImg.height) * logoH
|
||||||
|
// Calcular posición para centrar logo + texto juntos
|
||||||
|
pdf.setFontSize(8)
|
||||||
|
const textoAncho = pdf.getTextWidth('Observatorio Provincial de Seguridad Vial · Santa Cruz')
|
||||||
|
const gap = 3 // mm entre logo y texto
|
||||||
|
const totalAncho = logoW + gap + textoAncho
|
||||||
|
const startX = (W - totalAncho) / 2
|
||||||
|
|
||||||
|
pdf.addImage(logoImg, 'PNG', startX, pieY - logoH + 1, logoW, logoH)
|
||||||
|
pdf.setTextColor(180, 195, 230)
|
||||||
|
pdf.setFont('helvetica', 'normal')
|
||||||
|
pdf.text(
|
||||||
|
'Observatorio Provincial de Seguridad Vial · Santa Cruz',
|
||||||
|
startX + logoW + gap,
|
||||||
|
pieY,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Sin logo — solo texto centrado
|
||||||
|
pdf.setTextColor(180, 195, 230)
|
||||||
|
pdf.setFont('helvetica', 'normal')
|
||||||
|
pdf.setFontSize(8)
|
||||||
|
pdf.text('Observatorio Provincial de Seguridad Vial · Santa Cruz', W / 2, pieY, { align: 'center' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Captura un bloque con html2canvas ────────────────────────────────────────
|
||||||
async function capturarBloque(block) {
|
async function capturarBloque(block) {
|
||||||
await new Promise(r => setTimeout(r, 80))
|
await new Promise(r => setTimeout(r, 80))
|
||||||
return html2canvas(block, {
|
return html2canvas(block, {
|
||||||
@@ -51,26 +120,33 @@ async function capturarBloque(block) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function exportarPDF({ seccionesIds, year, onProgress }) {
|
export async function exportarPDF({ seccionesIds, year, onProgress }) {
|
||||||
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
|
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
|
||||||
const W = pdf.internal.pageSize.getWidth() // 297mm
|
const W = pdf.internal.pageSize.getWidth()
|
||||||
const H = pdf.internal.pageSize.getHeight() // 210mm
|
const H = pdf.internal.pageSize.getHeight()
|
||||||
const areaW = W - 16 // ancho disponible
|
const areaW = W - 16
|
||||||
const areaH = H - 13 - 10 // alto disponible (header 13mm + footer 10mm)
|
const areaH = H - 13 - 10
|
||||||
const topY = 15 // cursor inicial tras el header
|
const topY = 15
|
||||||
const marginX = (W - areaW) / 2
|
const marginX = (W - areaW) / 2
|
||||||
const gap = 3 // mm de separación entre bloques
|
const gap = 3
|
||||||
|
|
||||||
// ── Portada ───────────────────────────────────────────────────────────────
|
// ── Cargar logo una sola vez ─────────────────────────────────────────────
|
||||||
|
let logoImg = null
|
||||||
|
try {
|
||||||
|
logoImg = await loadImage('/logo-opsv.png')
|
||||||
|
} catch { /* sin logo */ }
|
||||||
|
|
||||||
|
|
||||||
|
// ── Portada ─────────────────────────────────────────────────────────────
|
||||||
pdf.setFillColor(37, 44, 97)
|
pdf.setFillColor(37, 44, 97)
|
||||||
pdf.rect(0, 0, W, H, 'F')
|
pdf.rect(0, 0, W, H, 'F')
|
||||||
|
|
||||||
try {
|
if (logoImg) {
|
||||||
const logoImg = await loadImage('/logo-opsv.png')
|
|
||||||
const logoW = 55
|
const logoW = 55
|
||||||
const logoH = (logoImg.height / logoImg.width) * logoW
|
const logoH = (logoImg.height / logoImg.width) * logoW
|
||||||
pdf.addImage(logoImg, 'PNG', (W - logoW) / 2, 35, logoW, logoH)
|
pdf.addImage(logoImg, 'PNG', (W - logoW) / 2, 35, logoW, logoH)
|
||||||
} catch { /* sin logo */ }
|
}
|
||||||
|
|
||||||
pdf.setTextColor(255, 255, 255)
|
pdf.setTextColor(255, 255, 255)
|
||||||
pdf.setFont('helvetica', 'bold')
|
pdf.setFont('helvetica', 'bold')
|
||||||
@@ -88,42 +164,64 @@ export async function exportarPDF({ seccionesIds, year, onProgress }) {
|
|||||||
})
|
})
|
||||||
pdf.text(`Generado el ${fecha}`, W / 2, 190, { align: 'center' })
|
pdf.text(`Generado el ${fecha}`, W / 2, 190, { align: 'center' })
|
||||||
|
|
||||||
// ── Secciones ─────────────────────────────────────────────────────────────
|
|
||||||
|
// ── Secciones ────────────────────────────────────────────────────────────
|
||||||
let pageNum = 1
|
let pageNum = 1
|
||||||
|
|
||||||
for (let i = 0; i < seccionesIds.length; i++) {
|
for (let i = 0; i < seccionesIds.length; i++) {
|
||||||
const secId = seccionesIds[i]
|
const secId = seccionesIds[i]
|
||||||
const secLabel = SECCIONES_EXPORTABLES.find(s => s.id === secId)?.label ?? secId
|
const secDef = SECCIONES_EXPORTABLES.find(s => s.id === secId)
|
||||||
|
const secLabel = secDef?.label ?? secId
|
||||||
|
const subtitulo = secDef?.subtitulo ?? ''
|
||||||
|
|
||||||
onProgress?.(Math.round(((i + 1) / seccionesIds.length) * 90))
|
onProgress?.(Math.round(((i + 1) / seccionesIds.length) * 90))
|
||||||
|
|
||||||
const el = document.getElementById(`pdf-section-${secId}`)
|
const el = document.getElementById(`pdf-section-${secId}`)
|
||||||
if (!el) continue
|
if (!el) continue
|
||||||
|
|
||||||
// ✅ Obtener todos los bloques atómicos de esta sección
|
|
||||||
const blocks = Array.from(el.querySelectorAll('[data-pdf-block]'))
|
const blocks = Array.from(el.querySelectorAll('[data-pdf-block]'))
|
||||||
if (!blocks.length) continue
|
if (!blocks.length) continue
|
||||||
|
|
||||||
// Primera página de la sección
|
// ── Carátula de la sección ────────────────────────────────────────
|
||||||
|
pdf.addPage()
|
||||||
|
pageNum++
|
||||||
|
drawCaratulaSeccion(pdf, secLabel, subtitulo, year, logoImg, W, H)
|
||||||
|
|
||||||
|
// ── Página con los bloques ────────────────────────────────────────
|
||||||
pdf.addPage()
|
pdf.addPage()
|
||||||
pageNum++
|
pageNum++
|
||||||
drawHeaderFooter(pdf, secLabel, year, pageNum, W, H)
|
drawHeaderFooter(pdf, secLabel, year, pageNum, W, H)
|
||||||
let cursorY = topY
|
|
||||||
|
|
||||||
|
// Precapturar todos los bloques para calcular altura total
|
||||||
|
const canvases = []
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
const canvas = await capturarBloque(block)
|
const canvas = await capturarBloque(block)
|
||||||
|
canvases.push(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular altura total para centrado vertical
|
||||||
|
const alturaTotal = canvases.reduce((acc, canvas) => {
|
||||||
|
const mmPerPx = areaW / canvas.width
|
||||||
|
let drawH = canvas.height * mmPerPx
|
||||||
|
if (drawH > areaH) drawH = areaH
|
||||||
|
return acc + drawH + gap
|
||||||
|
}, 0) - gap
|
||||||
|
|
||||||
|
let cursorY = alturaTotal <= areaH
|
||||||
|
? topY + (areaH - alturaTotal) / 2
|
||||||
|
: topY
|
||||||
|
|
||||||
|
for (const canvas of canvases) {
|
||||||
const mmPerPx = areaW / canvas.width
|
const mmPerPx = areaW / canvas.width
|
||||||
let drawH = canvas.height * mmPerPx
|
let drawH = canvas.height * mmPerPx
|
||||||
let drawW = areaW
|
let drawW = areaW
|
||||||
|
|
||||||
// Si el bloque es más alto que toda la página, escalar para que entre
|
|
||||||
if (drawH > areaH) {
|
if (drawH > areaH) {
|
||||||
const ratio = areaH / drawH
|
const ratio = areaH / drawH
|
||||||
drawH = areaH
|
drawH = areaH
|
||||||
drawW = areaW * ratio
|
drawW = areaW * ratio
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Si no cabe en lo que queda de página → nueva página
|
|
||||||
if (cursorY + drawH > topY + areaH) {
|
if (cursorY + drawH > topY + areaH) {
|
||||||
pdf.addPage()
|
pdf.addPage()
|
||||||
pageNum++
|
pageNum++
|
||||||
|
|||||||
Reference in New Issue
Block a user