Primer commit — OPSV Dashboard de siniestralidad vial

This commit is contained in:
2026-04-29 13:39:09 -03:00
commit ca7b159657
67 changed files with 12246 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
/**
* DebugPanel.jsx — muestra el resultado de las 3 consultas a Supabase
* Incluye checklist de RLS para ayudar a diagnosticar tablas vacías
*/
const C = {
surface:'#1C1D2B', texto:'#F0F2FF', muted:'#8B8FA8', border:'#2E3050',
verde:'#2D7A4F', rojo:'#C0392B', naranja:'#E8881A',
}
function StatusBadge({ n, error }) {
if (error) return <span style={{ color: C.rojo, fontWeight: 700 }}> Error: {error}</span>
if (n === 0) return <span style={{ color: C.naranja, fontWeight: 700 }}> 0 registros ver checklist</span>
return <span style={{ color: C.verde, fontWeight: 700 }}> {n.toLocaleString('es-AR')} registros</span>
}
export default function DebugPanel({ debug, visible, toggle }) {
if (!debug) return null
const hayError = Object.values(debug)
.filter(v => typeof v === 'object' && v?.registros !== undefined)
.some(v => v.registros === 0 || v.error)
return (
<div style={{ marginBottom: '1.25rem' }}>
<button
onClick={toggle}
style={{
background: hayError ? `${C.rojo}22` : `${C.verde}18`,
color: hayError ? C.rojo : C.verde,
border: `1px solid ${hayError ? C.rojo : C.verde}44`,
borderRadius: 7, padding: '0.3rem 0.9rem',
fontSize: '0.76rem', cursor: 'pointer', fontWeight: 600,
}}
>
{hayError ? '⚠' : '✓'} Diagnóstico Supabase {visible ? '▲' : '▼'}
</button>
{visible && (
<div style={{
background: C.surface, border: `1px solid ${C.border}`,
borderRadius: 10, padding: '1.25rem', marginTop: '0.6rem',
fontSize: '0.81rem',
}}>
{/* Resultados de consultas */}
<div style={{ color: C.texto, fontWeight: 700, marginBottom: '0.75rem' }}>
Resultado de consultas año {debug.año_consultado}, mes {debug.mes_consultado}
</div>
{['siniestros', 'involucrados', 'personas'].map(k => {
const v = debug[k]
if (!v) return null
return (
<div key={k} style={{
display: 'flex', gap: '1rem', padding: '0.4rem 0',
borderBottom: `1px solid ${C.border}44`,
alignItems: 'center',
}}>
<span style={{ color: C.muted, minWidth: 160, fontFamily: 'monospace' }}>
{v.tabla}
</span>
<StatusBadge n={v.registros} error={v.error} />
</div>
)
})}
{/* Checklist de ayuda cuando hay 0 registros */}
{hayError && (
<div style={{
background: `${C.naranja}12`, borderRadius: 8,
border: `1px solid ${C.naranja}33`,
padding: '0.9rem 1rem', marginTop: '1rem',
}}>
<div style={{ color: C.naranja, fontWeight: 700, marginBottom: '0.6rem' }}>
📋 Checklist cuando una tabla devuelve 0 registros
</div>
<ol style={{ color: C.muted, paddingLeft: '1.2rem', lineHeight: 2 }}>
<li>
<strong style={{ color: C.texto }}>Verificar RLS (Row Level Security)</strong>
<br />
En Supabase Table Editor seleccioná la tabla "RLS disabled" o
ejecutá en SQL Editor:
<code style={{ display: 'block', background: '#0008', borderRadius: 4,
padding: '0.3rem 0.6rem', marginTop: '0.3rem', color: '#7dd3fc',
fontFamily: 'monospace', fontSize: '0.78rem' }}>
{`-- Política de lectura pública para cada tabla:\nCREATE POLICY "public_read" ON "Involucrados" FOR SELECT USING (true);\nCREATE POLICY "public_read" ON "Personas" FOR SELECT USING (true);`}
</code>
</li>
<li style={{ marginTop: '0.4rem' }}>
<strong style={{ color: C.texto }}>Verificar nombre exacto de la tabla</strong>
<br />
<code style={{ color: '#7dd3fc', fontFamily: 'monospace' }}>
"Involucrados"
</code> y{' '}
<code style={{ color: '#7dd3fc', fontFamily: 'monospace' }}>
"Personas"
</code>{' '}
con mayúscula inicial (case-sensitive en Postgres)
</li>
<li style={{ marginTop: '0.4rem' }}>
<strong style={{ color: C.texto }}>Verificar que el año coincide</strong>
<br />
Los datos del CSV tienen{' '}
<code style={{ color: '#7dd3fc', fontFamily: 'monospace' }}>ano = 2025</code>.
El filtro actual es{' '}
<code style={{ color: '#7dd3fc', fontFamily: 'monospace' }}>
ano = {debug.año_consultado}
</code>
</li>
<li style={{ marginTop: '0.4rem' }}>
<strong style={{ color: C.texto }}>Verificar en SQL Editor directamente</strong>
<code style={{ display: 'block', background: '#0008', borderRadius: 4,
padding: '0.3rem 0.6rem', marginTop: '0.3rem', color: '#7dd3fc',
fontFamily: 'monospace', fontSize: '0.78rem' }}>
{`SELECT COUNT(*) FROM "Involucrados" WHERE ano = 2025;\nSELECT COUNT(*) FROM "Personas" WHERE ano = 2025;`}
</code>
</li>
</ol>
</div>
)}
</div>
)}
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
// src/components/ProtectedRoute.jsx
import { Navigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
export default function ProtectedRoute({ children, requiredRole = 'admin' }) {
const { user, isAdmin, isSuperAdmin, loading } = useAuth()
if (loading) {
return <div className="p-6">Cargando...</div>
}
if (!user) {
return <Navigate to="/login" replace />
}
// Para admin y superadmin
if (requiredRole === 'admin' && !isAdmin) {
return <Navigate to="/login" replace />
}
// Si en el futuro usás requiredRole="superadmin"
if (requiredRole === 'superadmin' && !isSuperAdmin) {
return <Navigate to="/login" replace />
}
return children
}
@@ -0,0 +1,74 @@
const COLORES = [
'#252C61',
'#C0392B',
'#E8881A',
'#2D7A4F',
'#3A7EBF',
'#8E44AD',
'#16A085',
'#E74C3C',
'#F39C12',
'#1ABC9C',
]
export default function DistribucionLocalidad({ siniestros }) {
const conteo = {}
siniestros.forEach((s) => {
const loc = s.localidad?.trim() || 'Sin datos'
conteo[loc] = (conteo[loc] || 0) + 1
})
const data = Object.entries(conteo)
.map(([localidad, total]) => ({ localidad, total }))
.sort((a, b) => b.total - a.total)
.slice(0, 10)
const maxVal = Math.max(...data.map((d) => d.total), 1)
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div className="flex flex-col gap-2.5">
{data.map((item, i) => {
const pct = (item.total / maxVal) * 100
return (
<div key={item.localidad} className="flex items-center gap-3">
<div
className={`w-[140px] min-w-[140px] text-right text-[11px] leading-[1.3] break-words text-opsv-muted ${
i === 0 ? 'font-bold' : 'font-normal'
}`}
>
{item.localidad}
</div>
<div className="h-[22px] flex-1 overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800">
<div
className="flex h-full items-center justify-end rounded-md pr-1.5 transition-all duration-300"
style={{
width: `${pct}%`,
background: COLORES[i % COLORES.length],
}}
>
{pct > 20 && (
<span className="text-[10px] font-bold text-white">
{item.total}
</span>
)}
</div>
</div>
{pct <= 20 && (
<div className="min-w-[24px] text-[11px] font-bold text-opsv-muted">
{item.total}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
import { calcularKPIs } from '../../utils/calculos'
import { COLOR } from '../../utils/colores'
const sectors = [
{ key: 'fatales', label: 'Fatales', color: COLOR.fatales },
{ key: 'conLes', label: 'Con Lesionados', color: COLOR.conLes },
{ key: 'sinLes', label: 'Sin Lesiones', color: COLOR.sinLes },
]
export default function DonutGravedad({ siniestros }) {
const kpis = calcularKPIs(siniestros)
const data = sectors.map((sector) => ({
name: sector.label,
value: kpis[sector.key],
color: sector.color,
}))
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>
<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%">
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={72}
outerRadius={110}
paddingAngle={4}
>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="text-4xl font-black text-opsv-navy">{kpis.total}</span>
<span className="mt-1 text-sm uppercase tracking-[0.3em] text-opsv-muted">Total</span>
</div>
</div>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{data.map((item) => (
<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">
<span className="h-2.5 w-2.5 rounded-full" style={{ background: item.color }} />
{item.name}
</div>
<div className="mt-3 text-3xl font-black text-opsv-navy">{item.value}</div>
</div>
))}
</div>
</div>
)
}
+164
View File
@@ -0,0 +1,164 @@
/**
* EstadoOcupante.jsx
* Columna: estado_ocupante_inicio → "Ileso" | "Herido Leve" | "Herido Grave" | "Fallecido"
* También muestra estado_ocupante_final para ver evolución
*/
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
Cell, ResponsiveContainer, LabelList,
} from 'recharts'
const C = {
surface:'#1C1D2B', texto:'#F0F2FF', muted:'#8B8FA8', border:'#2E3050',
}
const ESTADOS = [
{ key: 'Ileso', color: '#2D7A4F', icon: '🟢', label: 'Ileso' },
{ key: 'Herido Leve', color: '#E8881A', icon: '🟡', label: 'Herido Leve' },
{ key: 'Herido Grave', color: '#C0392B', icon: '🔴', label: 'Herido Grave' },
{ key: 'Fallecido', color: '#922B21', icon: '⚫', label: 'Fallecido' },
]
export default function EstadoOcupante({ personas }) {
if (!personas?.length) return null
const total = personas.length
// Estado inicial
const inicio = ESTADOS.map(e => ({
...e,
inicio: personas.filter(p => p.estado_ocupante_inicio === e.key).length,
final: personas.filter(p => p.estado_ocupante_final === e.key).length,
}))
// Para el gráfico de barras (estado al inicio)
const barData = inicio.map(e => ({
name: e.label,
value: e.inicio,
color: e.color,
}))
const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload?.[0]) return null
const v = payload[0].value
return (
<div style={{
background: '#12131A', border: `1px solid ${C.border}`,
borderRadius: 8, padding: '0.6rem 1rem',
}}>
<p style={{ color: C.texto, margin: 0, fontWeight: 700 }}>{label}</p>
<p style={{ color: C.muted, margin: '0.2rem 0 0', fontSize: '0.82rem' }}>
{v.toLocaleString('es-AR')} personas ({total ? ((v/total)*100).toFixed(1) : 0}%)
</p>
</div>
)
}
return (
<div style={{
background: C.surface, borderRadius: 14, padding: '1.25rem',
border: `1px solid ${C.border}`,
}}>
<div style={{ color: C.texto, fontWeight: 700, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
🏥 Estado del ocupante
</div>
<div style={{ color: C.muted, fontSize: '0.73rem', marginBottom: '1rem' }}>
Estado al inicio del siniestro {total.toLocaleString('es-AR')} personas
</div>
{/* KPI chips */}
<div style={{ display: 'flex', gap: '0.6rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
{inicio.map(e => (
<div key={e.key} style={{
background: `${e.color}1A`, borderRadius: 8,
padding: '0.45rem 0.7rem', borderLeft: `3px solid ${e.color}`,
flex: 1, minWidth: 85,
}}>
<div style={{ fontSize: '1rem', marginBottom: '0.1rem' }}>{e.icon}</div>
<div style={{ color: e.color, fontWeight: 800, fontSize: '1.2rem', fontVariantNumeric: 'tabular-nums' }}>
{e.inicio.toLocaleString('es-AR')}
</div>
<div style={{ color: C.muted, fontSize: '0.68rem' }}>
{e.label}
<br />
<span style={{ opacity: 0.65 }}>
{total ? ((e.inicio/total)*100).toFixed(1) : 0}%
</span>
</div>
</div>
))}
</div>
<ResponsiveContainer width="100%" height={180}>
<BarChart
data={barData}
margin={{ top: 16, right: 10, bottom: 0, left: -10 }}
barCategoryGap="28%"
>
<CartesianGrid strokeDasharray="3 3" stroke="#2E3050" vertical={false} />
<XAxis
dataKey="name"
tick={{ fill: C.muted, fontSize: 10 }}
axisLine={false} tickLine={false}
/>
<YAxis
tick={{ fill: C.muted, fontSize: 11 }}
axisLine={false} tickLine={false}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff08' }} />
<Bar dataKey="value" radius={[6,6,0,0]} maxBarSize={56}>
{barData.map(d => <Cell key={d.name} fill={d.color} />)}
<LabelList
dataKey="value"
position="top"
style={{ fill: C.muted, fontSize: 11, fontWeight: 600 }}
formatter={v => v > 0 ? v.toLocaleString('es-AR') : ''}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
{/* Tabla de evolución: inicio vs final */}
<div style={{ marginTop: '1.25rem', borderTop: `1px solid ${C.border}`, paddingTop: '0.9rem' }}>
<div style={{ color: C.muted, fontSize: '0.73rem', marginBottom: '0.5rem' }}>
Comparación inicio vs. final del siniestro
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ color: C.muted, fontSize: '0.72rem', fontWeight: 600, textAlign: 'left', padding: '0.2rem 0.5rem', borderBottom: `1px solid ${C.border}` }}>Estado</th>
<th style={{ color: C.muted, fontSize: '0.72rem', fontWeight: 600, textAlign: 'right', padding: '0.2rem 0.5rem', borderBottom: `1px solid ${C.border}` }}>Inicio</th>
<th style={{ color: C.muted, fontSize: '0.72rem', fontWeight: 600, textAlign: 'right', padding: '0.2rem 0.5rem', borderBottom: `1px solid ${C.border}` }}>Final</th>
<th style={{ color: C.muted, fontSize: '0.72rem', fontWeight: 600, textAlign: 'right', padding: '0.2rem 0.5rem', borderBottom: `1px solid ${C.border}` }}>Δ</th>
</tr>
</thead>
<tbody>
{inicio.map(e => {
const delta = e.final - e.inicio
return (
<tr key={e.key}>
<td style={{ color: e.color, fontSize: '0.78rem', padding: '0.3rem 0.5rem', fontWeight: 600 }}>
{e.icon} {e.key}
</td>
<td style={{ color: C.texto, fontSize: '0.78rem', padding: '0.3rem 0.5rem', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{e.inicio.toLocaleString('es-AR')}
</td>
<td style={{ color: C.texto, fontSize: '0.78rem', padding: '0.3rem 0.5rem', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{e.final.toLocaleString('es-AR')}
</td>
<td style={{
color: delta > 0 ? '#C0392B' : delta < 0 ? '#2D7A4F' : C.muted,
fontSize: '0.78rem', padding: '0.3rem 0.5rem', textAlign: 'right',
fontWeight: delta !== 0 ? 700 : 400, fontVariantNumeric: 'tabular-nums',
}}>
{delta > 0 ? `+${delta}` : delta !== 0 ? delta : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
+195
View File
@@ -0,0 +1,195 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { useChartTheme } from '../../hooks/useChartTheme'
const FRANJAS = [
{ key: '00-03', label: '00-03h' },
{ key: '03-06', label: '03-06h' },
{ key: '06-09', label: '06-09h' },
{ key: '09-12', label: '09-12h' },
{ key: '12-15', label: '12-15h' },
{ key: '15-18', label: '15-18h' },
{ key: '18-21', label: '18-21h' },
{ key: '21-24', label: '21-24h' },
]
function procesarFranjaHoraria(siniestros) {
const acumulado = {}
FRANJAS.forEach((f) => {
acumulado[f.key] = { franja: f.label, urbano: 0, rural: 0 }
})
siniestros.forEach((s) => {
const horaRaw =
s.hora_siniestro || s.siniestro_hora || s.hora_hecho || s.hora || ''
const hora = parseInt(String(horaRaw).split(':')[0], 10)
if (Number.isNaN(hora)) return
let franjaKey = null
if (hora >= 0 && hora < 3) franjaKey = '00-03'
else if (hora >= 3 && hora < 6) franjaKey = '03-06'
else if (hora >= 6 && hora < 9) franjaKey = '06-09'
else if (hora >= 9 && hora < 12) franjaKey = '09-12'
else if (hora >= 12 && hora < 15) franjaKey = '12-15'
else if (hora >= 15 && hora < 18) franjaKey = '15-18'
else if (hora >= 18 && hora < 21) franjaKey = '18-21'
else if (hora >= 21 && hora < 24) franjaKey = '21-24'
if (!franjaKey) return
const zona = String(
s.zona || s.zona_ocurrencia || s.area_siniestro || ''
).toLowerCase()
const esUrbano = zona.includes('urban') || zona.includes('ejido') || zona === 'u'
const esRural = zona.includes('rural') || zona.includes('ruta') || zona === 'r'
if (esUrbano) acumulado[franjaKey].urbano += 1
else if (esRural) acumulado[franjaKey].rural += 1
else acumulado[franjaKey].urbano += 1
})
return FRANJAS.map((f) => acumulado[f.key])
}
function CustomTooltip({
active,
payload,
label,
tooltipBg,
tooltipBorder,
tooltipLabel,
tickColor,
}) {
if (active && payload?.length) {
return (
<div
className="rounded-lg p-3 text-sm shadow-md"
style={{
background: tooltipBg,
border: `1px solid ${tooltipBorder}`,
}}
>
<p
className="mb-2 font-semibold"
style={{ color: tooltipLabel }}
>
{label}
</p>
{payload.map((p, i) => (
<p key={i} style={{ color: p.color }} className="font-medium">
<span style={{ color: tickColor }}>
{p.name === 'urbano' ? 'Urbano' : 'Rural'}:
</span>{' '}
<span className="font-bold">{p.value}</span> siniestros
</p>
))}
</div>
)
}
return null
}
export default function FranjaHoraria({ siniestros = [] }) {
const { tickColor, gridColor, tooltipBg, tooltipBorder, tooltipLabel } = useChartTheme()
const data = procesarFranjaHoraria(siniestros)
const tieneUrbano = data.some((d) => d.urbano > 0)
const tieneRural = data.some((d) => d.rural > 0)
if (!tieneUrbano && !tieneRural) {
return (
<div className="flex h-48 items-center justify-center rounded-[28px] border border-opsv-border bg-opsv-surface p-6 text-sm text-opsv-muted shadow-sm">
Sin datos de franja horaria disponibles
</div>
)
}
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke={gridColor}
vertical={false}
/>
<XAxis
dataKey="franja"
tick={{ fontSize: 11, fill: tickColor }}
axisLine={{ stroke: gridColor }}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 11, fill: tickColor }}
axisLine={false}
tickLine={false}
/>
<Tooltip
content={
<CustomTooltip
tooltipBg={tooltipBg}
tooltipBorder={tooltipBorder}
tooltipLabel={tooltipLabel}
tickColor={tickColor}
/>
}
/>
<Legend
formatter={(value) => (
<span style={{ fontSize: 12, color: tickColor, fontWeight: 500 }}>
{value === 'urbano' ? 'Urbano' : 'Rural'}
</span>
)}
/>
{tieneUrbano && (
<Line
type="monotone"
dataKey="urbano"
name="urbano"
stroke="#252C61"
strokeWidth={2.5}
dot={{ fill: '#252C61', r: 4, strokeWidth: 0 }}
activeDot={{ r: 6 }}
/>
)}
{tieneRural && (
<Line
type="monotone"
dataKey="rural"
name="rural"
stroke="#C0392B"
strokeWidth={2.5}
strokeDasharray="5 3"
dot={{ fill: '#C0392B', r: 4, strokeWidth: 0 }}
activeDot={{ r: 6 }}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
/**
* GeneroPersonas.jsx
* Columna: genero → "Masculino" | "Femenino" | "" (sin dato)
*/
import {
PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer
} from 'recharts'
const C = {
surface:'#1C1D2B', texto:'#F0F2FF', muted:'#8B8FA8', border:'#2E3050',
}
const COLORES = {
Masculino: '#3A7EBF',
Femenino: '#C0739A',
'Sin dato':'#4A4E6A',
}
const LABEL_CUSTOM = ({ cx, cy, midAngle, innerRadius, outerRadius, percent, name }) => {
if (percent < 0.04) return null
const RAD = Math.PI / 180
const r = innerRadius + (outerRadius - innerRadius) * 0.55
const x = cx + r * Math.cos(-midAngle * RAD)
const y = cy + r * Math.sin(-midAngle * RAD)
return (
<text x={x} y={y} fill="#fff" textAnchor="middle" dominantBaseline="central"
fontSize={12} fontWeight={700}>
{(percent * 100).toFixed(0)}%
</text>
)
}
export default function GeneroPersonas({ personas }) {
if (!personas?.length) return null
const conteo = { Masculino: 0, Femenino: 0, 'Sin dato': 0 }
personas.forEach(p => {
if (p.genero === 'Masculino') conteo.Masculino++
else if (p.genero === 'Femenino') conteo.Femenino++
else conteo['Sin dato']++
})
const data = Object.entries(conteo)
.filter(([, v]) => v > 0)
.map(([name, value]) => ({ name, value }))
return (
<div style={{
background: C.surface, borderRadius: 14, padding: '1.25rem',
border: `1px solid ${C.border}`,
}}>
<div style={{ color: C.texto, fontWeight: 700, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
👤 Género
</div>
<div style={{ color: C.muted, fontSize: '0.73rem', marginBottom: '1rem' }}>
Distribución por género {personas.length.toLocaleString('es-AR')} personas
</div>
{/* Mini KPIs */}
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
{data.map(d => (
<div key={d.name} style={{
background: `${COLORES[d.name]}22`, borderRadius: 8,
padding: '0.4rem 0.75rem', borderLeft: `3px solid ${COLORES[d.name]}`,
flex: 1, minWidth: 90,
}}>
<div style={{ color: COLORES[d.name], fontWeight: 800, fontSize: '1.3rem', fontVariantNumeric: 'tabular-nums' }}>
{d.value.toLocaleString('es-AR')}
</div>
<div style={{ color: C.muted, fontSize: '0.7rem' }}>{d.name}</div>
</div>
))}
</div>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={data}
cx="50%" cy="50%"
innerRadius={55} outerRadius={88}
paddingAngle={3}
dataKey="value"
labelLine={false}
label={LABEL_CUSTOM}
>
{data.map(entry => (
<Cell key={entry.name} fill={COLORES[entry.name]} stroke="none" />
))}
</Pie>
<Tooltip
formatter={(v, n) => [v.toLocaleString('es-AR'), n]}
contentStyle={{ background: '#12131A', border: `1px solid ${C.border}`, borderRadius: 8 }}
labelStyle={{ color: C.texto }} itemStyle={{ color: C.muted }}
/>
<Legend
formatter={v => <span style={{ color: C.muted, fontSize: 12 }}>{v}</span>}
iconType="circle"
/>
</PieChart>
</ResponsiveContainer>
</div>
)
}
@@ -0,0 +1,35 @@
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
const MESES = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']
export default function GravedadSiniestro({ siniestros }) {
const data = Array.from({ length: 12 }, (_, i) => ({
mes: MESES[i], Fatales: 0, 'Con lesionados': 0, 'Sin lesiones': 0,
}))
siniestros.forEach(s => {
const mes = parseInt(s.mes)
if (mes < 1 || mes > 12) return
const f = parseInt(s.fallecidos) || 0
const h = parseInt(s.heridos) || 0
if (f > 0) data[mes-1]['Fatales'] += 1
else if (h > 0) data[mes-1]['Con lesionados'] += 1
else data[mes-1]['Sin lesiones'] += 1
})
return (
<div style={{ background: '#fff', borderRadius: 12, padding: '1.5rem', boxShadow: '0 2px 12px rgba(37,44,97,0.10)' }}>
<h3 style={{ color: '#252C61', fontWeight: 700, marginBottom: '1rem', fontSize: '1rem' }}>Gravedad por Mes</h3>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="mes" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Legend iconSize={10} />
<Bar dataKey="Sin lesiones" fill="#252C61" stackId="a" />
<Bar dataKey="Con lesionados" fill="#E8881A" stackId="a" />
<Bar dataKey="Fatales" fill="#C0392B" stackId="a" radius={[4,4,0,0]} />
</BarChart>
</ResponsiveContainer>
</div>
)
}
+236
View File
@@ -0,0 +1,236 @@
import {
PieChart,
Pie,
Cell,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { COLOR } from '../../utils/colores'
import { calcularRangoEtario } from '../../utils/calculos'
import { useChartTheme } from '../../hooks/useChartTheme'
const COLORS = [
COLOR.navy,
COLOR.blue,
COLOR.orange,
COLOR.green,
COLOR.red,
'#8B5CF6',
]
const formatData = (obj) =>
Object.entries(obj).map(([name, value]) => ({ name, value }))
const RADIAN = Math.PI / 180
const renderLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }) => {
if (percent < 0.08) return null
const radius = innerRadius + (outerRadius - innerRadius) * 0.55
const x = cx + radius * Math.cos(-midAngle * RADIAN)
const y = cy + radius * Math.sin(-midAngle * RADIAN)
return (
<text
x={x}
y={y}
fill="white"
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fontWeight="700"
>
{`${(percent * 100).toFixed(0)}%`}
</text>
)
}
export default function PerfilVictimas({
personas,
involucrados,
soloFatales = false,
}) {
const { tickColor, gridColor, tooltipBg, tooltipBorder, tooltipLabel } =
useChartTheme()
const tipoMap = new Map()
involucrados?.forEach((i) =>
tipoMap.set(String(i.id_involucrado), i.tipo_involucrado)
)
const victimas = personas.filter((p) =>
soloFatales
? ['Fallecido'].includes(p.estado_ocupante_inicio)
: ['Fallecido', 'Herido Grave', 'Herido Leve'].includes(
p.estado_ocupante_inicio
)
)
const genero = {}
const usuario = {}
victimas.forEach((p) => {
genero[p.genero || 'Sin dato'] = (genero[p.genero || 'Sin dato'] || 0) + 1
const tipo = tipoMap.get(String(p.id_involucrado)) || 'Sin dato'
usuario[tipo] = (usuario[tipo] || 0) + 1
})
const generoData = formatData(genero)
const etarioData = calcularRangoEtario(victimas).map(({ rango, cantidad }) => ({
name: rango,
value: cantidad,
}))
const usuarioData = formatData(usuario).sort((a, b) => b.value - a.value)
const tooltipStyle = {
background: tooltipBg,
border: `1px solid ${tooltipBorder}`,
borderRadius: 16,
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
}
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div className="grid gap-6 xl:grid-cols-3">
<div className="rounded-[24px] bg-opsv-bg p-4">
<div className="mb-4 text-sm font-semibold text-opsv-text">
Género
</div>
<div className="h-[260px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={generoData}
dataKey="value"
nameKey="name"
cx="42%"
cy="50%"
outerRadius={75}
innerRadius={35}
paddingAngle={4}
label={renderLabel}
labelLine={false}
>
{generoData.map((entry, index) => (
<Cell key={entry.name} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: tooltipLabel, fontWeight: 700 }}
itemStyle={{ color: tickColor }}
/>
<Legend
layout="vertical"
verticalAlign="middle"
align="right"
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: '13px', lineHeight: '1.8' }}
formatter={(value) => (
<span style={{ color: tickColor, fontSize: 13 }}>{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
</div>
<div className="rounded-[24px] bg-opsv-bg p-4">
<div className="mb-4 text-sm font-semibold text-opsv-text">
Rango etario
</div>
<div className="h-[260px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={etarioData}
margin={{ left: 0, right: 10, top: 10, bottom: 5 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke={gridColor}
vertical={false}
/>
<XAxis
dataKey="name"
tick={{ fill: tickColor, fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: tickColor, fontSize: 11 }}
axisLine={false}
tickLine={false}
allowDecimals={false}
domain={[0, (dataMax) => Math.ceil(dataMax * 1.1)]}
/>
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: tooltipLabel, fontWeight: 700 }}
itemStyle={{ color: tickColor }}
/>
<Bar
dataKey="value"
fill={COLOR.orange}
radius={[6, 6, 0, 0]}
maxBarSize={40}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="rounded-[24px] bg-opsv-bg p-4">
<div className="mb-4 text-sm font-semibold text-opsv-text">
Tipo de usuario
</div>
<div className="h-[260px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={usuarioData}
layout="vertical"
margin={{ left: 0, right: 10, top: 0, bottom: 0 }}
>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
width={130}
tick={{ fill: tickColor, fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: tooltipLabel, fontWeight: 700 }}
itemStyle={{ color: tickColor }}
/>
<Bar
dataKey="value"
fill={COLOR.navy}
radius={[10, 10, 10, 10]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { COLOR } from '../../utils/colores'
import { useChartTheme } from '../../hooks/useChartTheme'
const getCategoria = (s) => {
const fallecidos = parseInt(s.cantidad_fallecidos ?? s.fallecidos, 10) || 0
const lesionados = parseInt(s.cantidad_lesionados ?? s.heridos, 10) || 0
if (fallecidos > 0) return 'fatales'
if (lesionados > 0) return 'conLes'
return 'sinLes'
}
export default function PorLocalidad({ siniestros, tipo = 'todas' }) {
const { tickColor, gridColor, tooltipBg, tooltipBorder, tooltipLabel } = useChartTheme()
const conteo = {}
siniestros.forEach((s) => {
const categoria = getCategoria(s)
if (tipo !== 'todas' && categoria !== tipo) return
const loc = (s.localidad || s.localidad_ocurrencia || 'Sin dato').trim() || 'Sin dato'
conteo[loc] = conteo[loc] || {
total: 0,
fatales: 0,
conLes: 0,
sinLes: 0,
}
conteo[loc].total += 1
conteo[loc][categoria] += 1
})
const data = Object.entries(conteo)
.map(([localidad, values]) => {
const mayorCategoria = ['fatales', 'conLes', 'sinLes'].sort(
(a, b) => values[b] - values[a]
)[0]
return {
localidad,
total: values.total,
categoria: mayorCategoria,
}
})
.sort((a, b) => b.total - a.total)
.slice(0, 10)
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div className="h-[360px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
layout="vertical"
data={data}
margin={{ top: 10, right: 20, left: 0, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke={gridColor}
vertical={false}
/>
<XAxis
type="number"
tick={{ fill: tickColor, fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<YAxis
type="category"
dataKey="localidad"
tick={{ fill: tickColor, fontSize: 12 }}
axisLine={false}
tickLine={false}
width={150}
/>
<Tooltip
cursor={{ fill: 'rgba(148, 163, 184, 0.12)' }}
contentStyle={{
background: tooltipBg,
border: `1px solid ${tooltipBorder}`,
borderRadius: 16,
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
}}
labelStyle={{ color: tooltipLabel, fontWeight: 700 }}
itemStyle={{ color: tickColor }}
/>
<Bar dataKey="total" radius={[0, 10, 10, 0]}>
{data.map((entry) => (
<Cell key={entry.localidad} fill={COLOR[entry.categoria]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { porTipoSiniestro } from '../../utils/calculos'
import { COLOR } from '../../utils/colores'
import { useChartTheme } from '../../hooks/useChartTheme'
const COLORS = [
COLOR.navy,
COLOR.red,
COLOR.orange,
COLOR.green,
'#6B7280',
'#8B5CF6',
'#0EA5E9',
]
const RADIAN = Math.PI / 180
function agruparConUmbral(datos, umbralPct = 10) {
if (!datos || datos.length === 0) return []
const total = datos.reduce((a, b) => a + b.value, 0)
if (total === 0) return []
const normalizados = datos.map((d) => ({
...d,
pct: (d.value / total) * 100,
}))
const principales = normalizados.filter((d) => d.pct >= umbralPct)
const menores = normalizados.filter((d) => d.pct < umbralPct)
if (menores.length > 0) {
const totalOtros = menores.reduce((s, d) => s + d.value, 0)
principales.push({
name: 'Otros',
value: totalOtros,
pct: (totalOtros / total) * 100,
})
}
return principales.sort((a, b) => b.value - a.value)
}
const CustomLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, pct }) => {
if (pct < 5) return null
const radius = innerRadius + (outerRadius - innerRadius) * 0.55
const x = cx + radius * Math.cos(-midAngle * RADIAN)
const y = cy + radius * Math.sin(-midAngle * RADIAN)
return (
<text
x={x}
y={y}
fill="white"
textAnchor="middle"
dominantBaseline="central"
fontSize={12}
fontWeight="700"
>
{`${pct.toFixed(0)}%`}
</text>
)
}
export default function PorTipoSiniestro({ siniestros }) {
const { tooltipBg, tooltipBorder, tooltipLabel, tickColor } = useChartTheme()
const data = agruparConUmbral(porTipoSiniestro(siniestros), 10)
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div className="h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
cx="60%"
cy="50%"
outerRadius={95}
innerRadius={58}
paddingAngle={4}
labelLine={false}
label={CustomLabel}
>
{data.map((entry, index) => (
<Cell key={entry.name} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
background: tooltipBg,
border: `1px solid ${tooltipBorder}`,
borderRadius: 16,
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
}}
labelStyle={{ color: tooltipLabel, fontWeight: 700 }}
itemStyle={{ color: tickColor }}
/>
<Legend
layout="vertical"
verticalAlign="bottom"
align="right"
iconType="circle"
formatter={(value) => (
<span className="text-sm text-opsv-text">{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
</div>
)
}
@@ -0,0 +1,281 @@
/**
* ProteccionPersonas.jsx — v3 CORREGIDA
*/
const VERDE = '#2D7A4F'
const ROJO = '#C0392B'
const GRIS = '#4A4E6A'
const NARANJA = '#E8881A'
const CON_HABITACULO = new Set([
'Automóvil',
'Camioneta/Utilitario',
'Transporte De Pasajeros',
'Transporte De Carga',
])
const SIN_HABITACULO = new Set([
'Motocicleta',
'Peatón',
'Bicicleta',
'Tracción A Sangre',
'Tracción a Sangre',
])
function pct(n, base) {
return base > 0 ? `${((n / base) * 100).toFixed(1)}%` : '—'
}
function IndicadorChip({
titulo,
icon,
base,
baseLabel,
si,
no,
sd,
colorSi = VERDE,
}) {
return (
<div className="rounded-xl border border-opsv-border/70 bg-opsv-surface/5 p-4">
<div className="mb-2 flex items-start justify-between">
<div className="flex items-center gap-1.5">
{icon && <span className="text-base">{icon}</span>}
<span className="text-sm font-bold text-opsv-navy">
{titulo}
</span>
</div>
<div className="rounded-md bg-opsv-surface/60 px-2 py-[0.1rem] text-[0.78rem] text-opsv-muted">
base: {base.toLocaleString('es-AR')} {baseLabel}
</div>
</div>
{base > 0 ? (
<>
<div className="mb-2 flex h-5 overflow-hidden rounded bg-opsv-bg/40">
{si > 0 && (
<div
className="transition-[width] duration-300"
style={{
width: pct(si, base),
background: colorSi,
}}
title={`Sí: ${si}`}
/>
)}
{no > 0 && (
<div
className="transition-[width] duration-300"
style={{
width: pct(no, base),
background: ROJO,
}}
title={`No: ${no}`}
/>
)}
{sd > 0 && (
<div
className="opacity-60 transition-[width] duration-300"
style={{
width: pct(sd, base),
background: GRIS,
}}
title={`S/D: ${sd}`}
/>
)}
</div>
<div className="flex flex-wrap gap-4 text-[0.78rem]">
{[
{ label: 'Sí', v: si, color: colorSi },
{ label: 'No', v: no, color: ROJO },
{ label: 'Sin dato', v: sd, color: GRIS },
]
.filter((item) => item.v > 0)
.map((item) => (
<div
key={item.label}
className="flex items-baseline gap-1"
style={{ color: item.color }}
>
<strong
className="tabular-nums"
>
{item.v.toLocaleString('es-AR')}
</strong>
<span className="opacity-80">
{` ${item.label} (${pct(item.v, base)})`}
</span>
</div>
))}
</div>
</>
) : (
<div className="text-[0.78rem] italic text-opsv-muted">
Sin datos para esta categoría en el período seleccionado
</div>
)}
</div>
)
}
function SeccionAlcohol({ personas }) {
const total = personas.length
const pos = personas.filter((p) => p.prueba_alcohol === 'Positivo').length
const neg = personas.filter((p) => p.prueba_alcohol === 'Negativo').length
const nr = personas.filter((p) => p.prueba_alcohol === 'No se Realizó').length
const sd = total - pos - neg - nr
const realizadas = pos + neg
const items = [
{ label: 'Positivo', v: pos, color: ROJO },
{ label: 'Negativo', v: neg, color: VERDE },
{ label: 'No realizada', v: nr, color: NARANJA },
{ label: 'Sin dato', v: sd, color: GRIS },
].filter((i) => i.v > 0)
return (
<div className="rounded-xl border border-opsv-border/70 bg-opsv-surface/5 p-4">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-sm font-bold text-opsv-navy">
Prueba de alcohol
</span>
<span className="rounded-md bg-opsv-surface/60 px-2 py-[0.1rem] text-[0.78rem] text-opsv-muted">
base: {total.toLocaleString('es-AR')} personas totales
</span>
</div>
<div className="mb-2 flex flex-wrap gap-2">
{items.map((item) => (
<div
key={item.label}
className="flex-1 min-w-[80px] rounded-lg border-l-[3px] px-3 py-2"
style={{
borderColor: item.color,
background: `${item.color}18`,
}}
>
<div
className="text-lg font-extrabold tabular-nums"
style={{ color: item.color }}
>
{item.v.toLocaleString('es-AR')}
</div>
<div className="text-[0.78rem] text-opsv-muted">
{item.label}
<br />
<span className="opacity-70">
{pct(item.v, total)}
</span>
</div>
</div>
))}
</div>
{realizadas > 0 && (
<div className="mt-2 text-[0.75rem] text-opsv-muted">
Tasa de positividad sobre pruebas realizadas (
{realizadas.toLocaleString('es-AR')}
):
{' '}
<strong
style={{ color: pos > 0 ? ROJO : VERDE }}
>
{((pos / realizadas) * 100).toFixed(1)}%
</strong>
</div>
)}
</div>
)
}
export default function ProteccionPersonas({ personas, involucrados }) {
if (!personas?.length) return null
const tipoMap = new Map()
if (involucrados?.length) {
involucrados.forEach((i) => {
if (i.id_involucrado != null && i.tipo_involucrado) {
tipoMap.set(String(i.id_involucrado), i.tipo_involucrado)
}
})
}
const personasConTipo = personas.map((p) => ({
...p,
_tipo: tipoMap.get(String(p.id_involucrado)) || null,
}))
const motociclistas = personasConTipo.filter(
(p) => p._tipo === 'Motocicleta'
)
const cascaSi = motociclistas.filter((p) => p.casco === 'Si').length
const cascaNo = motociclistas.filter((p) => p.casco === 'No').length
const cascaSD = motociclistas.length - cascaSi - cascaNo
const enHabitaculo = personasConTipo.filter((p) => {
if (!p._tipo) return false
if (SIN_HABITACULO.has(p._tipo)) return false
if (CON_HABITACULO.has(p._tipo)) return true
return true
})
const cinSi = enHabitaculo.filter(
(p) => p.cinturon_seguridad === 'Si'
).length
const cinNo = enHabitaculo.filter(
(p) => p.cinturon_seguridad === 'No'
).length
const cinSD = enHabitaculo.length - cinSi - cinNo
const airSi = enHabitaculo.filter((p) => p.airbag === 'Si').length
const airNo = enHabitaculo.filter((p) => p.airbag === 'No').length
const airSD = enHabitaculo.length - airSi - airNo
const sinMapa = tipoMap.size === 0
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
{sinMapa && (
<div className="mb-4 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[0.76rem] text-amber-600">
No se recibieron datos de Involucrados los cálculos de casco/cinturón/airbag
no pueden segmentarse por tipo de vehículo. Verificá que el componente reciba
la prop <code>involucrados</code>.
</div>
)}
<div className="flex flex-col gap-3">
<IndicadorChip
titulo="Uso de casco"
base={motociclistas.length}
baseLabel="motociclistas"
si={cascaSi}
no={cascaNo}
sd={cascaSD}
/>
<IndicadorChip
titulo="Uso de cinturón"
base={enHabitaculo.length}
baseLabel="en vehículo c/habitáculo"
si={cinSi}
no={cinNo}
sd={cinSD}
/>
<IndicadorChip
titulo="Airbag activado"
base={enHabitaculo.length}
baseLabel="en vehículo c/habitáculo"
si={airSi}
no={airNo}
sd={airSD}
colorSi="#3A7EBF"
/>
<SeccionAlcohol personas={personas} />
</div>
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell, ResponsiveContainer } from 'recharts'
import { calcularRangoEtario } from '../../utils/calculos'
const COLORES_RANGO = ['#252C61', '#3A4489', '#252C61', '#3A4489', '#252C61', '#3A4489', '#252C61']
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload?.length) {
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-md p-3 text-sm">
<p className="font-bold text-gray-800">{label}</p>
<p className="text-gray-600">
<span className="font-semibold">{payload[0].value}</span> Personas
</p>
</div>
)
}
return null
}
export default function RangoEtario({ personas = [] }) {
const data = calcularRangoEtario(personas)
if (!data.length) {
return (
<div className="rounded-[28px] border border-opsv-border bg-white p-6 shadow-sm flex flex-col items-center justify-center h-48 text-gray-400 text-sm gap-1">
<span>Sin datos de rango etario</span>
<span className="text-xs text-gray-300">Verificar campo en tabla Personas</span>
</div>
)
}
return (
<div className="rounded-[28px] border border-opsv-border bg-white p-6 shadow-sm">
<div className="mb-6">
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">Perfil etario</p>
<h3 className="mt-2 text-xl font-black text-opsv-navy">Distribucion por edad</h3>
</div>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data} layout="vertical" margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#E5E7EB" />
<XAxis
type="number"
tick={{ fontSize: 11, fill: '#6B7280' }}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="rango"
tick={{ fontSize: 12, fill: '#374151' }}
axisLine={false}
tickLine={false}
width={55}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#F3F4F6' }} />
<Bar dataKey="cantidad" radius={[0, 4, 4, 0]} maxBarSize={28}>
{data.map((_, i) => (
<Cell key={i} fill={COLORES_RANGO[i % COLORES_RANGO.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
/**
* RolPersona.jsx
* Columna: rol_persona_involucrada → "Conductor" | "Acompañante" | "Peatón"
* Cruza con genero para ver conductor por género
*/
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, Cell, ResponsiveContainer
} from 'recharts'
const C = {
surface:'#1C1D2B', texto:'#F0F2FF', muted:'#8B8FA8', border:'#2E3050',
}
const ROLES = [
{ key: 'Conductor', color: '#3A4489', icon: '🚗' },
{ key: 'Acompañante', color: '#3A7EBF', icon: '👥' },
{ key: 'Peatón', color: '#8E44AD', icon: '🚶' },
]
const GENERO_COLOR = { Masculino: '#3A7EBF', Femenino: '#C0739A', 'Sin dato': '#4A4E6A' }
export default function RolPersona({ personas }) {
if (!personas?.length) return null
// Conteo simple por rol
const totales = {}
ROLES.forEach(r => { totales[r.key] = 0 })
personas.forEach(p => {
if (totales[p.rol_persona_involucrada] !== undefined)
totales[p.rol_persona_involucrada]++
})
// Cruce rol × género para el gráfico apilado
const cruceData = ROLES.map(r => {
const grupo = personas.filter(p => p.rol_persona_involucrada === r.key)
const masc = grupo.filter(p => p.genero === 'Masculino').length
const fem = grupo.filter(p => p.genero === 'Femenino').length
const sd = grupo.length - masc - fem
return { name: r.key, Masculino: masc, Femenino: fem, 'Sin dato': sd, total: grupo.length }
})
const total = personas.length
const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null
return (
<div style={{
background: '#12131A', border: `1px solid ${C.border}`,
borderRadius: 8, padding: '0.6rem 1rem', minWidth: 160,
}}>
<p style={{ color: C.texto, margin: '0 0 0.4rem', fontWeight: 700 }}>{label}</p>
{payload.map(p => (
<p key={p.name} style={{ color: GENERO_COLOR[p.name], margin: '0.1rem 0', fontSize: '0.82rem' }}>
{p.name}: {p.value.toLocaleString('es-AR')}
</p>
))}
<p style={{ color: C.muted, margin: '0.3rem 0 0', fontSize: '0.78rem', borderTop:`1px solid ${C.border}`, paddingTop:'0.3rem' }}>
Total: {payload.reduce((a, p) => a + p.value, 0).toLocaleString('es-AR')}
{' '}({((payload.reduce((a,p)=>a+p.value,0)/total)*100).toFixed(1)}%)
</p>
</div>
)
}
return (
<div style={{
background: C.surface, borderRadius: 14, padding: '1.25rem',
border: `1px solid ${C.border}`,
}}>
<div style={{ color: C.texto, fontWeight: 700, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
🎭 Rol en el siniestro
</div>
<div style={{ color: C.muted, fontSize: '0.73rem', marginBottom: '1rem' }}>
Conductor / Acompañante / Peatón desglosado por género
</div>
{/* KPI chips por rol */}
<div style={{ display: 'flex', gap: '0.6rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
{ROLES.map(r => (
<div key={r.key} style={{
background: `${r.color}1A`, borderRadius: 8,
padding: '0.5rem 0.9rem', borderLeft: `3px solid ${r.color}`,
flex: 1, minWidth: 90,
}}>
<div style={{ fontSize: '1.1rem', marginBottom: '0.15rem' }}>{r.icon}</div>
<div style={{ color: r.color, fontWeight: 800, fontSize: '1.25rem', fontVariantNumeric: 'tabular-nums' }}>
{(totales[r.key] || 0).toLocaleString('es-AR')}
</div>
<div style={{ color: C.muted, fontSize: '0.7rem' }}>
{r.key}
<span style={{ opacity: 0.65 }}>
{' '}({total ? ((totales[r.key]/total)*100).toFixed(0) : 0}%)
</span>
</div>
</div>
))}
</div>
<ResponsiveContainer width="100%" height={200}>
<BarChart
data={cruceData}
margin={{ top: 8, right: 10, bottom: 0, left: -10 }}
barCategoryGap="30%"
>
<CartesianGrid strokeDasharray="3 3" stroke="#2E3050" vertical={false} />
<XAxis dataKey="name" tick={{ fill: C.muted, fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: C.muted, fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff08' }} />
<Legend formatter={v => <span style={{ color: C.muted, fontSize: 11 }}>{v}</span>} />
<Bar dataKey="Masculino" stackId="a" fill={GENERO_COLOR.Masculino} />
<Bar dataKey="Femenino" stackId="a" fill={GENERO_COLOR.Femenino} />
<Bar dataKey="Sin dato" stackId="a" fill={GENERO_COLOR['Sin dato']} radius={[4,4,0,0]} />
</BarChart>
</ResponsiveContainer>
</div>
)
}
+245
View File
@@ -0,0 +1,245 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts'
import { useChartTheme } from '../../hooks/useChartTheme'
// ─── Población provincial por año ─────
export const POBLACION_POR_ANO = {
2025: 334953,
2026: 335096,
2027: 335186,
2028: 335260,
2029: 335302,
2030: 335317,
}
export function getPoblacionAnual(year) {
return POBLACION_POR_ANO[year] ?? POBLACION_POR_ANO[2025]
}
// ─── Serie histórica ──────────────────
export const SERIE_HISTORICA = [
{ ano: 2013, siniestros: 1186, victimas: 46, tasa: 15.21 },
{ ano: 2014, siniestros: 1124, victimas: 57, tasa: 18.30 },
{ ano: 2015, siniestros: 1156, victimas: 47, tasa: 14.67 },
{ ano: 2016, siniestros: 1098, victimas: 47, tasa: 14.55 },
{ ano: 2017, siniestros: 1089, victimas: 48, tasa: 13.83 },
{ ano: 2018, siniestros: 1201, victimas: 32, tasa: 9.40 },
{ ano: 2019, siniestros: 1178, victimas: 31, tasa: 8.64 },
// 2020 excluido
{ ano: 2021, siniestros: 1043, victimas: 24, tasa: 6.40 },
{ ano: 2022, siniestros: 1134, victimas: 26, tasa: 7.80 },
{ ano: 2023, siniestros: 1198, victimas: 26, tasa: 7.71 },
{ ano: 2024, siniestros: 1238, victimas: 24, tasa: 7.12 },
]
// ─── Tooltip ──────────────────────────
const CustomTooltip = ({ active, payload, label, tooltipBg, tooltipBorder, tooltipLabel, tickColor }) => {
if (active && payload?.length) {
const row = payload[0]?.payload
return (
<div
className="rounded-lg p-3 text-sm shadow-md"
style={{
background: tooltipBg,
border: `1px solid ${tooltipBorder}`,
}}
>
<p className="mb-1 font-bold" style={{ color: tooltipLabel }}>
{label}
</p>
{row?.siniestros != null && (
<p className="text-xs" style={{ color: tickColor }}>
Siniestros fatales:{' '}
<span className="font-semibold">
{row.siniestros}
</span>
</p>
)}
{row?.victimas != null && (
<p className="text-xs" style={{ color: tickColor }}>
Víctimas fatales:{' '}
<span className="font-semibold">
{row.victimas}
</span>
</p>
)}
{payload[0].value != null && (
<p className="mt-1 text-xs" style={{ color: tooltipLabel }}>
Tasa:{' '}
<span className="font-semibold">
{payload[0].value}
</span>{' '}
c/100k hab.
</p>
)}
</div>
)
}
return null
}
// ─── Componente ───────────────────────
export default function SerieHistorica({
year,
siniestrosActual = 0,
victimasActual = 0,
datos,
}) {
const { tickColor, gridColor, tooltipBg, tooltipBorder, tooltipLabel } =
useChartTheme()
let dataFinal = datos?.length > 0 ? datos : [...SERIE_HISTORICA]
if (year && siniestrosActual > 0) {
const yaExiste = dataFinal.some((row) => row.ano === year)
if (yaExiste && year <= 2024) {
dataFinal = dataFinal.map((row) =>
row.ano === year
? { ...row, siniestros: siniestrosActual, victimas: victimasActual }
: row
)
} else {
const poblacion = getPoblacionAnual(year)
const tasaActual = Number(
((victimasActual / poblacion) * 100000).toFixed(2)
)
if (yaExiste) {
dataFinal = dataFinal.map((row) =>
row.ano === year
? {
...row,
siniestros: siniestrosActual,
victimas: victimasActual,
tasa: tasaActual,
}
: row
)
} else {
dataFinal = [
...dataFinal,
{
ano: year,
siniestros: siniestrosActual,
victimas: victimasActual,
tasa: tasaActual,
},
].sort((a, b) => a.ano - b.ano)
}
}
}
const tasasValidas = dataFinal.filter((r) => r.tasa != null).map((r) => r.tasa)
const promedio = tasasValidas.length
? tasasValidas.reduce((a, b) => a + b, 0) / tasasValidas.length
: 0
const primerAnio = dataFinal[0]?.ano
const ultimoAnio = dataFinal[dataFinal.length - 1]?.ano
return (
<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]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={dataFinal}
margin={{ top: 10, right: 90, left: 0, bottom: 5 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke={gridColor}
vertical={false}
/>
<XAxis
dataKey="ano"
tick={{ fontSize: 12, fill: tickColor }}
axisLine={{ stroke: gridColor }}
tickLine={false}
/>
<YAxis
domain={[0, 'auto']}
tick={{ fontSize: 12, fill: tickColor }}
axisLine={false}
tickLine={false}
label={{
value: 'c/100k hab.',
angle: -90,
position: 'insideLeft',
offset: 10,
style: { fontSize: 11, fill: tickColor },
}}
/>
<Tooltip
content={
<CustomTooltip
tooltipBg={tooltipBg}
tooltipBorder={tooltipBorder}
tooltipLabel={tooltipLabel}
tickColor={tickColor}
/>
}
/>
<ReferenceLine
y={promedio}
stroke="#CD9F2B"
strokeDasharray="4 4"
label={{
value: `Promedio (${promedio.toFixed(1)})`,
position: 'right',
fontSize: 10,
fill: '#CD9F2B',
}}
/>
<Line
type="monotone"
dataKey="tasa"
stroke="#252C61"
strokeWidth={2.5}
dot={{ fill: '#252C61', r: 4, strokeWidth: 0 }}
activeDot={{ r: 6, fill: '#252C61' }}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="mt-4 space-y-1">
<p className="text-xs text-opsv-muted">
Tasas 20132024: informes anuales OPSV Santa Cruz. Tasas 2025 en adelante:
cálculo propio sobre proyecciones INDEC base Censo Nacional de Población,
Hogares y Viviendas 2022.
</p>
<p className="text-xs 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>
)
}
+123
View File
@@ -0,0 +1,123 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts'
import { COLOR } from '../../utils/colores'
import { MESES } from '../../utils/calculos'
import { useChartTheme } from '../../hooks/useChartTheme'
const MONTH_SHORT = MESES.map((m) => m.slice(0, 3))
const parseFecha = (siniestro) => {
if (siniestro.fecha) {
const fecha = new Date(siniestro.fecha)
if (!Number.isNaN(fecha.getTime())) {
return {
mes: fecha.getMonth() + 1,
ano: fecha.getFullYear(),
}
}
}
const mes = parseInt(siniestro.mes, 10)
const ano = parseInt(
siniestro.ano ?? siniestro.año ?? siniestro.anio ?? siniestro.year,
10
)
if (!Number.isNaN(mes) && !Number.isNaN(ano)) {
return { mes, ano }
}
if (siniestro.mes_nombre) {
const idx = MONTH_SHORT.indexOf(siniestro.mes_nombre.slice(0, 3))
if (idx >= 0 && !Number.isNaN(ano)) {
return { mes: idx + 1, ano }
}
}
return null
}
const getCantidadFallecidos = (siniestro) =>
parseInt(siniestro.cantidad_fallecidos ?? siniestro.fallecidos, 10) || 0
const getCantidadLesionados = (siniestro) =>
parseInt(siniestro.cantidad_lesionados ?? siniestro.heridos, 10) || 0
export default function SiniestrosPorMes({ siniestros }) {
const { tickColor, gridColor, tooltipBg, tooltipBorder, tooltipLabel } = useChartTheme()
const acumulado = {}
siniestros.forEach((s) => {
const fecha = parseFecha(s)
if (!fecha?.mes || !fecha?.ano) return
const key = `${fecha.ano}-${String(fecha.mes).padStart(2, '0')}`
if (!acumulado[key]) {
acumulado[key] = {
key,
mes: `${MONTH_SHORT[fecha.mes - 1]} ${fecha.ano}`,
fatales: 0,
conLes: 0,
sinLes: 0,
}
}
const fatales = getCantidadFallecidos(s)
const lesionados = getCantidadLesionados(s)
if (fatales > 0) acumulado[key].fatales += 1
else if (lesionados > 0) acumulado[key].conLes += 1
else acumulado[key].sinLes += 1
})
const data = Object.values(acumulado).sort((a, b) => a.key.localeCompare(b.key))
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div className="h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 10, right: 0, left: -10, bottom: 0 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke={gridColor}
vertical={false}
/>
<XAxis
dataKey="mes"
tick={{ fill: tickColor, fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: tickColor, fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
background: tooltipBg,
border: `1px solid ${tooltipBorder}`,
borderRadius: 16,
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
}}
labelStyle={{ color: tooltipLabel, fontWeight: 700 }}
/>
<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="sinLes" stackId="a" fill={COLOR.sinLes} radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)
}
+193
View File
@@ -0,0 +1,193 @@
/**
* TipoInvolucrado.jsx — columna: tipo_involucrado
*/
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Cell,
ResponsiveContainer,
LabelList,
} from 'recharts'
import { useChartTheme } from '../../hooks/useChartTheme'
const TIPOS_CONFIG = [
{ key: 'Automóvil', color: '#3A4489', short: 'Auto' },
{ key: 'Camioneta/Utilitario', color: '#3A7EBF', short: 'Camioneta' },
{ key: 'Motocicleta', color: '#E8881A', short: 'Moto' },
{ key: 'Peatón', color: '#8E44AD', short: 'Peatón'},
{ key: 'Transporte De Pasajeros', color: '#16A085', short: 'T. Pasajeros' },
{ key: 'Transporte De Carga', color: '#2D7A4F', short: 'T. Carga' },
]
const COLOR_OTROS = '#4A4E6A'
export default function TipoInvolucrado({ involucrados }) {
const { tickColor, gridColor, tooltipBg, tooltipBorder, tooltipLabel } = useChartTheme()
if (!involucrados?.length) return null
const conteo = {}
involucrados.forEach((i) => {
const t = i.tipo_involucrado?.trim() || 'Sin dato'
conteo[t] = (conteo[t] || 0) + 1
})
const total = involucrados.length
const barData = []
let sumOtros = 0
TIPOS_CONFIG.forEach((tc) => {
if (conteo[tc.key]) {
barData.push({
name: tc.short,
full: tc.key,
value: conteo[tc.key],
color: tc.color,
icon: tc.icon,
})
}
})
Object.entries(conteo).forEach(([k, v]) => {
if (!TIPOS_CONFIG.find((tc) => tc.key === k)) sumOtros += v
})
if (sumOtros > 0) {
barData.push({
name: 'Otros',
full: 'Otros',
value: sumOtros,
color: COLOR_OTROS,
})
}
barData.sort((a, b) => b.value - a.value)
const CustomTooltip = ({ active, payload }) => {
if (!active || !payload?.[0]) return null
const d = payload[0].payload
return (
<div
className="rounded-[20px] p-4 text-sm shadow-md"
style={{
background: tooltipBg,
border: `1px solid ${tooltipBorder}`,
}}
>
<p className="mb-2 text-[0.7rem] font-semibold uppercase tracking-[0.35em] text-opsv-muted">
Tipo de involucrado
</p>
<h3
className="mb-3 text-xl font-black"
style={{ color: tooltipLabel }}
>
Vehículos y peatones
</h3>
<p className="text-sm" style={{ color: tickColor }}>
{d.full}: <span className="font-bold">{d.value.toLocaleString('es-AR')}</span> (
{((d.value / total) * 100).toFixed(1)}%)
</p>
</div>
)
}
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div className="mb-5 text-[0.73rem] text-opsv-muted">
{total.toLocaleString('es-AR')} vehículos y peatones registrados
</div>
{/* KPI chips — top 4 */}
<div className="mb-5 flex flex-wrap gap-2.5">
{barData.slice(0, 4).map((d) => (
<div
key={d.name}
className="flex-1 min-w-[90px] rounded-lg border-l-[3px] px-3 py-2"
style={{
borderColor: d.color,
background: `${d.color}1A`,
}}
>
<div className="mb-0.5 text-base">{d.icon}</div>
<div
className="text-[1.2rem] font-extrabold"
style={{
color: d.color,
fontVariantNumeric: 'tabular-nums',
}}
>
{d.value.toLocaleString('es-AR')}
</div>
<div className="text-[0.68rem] text-opsv-muted">
{d.name}
<br />
<span className="opacity-65">
{((d.value / total) * 100).toFixed(1)}%
</span>
</div>
</div>
))}
</div>
<div className="h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={barData}
layout="vertical"
margin={{ top: 0, right: 48, bottom: 0, left: 0 }}
barCategoryGap="20%"
>
<CartesianGrid
strokeDasharray="3 3"
stroke={gridColor}
horizontal={false}
/>
<XAxis
type="number"
tick={{ fill: tickColor, fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis
dataKey="name"
type="category"
tick={{ fill: tickColor, fontSize: 11 }}
axisLine={false}
tickLine={false}
width={80}
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: 'rgba(148,163,184,0.08)' }}
/>
<Bar dataKey="value" radius={[0, 6, 6, 0]} maxBarSize={32}>
{barData.map((d) => (
<Cell key={d.name} fill={d.color} />
))}
<LabelList
dataKey="value"
position="right"
style={{ fontSize: 11, fontWeight: 600 }}
formatter={(v) =>
v > 0 ? v.toLocaleString('es-AR') : ''
}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)
}
+116
View File
@@ -0,0 +1,116 @@
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts'
const COLORES_TIPO = ['#252C61', '#3A4489', '#CD9F2B', '#337C58', '#C0392B', '#80B0DE', '#6B7280']
const RADIAN = Math.PI / 180
// Agrupa categorías con menos del umbral% del total en "Otros"
function agruparPorUmbral(siniestros, umbralPct = 10) {
if (!siniestros || siniestros.length === 0) return []
const conteo = {}
siniestros.forEach((s) => {
// tipo_siniestro_unico es el campo principal; tipo_sinietro es el typo confirmado en BD
const tipo = (
s.tipo_siniestro_unico?.trim() ||
s.tipo_sinietro?.trim() ||
s.tipo_siniestro?.trim() ||
'Sin clasificar'
)
conteo[tipo] = (conteo[tipo] || 0) + 1
})
const total = Object.values(conteo).reduce((a, b) => a + b, 0)
if (total === 0) return []
const normalizados = Object.entries(conteo).map(([name, value]) => ({
name,
value,
pct: (value / total) * 100,
}))
const principales = normalizados.filter((d) => d.pct >= umbralPct)
const menores = normalizados.filter((d) => d.pct < umbralPct)
if (menores.length > 0) {
const totalOtros = menores.reduce((sum, d) => sum + d.value, 0)
principales.push({
name: 'Otros',
value: totalOtros,
pct: (totalOtros / total) * 100,
})
}
return principales.sort((a, b) => b.value - a.value)
}
const CustomLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, pct }) => {
if (pct < 5) return null
const radius = innerRadius + (outerRadius - innerRadius) * 0.55
const x = cx + radius * Math.cos(-midAngle * RADIAN)
const y = cy + radius * Math.sin(-midAngle * RADIAN)
return (
<text x={x} y={y} fill="white" textAnchor="middle" dominantBaseline="central" fontSize={12} fontWeight="700">
{`${pct.toFixed(0)}%`}
</text>
)
}
const CustomTooltip = ({ active, payload }) => {
if (active && payload?.length) {
const d = payload[0].payload
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-md p-3 text-sm">
<p className="font-semibold text-gray-800">{d.name}</p>
<p className="text-gray-600">Cantidad: <span className="font-bold">{d.value}</span></p>
<p className="text-gray-600">Porcentaje: <span className="font-bold">{d.pct.toFixed(1)}%</span></p>
</div>
)
}
return null
}
export default function TipoSiniestro({ siniestros = [] }) {
const data = agruparPorUmbral(siniestros, 10)
if (!data.length) {
return (
<div className="rounded-[28px] border border-opsv-border bg-white p-6 shadow-sm flex items-center justify-center h-48 text-gray-400 text-sm">
Sin datos disponibles
</div>
)
}
return (
<div className="rounded-[28px] border border-opsv-border bg-white p-6 shadow-sm">
<div className="mb-6">
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">Tipo de Siniestro</p>
<h3 className="mt-2 text-xl font-black text-opsv-navy">Distribucion por tipo</h3>
</div>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
outerRadius={100}
dataKey="value"
labelLine={false}
label={CustomLabel}
>
{data.map((_, i) => (
<Cell key={`cell-${i}`} fill={COLORES_TIPO[i % COLORES_TIPO.length]} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend
iconType="circle"
iconSize={10}
formatter={(value) => (
<span style={{ fontSize: 12, color: '#374151' }}>{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
const COLORES = ['#252C61','#3A4489','#C0392B','#E8881A','#2D7A4F','#3A7EBF','#8E44AD','#16A085']
export default function TipoVia({ siniestros }) {
const conteo = {}
siniestros.forEach(s => {
// ✅ nombre correcto: via_publica
const via = s.via_publica?.trim() || 'Sin datos'
conteo[via] = (conteo[via] || 0) + 1
})
const data = Object.entries(conteo).map(([via, total]) => ({ via, total })).sort((a,b) => b.total - a.total).slice(0,8)
return (
<div style={{ background: '#fff', borderRadius: 12, padding: '1.5rem', boxShadow: '0 2px 12px rgba(37,44,97,0.10)' }}>
<h3 style={{ color: '#252C61', fontWeight: 700, marginBottom: '1rem', fontSize: '1rem' }}>Tipo de Vía</h3>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={data} layout="vertical" margin={{ top: 5, right: 30, bottom: 5, left: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="via" tick={{ fontSize: 11 }} width={75} />
<Tooltip formatter={(val) => [val, 'Siniestros']} />
<Bar dataKey="total" radius={[0,4,4,0]}>
{data.map((_, i) => <Cell key={i} fill={COLORES[i % COLORES.length]} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { useChartTheme } from '../../hooks/useChartTheme'
const COLORES = {
Urbana: '#252C61',
Rural: '#E8881A',
'Sin datos': '#94A3B8',
}
const RADIAN = Math.PI / 180
function normalizarZona(valor) {
const texto = String(valor ?? '').trim().toLowerCase()
if (!texto) return 'Sin datos'
if (texto.includes('urb')) return 'Urbana'
if (texto.includes('rur')) return 'Rural'
return 'Sin datos'
}
export default function ZonaOcurrencia({ siniestros }) {
const { tooltipBg, tooltipBorder, tooltipLabel, tickColor } = useChartTheme()
const conteo = { Urbana: 0, Rural: 0, 'Sin datos': 0 }
siniestros.forEach((s) => {
const zona = normalizarZona(s.zona_ocurrencia)
conteo[zona] += 1
})
const data = Object.entries(conteo)
.map(([name, value]) => ({ name, value }))
.filter((item) => item.value > 0)
const total = data.reduce((acc, d) => acc + d.value, 0)
const renderLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }) => {
if (percent < 0.04) return null
const r = innerRadius + (outerRadius - innerRadius) * 0.5
const x = cx + r * Math.cos(-midAngle * RADIAN)
const y = cy + r * Math.sin(-midAngle * RADIAN)
return (
<text
x={x}
y={y}
fill="white"
textAnchor="middle"
dominantBaseline="central"
style={{ fontSize: '13px', fontWeight: 700 }}
>
{`${(percent * 100).toFixed(1)}%`}
</text>
)
}
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
outerRadius={100}
innerRadius={45}
dataKey="value"
labelLine={false}
label={renderLabel}
>
{data.map((entry, i) => (
<Cell key={i} fill={COLORES[entry.name] || '#8E44AD'} />
))}
</Pie>
<Tooltip
contentStyle={{
background: tooltipBg,
border: `1px solid ${tooltipBorder}`,
borderRadius: 16,
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
}}
labelStyle={{ color: tooltipLabel, fontWeight: 700 }}
itemStyle={{ color: tickColor }}
formatter={(val, name) => [
`${val} (${total ? ((val / total) * 100).toFixed(1) : 0}%)`,
name,
]}
/>
<Legend
iconType="circle"
iconSize={10}
formatter={(value) => (
<span style={{ fontSize: 12, color: tickColor }}>{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
import {
LayoutDashboard,
TrendingUp,
AlertTriangle,
Activity,
ShieldCheck,
FileText,
Sun,
} from 'lucide-react'
const SECCIONES = [
{ id: 'resumen', label: 'Resumen General', icon: LayoutDashboard },
{ id: 'historica', label: 'Serie Histórica Provincial', icon: TrendingUp },
{ id: 'fatales', label: 'Siniestros Fatales', icon: AlertTriangle },
{ id: 'lesionados', label: 'Con Lesionados', icon: Activity },
{ id: 'sinlesiones', label: 'Sin Lesiones', icon: ShieldCheck },
{ id: 'sintesis', label: 'Síntesis', icon: FileText },
{ id: 'veranovivo', label: 'Campañas Verano Vivo', icon: Sun },
]
export default function Sidebar({
seccion,
setSeccion,
year,
periodoDesde = '01/01',
periodoHasta = '31/12',
}) {
return (
<aside className="flex min-h-screen w-72 min-w-[280px] flex-col gap-6 bg-opsv-navy p-6 text-white dark:bg-slate-950">
<div className="space-y-4">
<div className="flex items-start gap-3">
<img
src="/logo-opsv.png"
alt="Observatorio Provincial de Seguridad Vial"
className="h-12 w-12 rounded-xl object-contain bg-white/5 p-1"
/>
<div className="min-w-0">
<div className="text-3xl font-black tracking-tight">OPSV</div>
<div className="mt-1 text-sm leading-5 text-slate-200">
Observatorio Provincial
<br />
de Seguridad Vial
</div>
<div className="mt-2 text-[11px] uppercase tracking-[0.25em] text-opsv-blue text-center">
APSV
</div>
<div className="mt-2 text-[11px] uppercase tracking-[0.25em] text-opsv-blue">
Ministerio de Seguridad · Santa Cruz
</div>
</div>
</div>
</div>
<div className="rounded-3xl border border-opsv-blue/20 bg-opsv-navy-dark p-5 dark:bg-slate-900">
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-blue">
Informe Siniestralidad Vial
</div>
<div className="mt-4 text-5xl font-black text-opsv-orange">
{year}
</div>
<div className="mt-2 text-sm text-white/70">
Período {periodoDesde} {periodoHasta}
</div>
</div>
<nav className="flex-1 space-y-2">
{SECCIONES.map((nav) => {
const Icon = nav.icon
const active = seccion === nav.id
return (
<button
key={nav.id}
type="button"
onClick={() => setSeccion(nav.id)}
className={`flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left text-sm font-semibold transition ${
active
? 'border-l-4 border-opsv-blue bg-opsv-navy-dark text-white dark:bg-slate-900'
: 'text-slate-300 hover:bg-white/10'
}`}
>
<Icon className="h-5 w-5 shrink-0" />
<span>{nav.label}</span>
</button>
)
})}
</nav>
<div className="rounded-3xl border border-opsv-blue/10 bg-opsv-navy-dark p-4 text-xs text-slate-300 dark:bg-slate-900">
<div className="font-semibold text-slate-100">OPSV Dashboard</div>
<div className="mt-1">Versión 1.0 · Observatorio Provincial de Seguridad Vial</div>
</div>
</aside>
)
}
+293
View File
@@ -0,0 +1,293 @@
// src/components/layout/Topbar.jsx
import { useEffect, useMemo, useRef, useState } from 'react'
import { CalendarRange, ChevronDown, X } from 'lucide-react'
import { Building2, MapPin } from 'lucide-react'
import ThemeToggle from '../ui/ThemeToggle'
import FilterSelect from '../ui/FilterSelect'
const TITULOS = {
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' },
fatales: { title: 'Siniestros Fatales', subtitle: 'Análisis específico de siniestros con víctimas fatales' },
lesionados: { title: 'Con Lesionados', subtitle: 'Análisis de siniestros con personas heridas' },
sinlesiones: { title: 'Sin Lesiones', subtitle: 'Siniestros sin víctimas fatales ni lesiones graves' },
sintesis: { title: 'Síntesis', subtitle: 'Resumen ejecutivo e insights del período' },
}
const AÑOS = [2026, 2025, 2024, 2023, 2022, 2021]
const MESES = [
{ value: 1, label: 'Enero' },
{ value: 2, label: 'Febrero' },
{ value: 3, label: 'Marzo' },
{ value: 4, label: 'Abril' },
{ value: 5, label: 'Mayo' },
{ value: 6, label: 'Junio' },
{ value: 7, label: 'Julio' },
{ value: 8, label: 'Agosto' },
{ value: 9, label: 'Septiembre' },
{ value: 10, label: 'Octubre' },
{ value: 11, label: 'Noviembre' },
{ value: 12, label: 'Diciembre' },
]
function periodoToValue(parte) {
if (!parte?.mes || !parte?.ano) return ''
return `${parte.ano}-${String(parte.mes).padStart(2, '0')}`
}
function valueToPeriodo(value) {
if (!value) return null
const [ano, mes] = value.split('-').map(Number)
if (!ano || !mes) return null
return { ano, mes }
}
function periodoToNumber(parte) {
if (!parte?.ano || !parte?.mes) return null
return parte.ano * 100 + parte.mes
}
function formatPeriodo(periodo) {
if (!periodo?.desde && !periodo?.hasta) return 'Filtro por fecha'
const formatear = (parte) => {
if (!parte?.mes || !parte?.ano) return '...'
const mes = MESES.find((m) => m.value === parte.mes)?.label ?? String(parte.mes).padStart(2, '0')
return `${mes} ${parte.ano}`
}
if (periodo?.desde && periodo?.hasta) return `${formatear(periodo.desde)}${formatear(periodo.hasta)}`
if (periodo?.desde) return `Desde ${formatear(periodo.desde)}`
return `Hasta ${formatear(periodo.hasta)}`
}
// 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 pillInactive =
'border-opsv-border dark:border-slate-600 bg-white dark:bg-slate-800 ' +
'text-opsv-navy dark:text-slate-100 ' +
'hover:border-opsv-navy/40 dark:hover:border-slate-400'
const pillActive =
'border-opsv-blue bg-opsv-blue/10 text-opsv-navy font-semibold ' +
'dark:border-sky-400 dark:bg-sky-500/15 dark:text-white'
export default function Topbar({
seccion,
year,
setYear,
periodo,
setPeriodo,
siniestrosCount,
departamentoFiltro,
setDepartamentoFiltro,
departamentosDisponibles,
localidadFiltro,
setLocalidadFiltro,
localidadesDisponibles,
onExportarPdf,
}) {
const { title, subtitle } = TITULOS[seccion] || TITULOS.resumen
const [openFiltro, setOpenFiltro] = useState(false)
const [desdeInput, setDesdeInput] = useState(periodoToValue(periodo?.desde))
const [hastaInput, setHastaInput] = useState(periodoToValue(periodo?.hasta))
const panelRef = useRef(null)
const filtroActivo = Boolean(periodo?.desde || periodo?.hasta)
useEffect(() => {
setDesdeInput(periodoToValue(periodo?.desde))
setHastaInput(periodoToValue(periodo?.hasta))
}, [periodo])
useEffect(() => {
function handleClickOutside(event) {
if (panelRef.current && !panelRef.current.contains(event.target)) {
setOpenFiltro(false)
}
}
function handleEscape(event) {
if (event.key === 'Escape') setOpenFiltro(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [])
const errorRango = useMemo(() => {
const desde = valueToPeriodo(desdeInput)
const hasta = valueToPeriodo(hastaInput)
const nDesde = periodoToNumber(desde)
const nHasta = periodoToNumber(hasta)
if (nDesde && nHasta && nDesde > nHasta) {
return 'La fecha "desde" no puede ser posterior a "hasta".'
}
return null
}, [desdeInput, hastaInput])
const aplicarFiltro = () => {
const desde = valueToPeriodo(desdeInput)
const hasta = valueToPeriodo(hastaInput)
const nDesde = periodoToNumber(desde)
const nHasta = periodoToNumber(hasta)
if (nDesde && nHasta && nDesde > nHasta) return
setPeriodo({ desde, hasta })
setOpenFiltro(false)
}
const limpiarFiltro = () => {
setDesdeInput('')
setHastaInput('')
setPeriodo({ desde: null, hasta: null })
setOpenFiltro(false)
}
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">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs 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>
<p className="mt-1 text-sm text-opsv-muted dark:text-slate-400">{subtitle}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* ── Selector de año ── */}
<FilterSelect
value={String(year)}
onChange={(v) => setYear(Number(v))}
options={AÑOS.map((v) => ({ value: String(v), label: String(v) }))}
placeholder="Año"
/>
{/* ── Filtro por período ── sin cambios, botón nativo ── */}
<div className="relative" ref={panelRef}>
<button
type="button"
onClick={() => setOpenFiltro((prev) => !prev)}
className={`${pillBase} font-semibold ${filtroActivo ? pillActive : pillInactive}`}
>
<CalendarRange className="h-4 w-4 shrink-0" />
<span>{formatPeriodo(periodo)}</span>
<ChevronDown className={`h-4 w-4 transition ${openFiltro ? 'rotate-180' : ''}`} />
</button>
{openFiltro && (
<div className="absolute right-0 z-30 mt-2 w-[320px] rounded-3xl border border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-900 p-4 shadow-xl">
<div className="mb-3">
<h3 className="text-sm font-bold text-opsv-navy dark:text-white">Filtrar por período</h3>
<p className="mt-1 text-xs text-opsv-muted dark:text-slate-400">
Seleccioná un rango mensual para acotar los siniestros analizados.
</p>
</div>
<div className="grid grid-cols-1 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-opsv-muted dark:text-slate-400">
Desde
</span>
<input
type="month"
value={desdeInput}
onChange={(e) => setDesdeInput(e.target.value)}
className="rounded-2xl border border-opsv-border dark:border-slate-700 bg-opsv-surface dark:bg-slate-800 px-3 py-2.5 text-sm text-opsv-navy dark:text-slate-100 outline-none focus:border-opsv-blue"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-opsv-muted dark:text-slate-400">
Hasta
</span>
<input
type="month"
value={hastaInput}
onChange={(e) => setHastaInput(e.target.value)}
className="rounded-2xl border border-opsv-border dark:border-slate-700 bg-opsv-surface dark:bg-slate-800 px-3 py-2.5 text-sm text-opsv-navy dark:text-slate-100 outline-none focus:border-opsv-blue"
/>
</label>
</div>
{errorRango && (
<div className="mt-3 rounded-2xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300">
{errorRango}
</div>
)}
<div className="mt-4 flex items-center justify-between gap-2">
<button
type="button"
onClick={limpiarFiltro}
className="inline-flex items-center gap-2 rounded-2xl border border-opsv-border dark:border-slate-700 bg-opsv-surface dark:bg-slate-800 px-4 py-2.5 text-sm font-medium text-opsv-muted dark:text-slate-300 transition hover:text-opsv-navy dark:hover:text-white"
>
<X className="h-4 w-4" />
Limpiar
</button>
<button
type="button"
onClick={aplicarFiltro}
disabled={Boolean(errorRango)}
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 disabled:cursor-not-allowed disabled:opacity-50"
>
Aplicar filtro
</button>
</div>
</div>
)}
</div>
{/* ── Filtro por departamento (oculto en Verano Vivo) ── */}
{seccion !== 'veranovivo' !== 'historica'&& (
<FilterSelect
icon={Building2}
value={departamentoFiltro}
onChange={setDepartamentoFiltro}
options={(departamentosDisponibles ?? []).map((d) => ({ value: d, label: d }))}
placeholder="Todos los deptos."
/>
)}
{/* ── Filtro por localidad (oculto en Verano Vivo, deshabilitado sin depto.) ── */}
{seccion !== 'veranovivo' !== 'historica' && (
<FilterSelect
icon={MapPin}
value={localidadFiltro}
onChange={setLocalidadFiltro}
options={(localidadesDisponibles ?? []).map((l) => ({ value: l, label: l }))}
placeholder="Todas las localidades"
placeholderEmpty="Seleccioná un depto."
disabled={!departamentoFiltro}
/>
)}
{/* ── Descargar PDF ── */}
<button
type="button"
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"
>
Descargar PDF
</button>
<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>
</header>
)
}
+47
View File
@@ -0,0 +1,47 @@
// src/components/ui/ChartCard.jsx
const HEIGHTS = {
sm: 'h-[300px]',
md: 'h-[360px]',
lg: 'h-[420px]',
auto: '',
}
export default function ChartCard({
kicker,
title,
subtitle,
height = 'md',
children,
className = '',
contentClassName = '',
}) {
return (
<section
data-pdf-block // única línea nueva
className={`rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm overflow-hidden ${className}`}
>
{(kicker || title || subtitle) && (
<header className="mb-5">
{kicker && (
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">
{kicker}
</p>
)}
{title && (
<h3 className="mt-2 text-xl font-black leading-tight text-opsv-navy">
{title}
</h3>
)}
{subtitle && (
<p className="mt-2 max-w-[68ch] text-sm leading-6 text-opsv-muted">
{subtitle}
</p>
)}
</header>
)}
<div className={`min-w-0 w-full ${HEIGHTS[height]} ${contentClassName}`}>
{children}
</div>
</section>
)
}
+10
View File
@@ -0,0 +1,10 @@
export default function ErrorBanner({ message }) {
if (!message) return null
return (
<div className="rounded-3xl border border-red-200 bg-red-50 p-5 text-sm text-red-800">
<div className="font-semibold">Error al cargar datos</div>
<p className="mt-1">{message}</p>
</div>
)
}
+119
View File
@@ -0,0 +1,119 @@
// src/components/ui/FilterSelect.jsx
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, X } from 'lucide-react'
export default function FilterSelect({
icon: Icon,
value,
onChange,
options, // [{ value: string, label: string }]
placeholder,
placeholderEmpty,
disabled = false,
className = '',
}) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
const selected = options.find(o => o.value === value)
// Cerrar al hacer click afuera o Escape
useEffect(() => {
function handleClickOutside(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
function handleEscape(e) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [])
const pillBase = 'relative flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm font-medium transition select-none'
const pillInactive = 'border-opsv-border dark:border-slate-600 bg-white dark:bg-slate-800 text-opsv-navy dark:text-slate-100 hover:border-opsv-navy/40 dark:hover:border-slate-400'
const pillActive = 'border-opsv-blue bg-opsv-blue/10 text-opsv-navy font-semibold dark:border-sky-400 dark:bg-sky-500/15 dark:text-white'
const pillDisabled = 'border-opsv-border dark:border-slate-700 bg-opsv-surface dark:bg-slate-800/50 text-opsv-muted dark:text-slate-500 cursor-not-allowed opacity-60'
const currentStyle = disabled ? pillDisabled : value ? pillActive : pillInactive
return (
<div ref={ref} className={`${pillBase} ${currentStyle} cursor-pointer ${className}`}>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
{/* Trigger */}
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setOpen(prev => !prev)}
className="flex items-center gap-1.5 bg-transparent outline-none disabled:cursor-not-allowed max-w-[160px]"
>
<span className="truncate">
{selected?.label ?? (disabled ? placeholderEmpty : placeholder)}
</span>
<ChevronDown className={`h-4 w-4 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{/* Botón limpiar */}
{value && !disabled && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onChange('') }}
className="ml-1 rounded-full hover:text-red-500 transition"
aria-label="Limpiar filtro"
>
<X className="h-3.5 w-3.5" />
</button>
)}
{/* Dropdown */}
{open && !disabled && (
<ul className="absolute top-full left-0 z-40 mt-2 min-w-[220px] max-h-64 overflow-y-auto
rounded-2xl border border-opsv-border dark:border-slate-600
bg-white dark:bg-slate-900
shadow-xl py-1">
{/* Opción vacía */}
<li>
<button
type="button"
onClick={() => { onChange(''); setOpen(false) }}
className={`w-full text-left px-4 py-2.5 text-sm transition
${!value
? 'bg-opsv-blue/10 text-opsv-navy font-semibold dark:bg-sky-500/15 dark:text-white'
: 'text-opsv-muted dark:text-slate-400 hover:bg-opsv-bg dark:hover:bg-slate-800 hover:text-opsv-navy dark:hover:text-white'
}`}
>
{placeholder}
</button>
</li>
{options.length === 0 && (
<li className="px-4 py-2.5 text-sm text-opsv-muted dark:text-slate-500 italic">
Sin opciones disponibles
</li>
)}
{options.map(opt => (
<li key={opt.value}>
<button
type="button"
onClick={() => { onChange(opt.value); setOpen(false) }}
className={`w-full text-left px-4 py-2.5 text-sm transition
${value === opt.value
? 'bg-opsv-blue/10 text-opsv-navy font-semibold dark:bg-sky-500/15 dark:text-white'
: 'text-opsv-text dark:text-slate-200 hover:bg-opsv-bg dark:hover:bg-slate-800 hover:text-opsv-navy dark:hover:text-white'
}`}
>
{opt.label}
</button>
</li>
))}
</ul>
)}
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
export default function KPICard({
label,
value,
color,
unit,
variation,
centered = false,
}) {
const formattedValue =
typeof value === 'number' ? value.toLocaleString('es-AR') : value
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div
className={`flex items-start justify-between gap-4 ${
centered ? 'flex-col items-center text-center' : ''
}`}
>
<div className={centered ? 'flex flex-col items-center' : ''}>
<div
className="text-3xl font-black text-opsv-navy"
style={color ? { color } : undefined}
>
{formattedValue}
{unit ? (
<span className="text-base font-semibold text-opsv-muted">
{' '}
{unit}
</span>
) : null}
</div>
<p className="mt-3 text-sm text-opsv-muted">
{label}
</p>
</div>
{variation ? (
<div className="rounded-2xl bg-opsv-bg px-3 py-2 text-sm font-semibold text-opsv-text">
{variation}
</div>
) : null}
</div>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
export default function LoadingSpinner() {
return (
<div className="flex min-h-[360px] flex-col items-center justify-center gap-4 rounded-3xl bg-white p-10 text-center shadow-sm">
<div className="h-14 w-14 animate-spin rounded-full border-4 border-opsv-blue border-t-transparent" />
<div className="text-lg font-semibold text-opsv-navy">Cargando datos...</div>
<p className="max-w-sm text-sm text-opsv-muted">Por favor espera mientras se carga la información de Supabase.</p>
</div>
)
}
+172
View File
@@ -0,0 +1,172 @@
import { useState } from 'react'
import { FileDown, Loader2, X } from 'lucide-react'
import { exportarPDF, SECCIONES_EXPORTABLES } from '../../utils/exportPdf'
export default function PdfExportModal({ year, onClose }) {
const [seleccionadas, setSeleccionadas] = useState(
SECCIONES_EXPORTABLES.map(s => s.id)
)
const [generando, setGenerando] = useState(false)
const [progreso, setProgreso] = useState(0)
const [error, setError] = useState(null)
function toggleSeccion(id) {
setSeleccionadas(prev =>
prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]
)
}
function toggleTodas() {
setSeleccionadas(prev =>
prev.length === SECCIONES_EXPORTABLES.length
? []
: SECCIONES_EXPORTABLES.map(s => s.id)
)
}
async function handleExportar() {
if (!seleccionadas.length) return
setGenerando(true)
setProgreso(0)
setError(null)
try {
await exportarPDF({
seccionesIds: SECCIONES_EXPORTABLES
.map(s => s.id)
.filter(id => seleccionadas.includes(id)),
year,
onProgress: setProgreso,
})
onClose()
} catch (e) {
console.error(e)
setError('Ocurrió un error al generar el PDF. Intentá de nuevo.')
} finally {
setGenerando(false)
}
}
const todasMarcadas = seleccionadas.length === SECCIONES_EXPORTABLES.length
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={!generando ? onClose : undefined}
/>
{/* Modal */}
<div className="relative w-full max-w-md rounded-3xl border border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-900 p-6 shadow-2xl">
{/* Header */}
<div className="flex items-start justify-between mb-5">
<div>
<h2 className="text-lg font-black text-opsv-navy dark:text-white">
Exportar a PDF
</h2>
<p className="mt-1 text-sm text-opsv-muted dark:text-slate-400">
Seleccioná las secciones a incluir en el informe {year}.
</p>
</div>
{!generando && (
<button
onClick={onClose}
className="rounded-full p-1.5 hover:bg-opsv-bg dark:hover:bg-slate-800 transition"
aria-label="Cerrar"
>
<X className="h-4 w-4 text-opsv-muted dark:text-slate-400" />
</button>
)}
</div>
{/* Seleccionar todas */}
<button
type="button"
onClick={toggleTodas}
disabled={generando}
className="mb-3 text-xs font-semibold text-opsv-blue dark:text-sky-400 hover:underline disabled:opacity-50"
>
{todasMarcadas ? 'Deseleccionar todas' : 'Seleccionar todas'}
</button>
{/* Checkboxes */}
<div className="flex flex-col gap-2 mb-5">
{SECCIONES_EXPORTABLES.map(sec => (
<label
key={sec.id}
className={`flex items-center gap-3 rounded-2xl border px-4 py-3 cursor-pointer transition
${seleccionadas.includes(sec.id)
? 'border-opsv-blue bg-opsv-blue/5 dark:border-sky-500 dark:bg-sky-500/10'
: 'border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-800 hover:border-opsv-navy/30'
}
${generando ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
<input
type="checkbox"
checked={seleccionadas.includes(sec.id)}
onChange={() => !generando && toggleSeccion(sec.id)}
className="h-4 w-4 rounded accent-opsv-navy dark:accent-sky-400"
/>
<span className="text-sm font-medium text-opsv-navy dark:text-slate-100">
{sec.label}
</span>
</label>
))}
</div>
{/* Barra de progreso */}
{generando && (
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-opsv-muted dark:text-slate-400">
Generando PDF...
</span>
<span className="text-xs font-semibold text-opsv-navy dark:text-white">
{progreso}%
</span>
</div>
<div className="h-2 w-full rounded-full bg-opsv-border dark:bg-slate-700 overflow-hidden">
<div
className="h-full rounded-full bg-opsv-navy dark:bg-sky-500 transition-all duration-300"
style={{ width: `${progreso}%` }}
/>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="mb-4 rounded-2xl border border-red-200 bg-red-50 dark:border-red-900/40 dark:bg-red-950/30 px-3 py-2 text-xs text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* Botones */}
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={onClose}
disabled={generando}
className="rounded-2xl border border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-800 px-4 py-2.5 text-sm font-medium text-opsv-muted dark:text-slate-300 hover:text-opsv-navy dark:hover:text-white transition disabled:opacity-50"
>
Cancelar
</button>
<button
type="button"
onClick={handleExportar}
disabled={generando || seleccionadas.length === 0}
className="flex items-center gap-2 rounded-2xl bg-opsv-navy dark:bg-slate-700 px-5 py-2.5 text-sm font-semibold text-white hover:bg-opsv-navy-dark dark:hover:bg-slate-600 transition disabled:cursor-not-allowed disabled:opacity-50"
>
{generando
? <><Loader2 className="h-4 w-4 animate-spin" /> Generando...</>
: <><FileDown className="h-4 w-4" /> Descargar PDF</>
}
</button>
</div>
</div>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { useState, useEffect } from 'react'
import { Sun, Moon } from 'lucide-react'
export default function ThemeToggle() {
const [dark, setDark] = useState(false)
useEffect(() => {
const saved = localStorage.getItem('opsv-theme')
if (saved === 'dark') {
document.documentElement.classList.add('dark') // ← era setAttribute
setDark(true)
}
}, [])
const toggle = () => {
const next = !dark
document.documentElement.classList.toggle('dark') // ← era setAttribute
localStorage.setItem('opsv-theme', next ? 'dark' : 'light')
setDark(next)
}
return (
<button
onClick={toggle}
title={dark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
className="flex items-center justify-center w-9 h-9 rounded-full border border-opsv-border hover:border-opsv-blue bg-opsv-surface hover:bg-opsv-bg transition-all"
>
{dark
? <Sun className="w-4 h-4 text-opsv-orange" />
: <Moon className="w-4 h-4 text-opsv-navy" />
}
</button>
)
}