Primer commit — OPSV Dashboard de siniestralidad vial

This commit is contained in:
2026-04-29 13:39:09 -03:00
commit ca7b159657
67 changed files with 12246 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
// src/components/ui/ChartCard.jsx
const HEIGHTS = {
sm: 'h-[300px]',
md: 'h-[360px]',
lg: 'h-[420px]',
auto: '',
}
export default function ChartCard({
kicker,
title,
subtitle,
height = 'md',
children,
className = '',
contentClassName = '',
}) {
return (
<section
data-pdf-block // única línea nueva
className={`rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm overflow-hidden ${className}`}
>
{(kicker || title || subtitle) && (
<header className="mb-5">
{kicker && (
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">
{kicker}
</p>
)}
{title && (
<h3 className="mt-2 text-xl font-black leading-tight text-opsv-navy">
{title}
</h3>
)}
{subtitle && (
<p className="mt-2 max-w-[68ch] text-sm leading-6 text-opsv-muted">
{subtitle}
</p>
)}
</header>
)}
<div className={`min-w-0 w-full ${HEIGHTS[height]} ${contentClassName}`}>
{children}
</div>
</section>
)
}
+10
View File
@@ -0,0 +1,10 @@
export default function ErrorBanner({ message }) {
if (!message) return null
return (
<div className="rounded-3xl border border-red-200 bg-red-50 p-5 text-sm text-red-800">
<div className="font-semibold">Error al cargar datos</div>
<p className="mt-1">{message}</p>
</div>
)
}
+119
View File
@@ -0,0 +1,119 @@
// src/components/ui/FilterSelect.jsx
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, X } from 'lucide-react'
export default function FilterSelect({
icon: Icon,
value,
onChange,
options, // [{ value: string, label: string }]
placeholder,
placeholderEmpty,
disabled = false,
className = '',
}) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
const selected = options.find(o => o.value === value)
// Cerrar al hacer click afuera o Escape
useEffect(() => {
function handleClickOutside(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
function handleEscape(e) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [])
const pillBase = 'relative flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm font-medium transition select-none'
const pillInactive = 'border-opsv-border dark:border-slate-600 bg-white dark:bg-slate-800 text-opsv-navy dark:text-slate-100 hover:border-opsv-navy/40 dark:hover:border-slate-400'
const pillActive = 'border-opsv-blue bg-opsv-blue/10 text-opsv-navy font-semibold dark:border-sky-400 dark:bg-sky-500/15 dark:text-white'
const pillDisabled = 'border-opsv-border dark:border-slate-700 bg-opsv-surface dark:bg-slate-800/50 text-opsv-muted dark:text-slate-500 cursor-not-allowed opacity-60'
const currentStyle = disabled ? pillDisabled : value ? pillActive : pillInactive
return (
<div ref={ref} className={`${pillBase} ${currentStyle} cursor-pointer ${className}`}>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
{/* Trigger */}
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setOpen(prev => !prev)}
className="flex items-center gap-1.5 bg-transparent outline-none disabled:cursor-not-allowed max-w-[160px]"
>
<span className="truncate">
{selected?.label ?? (disabled ? placeholderEmpty : placeholder)}
</span>
<ChevronDown className={`h-4 w-4 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{/* Botón limpiar */}
{value && !disabled && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onChange('') }}
className="ml-1 rounded-full hover:text-red-500 transition"
aria-label="Limpiar filtro"
>
<X className="h-3.5 w-3.5" />
</button>
)}
{/* Dropdown */}
{open && !disabled && (
<ul className="absolute top-full left-0 z-40 mt-2 min-w-[220px] max-h-64 overflow-y-auto
rounded-2xl border border-opsv-border dark:border-slate-600
bg-white dark:bg-slate-900
shadow-xl py-1">
{/* Opción vacía */}
<li>
<button
type="button"
onClick={() => { onChange(''); setOpen(false) }}
className={`w-full text-left px-4 py-2.5 text-sm transition
${!value
? 'bg-opsv-blue/10 text-opsv-navy font-semibold dark:bg-sky-500/15 dark:text-white'
: 'text-opsv-muted dark:text-slate-400 hover:bg-opsv-bg dark:hover:bg-slate-800 hover:text-opsv-navy dark:hover:text-white'
}`}
>
{placeholder}
</button>
</li>
{options.length === 0 && (
<li className="px-4 py-2.5 text-sm text-opsv-muted dark:text-slate-500 italic">
Sin opciones disponibles
</li>
)}
{options.map(opt => (
<li key={opt.value}>
<button
type="button"
onClick={() => { onChange(opt.value); setOpen(false) }}
className={`w-full text-left px-4 py-2.5 text-sm transition
${value === opt.value
? 'bg-opsv-blue/10 text-opsv-navy font-semibold dark:bg-sky-500/15 dark:text-white'
: 'text-opsv-text dark:text-slate-200 hover:bg-opsv-bg dark:hover:bg-slate-800 hover:text-opsv-navy dark:hover:text-white'
}`}
>
{opt.label}
</button>
</li>
))}
</ul>
)}
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
export default function KPICard({
label,
value,
color,
unit,
variation,
centered = false,
}) {
const formattedValue =
typeof value === 'number' ? value.toLocaleString('es-AR') : value
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div
className={`flex items-start justify-between gap-4 ${
centered ? 'flex-col items-center text-center' : ''
}`}
>
<div className={centered ? 'flex flex-col items-center' : ''}>
<div
className="text-3xl font-black text-opsv-navy"
style={color ? { color } : undefined}
>
{formattedValue}
{unit ? (
<span className="text-base font-semibold text-opsv-muted">
{' '}
{unit}
</span>
) : null}
</div>
<p className="mt-3 text-sm text-opsv-muted">
{label}
</p>
</div>
{variation ? (
<div className="rounded-2xl bg-opsv-bg px-3 py-2 text-sm font-semibold text-opsv-text">
{variation}
</div>
) : null}
</div>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
export default function LoadingSpinner() {
return (
<div className="flex min-h-[360px] flex-col items-center justify-center gap-4 rounded-3xl bg-white p-10 text-center shadow-sm">
<div className="h-14 w-14 animate-spin rounded-full border-4 border-opsv-blue border-t-transparent" />
<div className="text-lg font-semibold text-opsv-navy">Cargando datos...</div>
<p className="max-w-sm text-sm text-opsv-muted">Por favor espera mientras se carga la información de Supabase.</p>
</div>
)
}
+172
View File
@@ -0,0 +1,172 @@
import { useState } from 'react'
import { FileDown, Loader2, X } from 'lucide-react'
import { exportarPDF, SECCIONES_EXPORTABLES } from '../../utils/exportPdf'
export default function PdfExportModal({ year, onClose }) {
const [seleccionadas, setSeleccionadas] = useState(
SECCIONES_EXPORTABLES.map(s => s.id)
)
const [generando, setGenerando] = useState(false)
const [progreso, setProgreso] = useState(0)
const [error, setError] = useState(null)
function toggleSeccion(id) {
setSeleccionadas(prev =>
prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]
)
}
function toggleTodas() {
setSeleccionadas(prev =>
prev.length === SECCIONES_EXPORTABLES.length
? []
: SECCIONES_EXPORTABLES.map(s => s.id)
)
}
async function handleExportar() {
if (!seleccionadas.length) return
setGenerando(true)
setProgreso(0)
setError(null)
try {
await exportarPDF({
seccionesIds: SECCIONES_EXPORTABLES
.map(s => s.id)
.filter(id => seleccionadas.includes(id)),
year,
onProgress: setProgreso,
})
onClose()
} catch (e) {
console.error(e)
setError('Ocurrió un error al generar el PDF. Intentá de nuevo.')
} finally {
setGenerando(false)
}
}
const todasMarcadas = seleccionadas.length === SECCIONES_EXPORTABLES.length
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={!generando ? onClose : undefined}
/>
{/* Modal */}
<div className="relative w-full max-w-md rounded-3xl border border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-900 p-6 shadow-2xl">
{/* Header */}
<div className="flex items-start justify-between mb-5">
<div>
<h2 className="text-lg font-black text-opsv-navy dark:text-white">
Exportar a PDF
</h2>
<p className="mt-1 text-sm text-opsv-muted dark:text-slate-400">
Seleccioná las secciones a incluir en el informe {year}.
</p>
</div>
{!generando && (
<button
onClick={onClose}
className="rounded-full p-1.5 hover:bg-opsv-bg dark:hover:bg-slate-800 transition"
aria-label="Cerrar"
>
<X className="h-4 w-4 text-opsv-muted dark:text-slate-400" />
</button>
)}
</div>
{/* Seleccionar todas */}
<button
type="button"
onClick={toggleTodas}
disabled={generando}
className="mb-3 text-xs font-semibold text-opsv-blue dark:text-sky-400 hover:underline disabled:opacity-50"
>
{todasMarcadas ? 'Deseleccionar todas' : 'Seleccionar todas'}
</button>
{/* Checkboxes */}
<div className="flex flex-col gap-2 mb-5">
{SECCIONES_EXPORTABLES.map(sec => (
<label
key={sec.id}
className={`flex items-center gap-3 rounded-2xl border px-4 py-3 cursor-pointer transition
${seleccionadas.includes(sec.id)
? 'border-opsv-blue bg-opsv-blue/5 dark:border-sky-500 dark:bg-sky-500/10'
: 'border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-800 hover:border-opsv-navy/30'
}
${generando ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
<input
type="checkbox"
checked={seleccionadas.includes(sec.id)}
onChange={() => !generando && toggleSeccion(sec.id)}
className="h-4 w-4 rounded accent-opsv-navy dark:accent-sky-400"
/>
<span className="text-sm font-medium text-opsv-navy dark:text-slate-100">
{sec.label}
</span>
</label>
))}
</div>
{/* Barra de progreso */}
{generando && (
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-opsv-muted dark:text-slate-400">
Generando PDF...
</span>
<span className="text-xs font-semibold text-opsv-navy dark:text-white">
{progreso}%
</span>
</div>
<div className="h-2 w-full rounded-full bg-opsv-border dark:bg-slate-700 overflow-hidden">
<div
className="h-full rounded-full bg-opsv-navy dark:bg-sky-500 transition-all duration-300"
style={{ width: `${progreso}%` }}
/>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="mb-4 rounded-2xl border border-red-200 bg-red-50 dark:border-red-900/40 dark:bg-red-950/30 px-3 py-2 text-xs text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* Botones */}
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={onClose}
disabled={generando}
className="rounded-2xl border border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-800 px-4 py-2.5 text-sm font-medium text-opsv-muted dark:text-slate-300 hover:text-opsv-navy dark:hover:text-white transition disabled:opacity-50"
>
Cancelar
</button>
<button
type="button"
onClick={handleExportar}
disabled={generando || seleccionadas.length === 0}
className="flex items-center gap-2 rounded-2xl bg-opsv-navy dark:bg-slate-700 px-5 py-2.5 text-sm font-semibold text-white hover:bg-opsv-navy-dark dark:hover:bg-slate-600 transition disabled:cursor-not-allowed disabled:opacity-50"
>
{generando
? <><Loader2 className="h-4 w-4 animate-spin" /> Generando...</>
: <><FileDown className="h-4 w-4" /> Descargar PDF</>
}
</button>
</div>
</div>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { useState, useEffect } from 'react'
import { Sun, Moon } from 'lucide-react'
export default function ThemeToggle() {
const [dark, setDark] = useState(false)
useEffect(() => {
const saved = localStorage.getItem('opsv-theme')
if (saved === 'dark') {
document.documentElement.classList.add('dark') // ← era setAttribute
setDark(true)
}
}, [])
const toggle = () => {
const next = !dark
document.documentElement.classList.toggle('dark') // ← era setAttribute
localStorage.setItem('opsv-theme', next ? 'dark' : 'light')
setDark(next)
}
return (
<button
onClick={toggle}
title={dark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
className="flex items-center justify-center w-9 h-9 rounded-full border border-opsv-border hover:border-opsv-blue bg-opsv-surface hover:bg-opsv-bg transition-all"
>
{dark
? <Sun className="w-4 h-4 text-opsv-orange" />
: <Moon className="w-4 h-4 text-opsv-navy" />
}
</button>
)
}