Primer commit — OPSV Dashboard de siniestralidad vial
This commit is contained in:
@@ -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