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