Primer commit — OPSV Dashboard de siniestralidad vial
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Variables de entorno (todas las variantes)
|
||||
.env
|
||||
.env.development
|
||||
.env.production
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Supabase Dashboard.docx
|
||||
@@ -0,0 +1,151 @@
|
||||
# OPSV — Dashboard de Siniestralidad Vial
|
||||
|
||||
> Plataforma interactiva de análisis y visualización de datos de siniestros viales de la provincia de Santa Cruz.
|
||||
|
||||
Desarrollado por el **Observatorio Provincial de Seguridad Vial (OPSV)**,
|
||||
dependiente de la **Agencia Provincial de Seguridad Vial**,
|
||||
**Ministerio de Seguridad de la Provincia de Santa Cruz**.
|
||||
|
||||
---
|
||||
|
||||
## ¿Qué es este proyecto?
|
||||
|
||||
El OPSV Dashboard es una aplicación web que permite visualizar, analizar y exportar datos de siniestros viales registrados en la provincia. Incluye filtros por período, campañas de seguridad vial (como *Verano Vivo* ), desglose por rutas, localidades, tipo de siniestro y severidad (fatal, con lesiones, sin lesiones).
|
||||
|
||||
La aplicación se conecta a una base de datos en la nube (**Supabase**) para obtener los datos en tiempo real y permite generar reportes en PDF desde el propio navegador.
|
||||
|
||||
---
|
||||
|
||||
## Tecnologías utilizadas
|
||||
|
||||
| Tecnología | ¿Para qué se usa? |
|
||||
|---|---|
|
||||
| **React** | Construye la interfaz visual (pantallas, gráficos, botones) |
|
||||
| **Vite** | Motor que arranca la aplicación en la computadora para desarrollar |
|
||||
| **Tailwind CSS** | Sistema de estilos visuales (colores, espaciado, tipografía) |
|
||||
| **Supabase** | Base de datos en la nube, tres bases de datos (siniestros, Involucrados, Personas. Con KeyID para vincularlas ) Motor de base de datos PostgreSQL|
|
||||
| **Recharts** | Librería para dibujar los gráficos (líneas, barras, donuts) |
|
||||
| **React Router** | Maneja la navegación entre secciones del dashboard |
|
||||
| **jsPDF + html2canvas** | Genera y descarga reportes en formato PDF |
|
||||
| **Lucide React** | Íconos de la interfaz |
|
||||
|
||||
---
|
||||
|
||||
## Estructura del proyecto
|
||||
|
||||
```
|
||||
opsv-dashboard/
|
||||
├── src/ ← Todo el código fuente de la aplicación
|
||||
│ ├── components/ ← Componentes visuales reutilizables (tarjetas, botones, etc.)
|
||||
│ ├── sections/ ← Secciones del dashboard
|
||||
│ ├── utils/ ← Funciones de cálculo y datos históricos
|
||||
│ └── main.jsx ← Punto de entrada de la aplicación
|
||||
├── dist/ ← Versión compilada lista para publicar en internet
|
||||
├── .env.local ← Variables de entorno secretas (No subir a Github)
|
||||
├── index.html ← Página principal de la aplicación
|
||||
├── package.json ← Lista de dependencias y comandos del proyecto
|
||||
├── vite.config.js ← Configuración del motor de desarrollo
|
||||
└── tailwind.config.js ← Configuración del sistema de estilos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requisitos previos
|
||||
|
||||
Antes de poder ejecutar el proyecto en entorno local se necesita:
|
||||
|
||||
- **Node.js** (versión 18 o superior) → [Descargar en nodejs.org](https://nodejs.org)
|
||||
- **npm** (se instala automáticamente junto con Node.js)
|
||||
|
||||
Para verificar que están instalados, abrí una terminal y ejecutá:
|
||||
|
||||
```bash
|
||||
node --version
|
||||
npm --version
|
||||
```
|
||||
|
||||
Ambos comandos deben mostrar un número de versión.
|
||||
|
||||
---
|
||||
|
||||
## Instalación paso a paso
|
||||
|
||||
### 1. Clonar o copiar el proyecto
|
||||
|
||||
Si usás Git:
|
||||
|
||||
```bash
|
||||
git clone <url-del-repositorio>
|
||||
cd opsv-dashboard
|
||||
```
|
||||
|
||||
Si no usás Git, simplemente copiá la carpeta del proyecto a tu computadora.
|
||||
|
||||
### 2. Instalar las dependencias
|
||||
|
||||
Abrí una terminal dentro de la carpeta del proyecto y ejecutá:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
> Esto descarga todas las herramientas necesarias (React, Recharts, etc.) dentro de la carpeta `node_modules`. Puede tardar unos minutos la primera vez.
|
||||
|
||||
### 3. Configurar las variables de entorno
|
||||
|
||||
Creá un archivo llamado `.env.local` en la raíz del proyecto (si no existe ya) con el siguiente contenido:
|
||||
|
||||
```
|
||||
VITE_SUPABASE_URL=https://tu-proyecto.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=tu-clave-publica-aqui
|
||||
```
|
||||
|
||||
> Estos valores los encontrás en el panel de tu proyecto en [supabase.com](https://supabase.com) → Settings → API.
|
||||
|
||||
### 4. Iniciar la aplicación
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Luego abrí el navegador y entrá a: **http://localhost:5173**
|
||||
|
||||
---
|
||||
|
||||
## Comandos disponibles
|
||||
|
||||
| Comando | ¿Qué hace? |
|
||||
|---|---|
|
||||
| `npm run dev` | Inicia el servidor de desarrollo local (para trabajar y probar cambios) |
|
||||
| `npm run build` | Compila la aplicación para producción (genera la carpeta `dist/`) |
|
||||
| `npm run preview` | Previsualiza la versión compilada antes de publicarla |
|
||||
|
||||
---
|
||||
|
||||
## Despliegue (publicar en internet)
|
||||
|
||||
Para publicar la aplicación en un servidor:
|
||||
|
||||
1. Ejecutá `npm run build`. Esto genera la carpeta `dist/` con todos los archivos listos.
|
||||
2. Subí el contenido de la carpeta `dist/` al servidor web o servicio de hosting (Netlify, Vercel, servidor propio, etc.).
|
||||
|
||||
> **Importante:** Las variables de entorno del archivo `.env.local` deben configurarse también en el entorno de producción. Nunca subas el archivo `.env.local` a un repositorio público.
|
||||
|
||||
---
|
||||
|
||||
## Seguridad
|
||||
|
||||
- El archivo `.env.local` contiene claves de acceso a la base de datos. Está incluido en `.gitignore` para que **nunca se suba accidentalmente** a GitHub u otro repositorio.
|
||||
- La aplicación utiliza la **clave pública (anon key)** de Supabase, cuyo acceso está limitado por las políticas de seguridad (Row Level Security) configuradas en la base de datos.
|
||||
|
||||
---
|
||||
|
||||
## Contacto y mantenimiento
|
||||
|
||||
**Observatorio Provincial de Seguridad Vial (OPSV)**
|
||||
Agencia Provincial de Seguridad Vial
|
||||
Ministerio de Seguridad — Provincia de Santa Cruz
|
||||
|
||||
---
|
||||
|
||||
*Última actualización: abril 2026*
|
||||
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700;900&family=Barlow+Semi+Condensed:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
<title>opsv-dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,888 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OPSV — Plan de Arquitectura v2.0</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700;800&family=Source+Code+Pro:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--brand: #252C61;
|
||||
--brand-mid: #3A4490;
|
||||
--brand-light: #E8EAF6;
|
||||
--accent: #E63946;
|
||||
--accent-soft: #FDECEA;
|
||||
--green: #2D7A4F;
|
||||
--green-soft: #E6F4EE;
|
||||
--gold: #C9A84C;
|
||||
--gold-soft: #FDF6E3;
|
||||
--text: #1A1D2E;
|
||||
--text-muted: #5A5F7A;
|
||||
--text-faint: #9EA3BC;
|
||||
--surface: #F7F8FC;
|
||||
--surface-2: #FFFFFF;
|
||||
--border: rgba(37,44,97,0.12);
|
||||
--radius: 10px;
|
||||
--radius-lg: 16px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ─── HEADER ─────────────────────────────── */
|
||||
.doc-header {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
padding: 48px 40px 40px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.doc-header::before {
|
||||
content: '';
|
||||
position: absolute; top: 0; right: 0;
|
||||
width: 400px; height: 100%;
|
||||
background: linear-gradient(135deg, transparent 40%, rgba(255,255,255,0.04) 100%);
|
||||
}
|
||||
.doc-header .badge {
|
||||
display: inline-block;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
border-radius: 100px;
|
||||
padding: 4px 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.doc-header h1 {
|
||||
font-size: 28px; font-weight: 800;
|
||||
line-height: 1.2; margin-bottom: 10px;
|
||||
}
|
||||
.doc-header p {
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 14px; font-weight: 400;
|
||||
max-width: 560px;
|
||||
}
|
||||
.doc-header .meta {
|
||||
display: flex; gap: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.doc-header .meta span {
|
||||
font-size: 12px; color: rgba(255,255,255,0.6);
|
||||
font-weight: 500;
|
||||
}
|
||||
.doc-header .meta strong { color: white; }
|
||||
|
||||
/* ─── LAYOUT ──────────────────────────────── */
|
||||
.doc-body {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px 80px;
|
||||
}
|
||||
|
||||
/* ─── SECTION TITLES ──────────────────────── */
|
||||
.section-title {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 48px 0 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--brand-light);
|
||||
}
|
||||
.section-title .num {
|
||||
width: 28px; height: 28px;
|
||||
background: var(--brand); color: white;
|
||||
border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.section-title h2 {
|
||||
font-size: 17px; font-weight: 700;
|
||||
color: var(--brand);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ─── CARDS GRID ──────────────────────────── */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 4px; height: 100%;
|
||||
background: var(--brand);
|
||||
}
|
||||
.card.green::before { background: var(--green); }
|
||||
.card.gold::before { background: var(--gold); }
|
||||
.card.red::before { background: var(--accent); }
|
||||
|
||||
.card .card-icon {
|
||||
font-size: 22px; margin-bottom: 10px;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 13px; font-weight: 700;
|
||||
color: var(--brand); margin-bottom: 6px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.card.green h3 { color: var(--green); }
|
||||
.card.gold h3 { color: #8B6914; }
|
||||
.card.red h3 { color: var(--accent); }
|
||||
|
||||
.card p {
|
||||
font-size: 13px; color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.card .tag {
|
||||
display: inline-block; margin-top: 10px;
|
||||
background: var(--brand-light); color: var(--brand);
|
||||
border-radius: 100px; padding: 2px 10px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.card.green .tag { background: var(--green-soft); color: var(--green); }
|
||||
.card.gold .tag { background: var(--gold-soft); color: #8B6914; }
|
||||
.card.red .tag { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
/* ─── STACK TABLE ──────────────────────────── */
|
||||
.stack-table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stack-table th {
|
||||
background: var(--brand);
|
||||
color: white; font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.07em;
|
||||
padding: 10px 16px; text-align: left;
|
||||
}
|
||||
.stack-table td {
|
||||
padding: 12px 16px; font-size: 13px;
|
||||
border-top: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
.stack-table tr:hover td { background: var(--brand-light); }
|
||||
.stack-table .pill {
|
||||
display: inline-block;
|
||||
background: var(--brand-light); color: var(--brand);
|
||||
border-radius: 100px; padding: 1px 8px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.stack-table .pill.free { background: var(--green-soft); color: var(--green); }
|
||||
.stack-table td strong { color: var(--brand); font-weight: 600; }
|
||||
.stack-table td .why { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
|
||||
|
||||
/* ─── ARQUITECTURA SVG ─────────────────────── */
|
||||
.arch-wrapper {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px 24px;
|
||||
margin-bottom: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.arch-wrapper svg { max-width: 100%; display: block; margin: 0 auto; }
|
||||
|
||||
/* ─── ROLES TABLE ──────────────────────────── */
|
||||
.roles-table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
}
|
||||
.roles-table th {
|
||||
background: var(--brand); color: white;
|
||||
padding: 10px 14px; text-align: left;
|
||||
font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.07em;
|
||||
}
|
||||
.roles-table td {
|
||||
padding: 11px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.roles-table tr:hover td { background: var(--brand-light); }
|
||||
.check { color: var(--green); font-weight: 700; }
|
||||
.cross { color: #CCC; font-weight: 700; }
|
||||
.partial { color: var(--gold); font-weight: 700; }
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
border-radius: 100px; padding: 2px 10px;
|
||||
font-size: 11px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
.rb-superadmin { background: #E8EAF6; color: #252C61; }
|
||||
.rb-admin { background: #E6F4EE; color: #2D7A4F; }
|
||||
.rb-editor { background: #FDF6E3; color: #8B6914; }
|
||||
.rb-public { background: #F5F5F5; color: #555; }
|
||||
|
||||
/* ─── SCHEMA ───────────────────────────────── */
|
||||
.schema-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.schema-card {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.schema-card .schema-header {
|
||||
background: var(--brand);
|
||||
color: white; padding: 8px 14px;
|
||||
font-size: 12px; font-weight: 700;
|
||||
font-family: 'Source Code Pro', monospace;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.schema-card ul { list-style: none; padding: 10px 0; }
|
||||
.schema-card li {
|
||||
padding: 5px 14px;
|
||||
font-family: 'Source Code Pro', monospace;
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
display: flex; justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.schema-card li:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.schema-card li .col-name { color: var(--text); font-weight: 500; }
|
||||
.schema-card li .col-type { color: var(--brand-mid); font-size: 11px; }
|
||||
.schema-card li .pk { color: var(--gold); font-size: 10px; font-weight: 700; }
|
||||
.schema-card li .fk { color: var(--green); font-size: 10px; font-weight: 700; }
|
||||
|
||||
/* ─── FOLDER TREE ──────────────────────────── */
|
||||
.folder-tree {
|
||||
background: #1A1D2E;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
font-family: 'Source Code Pro', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
color: #9EA3BC;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.folder-tree .dir { color: #7FDBFF; }
|
||||
.folder-tree .file { color: #ADBAC7; }
|
||||
.folder-tree .cmt { color: #5A5F7A; font-style: italic; }
|
||||
.folder-tree .key { color: #E6DB74; }
|
||||
|
||||
/* ─── PHASES ───────────────────────────────── */
|
||||
.phases { display: flex; flex-direction: column; gap: 12px; }
|
||||
.phase-card {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
}
|
||||
.phase-num {
|
||||
background: var(--brand);
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 20px 10px;
|
||||
color: white;
|
||||
}
|
||||
.phase-num .ph { font-size: 10px; font-weight: 600; opacity: 0.7; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.phase-num .n { font-size: 32px; font-weight: 800; line-height: 1; }
|
||||
.phase-num .wk { font-size: 11px; opacity: 0.7; margin-top: 4px; }
|
||||
|
||||
.phase-content { padding: 20px 22px; }
|
||||
.phase-content h3 { font-size: 15px; font-weight: 700; color: var(--brand); margin-bottom: 8px; }
|
||||
.phase-tasks { list-style: none; display: flex; flex-direction: column; gap: 4px; }
|
||||
.phase-tasks li {
|
||||
font-size: 13px; color: var(--text-muted);
|
||||
padding-left: 16px; position: relative;
|
||||
}
|
||||
.phase-tasks li::before {
|
||||
content: '→';
|
||||
position: absolute; left: 0;
|
||||
color: var(--brand); font-weight: 700;
|
||||
}
|
||||
.phase-outcome {
|
||||
margin-top: 12px; padding: 8px 12px;
|
||||
background: var(--brand-light);
|
||||
border-radius: 6px;
|
||||
font-size: 12px; color: var(--brand); font-weight: 600;
|
||||
}
|
||||
.phase-outcome::before { content: '✓ Entregable: '; }
|
||||
|
||||
/* ─── CALLOUT ──────────────────────────────── */
|
||||
.callout {
|
||||
background: var(--brand-light);
|
||||
border-left: 4px solid var(--brand);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
padding: 14px 18px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px; color: var(--brand);
|
||||
}
|
||||
.callout strong { font-weight: 700; }
|
||||
.callout.warning {
|
||||
background: var(--gold-soft);
|
||||
border-color: var(--gold);
|
||||
color: #6B4F00;
|
||||
}
|
||||
|
||||
/* ─── FOOTER ───────────────────────────────── */
|
||||
.doc-footer {
|
||||
background: var(--brand);
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 12px;
|
||||
margin-top: 60px;
|
||||
}
|
||||
.doc-footer strong { color: white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- HEADER -->
|
||||
<header class="doc-header">
|
||||
<div class="badge">Especificación Técnica</div>
|
||||
<h1>OPSV Dashboard v2.0<br>Plan de Arquitectura</h1>
|
||||
<p>Documento de referencia para el desarrollo con Claude Code. Define stack tecnológico, estructura de base de datos, roles de usuario y fases de desarrollo.</p>
|
||||
<div class="meta">
|
||||
<span><strong>Versión</strong> 2.0</span>
|
||||
<span><strong>Fecha</strong> Abril 2026</span>
|
||||
<span><strong>Stack</strong> React + Supabase + Vercel</span>
|
||||
<span><strong>Modalidad</strong> Solo developer · Claude Code</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="doc-body">
|
||||
|
||||
<!-- 1. DECISIONES -->
|
||||
<div class="section-title">
|
||||
<div class="num">1</div>
|
||||
<h2>Decisiones de arquitectura</h2>
|
||||
</div>
|
||||
|
||||
<div class="cards-grid">
|
||||
<div class="card">
|
||||
<div class="card-icon">⚛️</div>
|
||||
<h3>Frontend</h3>
|
||||
<p>React + Vite — mismo entorno que usaste en el dashboard anterior. Claude Code lo maneja perfectamente.</p>
|
||||
<span class="tag">React 18 + Vite 5</span>
|
||||
</div>
|
||||
<div class="card green">
|
||||
<div class="card-icon">🗄️</div>
|
||||
<h3>Backend + Auth</h3>
|
||||
<p>Supabase: base de datos PostgreSQL, autenticación email/password, storage para los CSV. Todo gratuito.</p>
|
||||
<span class="tag">Supabase Free Tier</span>
|
||||
</div>
|
||||
<div class="card gold">
|
||||
<div class="card-icon">🚀</div>
|
||||
<h3>Hosting</h3>
|
||||
<p>Vercel: deploy automático desde GitHub, HTTPS incluido, dominio gratuito bajo vercel.app.</p>
|
||||
<span class="tag">Vercel Hobby (gratis)</span>
|
||||
</div>
|
||||
<div class="card red">
|
||||
<div class="card-icon">🎨</div>
|
||||
<h3>UI + Gráficos</h3>
|
||||
<p>Tailwind CSS para estilos. Recharts para visualizaciones (mismo que ya usás). Lucide para iconos.</p>
|
||||
<span class="tag">Tailwind + Recharts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout">
|
||||
<strong>¿Por qué no GitHub Pages?</strong> GitHub Pages es estático puro — no puede manejar autenticación ni guardar datos. Vercel sí, y también es 100% gratuito con deploy automático cada vez que hacés push.
|
||||
</div>
|
||||
|
||||
<!-- 2. STACK DETALLE -->
|
||||
<div class="section-title">
|
||||
<div class="num">2</div>
|
||||
<h2>Stack tecnológico completo</h2>
|
||||
</div>
|
||||
|
||||
<table class="stack-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Capa</th>
|
||||
<th>Tecnología</th>
|
||||
<th>Para qué sirve</th>
|
||||
<th>Costo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>UI Framework</strong></td>
|
||||
<td>React 18 + Vite</td>
|
||||
<td>
|
||||
Interfaz del dashboard y panel admin
|
||||
<div class="why">Familiar para Claude Code, ecosistema enorme</div>
|
||||
</td>
|
||||
<td><span class="pill free">Gratis</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Estilos</strong></td>
|
||||
<td>Tailwind CSS v3</td>
|
||||
<td>
|
||||
Diseño responsivo, paleta institucional OPSV
|
||||
<div class="why">Claude Code genera Tailwind excelente</div>
|
||||
</td>
|
||||
<td><span class="pill free">Gratis</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Gráficos</strong></td>
|
||||
<td>Recharts</td>
|
||||
<td>
|
||||
Todos los gráficos del dashboard actual
|
||||
<div class="why">Mismo que el dashboard anterior → migración directa</div>
|
||||
</td>
|
||||
<td><span class="pill free">Gratis</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Base de datos</strong></td>
|
||||
<td>Supabase PostgreSQL</td>
|
||||
<td>
|
||||
Config del dashboard, textos editables, roles, metadatos de CSV
|
||||
<div class="why">500MB gratis, más que suficiente para configuración</div>
|
||||
</td>
|
||||
<td><span class="pill free">Gratis</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Autenticación</strong></td>
|
||||
<td>Supabase Auth</td>
|
||||
<td>
|
||||
Login email/contraseña con roles (superadmin, admin, editor)
|
||||
<div class="why">Email confirmado + recupero de contraseña incluidos</div>
|
||||
</td>
|
||||
<td><span class="pill free">Gratis</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Storage CSV</strong></td>
|
||||
<td>Supabase Storage</td>
|
||||
<td>
|
||||
Almacenamiento de los 3 archivos CSV mensuales
|
||||
<div class="why">1GB gratis, historial de versiones posible</div>
|
||||
</td>
|
||||
<td><span class="pill free">Gratis</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Hosting</strong></td>
|
||||
<td>Vercel</td>
|
||||
<td>
|
||||
Publicación del sitio, deploy automático desde GitHub
|
||||
<div class="why">Cada push = nueva versión publicada en segundos</div>
|
||||
</td>
|
||||
<td><span class="pill free">Gratis</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Repositorio</strong></td>
|
||||
<td>GitHub</td>
|
||||
<td>
|
||||
Control de versiones, integración con Vercel
|
||||
<div class="why">Todo el código centralizado, historial completo</div>
|
||||
</td>
|
||||
<td><span class="pill free">Gratis</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 3. ARQUITECTURA -->
|
||||
<div class="section-title">
|
||||
<div class="num">3</div>
|
||||
<h2>Diagrama de arquitectura</h2>
|
||||
</div>
|
||||
|
||||
<div class="arch-wrapper">
|
||||
<svg viewBox="0 0 820 420" xmlns="http://www.w3.org/2000/svg" font-family="Montserrat, sans-serif">
|
||||
<!-- Background zones -->
|
||||
<rect x="10" y="10" width="240" height="400" rx="12" fill="#F7F8FC" stroke="#E8EAF6" stroke-width="1.5"/>
|
||||
<rect x="290" y="10" width="240" height="400" rx="12" fill="#F7F8FC" stroke="#E8EAF6" stroke-width="1.5"/>
|
||||
<rect x="570" y="10" width="240" height="400" rx="12" fill="#F7F8FC" stroke="#E8EAF6" stroke-width="1.5"/>
|
||||
|
||||
<!-- Zone labels -->
|
||||
<text x="130" y="36" text-anchor="middle" font-size="10" font-weight="700" fill="#9EA3BC" letter-spacing="0.1em">USUARIOS</text>
|
||||
<text x="410" y="36" text-anchor="middle" font-size="10" font-weight="700" fill="#9EA3BC" letter-spacing="0.1em">FRONTEND · VERCEL</text>
|
||||
<text x="690" y="36" text-anchor="middle" font-size="10" font-weight="700" fill="#9EA3BC" letter-spacing="0.1em">BACKEND · SUPABASE</text>
|
||||
|
||||
<!-- USUARIO PÚBLICO -->
|
||||
<rect x="30" y="55" width="200" height="72" rx="10" fill="white" stroke="#ddd" stroke-width="1.5"/>
|
||||
<circle cx="58" cy="91" r="14" fill="#F5F5F5" stroke="#ddd"/>
|
||||
<text x="58" y="96" text-anchor="middle" font-size="14">👤</text>
|
||||
<text x="80" y="83" font-size="12" font-weight="700" fill="#333">Usuario Público</text>
|
||||
<text x="80" y="100" font-size="11" fill="#888">Sin login requerido</text>
|
||||
<rect x="80" y="108" width="120" height="13" rx="6" fill="#F5F5F5"/>
|
||||
<text x="140" y="119" text-anchor="middle" font-size="10" fill="#888">Acceso al dashboard</text>
|
||||
|
||||
<!-- ADMIN -->
|
||||
<rect x="30" y="150" width="200" height="90" rx="10" fill="white" stroke="#252C61" stroke-width="1.5"/>
|
||||
<circle cx="58" cy="195" r="14" fill="#E8EAF6" stroke="#252C61"/>
|
||||
<text x="58" y="200" text-anchor="middle" font-size="14">🔐</text>
|
||||
<text x="80" y="183" font-size="12" font-weight="700" fill="#252C61">Administrador</text>
|
||||
<text x="80" y="200" font-size="11" fill="#5A5F7A">Login email/password</text>
|
||||
<rect x="80" y="208" width="108" height="13" rx="6" fill="#E8EAF6"/>
|
||||
<text x="134" y="219" text-anchor="middle" font-size="10" fill="#252C61">superadmin | admin</text>
|
||||
<rect x="80" y="225" width="80" height="13" rx="6" fill="#E6F4EE"/>
|
||||
<text x="120" y="236" text-anchor="middle" font-size="10" fill="#2D7A4F">editor</text>
|
||||
|
||||
<!-- ARROWS user → frontend -->
|
||||
<line x1="230" y1="91" x2="290" y2="91" stroke="#ccc" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<polygon points="287,87 295,91 287,95" fill="#ccc"/>
|
||||
<line x1="230" y1="195" x2="290" y2="195" stroke="#252C61" stroke-width="1.5"/>
|
||||
<polygon points="287,191 295,195 287,199" fill="#252C61"/>
|
||||
|
||||
<!-- FRONTEND BOXES -->
|
||||
<!-- Dashboard público -->
|
||||
<rect x="305" y="55" width="210" height="60" rx="10" fill="white" stroke="#ddd" stroke-width="1.5"/>
|
||||
<text x="320" y="80" font-size="12" font-weight="700" fill="#333">Dashboard Público</text>
|
||||
<text x="320" y="97" font-size="11" fill="#888">/ · sin autenticación</text>
|
||||
<rect x="390" y="103" width="90" height="8" rx="4" fill="#F0F0F0"/>
|
||||
<text x="435" y="110" text-anchor="middle" font-size="9" fill="#999">React + Recharts</text>
|
||||
|
||||
<!-- Panel Admin -->
|
||||
<rect x="305" y="135" width="210" height="80" rx="10" fill="white" stroke="#252C61" stroke-width="1.5"/>
|
||||
<text x="320" y="158" font-size="12" font-weight="700" fill="#252C61">Panel Admin</text>
|
||||
<text x="320" y="175" font-size="11" fill="#5A5F7A">/admin · ruta protegida</text>
|
||||
<text x="320" y="193" font-size="11" fill="#5A5F7A">Upload CSV · Editar textos</text>
|
||||
<rect x="320" y="200" width="128" height="9" rx="4" fill="#E8EAF6"/>
|
||||
<text x="384" y="208" text-anchor="middle" font-size="9" fill="#252C61">React Router + Auth guard</text>
|
||||
|
||||
<!-- Login -->
|
||||
<rect x="305" y="235" width="210" height="50" rx="10" fill="white" stroke="#ddd" stroke-width="1.5"/>
|
||||
<text x="320" y="256" font-size="12" font-weight="700" fill="#333">Login / Auth</text>
|
||||
<text x="320" y="273" font-size="11" fill="#888">/login · email + password</text>
|
||||
|
||||
<!-- GitHub / Deploy -->
|
||||
<rect x="305" y="305" width="210" height="90" rx="10" fill="#1A1D2E" stroke="#333" stroke-width="1"/>
|
||||
<text x="320" y="328" font-size="11" font-weight="700" fill="#7FDBFF">GitHub Repository</text>
|
||||
<text x="320" y="346" font-size="10" fill="#9EA3BC">Push → Vercel auto-deploy</text>
|
||||
<text x="320" y="364" font-size="10" fill="#9EA3BC">Variables de entorno Supabase</text>
|
||||
<text x="320" y="382" font-size="10" fill="#9EA3BC">en Vercel (VITE_SUPABASE_URL)</text>
|
||||
|
||||
<!-- ARROWS frontend → backend -->
|
||||
<line x1="515" y1="85" x2="570" y2="85" stroke="#ccc" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<polygon points="567,81 575,85 567,89" fill="#ccc"/>
|
||||
<line x1="515" y1="175" x2="570" y2="175" stroke="#252C61" stroke-width="1.5"/>
|
||||
<polygon points="567,171 575,175 567,179" fill="#252C61"/>
|
||||
<line x1="515" y1="260" x2="570" y2="260" stroke="#2D7A4F" stroke-width="1.5"/>
|
||||
<polygon points="567,256 575,260 567,264" fill="#2D7A4F"/>
|
||||
|
||||
<!-- SUPABASE boxes -->
|
||||
<!-- Auth -->
|
||||
<rect x="585" y="55" width="210" height="60" rx="10" fill="white" stroke="#2D7A4F" stroke-width="1.5"/>
|
||||
<text x="600" y="78" font-size="12" font-weight="700" fill="#2D7A4F">Supabase Auth</text>
|
||||
<text x="600" y="95" font-size="11" fill="#5A5F7A">JWT · Roles · Email confirm</text>
|
||||
<rect x="600" y="102" width="70" height="8" rx="4" fill="#E6F4EE"/>
|
||||
<text x="635" y="109" text-anchor="middle" font-size="9" fill="#2D7A4F">user_roles table</text>
|
||||
|
||||
<!-- DB -->
|
||||
<rect x="585" y="135" width="210" height="90" rx="10" fill="white" stroke="#252C61" stroke-width="1.5"/>
|
||||
<text x="600" y="157" font-size="12" font-weight="700" fill="#252C61">PostgreSQL DB</text>
|
||||
<text x="600" y="174" font-size="11" fill="#5A5F7A">dashboard_config</text>
|
||||
<text x="600" y="191" font-size="11" fill="#5A5F7A">user_roles · datasets</text>
|
||||
<text x="600" y="208" font-size="11" fill="#5A5F7A">Textos · Chart config</text>
|
||||
<rect x="600" y="214" width="55" height="8" rx="4" fill="#E8EAF6"/>
|
||||
<text x="627" y="221" text-anchor="middle" font-size="9" fill="#252C61">Row Level Security</text>
|
||||
|
||||
<!-- Storage -->
|
||||
<rect x="585" y="245" width="210" height="70" rx="10" fill="white" stroke="#C9A84C" stroke-width="1.5"/>
|
||||
<text x="600" y="268" font-size="12" font-weight="700" fill="#8B6914">Supabase Storage</text>
|
||||
<text x="600" y="285" font-size="11" fill="#5A5F7A">siniestros.csv</text>
|
||||
<text x="600" y="302" font-size="11" fill="#5A5F7A">personas.csv · involucrados.csv</text>
|
||||
|
||||
<!-- API label -->
|
||||
<text x="543" y="130" text-anchor="middle" font-size="10" fill="#9EA3BC" transform="rotate(-90 543 130)">REST API</text>
|
||||
<text x="543" y="220" text-anchor="middle" font-size="10" fill="#9EA3BC" transform="rotate(-90 543 220)">SDK Client</text>
|
||||
|
||||
<!-- Bottom label -->
|
||||
<text x="690" y="340" text-anchor="middle" font-size="11" fill="#C9A84C" font-weight="600">Free tier · 500MB DB + 1GB Storage</text>
|
||||
<text x="690" y="358" text-anchor="middle" font-size="11" fill="#9EA3BC">Respaldo automático · SSL incluido</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 4. ROLES -->
|
||||
<div class="section-title">
|
||||
<div class="num">4</div>
|
||||
<h2>Roles de usuario y permisos</h2>
|
||||
</div>
|
||||
|
||||
<table class="roles-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rol</th>
|
||||
<th>Ver dashboard</th>
|
||||
<th>Subir CSV</th>
|
||||
<th>Editar textos/resumen</th>
|
||||
<th>Config gráficos</th>
|
||||
<th>Gestionar usuarios</th>
|
||||
<th>Acceso</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="role-badge rb-superadmin">Superadmin</span></td>
|
||||
<td class="check">✓</td>
|
||||
<td class="check">✓</td>
|
||||
<td class="check">✓</td>
|
||||
<td class="check">✓</td>
|
||||
<td class="check">✓ Crear/borrar admins</td>
|
||||
<td>Login requerido</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="role-badge rb-admin">Admin</span></td>
|
||||
<td class="check">✓</td>
|
||||
<td class="check">✓</td>
|
||||
<td class="check">✓</td>
|
||||
<td class="check">✓</td>
|
||||
<td class="cross">✗</td>
|
||||
<td>Login requerido</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="role-badge rb-editor">Editor</span></td>
|
||||
<td class="check">✓</td>
|
||||
<td class="cross">✗</td>
|
||||
<td class="check">✓ Solo textos</td>
|
||||
<td class="cross">✗</td>
|
||||
<td class="cross">✗</td>
|
||||
<td>Login requerido</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="role-badge rb-public">Público</span></td>
|
||||
<td class="check">✓ Solo lectura</td>
|
||||
<td class="cross">✗</td>
|
||||
<td class="cross">✗</td>
|
||||
<td class="cross">✗</td>
|
||||
<td class="cross">✗</td>
|
||||
<td>Sin login · acceso libre</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 5. ESQUEMA DB -->
|
||||
<div class="section-title">
|
||||
<div class="num">5</div>
|
||||
<h2>Esquema de base de datos (Supabase)</h2>
|
||||
</div>
|
||||
|
||||
<div class="schema-grid">
|
||||
<div class="schema-card">
|
||||
<div class="schema-header">user_roles</div>
|
||||
<ul>
|
||||
<li><span class="col-name">id</span><span class="col-type">uuid <span class="pk">PK</span></span></li>
|
||||
<li><span class="col-name">user_id</span><span class="col-type">uuid <span class="fk">FK auth</span></span></li>
|
||||
<li><span class="col-name">role</span><span class="col-type">enum</span></li>
|
||||
<li><span class="col-name">created_at</span><span class="col-type">timestamp</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="schema-card">
|
||||
<div class="schema-header">datasets</div>
|
||||
<ul>
|
||||
<li><span class="col-name">id</span><span class="col-type">uuid <span class="pk">PK</span></span></li>
|
||||
<li><span class="col-name">type</span><span class="col-type">text</span></li>
|
||||
<li><span class="col-name">year</span><span class="col-type">integer</span></li>
|
||||
<li><span class="col-name">month</span><span class="col-type">integer</span></li>
|
||||
<li><span class="col-name">storage_path</span><span class="col-type">text</span></li>
|
||||
<li><span class="col-name">uploaded_by</span><span class="col-type">uuid <span class="fk">FK</span></span></li>
|
||||
<li><span class="col-name">created_at</span><span class="col-type">timestamp</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="schema-card">
|
||||
<div class="schema-header">dashboard_config</div>
|
||||
<ul>
|
||||
<li><span class="col-name">id</span><span class="col-type">uuid <span class="pk">PK</span></span></li>
|
||||
<li><span class="col-name">key</span><span class="col-type">text unique</span></li>
|
||||
<li><span class="col-name">value</span><span class="col-type">jsonb</span></li>
|
||||
<li><span class="col-name">updated_by</span><span class="col-type">uuid <span class="fk">FK</span></span></li>
|
||||
<li><span class="col-name">updated_at</span><span class="col-type">timestamp</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="schema-card">
|
||||
<div class="schema-header">resumen_content</div>
|
||||
<ul>
|
||||
<li><span class="col-name">id</span><span class="col-type">uuid <span class="pk">PK</span></span></li>
|
||||
<li><span class="col-name">year</span><span class="col-type">integer</span></li>
|
||||
<li><span class="col-name">month</span><span class="col-type">integer</span></li>
|
||||
<li><span class="col-name">title</span><span class="col-type">text</span></li>
|
||||
<li><span class="col-name">body</span><span class="col-type">text (md)</span></li>
|
||||
<li><span class="col-name">published</span><span class="col-type">boolean</span></li>
|
||||
<li><span class="col-name">created_by</span><span class="col-type">uuid <span class="fk">FK</span></span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="schema-card">
|
||||
<div class="schema-header">chart_config</div>
|
||||
<ul>
|
||||
<li><span class="col-name">id</span><span class="col-type">uuid <span class="pk">PK</span></span></li>
|
||||
<li><span class="col-name">chart_key</span><span class="col-type">text unique</span></li>
|
||||
<li><span class="col-name">title</span><span class="col-type">text</span></li>
|
||||
<li><span class="col-name">chart_type</span><span class="col-type">enum</span></li>
|
||||
<li><span class="col-name">color_palette</span><span class="col-type">jsonb</span></li>
|
||||
<li><span class="col-name">visible</span><span class="col-type">boolean</span></li>
|
||||
<li><span class="col-name">updated_at</span><span class="col-type">timestamp</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout">
|
||||
<strong>Row Level Security (RLS)</strong> — Supabase protege cada tabla con políticas: el público solo puede leer, los admins pueden escribir. Esto se configura una vez con SQL y funciona automáticamente.
|
||||
</div>
|
||||
|
||||
<!-- 6. ESTRUCTURA DE CARPETAS -->
|
||||
<div class="section-title">
|
||||
<div class="num">6</div>
|
||||
<h2>Estructura del proyecto</h2>
|
||||
</div>
|
||||
|
||||
<div class="folder-tree">
|
||||
<span class="dir">opsv-dashboard/</span>
|
||||
├── <span class="dir">src/</span>
|
||||
│ ├── <span class="dir">components/</span> <span class="cmt"># Componentes reutilizables</span>
|
||||
│ │ ├── <span class="file">charts/</span> <span class="cmt"># BarChart, LineChart, etc.</span>
|
||||
│ │ ├── <span class="file">ui/</span> <span class="cmt"># Button, Card, Modal, Input</span>
|
||||
│ │ └── <span class="file">layout/</span> <span class="cmt"># Navbar, Sidebar, Footer</span>
|
||||
│ │
|
||||
│ ├── <span class="dir">pages/</span> <span class="cmt"># Páginas / rutas</span>
|
||||
│ │ ├── <span class="file">Dashboard.jsx</span> <span class="cmt"># / → público</span>
|
||||
│ │ ├── <span class="file">Login.jsx</span> <span class="cmt"># /login</span>
|
||||
│ │ └── <span class="dir">admin/</span>
|
||||
│ │ ├── <span class="file">AdminPanel.jsx</span> <span class="cmt"># /admin → panel principal</span>
|
||||
│ │ ├── <span class="file">UploadCSV.jsx</span> <span class="cmt"># /admin/upload</span>
|
||||
│ │ ├── <span class="file">EditResumen.jsx</span> <span class="cmt"># /admin/resumen</span>
|
||||
│ │ ├── <span class="file">EditCharts.jsx</span> <span class="cmt"># /admin/charts</span>
|
||||
│ │ └── <span class="file">ManageUsers.jsx</span> <span class="cmt"># /admin/users (superadmin)</span>
|
||||
│ │
|
||||
│ ├── <span class="dir">hooks/</span> <span class="cmt"># Custom hooks</span>
|
||||
│ │ ├── <span class="file">useAuth.js</span> <span class="cmt"># Estado de sesión Supabase</span>
|
||||
│ │ ├── <span class="file">useCSVData.js</span> <span class="cmt"># Carga y parseo de CSV</span>
|
||||
│ │ └── <span class="file">useDashConfig.js</span> <span class="cmt"># Config editable desde DB</span>
|
||||
│ │
|
||||
│ ├── <span class="dir">lib/</span>
|
||||
│ │ └── <span class="file">supabase.js</span> <span class="cmt"># Cliente Supabase (1 línea)</span>
|
||||
│ │
|
||||
│ ├── <span class="dir">utils/</span>
|
||||
│ │ └── <span class="file">parseCSV.js</span> <span class="cmt"># Lógica de procesamiento CSV</span>
|
||||
│ │
|
||||
│ └── <span class="file">App.jsx</span> <span class="cmt"># Rutas + Auth provider</span>
|
||||
│
|
||||
├── <span class="file">.env.local</span> <span class="cmt"># VITE_SUPABASE_URL + ANON_KEY (no subir a GitHub)</span>
|
||||
├── <span class="file">.env.example</span> <span class="cmt"># Template sin valores reales (sí subir)</span>
|
||||
├── <span class="file">vercel.json</span> <span class="cmt"># Configuración de Vercel (SPA redirect)</span>
|
||||
├── <span class="file">tailwind.config.js</span> <span class="cmt"># Paleta institucional OPSV #252C61</span>
|
||||
└── <span class="file">vite.config.js</span>
|
||||
</div>
|
||||
|
||||
<!-- 7. FASES -->
|
||||
<div class="section-title">
|
||||
<div class="num">7</div>
|
||||
<h2>Fases de desarrollo</h2>
|
||||
</div>
|
||||
|
||||
<div class="phases">
|
||||
<div class="phase-card">
|
||||
<div class="phase-num">
|
||||
<span class="ph">Fase</span>
|
||||
<span class="n">1</span>
|
||||
<span class="wk">2–3 sem</span>
|
||||
</div>
|
||||
<div class="phase-content">
|
||||
<h3>Fundación del proyecto</h3>
|
||||
<ul class="phase-tasks">
|
||||
<li>Crear proyecto en Supabase (auth + storage + DB)</li>
|
||||
<li>Scaffold React + Vite con Tailwind y paleta OPSV</li>
|
||||
<li>Sistema de autenticación: login, logout, sesión persistente</li>
|
||||
<li>Ruta protegida <code>/admin</code> con auth guard</li>
|
||||
<li>Conectar Vercel ↔ GitHub (deploy automático)</li>
|
||||
</ul>
|
||||
<div class="phase-outcome">Sitio online vacío con login funcional y deploy automático</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phase-card">
|
||||
<div class="phase-num">
|
||||
<div class="ph">Fase</div>
|
||||
<div class="n">2</div>
|
||||
<div class="wk">3–4 sem</div>
|
||||
</div>
|
||||
<div class="phase-content">
|
||||
<h3>Dashboard público</h3>
|
||||
<ul class="phase-tasks">
|
||||
<li>Upload manual de los 3 CSV desde panel admin</li>
|
||||
<li>Parser CSV en el cliente (Papa Parse)</li>
|
||||
<li>Migrar todos los gráficos actuales (Recharts)</li>
|
||||
<li>Filtros por año y mes funcionales</li>
|
||||
<li>Sección Resumen con contenido desde DB</li>
|
||||
<li>Diseño responsivo institucional</li>
|
||||
</ul>
|
||||
<div class="phase-outcome">Dashboard público funcional idéntico al actual, pero con datos dinámicos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phase-card">
|
||||
<div class="phase-num">
|
||||
<div class="ph">Fase</div>
|
||||
<div class="n">3</div>
|
||||
<div class="wk">3–4 sem</div>
|
||||
</div>
|
||||
<div class="phase-content">
|
||||
<h3>Panel administrador</h3>
|
||||
<ul class="phase-tasks">
|
||||
<li>Subida de CSV mensuales (3 archivos a la vez)</li>
|
||||
<li>Editor de texto enriquecido para sección Resumen</li>
|
||||
<li>Editor de títulos y configuración de gráficos</li>
|
||||
<li>Selector de tipo de gráfico (barra/línea/área) por visualización</li>
|
||||
<li>Selector de colores con paleta institucional</li>
|
||||
<li>Vista previa antes de publicar</li>
|
||||
</ul>
|
||||
<div class="phase-outcome">Panel admin completo con edición en tiempo real y previsualización</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phase-card">
|
||||
<div class="phase-num">
|
||||
<div class="ph">Fase</div>
|
||||
<div class="n">4</div>
|
||||
<div class="wk">2–3 sem</div>
|
||||
</div>
|
||||
<div class="phase-content">
|
||||
<h3>Escalabilidad y pulido</h3>
|
||||
<ul class="phase-tasks">
|
||||
<li>Gestión de múltiples usuarios con roles (admin, editor)</li>
|
||||
<li>Historial de versiones de CSV por mes/año</li>
|
||||
<li>Exportación PDF desde el panel admin</li>
|
||||
<li>Notificaciones de carga exitosa / errores</li>
|
||||
<li>Pruebas finales y documentación básica</li>
|
||||
</ul>
|
||||
<div class="phase-outcome">Sistema production-ready con multi-usuario y gestión de versiones</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout warning">
|
||||
<strong>Estimación realista para una persona con Claude Code:</strong> Fases 1 + 2 son las más importantes y deberían estar listas en ~6 semanas trabajando 2–3 horas/día. Las fases 3 y 4 pueden ir en paralelo con el uso real del sistema.
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="doc-footer">
|
||||
<strong>OPSV — Observatorio Provincial de Seguridad Vial · Santa Cruz</strong> |
|
||||
Arquitectura v2.0 · Abril 2026 |
|
||||
Stack: React + Supabase + Vercel
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3831
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "opsv-dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.103.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"html2canvas-pro": "^2.0.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
+184
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
// src/App.jsx
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Login from './pages/Login'
|
||||
import Admin from './pages/Admin'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/admin" element={
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<Admin />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// src/hooks/useAuth.js
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [role, setRole] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
async function fetchRole(userId) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('user_roles')
|
||||
.select('role')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
console.log('🔵 fetchRole data/error:', data, error)
|
||||
|
||||
if (error) throw error
|
||||
setRole(data?.role ?? null)
|
||||
} catch (e) {
|
||||
console.error('🔴 fetchRole:', e.message)
|
||||
setRole(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.getSession()
|
||||
if (error) console.error('🔴 getSession:', error.message)
|
||||
|
||||
if (!mounted) return
|
||||
|
||||
const currentUser = data?.session?.user ?? null
|
||||
console.log('🔵 init getSession session:', data?.session)
|
||||
|
||||
setUser(currentUser)
|
||||
setLoading(false) // <- importante: liberar loading acá
|
||||
|
||||
if (currentUser) {
|
||||
fetchRole(currentUser.id) // <- sin await
|
||||
} else {
|
||||
setRole(null)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('🔴 init auth:', e.message)
|
||||
if (mounted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
(_event, session) => {
|
||||
if (!mounted) return
|
||||
|
||||
console.log('🟡 onAuthStateChange:', _event, session)
|
||||
|
||||
const currentUser = session?.user ?? null
|
||||
setUser(currentUser)
|
||||
setLoading(false) // <- importante: no esperar rol
|
||||
|
||||
if (currentUser) {
|
||||
fetchRole(currentUser.id) // <- sin await
|
||||
} else {
|
||||
setRole(null)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function signIn(email, password) {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
const { error } = await supabase.auth.signOut()
|
||||
if (error) throw error
|
||||
setUser(null)
|
||||
setRole(null)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const isSuperAdmin = role === 'superadmin'
|
||||
const isAdmin = role === 'admin' || isSuperAdmin
|
||||
const isEditor = role === 'editor' || isAdmin
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
role,
|
||||
loading,
|
||||
isSuperAdmin,
|
||||
isAdmin,
|
||||
isEditor,
|
||||
signIn,
|
||||
signOut
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) throw new Error('useAuth debe usarse dentro de <AuthProvider>')
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// src/hooks/useChartTheme.js
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useChartTheme() {
|
||||
const [isDark, setIsDark] = useState(
|
||||
document.documentElement.classList.contains('dark')
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'))
|
||||
})
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Texto de ejes / labels
|
||||
tickColor: isDark ? '#E5E7EB' : '#4B5563',
|
||||
|
||||
// Líneas de grilla
|
||||
gridColor: isDark ? '#273549' : '#E5E7EB',
|
||||
|
||||
// Tooltip
|
||||
tooltipBg: isDark ? '#020617' : '#FFFFFF',
|
||||
tooltipBorder: isDark ? '#1E293B' : '#E5E7EB',
|
||||
tooltipLabel: isDark ? '#F9FAFB' : '#111827',
|
||||
|
||||
// Colores de series para reusar
|
||||
seriePrimary: isDark ? '#60A5FA' : '#252C61', // azul principal
|
||||
serieSecondary: isDark ? '#FACC15' : '#CD9F2B', // dorado/promedio
|
||||
serieNeutral: isDark ? '#9CA3AF' : '#6B7280', // líneas auxiliares
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { supabasePublic } from '../lib/supabase'
|
||||
|
||||
function estaEnPeriodo(item, periodo) {
|
||||
if (!periodo?.desde || !periodo?.hasta) return true
|
||||
|
||||
const ano = Number(item.ano)
|
||||
const mes = Number(item.mes)
|
||||
|
||||
const desdeValor = periodo.desde.ano * 100 + periodo.desde.mes
|
||||
const hastaValor = periodo.hasta.ano * 100 + periodo.hasta.mes
|
||||
const actualValor = ano * 100 + mes
|
||||
|
||||
return actualValor >= desdeValor && actualValor <= hastaValor
|
||||
}
|
||||
|
||||
export function useData(year = null, periodo = { desde: null, hasta: null }) {
|
||||
const [siniestros, setSiniestros] = useState([])
|
||||
const [involucrados, setInvolucrados] = useState([])
|
||||
const [personas, setPersonas] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const periodoKey = JSON.stringify(periodo)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const periodoActual = JSON.parse(periodoKey)
|
||||
const periodoActivo = periodoActual.desde && periodoActual.hasta
|
||||
|
||||
try {
|
||||
let qS = supabasePublic.from('siniestros').select('*')
|
||||
let qI = supabasePublic.from('Involucrados').select('*')
|
||||
let qP = supabasePublic.from('Personas').select('*')
|
||||
|
||||
if (periodoActivo) {
|
||||
const anoDesde = periodoActual.desde.ano
|
||||
const anoHasta = periodoActual.hasta.ano
|
||||
|
||||
qS = qS.gte('ano', anoDesde).lte('ano', anoHasta)
|
||||
qI = qI.gte('ano', anoDesde).lte('ano', anoHasta)
|
||||
qP = qP.gte('ano', anoDesde).lte('ano', anoHasta)
|
||||
} else if (year) {
|
||||
qS = qS.eq('ano', year)
|
||||
qI = qI.eq('ano', year)
|
||||
qP = qP.eq('ano', year)
|
||||
}
|
||||
|
||||
const [resS, resI, resP] = await Promise.all([qS, qI, qP])
|
||||
|
||||
if (resS.error) throw resS.error
|
||||
if (resI.error) throw resI.error
|
||||
if (resP.error) throw resP.error
|
||||
|
||||
let dataS = resS.data || []
|
||||
let dataI = resI.data || []
|
||||
let dataP = resP.data || []
|
||||
|
||||
if (periodoActivo) {
|
||||
dataS = dataS.filter(item => estaEnPeriodo(item, periodoActual))
|
||||
dataI = dataI.filter(item => estaEnPeriodo(item, periodoActual))
|
||||
dataP = dataP.filter(item => estaEnPeriodo(item, periodoActual))
|
||||
}
|
||||
|
||||
console.log('useData fetch', {
|
||||
year,
|
||||
periodo: periodoActual,
|
||||
siniestros: dataS.length,
|
||||
involucrados: dataI.length,
|
||||
personas: dataP.length,
|
||||
})
|
||||
|
||||
setSiniestros(dataS)
|
||||
setInvolucrados(dataI)
|
||||
setPersonas(dataP)
|
||||
} catch (err) {
|
||||
setError(err?.message || String(err))
|
||||
console.error('useData error', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [year, periodoKey])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
return { siniestros, involucrados, personas, loading, error, refetch: fetchData }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* useSiniestralidad.js — v2 CORREGIDO
|
||||
*
|
||||
* CAMBIO CRÍTICO: Persona e Involucrados se consultan por ano/mes directamente,
|
||||
* NO por id_feu con .in(). Motivo: .in() con muchos IDs supera el límite de URL
|
||||
* de PostgREST y devuelve array vacío sin error visible.
|
||||
*
|
||||
* El filtro de zona se aplica en memoria en Dashboard.jsx usando id_feu.
|
||||
*
|
||||
* Paginación: .range(0, 4999) para hasta 5000 filas por tabla.
|
||||
* Si tu dataset tiene más, aumentá el límite.
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
|
||||
// ── Nombres EXACTOS de tablas en Supabase (case-sensitive) ────
|
||||
const T = {
|
||||
siniestros: 'siniestros',
|
||||
involucrados: 'Involucrados', // I mayúscula
|
||||
personas: 'Personas', // P mayúscula
|
||||
}
|
||||
|
||||
// ── Columnas seleccionadas ────────────────────────────────────
|
||||
const SEL = {
|
||||
sin: [
|
||||
'id_feu','mes','ano','siniestro_hora','localidad','departamento',
|
||||
'zona_ocurrencia','via_publica','tipo_siniestro_unico',
|
||||
'fallecidos','heridos','ilesos','dia_semana','es_fin_semana',
|
||||
'configuracion_de_la_via','luminosidad','latitud','longitud',
|
||||
].join(','),
|
||||
|
||||
inv: [
|
||||
'id_feu','tipo_involucrado','marca','modelo','color','ano','mes',
|
||||
].join(','),
|
||||
|
||||
per: [
|
||||
'id_feu','genero','edad','rol_persona_involucrada','ubicacion_vehiculo',
|
||||
'estado_ocupante_inicio','estado_ocupante_final','categoria_siniestro',
|
||||
'cinturon_seguridad','casco','airbag','prueba_alcohol',
|
||||
'fuga','nacionalidad','ano','mes',
|
||||
].join(','),
|
||||
}
|
||||
|
||||
const MAX_ROWS = 4999 // ajustá si tu dataset es mayor
|
||||
|
||||
async function queryByAnoMes(tabla, select, año, mes) {
|
||||
let q = supabase.from(tabla).select(select).eq('ano', año).range(0, MAX_ROWS)
|
||||
if (mes > 0) q = q.eq('mes', mes)
|
||||
const { data, error } = await q
|
||||
return { data: data ?? [], error }
|
||||
}
|
||||
|
||||
export function useSiniestralidad(año, mes) {
|
||||
const [siniestros, setSiniestros] = useState([])
|
||||
const [involucrados, setInvolucrados] = useState([])
|
||||
const [personas, setPersonas] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [debug, setDebug] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function cargar() {
|
||||
setLoading(true)
|
||||
|
||||
// ── Las 3 consultas en PARALELO — cada una por ano/mes ─
|
||||
const [resS, resI, resP] = await Promise.all([
|
||||
queryByAnoMes(T.siniestros, SEL.sin, año, mes),
|
||||
queryByAnoMes(T.involucrados, SEL.inv, año, mes),
|
||||
queryByAnoMes(T.personas, SEL.per, año, mes),
|
||||
])
|
||||
|
||||
const log = {
|
||||
año_consultado: año,
|
||||
mes_consultado: mes > 0 ? mes : 'todos',
|
||||
siniestros: {
|
||||
tabla: T.siniestros,
|
||||
registros: resS.data.length,
|
||||
error: resS.error?.message ?? null,
|
||||
},
|
||||
involucrados: {
|
||||
tabla: T.involucrados,
|
||||
registros: resI.data.length,
|
||||
error: resI.error?.message ?? null,
|
||||
},
|
||||
personas: {
|
||||
tabla: T.personas,
|
||||
registros: resP.data.length,
|
||||
error: resP.error?.message ?? null,
|
||||
},
|
||||
}
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
const errorEntries = [resS.error, resI.error, resP.error].filter(Boolean)
|
||||
const errorMessage = errorEntries.length
|
||||
? errorEntries.map((err) => err?.message || String(err)).join(' | ')
|
||||
: null
|
||||
|
||||
setSiniestros(resS.data)
|
||||
setInvolucrados(resI.data)
|
||||
setPersonas(resP.data)
|
||||
setDebug(log)
|
||||
setError(errorMessage)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
cargar()
|
||||
return () => { cancelled = true }
|
||||
}, [año, mes])
|
||||
|
||||
return { siniestros, involucrados, personas, loading, debug, error }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Colores de marca */
|
||||
--color-opsv-navy: #252C61;
|
||||
--color-opsv-navy-dark: #1a2050;
|
||||
--color-opsv-blue: #80B0DE;
|
||||
--color-opsv-red: #C44228;
|
||||
--color-opsv-orange: #CD9F2B;
|
||||
--color-opsv-green: #337C58;
|
||||
|
||||
/* Colores de superficie (modo claro) */
|
||||
--color-opsv-bg: #F4F6F8;
|
||||
--color-opsv-surface: #FFFFFF;
|
||||
--color-opsv-border: #E2E8F0;
|
||||
--color-opsv-text: #1A202C;
|
||||
--color-opsv-muted: #4A5568;
|
||||
--color-opsv-faint: #718096;
|
||||
|
||||
/* Tipografías */
|
||||
--font-family-heading: 'Montserrat', sans-serif;
|
||||
--font-family-body: 'Barlow Semi Condensed', sans-serif;
|
||||
}
|
||||
|
||||
/* ── Dark mode ── */
|
||||
html.dark {
|
||||
--color-opsv-bg: #0f1117;
|
||||
--color-opsv-surface: #1a1d27;
|
||||
--color-opsv-border: #2d3148;
|
||||
--color-opsv-text: #e2e8f0;
|
||||
--color-opsv-muted: #94a3b8;
|
||||
--color-opsv-faint: #64748b;
|
||||
--color-opsv-navy: #a8b4ff;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: theme(--font-family-body);
|
||||
color: theme(--color-opsv-text);
|
||||
background: theme(--color-opsv-bg);
|
||||
}
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: theme(--color-opsv-bg);
|
||||
color: theme(--color-opsv-text);
|
||||
transition: background 250ms ease, color 250ms ease;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#root { min-height: 100vh; }
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: theme(--font-family-heading);
|
||||
color: theme(--color-opsv-navy);
|
||||
margin: 0;
|
||||
}
|
||||
/* ── Override de colores modernos para captura html2canvas ── */
|
||||
[data-pdf-render="true"],
|
||||
[data-pdf-render="true"] * {
|
||||
/* Reemplaza oklch/oklab con equivalentes hex */
|
||||
--shadow-sm: 0 1px 2px rgba(30, 27, 20, 0.06) !important;
|
||||
--shadow-md: 0 4px 12px rgba(30, 27, 20, 0.08) !important;
|
||||
--shadow-lg: 0 12px 32px rgba(30, 27, 20, 0.12) !important;
|
||||
--color-divider: #dcd9d5 !important;
|
||||
--color-border: #d4d1ca !important;
|
||||
|
||||
/* Neutraliza cualquier box-shadow que use oklch directamente */
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
[data-pdf-render="true"] .recharts-wrapper,
|
||||
[data-pdf-render="true"] canvas {
|
||||
/* Forzar dimensiones explícitas para evitar el width(-1) */
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||
|
||||
// Cliente principal (con auth)
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey)
|
||||
|
||||
// Cliente para datos públicos (sin auth, sin conflicto)
|
||||
export const supabasePublic = createClient(supabaseUrl, supabaseKey, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
storageKey: 'supabase-public', // ← clave distinta = no hay conflicto
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
// src/main.jsx
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { AuthProvider } from './hooks/useAuth'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
// src/pages/Admin.jsx
|
||||
import { useEffect, useState } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { LogOut, Settings, Database, FileText } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import ImportarCSV from './ImportarCSV'
|
||||
|
||||
export default function Admin() {
|
||||
const { user, role, isSuperAdmin, signOut, loading } = useAuth()
|
||||
const [stats, setStats] = useState({ siniestros: 0, personas: 0, involucrados: 0 })
|
||||
const [statsLoading, setStatsLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
const [vista, setVista] = useState('panel')
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
|
||||
const fetchStats = async () => {
|
||||
const [resS, resP, resI] = await Promise.all([
|
||||
supabase.from('siniestros').select('id_feu', { count: 'exact', head: true }),
|
||||
supabase.from('Personas').select('id_persona', { count: 'exact', head: true }),
|
||||
supabase.from('Involucrados').select('id_involucrado', { count: 'exact', head: true }),
|
||||
])
|
||||
setStats({
|
||||
siniestros: resS.count || 0,
|
||||
personas: resP.count || 0,
|
||||
involucrados: resI.count || 0,
|
||||
})
|
||||
setStatsLoading(false)
|
||||
}
|
||||
|
||||
fetchStats()
|
||||
}, [user])
|
||||
|
||||
const handleLogout = async () => {
|
||||
|
||||
navigate('/Dashboard', { replace: true })
|
||||
await signOut()
|
||||
}
|
||||
|
||||
if (loading || statsLoading) return (
|
||||
<div className="min-h-screen bg-opsv-bg flex items-center justify-center">
|
||||
<div className="text-opsv-muted">Cargando...</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Header reutilizable para ambas vistas
|
||||
const Header = ({ titulo, onVolver }) => (
|
||||
<div className="bg-white border-b border-opsv-border">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{onVolver && (
|
||||
<>
|
||||
<button
|
||||
onClick={onVolver}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-opsv-muted hover:text-opsv-text transition"
|
||||
>
|
||||
← Volver al panel
|
||||
</button>
|
||||
<span className="text-opsv-border">|</span>
|
||||
</>
|
||||
)}
|
||||
<h1 className="text-lg font-black text-opsv-navy">{titulo}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-opsv-text">{user?.email}</p>
|
||||
<p className="text-xs text-opsv-muted capitalize">{role}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-sm font-semibold rounded-xl hover:bg-red-700 transition"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Salir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (vista === 'importar') return (
|
||||
<div className="min-h-screen bg-opsv-bg">
|
||||
<Header titulo="Importar CSV" onVolver={() => setVista('panel')} />
|
||||
<ImportarCSV />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-opsv-bg">
|
||||
<Header titulo="OPSV Admin" />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{/* Tarjetas de stats */}
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-12">
|
||||
<div className="bg-white rounded-3xl p-6 border-l-4 border-red-600 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">Siniestros</p>
|
||||
<h3 className="text-4xl font-black text-red-600 mt-2">
|
||||
{stats.siniestros.toLocaleString('es-AR')}
|
||||
</h3>
|
||||
</div>
|
||||
<Database className="h-12 w-12 text-red-200" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-3xl p-6 border-l-4 border-opsv-blue shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">Personas</p>
|
||||
<h3 className="text-4xl font-black text-opsv-blue mt-2">
|
||||
{stats.personas.toLocaleString('es-AR')}
|
||||
</h3>
|
||||
</div>
|
||||
<FileText className="h-12 w-12 text-opsv-blue/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-3xl p-6 border-l-4 border-opsv-orange shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">Involucrados</p>
|
||||
<h3 className="text-4xl font-black text-opsv-orange mt-2">
|
||||
{stats.involucrados.toLocaleString('es-AR')}
|
||||
</h3>
|
||||
</div>
|
||||
<Settings className="h-12 w-12 text-opsv-orange/20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Funcionalidades */}
|
||||
<div className="bg-white rounded-3xl p-8 border border-opsv-border">
|
||||
<h2 className="text-xl font-black text-opsv-navy mb-4 flex items-center gap-2">
|
||||
<Settings className="h-6 w-6" />
|
||||
Funcionalidades disponibles
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="p-4 bg-opsv-bg rounded-2xl border border-opsv-border">
|
||||
<h3 className="font-semibold text-opsv-navy mb-2">📊 Dashboard</h3>
|
||||
<p className="text-sm text-opsv-muted">
|
||||
Visualiza todas las secciones de análisis de siniestralidad vial
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="text-opsv-blue text-sm font-semibold mt-3 inline-block hover:underline"
|
||||
>
|
||||
Ir al dashboard →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setVista('importar')}
|
||||
className="p-4 bg-opsv-bg rounded-2xl border border-opsv-border cursor-pointer hover:border-opsv-blue hover:shadow-sm transition"
|
||||
>
|
||||
<h3 className="font-semibold text-opsv-navy mb-2">📤 Importar datos</h3>
|
||||
<p className="text-sm text-opsv-muted">
|
||||
Carga nuevos datos CSV desde la base de siniestros
|
||||
</p>
|
||||
<span className="text-opsv-blue text-sm font-semibold mt-3 inline-block">
|
||||
Abrir importador →
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isSuperAdmin && (
|
||||
<div className="p-4 bg-opsv-bg rounded-2xl border border-opsv-border opacity-50">
|
||||
<h3 className="font-semibold text-opsv-navy mb-2">⚙️ Configuración</h3>
|
||||
<p className="text-sm text-opsv-muted">
|
||||
Ajusta parámetros del dashboard (próximamente)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-opsv-bg rounded-2xl border border-opsv-border opacity-50">
|
||||
<h3 className="font-semibold text-opsv-navy mb-2">📥 Exportar reportes</h3>
|
||||
<p className="text-sm text-opsv-muted">
|
||||
Descarga informes en PDF (próximamente)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center text-sm text-opsv-muted">
|
||||
<p>OPSV Dashboard v1.0 — Agencia Provincial de Seguridad Vial, Santa Cruz</p>
|
||||
<p className="mt-1">Para soporte: admin@opsv.sc.gov.ar</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// src/pages/Dashboard.jsx
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { useData } from '../hooks/useData'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import Sidebar from '../components/layout/Sidebar'
|
||||
import Topbar from '../components/layout/Topbar'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner'
|
||||
import ErrorBanner from '../components/ui/ErrorBanner'
|
||||
import SecResumen from './SecResumen'
|
||||
import SecHistorica from './SecHistorica'
|
||||
import SecFatales from './SecFatales'
|
||||
import SecLesionados from './SecLesionados'
|
||||
import SecSinLesiones from './SecSinLesiones'
|
||||
import SecSintesis from './SecSintesis'
|
||||
import SecVeranoVivo from './SecVeranoVivo'
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import PdfExportModal from '../components/ui/PdfExportModal'
|
||||
|
||||
|
||||
function SectionPlaceholder({ title, description }) {
|
||||
return (
|
||||
<section className="rounded-3xl border border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm">
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted dark:text-slate-400">{title}</div>
|
||||
<h2 className="mt-4 text-2xl font-black text-opsv-navy dark:text-white">{description}</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-opsv-muted dark:text-slate-400">
|
||||
Este espacio está reservado para la próxima fase de implementación.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function AdminFooter() {
|
||||
const { user, isAdmin } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<footer className="mt-8 border-t border-opsv-border dark:border-slate-700 px-2 py-4 flex items-center justify-between">
|
||||
<p className="text-xs text-opsv-muted dark:text-slate-500">
|
||||
OPSV Dashboard — Agencia Provincial de Seguridad Vial, Santa Cruz
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate(user && isAdmin ? '/admin' : '/login')}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs text-opsv-muted dark:text-slate-400 border border-opsv-border dark:border-slate-700 hover:text-opsv-navy dark:hover:text-white hover:border-opsv-navy dark:hover:border-slate-500 transition"
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
{user && isAdmin ? 'Panel Admin' : 'Acceso Admin'}
|
||||
</button>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default function Dashboard() {
|
||||
const [seccion, setSeccion] = useState('resumen')
|
||||
const [year, setYear] = useState(2025)
|
||||
const [periodo, setPeriodo] = useState({ desde: null, hasta: null })
|
||||
const [departamentoFiltro, setDepartamentoFiltro] = useState('')
|
||||
const [localidadFiltro, setLocalidadFiltro] = useState('')
|
||||
const [modalPdf, setModalPdf] = useState(false)
|
||||
|
||||
// Hook principal — año seleccionado por el usuario
|
||||
const { siniestros, personas, involucrados, loading, error } = useData(year, periodo)
|
||||
|
||||
// ── Departamentos disponibles para el año/período activo ─────────────────
|
||||
const departamentosDisponibles = useMemo(() => {
|
||||
return [...new Set(
|
||||
(siniestros ?? []).map(s => s.departamento).filter(Boolean)
|
||||
)].sort()
|
||||
}, [siniestros])
|
||||
|
||||
// ── Localidades filtradas por el departamento seleccionado ───────────────
|
||||
const localidadesDisponibles = useMemo(() => {
|
||||
const base = departamentoFiltro
|
||||
? (siniestros ?? []).filter(s => s.departamento === departamentoFiltro)
|
||||
: (siniestros ?? [])
|
||||
return [...new Set(base.map(s => s.localidad).filter(Boolean))].sort()
|
||||
}, [siniestros, departamentoFiltro])
|
||||
|
||||
// ── Reset en cascada: año/período → limpia todo ──────────────────────────
|
||||
useEffect(() => {
|
||||
setDepartamentoFiltro('')
|
||||
setLocalidadFiltro('')
|
||||
}, [year, periodo])
|
||||
|
||||
// ── Reset en cascada: departamento → limpia localidad ───────────────────
|
||||
useEffect(() => {
|
||||
setLocalidadFiltro('')
|
||||
}, [departamentoFiltro])
|
||||
|
||||
// ── Array final con ambos filtros aplicados ──────────────────────────────
|
||||
const siniestrosFiltrados = useMemo(() => {
|
||||
let result = siniestros ?? []
|
||||
if (departamentoFiltro) result = result.filter(s => s.departamento === departamentoFiltro)
|
||||
if (localidadFiltro) result = result.filter(s => s.localidad === localidadFiltro)
|
||||
return result
|
||||
}, [siniestros, departamentoFiltro, localidadFiltro])
|
||||
|
||||
// ── Verano Vivo: datos históricos de campañas anteriores ─────────────────
|
||||
const { siniestros: sinVV2022 } = useData(2022, { desde: null, hasta: null })
|
||||
const { siniestros: sinVV2023 } = useData(2023, { desde: null, hasta: null })
|
||||
const { siniestros: sinVV2024 } = useData(2024, { desde: null, hasta: null })
|
||||
|
||||
const siniestrosVV = useMemo(() => {
|
||||
const all = [
|
||||
...(sinVV2022 ?? []),
|
||||
...(sinVV2023 ?? []),
|
||||
...(sinVV2024 ?? []),
|
||||
...(siniestros ?? []),
|
||||
]
|
||||
const seen = new Set()
|
||||
return all.filter(s => {
|
||||
const id = s.id_feu
|
||||
if (id == null) return true
|
||||
if (seen.has(id)) return false
|
||||
seen.add(id)
|
||||
return true
|
||||
})
|
||||
}, [sinVV2022, sinVV2023, sinVV2024, siniestros])
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen overflow-hidden bg-opsv-bg">
|
||||
<Sidebar seccion={seccion} setSeccion={setSeccion} year={year} />
|
||||
|
||||
<div className="flex min-h-screen flex-1 flex-col overflow-hidden">
|
||||
<Topbar
|
||||
seccion={seccion}
|
||||
year={year}
|
||||
setYear={setYear}
|
||||
periodo={periodo}
|
||||
setPeriodo={setPeriodo}
|
||||
siniestrosCount={siniestrosFiltrados.length}
|
||||
departamentoFiltro={departamentoFiltro}
|
||||
setDepartamentoFiltro={setDepartamentoFiltro}
|
||||
departamentosDisponibles={departamentosDisponibles}
|
||||
localidadFiltro={localidadFiltro}
|
||||
setLocalidadFiltro={setLocalidadFiltro}
|
||||
localidadesDisponibles={localidadesDisponibles}
|
||||
onExportarPdf={() => setModalPdf(true)}
|
||||
/>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-6 bg-opsv-bg">
|
||||
{error && <ErrorBanner message={error} />}
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : seccion === 'resumen' ? (
|
||||
<SecResumen siniestros={siniestrosFiltrados} periodo={periodo} />
|
||||
) : seccion === 'historica' ? (
|
||||
<SecHistorica siniestros={siniestros} year={year} />
|
||||
) : seccion === 'fatales' ? (
|
||||
<SecFatales siniestros={siniestrosFiltrados} personas={personas} involucrados={involucrados} periodo={periodo} />
|
||||
) : seccion === 'lesionados' ? (
|
||||
<SecLesionados siniestros={siniestrosFiltrados} personas={personas} involucrados={involucrados} periodo={periodo} />
|
||||
) : seccion === 'sinlesiones' ? (
|
||||
<SecSinLesiones siniestros={siniestrosFiltrados} involucrados={involucrados} periodo={periodo} />
|
||||
) : seccion === 'sintesis' ? (
|
||||
<SecSintesis siniestros={siniestrosFiltrados} personas={personas} involucrados={involucrados} periodo={periodo} />
|
||||
) : seccion === 'veranovivo' ? (
|
||||
<SecVeranoVivo siniestros={siniestrosVV} />
|
||||
) : (
|
||||
<SectionPlaceholder
|
||||
title={seccion}
|
||||
description={`Vista de la sección ${seccion}`}
|
||||
/>
|
||||
)}
|
||||
<AdminFooter />
|
||||
</main>
|
||||
{/* ── Contenedor oculto para captura PDF ─────────────────────────── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-pdf-render="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
top: 0,
|
||||
width: '1280px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: -1,
|
||||
}}
|
||||
>
|
||||
<div id="pdf-section-resumen" className="p-6 bg-opsv-bg">
|
||||
<SecResumen siniestros={siniestrosFiltrados} periodo={periodo} />
|
||||
</div>
|
||||
<div id="pdf-section-historica" className="p-6 bg-opsv-bg">
|
||||
<SecHistorica siniestros={siniestros} year={year} />
|
||||
</div>
|
||||
<div id="pdf-section-fatales" className="p-6 bg-opsv-bg">
|
||||
<SecFatales siniestros={siniestrosFiltrados} personas={personas} involucrados={involucrados} periodo={periodo} />
|
||||
</div>
|
||||
<div id="pdf-section-lesionados" className="p-6 bg-opsv-bg">
|
||||
<SecLesionados siniestros={siniestrosFiltrados} personas={personas} involucrados={involucrados} periodo={periodo} />
|
||||
</div>
|
||||
<div id="pdf-section-sinlesiones" className="p-6 bg-opsv-bg">
|
||||
<SecSinLesiones siniestros={siniestrosFiltrados} involucrados={involucrados} periodo={periodo} />
|
||||
</div>
|
||||
<div id="pdf-section-sintesis" className="p-6 bg-opsv-bg">
|
||||
<SecSintesis siniestros={siniestrosFiltrados} personas={personas} involucrados={involucrados} periodo={periodo} />
|
||||
</div>
|
||||
<div id="pdf-section-veranovivo" className="p-6 bg-opsv-bg">
|
||||
<SecVeranoVivo siniestros={siniestrosVV} />
|
||||
</div>
|
||||
</div>
|
||||
{/* ────────────────────────────────────────────────────────────────── */}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Modal PDF */}
|
||||
{modalPdf && (
|
||||
<PdfExportModal
|
||||
year={year}
|
||||
onClose={() => setModalPdf(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
// src/pages/ImportarCSV.jsx
|
||||
import { useState, useRef } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { Upload, CheckCircle, XCircle, AlertTriangle, FileText, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
|
||||
|
||||
|
||||
// Columnas que EXISTEN en Supabase (no todas las del CSV)
|
||||
const ESQUEMAS = {
|
||||
siniestros: [
|
||||
"id_feu","numero_formulario","expediente_judicial","dependencia","siniestro_fecha",
|
||||
"siniestro_hora","dia","mes","ano","provincia","departamento","id_departamento",
|
||||
"localidad","zona_ocurrencia","via_publica","nombre_via","altura_km","entre_calle_1",
|
||||
"entre_calle_2","latitud","longitud","ilesos","heridos","fallecidos","vehiculos",
|
||||
"peatones","tipo_siniestro_multiple","tipo_siniestro_unico","despiste_previo",
|
||||
"cantidad_de_involucrados","configuracion_de_la_via","material_de_la_calzada",
|
||||
"estado_de_la_calzada","estado_fisico_ambiental","division_fisica_de_la_via",
|
||||
"luminosidad","luz_artificial","dia_semana","es_fin_semana"
|
||||
],
|
||||
Involucrados: [
|
||||
"id_feu","numero_formulario","id_involucrado","siniestro_fecha","dia","mes","ano",
|
||||
"provincia","numero","tipo_involucrado","marca","modelo","color"
|
||||
],
|
||||
Personas: [
|
||||
"id_feu","numero_formulario","id_involucrado","id_feu_persona","id_persona",
|
||||
"siniestro_fecha","dia","mes","ano","rol_persona_involucrada","ubicacion_vehiculo",
|
||||
"estado_ocupante_inicio","fuga","genero","nacionalidad","edad","provincia",
|
||||
"airbag","cinturon_seguridad","casco","prueba_alcohol","estado_ocupante_final",
|
||||
"categoria_siniestro"
|
||||
]
|
||||
}
|
||||
|
||||
const TABLAS = ['siniestros', 'Involucrados', 'Personas']
|
||||
|
||||
|
||||
function calcularDiaSemana(fechaStr) {
|
||||
if (!fechaStr) return { dia_semana: null, es_fin_semana: false }
|
||||
|
||||
let dia, mes, anio
|
||||
const partesFecha = String(fechaStr).split(' ')[0]
|
||||
|
||||
if (partesFecha.includes('/')) {
|
||||
const p = partesFecha.split('/')
|
||||
dia = p[0]
|
||||
mes = p[1]
|
||||
anio = p[2]
|
||||
} else if (partesFecha.includes('-')) {
|
||||
const p = partesFecha.split('-')
|
||||
anio = p[0]
|
||||
mes = p[1]
|
||||
dia = p[2]
|
||||
} else {
|
||||
return { dia_semana: null, es_fin_semana: false }
|
||||
}
|
||||
|
||||
const fecha = new Date(Number(anio), Number(mes) - 1, Number(dia))
|
||||
if (isNaN(fecha.getTime())) return { dia_semana: null, es_fin_semana: false }
|
||||
|
||||
const dias = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']
|
||||
const dia_semana = dias[fecha.getDay()]
|
||||
const es_fin_semana = ['Friday','Saturday','Sunday'].includes(dia_semana)
|
||||
|
||||
return { dia_semana, es_fin_semana }
|
||||
}
|
||||
|
||||
|
||||
// Parser CSV robusto: respeta comillas y comas dentro de celdas
|
||||
function parseCSVLine(line) {
|
||||
const result = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
const next = line[i + 1]
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && next === '"') {
|
||||
current += '"'
|
||||
i++
|
||||
} else {
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
result.push(current.replace(/\r/g, '').trim())
|
||||
current = ''
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
|
||||
result.push(current.replace(/\r/g, '').trim())
|
||||
return result
|
||||
}
|
||||
|
||||
function parseCSV(text) {
|
||||
const lines = text.trim().split('\n')
|
||||
if (lines.length < 2) return { headers: [], rows: [] }
|
||||
|
||||
const headers = parseCSVLine(lines[0])
|
||||
|
||||
const rows = lines
|
||||
.slice(1)
|
||||
.map(line => {
|
||||
const values = parseCSVLine(line)
|
||||
const obj = {}
|
||||
headers.forEach((h, i) => {
|
||||
obj[h] = values[i] ?? ''
|
||||
})
|
||||
return obj
|
||||
})
|
||||
.filter(row => Object.values(row).some(v => v !== ''))
|
||||
|
||||
return { headers, rows }
|
||||
}
|
||||
|
||||
|
||||
function toIntOrNull(value) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const n = Number(value)
|
||||
return Number.isInteger(n) ? n : null
|
||||
}
|
||||
|
||||
function toFloatOrNull(value) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const n = Number(value)
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
|
||||
// Filtra solo columnas del esquema y convierte tipos
|
||||
function limpiarFila(row, tabla) {
|
||||
const columnasValidas = ESQUEMAS[tabla].filter(c => c !== 'dia_semana' && c !== 'es_fin_semana')
|
||||
const cleaned = {}
|
||||
|
||||
columnasValidas.forEach(col => {
|
||||
cleaned[col] = (row[col] !== undefined && row[col] !== '') ? row[col] : null
|
||||
})
|
||||
|
||||
if (tabla === 'siniestros') {
|
||||
const { dia_semana, es_fin_semana } = calcularDiaSemana(cleaned.siniestro_fecha)
|
||||
cleaned.dia_semana = dia_semana
|
||||
cleaned.es_fin_semana = es_fin_semana
|
||||
}
|
||||
|
||||
const enteros = [
|
||||
'dia','mes','ano','ilesos','heridos','fallecidos','vehiculos','peatones',
|
||||
'cantidad_de_involucrados','numero','edad',
|
||||
'id_feu','id_involucrado','id_feu_persona','id_persona'
|
||||
]
|
||||
|
||||
const decimales = ['altura_km', 'latitud', 'longitud']
|
||||
|
||||
enteros.forEach(col => {
|
||||
if (col in cleaned) cleaned[col] = toIntOrNull(cleaned[col])
|
||||
})
|
||||
|
||||
decimales.forEach(col => {
|
||||
if (col in cleaned) cleaned[col] = toFloatOrNull(cleaned[col])
|
||||
})
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
|
||||
const estadoInicial = () => ({
|
||||
archivo: null, nombre: '', headers: [], rows: [],
|
||||
columnasFaltantes: [], columnasExtra: [],
|
||||
valido: false, cargado: false, resultado: null,
|
||||
expandido: false, error: null
|
||||
})
|
||||
|
||||
|
||||
export default function ImportarCSV() {
|
||||
const [archivos, setArchivos] = useState({
|
||||
siniestros: estadoInicial(),
|
||||
Involucrados: estadoInicial(),
|
||||
Personas: estadoInicial()
|
||||
})
|
||||
const [importando, setImportando] = useState(false)
|
||||
const [progreso, setProgreso] = useState({})
|
||||
const refs = {
|
||||
siniestros: useRef(),
|
||||
Involucrados: useRef(),
|
||||
Personas: useRef()
|
||||
}
|
||||
|
||||
function handleFile(tabla, e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const { headers, rows } = parseCSV(ev.target.result)
|
||||
const esperadas = ESQUEMAS[tabla].filter(c => c !== 'dia_semana' && c !== 'es_fin_semana')
|
||||
const columnasFaltantes = esperadas.filter(c => !headers.includes(c))
|
||||
const columnasExtra = headers.filter(c => !ESQUEMAS[tabla].includes(c))
|
||||
const valido = columnasFaltantes.length === 0
|
||||
|
||||
setArchivos(prev => ({
|
||||
...prev,
|
||||
[tabla]: {
|
||||
...estadoInicial(),
|
||||
archivo: file,
|
||||
nombre: file.name,
|
||||
headers,
|
||||
rows,
|
||||
columnasFaltantes,
|
||||
columnasExtra,
|
||||
valido,
|
||||
error: valido ? null : `Faltan ${columnasFaltantes.length} columna(s) requerida(s)`
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
async function importarTabla(tabla) {
|
||||
const estado = archivos[tabla]
|
||||
if (!estado.valido || !estado.rows.length) return
|
||||
|
||||
setProgreso(p => ({
|
||||
...p,
|
||||
[tabla]: { estado: 'importando', procesados: 0, total: estado.rows.length }
|
||||
}))
|
||||
|
||||
try {
|
||||
const filas = estado.rows.map(row => limpiarFila(row, tabla))
|
||||
const BATCH = 100
|
||||
let insertados = 0
|
||||
let errores = 0
|
||||
|
||||
for (let i = 0; i < filas.length; i += BATCH) {
|
||||
const lote = filas.slice(i, i + BATCH)
|
||||
|
||||
const { error } = await supabase
|
||||
.from(tabla)
|
||||
.upsert(lote, {
|
||||
onConflict: {
|
||||
siniestros: 'id_feu',
|
||||
Involucrados: 'id_involucrado',
|
||||
Personas: 'id_feu_persona'
|
||||
}[tabla],
|
||||
ignoreDuplicates: false
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error(`🔴 Error lote ${i}:`, error)
|
||||
console.log('🔴 MSG:', error?.message)
|
||||
console.error('🔴 Primer registro:', JSON.stringify(lote[0], null, 2))
|
||||
errores += lote.length
|
||||
} else {
|
||||
insertados += lote.length
|
||||
}
|
||||
|
||||
setProgreso(p => ({
|
||||
...p,
|
||||
[tabla]: {
|
||||
estado: 'importando',
|
||||
procesados: i + lote.length,
|
||||
total: filas.length
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
setArchivos(prev => ({
|
||||
...prev,
|
||||
[tabla]: {
|
||||
...prev[tabla],
|
||||
cargado: true,
|
||||
resultado: { insertados, errores }
|
||||
}
|
||||
}))
|
||||
|
||||
setProgreso(p => ({ ...p, [tabla]: { estado: 'listo' } }))
|
||||
} catch (e) {
|
||||
setArchivos(prev => ({
|
||||
...prev,
|
||||
[tabla]: { ...prev[tabla], error: e.message }
|
||||
}))
|
||||
setProgreso(p => ({ ...p, [tabla]: { estado: 'error' } }))
|
||||
}
|
||||
}
|
||||
|
||||
async function importarTodo() {
|
||||
setImportando(true)
|
||||
for (const tabla of TABLAS) {
|
||||
if (archivos[tabla].valido && !archivos[tabla].cargado) {
|
||||
await importarTabla(tabla)
|
||||
}
|
||||
}
|
||||
setImportando(false)
|
||||
}
|
||||
|
||||
const algunoValido = TABLAS.some(t => archivos[t].valido && !archivos[t].cargado)
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-opsv-text">Importar datos CSV</h1>
|
||||
<p className="text-sm text-opsv-muted mt-1">
|
||||
Cargá los tres archivos. Las columnas extra del CSV se ignoran automáticamente.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-2xl p-4 text-sm text-blue-800">
|
||||
<p className="font-semibold mb-1">📋 Orden recomendado:</p>
|
||||
<ol className="list-decimal list-inside space-y-0.5">
|
||||
<li><strong>siniestros</strong> — tabla principal (id_feu). Se calculan dia_semana y es_fin_semana automáticamente.</li>
|
||||
<li><strong>Involucrados</strong> — referencia id_feu + id_involucrado</li>
|
||||
<li><strong>Personas</strong> — referencia id_feu + id_involucrado + id_persona</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{TABLAS.map(tabla => {
|
||||
const est = archivos[tabla]
|
||||
const prog = progreso[tabla]
|
||||
const porcentaje = prog?.total ? Math.round((prog.procesados / prog.total) * 100) : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabla}
|
||||
className={`bg-white rounded-2xl border shadow-sm overflow-hidden
|
||||
${est.cargado ? 'border-green-300' : est.error ? 'border-red-300' : est.valido ? 'border-blue-300' : 'border-opsv-border'}`}
|
||||
>
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center
|
||||
${est.cargado ? 'bg-green-100' : est.valido ? 'bg-blue-100' : 'bg-slate-100'}`}>
|
||||
{est.cargado
|
||||
? <CheckCircle className="h-5 w-5 text-green-600" />
|
||||
: est.valido
|
||||
? <FileText className="h-5 w-5 text-blue-600" />
|
||||
: <Upload className="h-5 w-5 text-slate-400" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-opsv-text">{tabla}</p>
|
||||
<p className="text-xs text-opsv-muted">
|
||||
{est.nombre
|
||||
? `${est.rows.length} filas · ${est.headers.length} columnas en CSV`
|
||||
: `${ESQUEMAS[tabla].length} columnas requeridas en Supabase`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{est.valido && !est.cargado && (
|
||||
<button
|
||||
onClick={() => importarTabla(tabla)}
|
||||
disabled={prog?.estado === 'importando'}
|
||||
className="px-3 py-1.5 bg-opsv-navy text-white text-xs font-semibold rounded-xl hover:bg-opacity-90 transition disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
{prog?.estado === 'importando'
|
||||
? <><Loader2 className="h-3 w-3 animate-spin" /> Importando...</>
|
||||
: 'Importar'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => refs[tabla].current?.click()}
|
||||
className="px-3 py-1.5 border border-opsv-border text-opsv-text text-xs font-semibold rounded-xl hover:bg-slate-50 transition"
|
||||
>
|
||||
{est.nombre ? 'Cambiar' : 'Seleccionar CSV'}
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={refs[tabla]}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={e => handleFile(tabla, e)}
|
||||
/>
|
||||
|
||||
{est.rows.length > 0 && (
|
||||
<button
|
||||
onClick={() => setArchivos(prev => ({
|
||||
...prev,
|
||||
[tabla]: { ...prev[tabla], expandido: !prev[tabla].expandido }
|
||||
}))}
|
||||
className="p-1.5 text-opsv-muted hover:text-opsv-text transition"
|
||||
>
|
||||
{est.expandido ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prog?.estado === 'importando' && (
|
||||
<div className="px-5 pb-3">
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-opsv-navy h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${porcentaje}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-opsv-muted mt-1">
|
||||
{prog.procesados} / {prog.total} registros
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{est.cargado && est.resultado && (
|
||||
<div className="px-5 pb-4">
|
||||
<div className="bg-green-50 rounded-xl p-3 text-sm text-green-800 flex gap-4">
|
||||
<span>✅ <strong>{est.resultado.insertados}</strong> insertados</span>
|
||||
{est.resultado.errores > 0 && <span>⚠️ <strong>{est.resultado.errores}</strong> con error</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{est.nombre && !est.cargado && (
|
||||
<div className="px-5 pb-4 space-y-2">
|
||||
{est.columnasFaltantes.length > 0 && (
|
||||
<div className="bg-red-50 rounded-xl p-3 text-xs text-red-700">
|
||||
<p className="font-semibold flex items-center gap-1 mb-1">
|
||||
<XCircle className="h-3.5 w-3.5" /> Columnas faltantes ({est.columnasFaltantes.length}):
|
||||
</p>
|
||||
<p className="font-mono">{est.columnasFaltantes.join(', ')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{est.columnasExtra.length > 0 && (
|
||||
<div className="bg-yellow-50 rounded-xl p-3 text-xs text-yellow-700">
|
||||
<p className="font-semibold flex items-center gap-1 mb-1">
|
||||
<AlertTriangle className="h-3.5 w-3.5" /> Columnas extra en CSV (se ignoran automáticamente):
|
||||
</p>
|
||||
<p className="font-mono">{est.columnasExtra.join(', ')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{est.valido && (
|
||||
<div className="bg-green-50 rounded-xl p-3 text-xs text-green-700 flex items-center gap-1">
|
||||
<CheckCircle className="h-3.5 w-3.5" /> Todas las columnas requeridas presentes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{est.expandido && est.rows.length > 0 && (
|
||||
<div className="border-t border-opsv-border overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{est.headers.slice(0, 8).map(h => (
|
||||
<th key={h} className="px-3 py-2 text-left font-semibold text-opsv-muted whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
{est.headers.length > 8 && (
|
||||
<th className="px-3 py-2 text-opsv-muted">+{est.headers.length - 8} más</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{est.rows.slice(0, 5).map((row, i) => (
|
||||
<tr key={i} className="border-t border-opsv-border">
|
||||
{est.headers.slice(0, 8).map(h => (
|
||||
<td key={h} className="px-3 py-2 text-opsv-text whitespace-nowrap max-w-32 truncate">
|
||||
{row[h] ?? '-'}
|
||||
</td>
|
||||
))}
|
||||
{est.headers.length > 8 && <td className="px-3 py-2 text-opsv-muted">...</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{est.rows.length > 5 && (
|
||||
<p className="px-4 py-2 text-xs text-opsv-muted bg-slate-50">
|
||||
Mostrando 5 de {est.rows.length} filas
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{algunoValido && (
|
||||
<button
|
||||
onClick={importarTodo}
|
||||
disabled={importando}
|
||||
className="w-full py-3 bg-gradient-to-r from-opsv-navy to-opsv-navy-dark text-white font-semibold rounded-2xl hover:shadow-lg transition disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{importando
|
||||
? <><Loader2 className="h-5 w-5 animate-spin" /> Importando...</>
|
||||
: `Importar ${TABLAS.filter(t => archivos[t].valido && !archivos[t].cargado).length} tabla(s)`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{TABLAS.every(t => archivos[t].cargado) && (
|
||||
<div className="bg-green-50 border border-green-300 rounded-2xl p-5 text-center">
|
||||
<CheckCircle className="h-8 w-8 text-green-600 mx-auto mb-2" />
|
||||
<p className="font-bold text-green-800 text-lg">¡Importación completada!</p>
|
||||
<p className="text-sm text-green-700 mt-1">Los tres archivos fueron procesados correctamente.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/// src/pages/Login.jsx
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { AlertCircle, LogIn } from 'lucide-react'
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loadingLocal, setLoadingLocal] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const { signIn, user, isAdmin, loading: authLoading } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Navegar cuando ya hay user (y opcionalmente admin)
|
||||
useEffect(() => {
|
||||
console.log('🔍 Auth state:', {
|
||||
authLoading,
|
||||
user: !!user,
|
||||
isAdmin
|
||||
})
|
||||
|
||||
if (!authLoading && user ) {
|
||||
navigate('/admin', { replace: true })
|
||||
}
|
||||
}, [authLoading, user, isAdmin, navigate])
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoadingLocal(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await signIn(email, password)
|
||||
// navigate lo hace el useEffect cuando cambie user
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoadingLocal(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-opsv-navy via-opsv-navy to-opsv-blue">
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-opsv-blue/20 rounded-full blur-3xl -mr-48 -mt-48" />
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-opsv-orange/20 rounded-full blur-3xl -ml-48 -mb-48" />
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md mx-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl border border-opsv-border">
|
||||
<div className="bg-gradient-to-r from-opsv-navy to-opsv-navy-dark px-8 py-12 text-center">
|
||||
<div className="text-4xl font-black text-white mb-2">OPSV</div>
|
||||
<div className="text-sm font-semibold text-opsv-blue uppercase tracking-[0.35em]">
|
||||
Agencia Provincial de Seguridad Vial
|
||||
</div>
|
||||
<div className="text-xs text-slate-300 mt-2">Santa Cruz, Argentina</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="px-8 py-10">
|
||||
{error && (
|
||||
<div className="mb-6 flex items-center gap-3 bg-red-50 border border-red-200 rounded-2xl p-4">
|
||||
<AlertCircle className="h-5 w-5 text-red-600 flex-shrink-0" />
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-semibold text-opsv-text uppercase tracking-wide mb-2"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder="admin@opsv.sc.gov.ar"
|
||||
className="w-full px-4 py-3 border border-opsv-border rounded-2xl bg-opsv-bg focus:outline-none focus:ring-2 focus:ring-opsv-blue focus:border-transparent transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-semibold text-opsv-text uppercase tracking-wide mb-2"
|
||||
>
|
||||
Contraseña
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-3 border border-opsv-border rounded-2xl bg-opsv-bg focus:outline-none focus:ring-2 focus:ring-opsv-blue focus:border-transparent transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loadingLocal}
|
||||
className="w-full mt-8 px-6 py-3 bg-slate-800 hover:bg-slate-900 text-white font-semibold rounded-2xl hover:shadow-lg transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 uppercase tracking-wide"
|
||||
>
|
||||
<LogIn className="h-5 w-5" />
|
||||
{loadingLocal ? 'Ingresando...' : 'Ingresar'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-opsv-border text-center text-xs text-opsv-muted">
|
||||
<p>Panel administrativo reservado para personal autorizado</p>
|
||||
<p className="mt-2">
|
||||
Para soporte, contactá a:{' '}
|
||||
<span className="font-semibold">admin@opsv.sc.gov.ar</span>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useMemo } from 'react'
|
||||
import KPICard from '../components/ui/KPICard'
|
||||
import SiniestrosPorMes from '../components/charts/SiniestrosPorMes'
|
||||
import PorTipoSiniestro from '../components/charts/PorTipoSiniestro'
|
||||
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
||||
import PorLocalidad from '../components/charts/PorLocalidad'
|
||||
import PerfilVictimas from '../components/charts/PerfilVictimas'
|
||||
import ProteccionPersonas from '../components/charts/ProteccionPersonas'
|
||||
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
||||
import ChartCard from '../components/ui/ChartCard'
|
||||
|
||||
const SECTION_COLORS = {
|
||||
total: '#252C61',
|
||||
fatales: '#C44228',
|
||||
victimas: '#922B21',
|
||||
urbano: '#80B0DE',
|
||||
rural: '#337C58',
|
||||
}
|
||||
|
||||
export default function SecFatales({ siniestros, personas, involucrados }) {
|
||||
const filtrarFatales = useMemo(
|
||||
() => siniestros.filter((s) => Number(s.cantidad_fallecidos ?? s.fallecidos) > 0),
|
||||
[siniestros],
|
||||
)
|
||||
|
||||
const total = filtrarFatales.length
|
||||
const victimas = filtrarFatales.reduce(
|
||||
(acc, s) => acc + Number((s.cantidad_fallecidos ?? s.fallecidos) || 0),
|
||||
0,
|
||||
)
|
||||
const diarios = total ? (total / 365).toFixed(1) : '0.0'
|
||||
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* KPIs */}
|
||||
<div data-pdf-block className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<KPICard label="Siniestros fatales" value={total} color={SECTION_COLORS.fatales} />
|
||||
<KPICard label="Víctimas fatales" value={victimas} color={SECTION_COLORS.victimas} />
|
||||
<KPICard label="Promedio diario" value={diarios} unit="x día" color={SECTION_COLORS.urbano} />
|
||||
<KPICard label="% sobre total" value={pct} color={SECTION_COLORS.rural} />
|
||||
</div>
|
||||
|
||||
{/* Evolución y tipo de siniestro */}
|
||||
<div className="grid gap-6 xl:grid-cols-[1.6fr_1fr] items-start">
|
||||
<ChartCard
|
||||
kicker="Evolución temporal"
|
||||
title="Siniestros fatales por mes"
|
||||
subtitle="Cantidad de siniestros con al menos una víctima fatal, agregados por mes del periodo seleccionado."
|
||||
height="lg"
|
||||
>
|
||||
<SiniestrosPorMes siniestros={filtrarFatales} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
kicker="Características del siniestro"
|
||||
title="Por tipo de siniestro"
|
||||
subtitle="Distribución de los siniestros fatales según tipo de evento vial."
|
||||
height="md"
|
||||
>
|
||||
<PorTipoSiniestro siniestros={filtrarFatales} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Franja horaria */}
|
||||
<ChartCard
|
||||
kicker="Condiciones temporales"
|
||||
title="Franja horaria de los siniestros fatales"
|
||||
subtitle="Cantidad de siniestros fatales según la franja horaria en que ocurrieron."
|
||||
height="md"
|
||||
>
|
||||
<FranjaHoraria siniestros={filtrarFatales} />
|
||||
</ChartCard>
|
||||
|
||||
{/* Distribución territorial */}
|
||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_2.1fr] items-start">
|
||||
<ChartCard
|
||||
kicker="Distribución territorial"
|
||||
title="Zona de ocurrencia"
|
||||
subtitle="Proporción de siniestros fatales ocurridos en ámbitos urbanos y rurales."
|
||||
height="sm"
|
||||
>
|
||||
<ZonaOcurrencia siniestros={filtrarFatales} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
kicker="Distribución territorial"
|
||||
title="Siniestros fatales por localidad (Top 10)"
|
||||
subtitle="Las 10 localidades con mayor cantidad de siniestros fatales registrados en el periodo analizado."
|
||||
height="lg"
|
||||
>
|
||||
<PorLocalidad siniestros={filtrarFatales} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Perfil de víctimas */}
|
||||
<ChartCard
|
||||
kicker="Perfil de víctimas"
|
||||
title="Características de las víctimas fatales"
|
||||
subtitle="Distribución de las víctimas fatales según edad, género y Tipo de Vehículo."
|
||||
height="lg"
|
||||
>
|
||||
<PerfilVictimas personas={personas} involucrados={involucrados} soloFatales={true} />
|
||||
</ChartCard>
|
||||
|
||||
{/* Seguridad pasiva */}
|
||||
<ChartCard
|
||||
kicker="Seguridad pasiva"
|
||||
title="Uso de elementos de protección"
|
||||
subtitle="Uso de cinturón de seguridad, casco y otros elementos de protección en los siniestros fatales. (Bases ajustadas por tipo de vehículo)"
|
||||
height="auto"
|
||||
>
|
||||
<ProteccionPersonas personas={personas} involucrados={involucrados} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { useMemo } from 'react'
|
||||
import { calcularKPIs } from '../utils/calculos'
|
||||
import SerieHistorica, {
|
||||
SERIE_HISTORICA,
|
||||
getPoblacionAnual,
|
||||
} from '../components/charts/SerieHistorica'
|
||||
import PorLocalidad from '../components/charts/PorLocalidad'
|
||||
import PorTipoSiniestro from '../components/charts/PorTipoSiniestro'
|
||||
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
||||
import KPICard from '../components/ui/KPICard'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { useChartTheme } from '../hooks/useChartTheme'
|
||||
import { COLOR } from '../utils/colores'
|
||||
|
||||
function calcularPorcentaje(base, actual) {
|
||||
if (!base || base === 0) return '0%'
|
||||
const diff = actual - base
|
||||
const sign = diff >= 0 ? '▲' : '▼'
|
||||
return `${sign} ${Math.abs(((diff / base) * 100).toFixed(1))}%`
|
||||
}
|
||||
|
||||
export default function SecHistorica({ siniestros, year }) {
|
||||
const yearNum = Number(year)
|
||||
const kpis = calcularKPIs(siniestros)
|
||||
|
||||
// Hook se usa DENTRO del componente
|
||||
const { tickColor, gridColor, tooltipBg, tooltipBorder, tooltipLabel } =
|
||||
useChartTheme()
|
||||
|
||||
const serieComparativa = useMemo(() => {
|
||||
const base = [...SERIE_HISTORICA]
|
||||
const yaExiste = base.some((row) => row.ano === yearNum)
|
||||
|
||||
if (!yaExiste && kpis.total > 0) {
|
||||
const pob = getPoblacionAnual(yearNum)
|
||||
const tasa = Number(((kpis.victimas / pob) * 100000).toFixed(2))
|
||||
|
||||
return [
|
||||
...base,
|
||||
{
|
||||
ano: yearNum,
|
||||
siniestros: kpis.total,
|
||||
victimas: kpis.victimas,
|
||||
tasa,
|
||||
},
|
||||
].sort((a, b) => a.ano - b.ano)
|
||||
}
|
||||
|
||||
return base
|
||||
}, [yearNum, kpis.total, kpis.victimas])
|
||||
|
||||
const yearActualData = useMemo(
|
||||
() => serieComparativa.find((row) => row.ano === yearNum) ?? null,
|
||||
[serieComparativa, yearNum],
|
||||
)
|
||||
|
||||
const prevYearData = useMemo(
|
||||
() => serieComparativa.find((row) => row.ano === yearNum - 1) ?? null,
|
||||
[serieComparativa, yearNum],
|
||||
)
|
||||
|
||||
const victimasPorAno = useMemo(
|
||||
() =>
|
||||
serieComparativa.map((row) => ({ ano: row.ano, victimas: row.victimas })),
|
||||
[serieComparativa],
|
||||
)
|
||||
|
||||
const dataTabla = serieComparativa
|
||||
|
||||
const serieParaExtremos = useMemo(() => {
|
||||
const base = [...SERIE_HISTORICA]
|
||||
|
||||
const existe2025 = base.some((row) => row.ano === 2025)
|
||||
if (!existe2025) {
|
||||
base.push({
|
||||
ano: 2025,
|
||||
siniestros: 0,
|
||||
victimas: 21,
|
||||
tasa: 6.27,
|
||||
})
|
||||
}
|
||||
|
||||
return base
|
||||
.filter((row) => row.ano !== 2020 && row.victimas != null)
|
||||
.sort((a, b) => a.ano - b.ano)
|
||||
}, [])
|
||||
|
||||
const victimasActual = yearActualData?.victimas ?? kpis.victimas ?? null
|
||||
|
||||
const comparativoVictimas =
|
||||
prevYearData?.victimas != null && victimasActual != null
|
||||
? calcularPorcentaje(prevYearData.victimas, victimasActual)
|
||||
: '—'
|
||||
|
||||
const serieVictimasValidas = serieComparativa.filter(
|
||||
(row) => row.ano !== 2020 && row.victimas != null,
|
||||
)
|
||||
|
||||
const maxEntry =
|
||||
serieParaExtremos.length > 0
|
||||
? serieParaExtremos.reduce(
|
||||
(max, row) => (max == null || row.victimas > max.victimas ? row : max),
|
||||
null,
|
||||
)
|
||||
: null
|
||||
const maxHistorico = maxEntry?.victimas ?? null
|
||||
const maxAno = maxEntry?.ano ?? null
|
||||
|
||||
const minEntry =
|
||||
serieParaExtremos.length > 0
|
||||
? serieParaExtremos.reduce(
|
||||
(min, row) => (min == null || row.victimas < min.victimas ? row : min),
|
||||
null,
|
||||
)
|
||||
: null
|
||||
const minHistorico = minEntry?.victimas ?? null
|
||||
const minAno = minEntry?.ano ?? null
|
||||
|
||||
const serieOrdenada = [...serieVictimasValidas].sort((a, b) => a.ano - b.ano)
|
||||
const ultimos10 = serieOrdenada.filter((row) => row.ano <= yearNum).slice(-10)
|
||||
|
||||
const promedio10 =
|
||||
ultimos10.length > 0
|
||||
? (
|
||||
ultimos10.reduce((acc, row) => acc + row.victimas, 0) / ultimos10.length
|
||||
).toFixed(1)
|
||||
: null
|
||||
|
||||
const rango10Desde = ultimos10[0]?.ano ?? null
|
||||
const rango10Hasta = ultimos10[ultimos10.length - 1]?.ano ?? null
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* KPIs históricas */}
|
||||
<div data-pdf-block className="grid gap-4 xl:grid-cols-5">
|
||||
<KPICard
|
||||
label={
|
||||
yearActualData
|
||||
? `Víctimas fatales (${yearActualData.ano})`
|
||||
: 'Víctimas fatales'
|
||||
}
|
||||
value={victimasActual ?? '—'}
|
||||
color={COLOR.red}
|
||||
/>
|
||||
<KPICard
|
||||
label={
|
||||
prevYearData && yearActualData
|
||||
? `Variación vs. año anterior (${prevYearData.ano}–${yearActualData.ano})`
|
||||
: 'Variación vs. año anterior'
|
||||
}
|
||||
value={comparativoVictimas}
|
||||
color={COLOR.gold}
|
||||
/>
|
||||
<KPICard
|
||||
label={
|
||||
maxAno
|
||||
? `Máximo histórico de víctimas (${maxAno})`
|
||||
: 'Máximo histórico de víctimas'
|
||||
}
|
||||
value={maxHistorico ?? '—'}
|
||||
color={COLOR.navy}
|
||||
/>
|
||||
<KPICard
|
||||
label={
|
||||
minAno
|
||||
? `Mínimo histórico de víctimas (${minAno})`
|
||||
: 'Mínimo histórico de víctimas'
|
||||
}
|
||||
value={minHistorico ?? '—'}
|
||||
color={COLOR.blue}
|
||||
/>
|
||||
<KPICard
|
||||
label={
|
||||
rango10Desde && rango10Hasta
|
||||
? `Promedio últimos 10 años (${rango10Desde}–${rango10Hasta})`
|
||||
: 'Promedio últimos 10 años'
|
||||
}
|
||||
value={promedio10 ?? '—'}
|
||||
color={COLOR.green}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gráfico de tasa + barras de víctimas */}
|
||||
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]">
|
||||
<SerieHistorica
|
||||
year={yearNum}
|
||||
siniestrosActual={kpis.total}
|
||||
victimasActual={kpis.victimas}
|
||||
/>
|
||||
<div data-pdf-block 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">
|
||||
Víctimas fatales
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl font-black text-opsv-navy">
|
||||
Evolución anual
|
||||
</h3>
|
||||
</div>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={victimasPorAno}
|
||||
margin={{ top: 10, right: 20, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke={gridColor}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="ano"
|
||||
tick={{ fill: tickColor, fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: tickColor, fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
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="victimas" fill={COLOR.red} radius={[8, 8, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Distribución por tipo, franja y localidad */}
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<PorTipoSiniestro siniestros={siniestros} />
|
||||
<FranjaHoraria siniestros={siniestros} />
|
||||
<PorLocalidad siniestros={siniestros} />
|
||||
</div>
|
||||
|
||||
{/* Tabla histórica */}
|
||||
<div data-pdf-block 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">
|
||||
Tabla histórica
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl font-black text-opsv-navy">
|
||||
Histórico de siniestros
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className="min-w-full text-left text-sm"
|
||||
style={{ borderCollapse: 'collapse' }}
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b-2 border-opsv-border">
|
||||
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">
|
||||
Año
|
||||
</th>
|
||||
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">
|
||||
Siniestros
|
||||
</th>
|
||||
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">
|
||||
Víctimas
|
||||
</th>
|
||||
<th className="pb-3 font-bold uppercase tracking-wider text-opsv-navy">
|
||||
Tasa
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataTabla.map((row) => (
|
||||
<tr
|
||||
key={row.ano}
|
||||
className={`border-b border-opsv-border/60 transition-colors hover:bg-opsv-bg ${
|
||||
row.ano === yearNum &&
|
||||
!SERIE_HISTORICA.find((r) => r.ano === yearNum)
|
||||
? 'bg-blue-500/5 font-semibold'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 pr-6 font-medium text-opsv-navy">
|
||||
{row.ano}
|
||||
</td>
|
||||
<td className="py-3 pr-6 text-opsv-text">
|
||||
{row.siniestros}
|
||||
</td>
|
||||
<td className="py-3 pr-6 text-opsv-text">
|
||||
{row.victimas ?? '—'}
|
||||
</td>
|
||||
<td className="py-3 text-opsv-text">{row.tasa ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useMemo } from 'react'
|
||||
import KPICard from '../components/ui/KPICard'
|
||||
import SiniestrosPorMes from '../components/charts/SiniestrosPorMes'
|
||||
import PorTipoSiniestro from '../components/charts/PorTipoSiniestro'
|
||||
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
||||
import PorLocalidad from '../components/charts/PorLocalidad'
|
||||
import PerfilVictimas from '../components/charts/PerfilVictimas'
|
||||
import ProteccionPersonas from '../components/charts/ProteccionPersonas'
|
||||
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
||||
import ChartCard from '../components/ui/ChartCard'
|
||||
|
||||
export default function SecLesionados({ siniestros, personas, involucrados }) {
|
||||
const filtrarLesionados = useMemo(
|
||||
() =>
|
||||
siniestros.filter((s) => {
|
||||
const fatales = Number(s.cantidad_fallecidos ?? s.fallecidos)
|
||||
const lesion = Number(s.cantidad_lesionados ?? s.heridos)
|
||||
return fatales === 0 && lesion > 0
|
||||
}),
|
||||
[siniestros],
|
||||
)
|
||||
|
||||
const total = filtrarLesionados.length
|
||||
const lesionados = filtrarLesionados.reduce(
|
||||
(acc, s) => acc + Number((s.cantidad_lesionados ?? s.heridos) || 0),
|
||||
0,
|
||||
)
|
||||
const diarios = total ? (total / 365).toFixed(1) : '0.0'
|
||||
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* KPIs */}
|
||||
<div data-pdf-block className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<KPICard label="Siniestros con lesionados" value={total} color="#CD9F2B" />
|
||||
<KPICard label="Total lesionados" value={lesionados} color="#E8881A" />
|
||||
<KPICard label="Promedio diario" value={diarios} unit="x día" color="#80B0DE" />
|
||||
<KPICard label="% sobre total" value={pct} color="#337C58" />
|
||||
</div>
|
||||
|
||||
{/* Evolución y tipo de siniestro */}
|
||||
<div className="grid gap-6 xl:grid-cols-[1.6fr_1fr] items-start">
|
||||
<ChartCard
|
||||
kicker="Evolución temporal"
|
||||
title="Siniestros con lesionados por mes"
|
||||
subtitle="Cantidad de siniestros sin fallecidos pero con al menos una persona lesionada, agregados por mes del periodo seleccionado."
|
||||
height="lg"
|
||||
>
|
||||
<SiniestrosPorMes siniestros={filtrarLesionados} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
kicker="Características del siniestro"
|
||||
title="Por tipo de siniestro"
|
||||
subtitle="Distribución de los siniestros con lesionados según tipo de evento vial."
|
||||
height="md"
|
||||
>
|
||||
<PorTipoSiniestro siniestros={filtrarLesionados} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Franja horaria */}
|
||||
<ChartCard
|
||||
kicker="Condiciones temporales"
|
||||
title="Franja horaria de los siniestros con lesionados"
|
||||
subtitle="Cantidad de siniestros con lesionados según la franja horaria del día en que ocurrieron."
|
||||
height="md"
|
||||
>
|
||||
<FranjaHoraria siniestros={filtrarLesionados} />
|
||||
</ChartCard>
|
||||
|
||||
{/* Distribución territorial */}
|
||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_2.1fr] items-start">
|
||||
<ChartCard
|
||||
kicker="Distribución territorial"
|
||||
title="Zona de ocurrencia"
|
||||
subtitle="Proporción de siniestros con lesionados ocurridos en ámbitos urbanos y rurales."
|
||||
height="sm"
|
||||
>
|
||||
<ZonaOcurrencia siniestros={filtrarLesionados} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
kicker="Distribución territorial"
|
||||
title="Siniestros con lesionados por localidad (Top 10)"
|
||||
subtitle="Las 10 localidades con mayor cantidad de siniestros con personas lesionadas en el periodo analizado."
|
||||
height="lg"
|
||||
>
|
||||
<PorLocalidad siniestros={filtrarLesionados} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Perfil de víctimas */}
|
||||
<ChartCard
|
||||
kicker="Perfil de víctimas"
|
||||
title="Características de las personas lesionadas"
|
||||
subtitle="Distribución de las personas lesionadas según edad, género y Tipo de Vehículo."
|
||||
height="lg"
|
||||
>
|
||||
<PerfilVictimas personas={personas} involucrados={involucrados} />
|
||||
</ChartCard>
|
||||
|
||||
{/* Seguridad pasiva */}
|
||||
<ChartCard
|
||||
kicker="Seguridad pasiva"
|
||||
title="Uso de elementos de protección"
|
||||
subtitle="Uso de cinturón de seguridad, casco y otros elementos de protección en los siniestros con lesionados."
|
||||
height="auto"
|
||||
>
|
||||
<ProteccionPersonas personas={personas} involucrados={involucrados} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import KPICard from '../components/ui/KPICard'
|
||||
import SiniestrosPorMes from '../components/charts/SiniestrosPorMes'
|
||||
import DonutGravedad from '../components/charts/DonutGravedad'
|
||||
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
||||
import {
|
||||
calcularKPIs,
|
||||
calcularTablaComparativa,
|
||||
POBLACION_DEPTO,
|
||||
} from '../utils/calculos'
|
||||
|
||||
export default function SecResumen({ siniestros }) {
|
||||
const kpis = calcularKPIs(siniestros)
|
||||
const tablaData = calcularTablaComparativa(siniestros)
|
||||
|
||||
console.log(
|
||||
'Departamentos en BD:',
|
||||
[...new Set(siniestros.map((s) => s.departamento))]
|
||||
)
|
||||
console.log(
|
||||
'SecResumen recibe siniestros:',
|
||||
siniestros.length,
|
||||
siniestros[0]?.ano,
|
||||
siniestros[0]?.mes
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div data-pdf-block className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<KPICard label="Total siniestros" value={kpis.total} color="#252C61" />
|
||||
<KPICard label="Siniestros fatales" value={kpis.fatales} color="#C44228" />
|
||||
<KPICard label="Con lesionados" value={kpis.conLes} color="#CD9F2B" />
|
||||
<KPICard label="Sin lesiones" value={kpis.sinLes} color="#337C58" />
|
||||
<KPICard label="Victimas fatales" value={kpis.victimas} color="#922B21" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.7fr_1fr]">
|
||||
<SiniestrosPorMes siniestros={siniestros} />
|
||||
<DonutGravedad siniestros={siniestros} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_2.1fr]">
|
||||
<ZonaOcurrencia siniestros={siniestros} />
|
||||
|
||||
<div data-pdf-block 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">
|
||||
Comparativa por departamento
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl font-black text-opsv-navy">
|
||||
Siniestros fatales por departamento
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{tablaData.length === 0 ? (
|
||||
<p className="py-4 text-sm text-opsv-muted">
|
||||
Sin siniestros fatales registrados para el periodo seleccionado.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr className="border-b border-opsv-border">
|
||||
<th className="border-b border-opsv-border px-4 py-3 text-left text-sm font-bold uppercase tracking-wider text-opsv-navy">
|
||||
Departamento
|
||||
</th>
|
||||
<th className="border-b border-opsv-border px-4 py-3 text-right text-sm font-bold uppercase tracking-wider text-opsv-navy">
|
||||
Siniestros fatales
|
||||
</th>
|
||||
<th className="border-b border-opsv-border px-4 py-3 text-right text-sm font-bold uppercase tracking-wider text-opsv-navy">
|
||||
Víctimas fatales
|
||||
</th>
|
||||
<th className="border-b border-opsv-border px-4 py-3 text-right text-sm font-bold uppercase tracking-wider text-opsv-navy">
|
||||
Tasa c/100k Hab.
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{tablaData.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-opsv-border/60 hover:bg-opsv-bg"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm font-medium text-opsv-text">
|
||||
{row.departamento}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-opsv-muted">
|
||||
{row.siniestrosFatales}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-opsv-muted">
|
||||
{row.victimasFatales}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-semibold text-opsv-navy">
|
||||
{row.tasa !== null && row.tasa !== '-' ? row.tasa : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useMemo } from 'react'
|
||||
import KPICard from '../components/ui/KPICard'
|
||||
import SiniestrosPorMes from '../components/charts/SiniestrosPorMes'
|
||||
import PorTipoSiniestro from '../components/charts/PorTipoSiniestro'
|
||||
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
||||
import PorLocalidad from '../components/charts/PorLocalidad'
|
||||
import TipoInvolucrado from '../components/charts/TipoInvolucrado'
|
||||
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
||||
import ChartCard from '../components/ui/ChartCard'
|
||||
|
||||
export default function SecSinLesiones({ siniestros, involucrados }) {
|
||||
const filtrarSinLesiones = useMemo(
|
||||
() =>
|
||||
siniestros.filter((s) => {
|
||||
const fatales = Number(s.cantidad_fallecidos ?? s.fallecidos)
|
||||
const lesion = Number(s.cantidad_lesionados ?? s.heridos)
|
||||
return fatales === 0 && lesion === 0
|
||||
}),
|
||||
[siniestros],
|
||||
)
|
||||
|
||||
const total = filtrarSinLesiones.length
|
||||
const diarios = total ? (total / 365).toFixed(1) : '0.0'
|
||||
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
||||
const ilesos = filtrarSinLesiones.reduce((acc, s) => acc + Number(s.ilesos || 0), 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* KPIs */}
|
||||
<div data-pdf-block className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<KPICard label="Siniestros sin lesiones" value={total} color="#337C58" />
|
||||
<KPICard label="Promedio diario" value={diarios} unit="x día" color="#80B0DE" />
|
||||
<KPICard label="% sobre total" value={pct} color="#CD9F2B" />
|
||||
</div>
|
||||
|
||||
{/* Evolución y tipo */}
|
||||
<div className="grid gap-6 xl:grid-cols-[1.6fr_1fr] items-start">
|
||||
<ChartCard
|
||||
kicker="Evolución temporal"
|
||||
title="Siniestros sin lesiones por mes"
|
||||
subtitle="Cantidad de siniestros sin lesiones, agregados por mes del periodo seleccionado."
|
||||
height="auto"
|
||||
>
|
||||
<SiniestrosPorMes siniestros={filtrarSinLesiones} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
kicker="Características del siniestro"
|
||||
title="Por tipo de siniestro"
|
||||
subtitle="Distribución de los siniestros sin lesiones según tipo de evento vial."
|
||||
height="auto"
|
||||
>
|
||||
<PorTipoSiniestro siniestros={filtrarSinLesiones} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Franja horaria */}
|
||||
<ChartCard
|
||||
kicker="Condiciones temporales"
|
||||
title="Franja horaria de los siniestros sin lesiones"
|
||||
subtitle="Cantidad de siniestros sin lesiones según la franja horaria del día en que ocurrieron y la zona."
|
||||
height="auto"
|
||||
>
|
||||
<FranjaHoraria siniestros={filtrarSinLesiones} />
|
||||
</ChartCard>
|
||||
|
||||
{/* Distribución territorial */}
|
||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_2.1fr] items-start">
|
||||
<ChartCard
|
||||
kicker="Distribución territorial"
|
||||
title="Zona de ocurrencia"
|
||||
subtitle="Proporción de siniestros sin lesiones ocurridos en ámbitos urbanos y rurales."
|
||||
height="auto"
|
||||
>
|
||||
<ZonaOcurrencia siniestros={filtrarSinLesiones} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
kicker="Distribución territorial"
|
||||
title="Siniestros sin lesiones por localidad (Top 10)"
|
||||
subtitle="Las 10 localidades con mayor cantidad de siniestros sin lesiones en el periodo analizado."
|
||||
height="lg"
|
||||
>
|
||||
<PorLocalidad siniestros={filtrarSinLesiones} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Perfil de involucrados */}
|
||||
<ChartCard
|
||||
kicker="Perfil de involucrados"
|
||||
title="Tipo de involucrado"
|
||||
subtitle="Distribución de los involucrados en siniestros sin lesiones según vehiculo involucrado."
|
||||
height="auto"
|
||||
className="mx-auto w-full max-w-4xl"
|
||||
>
|
||||
<TipoInvolucrado involucrados={involucrados} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useMemo } from 'react'
|
||||
import { calcularSintesis } from '../utils/calculos'
|
||||
import { SERIE_HISTORICA } from '../components/charts/SerieHistorica'
|
||||
import ChartCard from '../components/ui/ChartCard'
|
||||
import { COLOR } from '../utils/colores'
|
||||
|
||||
// ── Ficha compacta vertical ───────────────────────────────────────────────────
|
||||
function Ficha({ kicker, title, color, destacado, datos }) {
|
||||
return (
|
||||
<ChartCard kicker={kicker} title={title} height="auto" contentClassName="min-h-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
{/* Dato destacado */}
|
||||
<div
|
||||
className="flex flex-col justify-center rounded-[14px] px-4 py-4"
|
||||
style={{ background: `${color}12` }}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em]" style={{ color }}>
|
||||
{destacado.label}
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-black leading-tight text-opsv-navy">
|
||||
{destacado.valor ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Grilla de datos secundarios */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{datos.map(({ label, valor }) => (
|
||||
<div key={label} className="rounded-[12px] bg-opsv-bg px-3 py-2.5">
|
||||
<p className="text-xs font-medium text-opsv-muted">{label}</p>
|
||||
<p className="mt-0.5 text-sm font-black text-opsv-navy">{valor ?? '—'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Componente principal ──────────────────────────────────────────────────────
|
||||
export default function SecSintesis({ siniestros, personas, involucrados }) {
|
||||
const s = useMemo(
|
||||
() => calcularSintesis(siniestros, personas, involucrados),
|
||||
[siniestros, personas, involucrados],
|
||||
)
|
||||
|
||||
// Serie válida: excluye 2020, agrega 2025 si no está, ordena por año
|
||||
const serieValida = useMemo(() => {
|
||||
const base = [...SERIE_HISTORICA]
|
||||
const existe2025 = base.some((r) => r.ano === 2025)
|
||||
if (!existe2025) {
|
||||
base.push({ ano: 2025, siniestros: 0, victimas: 21, tasa: 6.27 })
|
||||
}
|
||||
return base
|
||||
.filter((r) => r.ano !== 2020 && r.victimas != null)
|
||||
.sort((a, b) => a.ano - b.ano)
|
||||
}, [])
|
||||
|
||||
const anoMaxVictimas = serieValida.reduce((a, b) => (a.victimas > b.victimas ? a : b))
|
||||
const anoMinVictimas = serieValida.reduce((a, b) => (a.victimas < b.victimas ? a : b))
|
||||
|
||||
const ultimoAno = serieValida[serieValida.length - 1]
|
||||
const penultimoAno = serieValida[serieValida.length - 2]
|
||||
|
||||
const tendencia =
|
||||
ultimoAno && penultimoAno
|
||||
? ultimoAno.victimas >= penultimoAno.victimas ? '▲ Sube' : '▼ Baja'
|
||||
: '—'
|
||||
|
||||
const tendenciaColor = tendencia.includes('▲') ? COLOR.fatales : COLOR.green
|
||||
|
||||
const pctVariacion =
|
||||
ultimoAno && penultimoAno
|
||||
? (((ultimoAno.victimas - penultimoAno.victimas) / penultimoAno.victimas) * 100).toFixed(1)
|
||||
: null
|
||||
|
||||
const fichas = [
|
||||
{ kicker: 'Análisis por tipo', title: 'Siniestros fatales', color: COLOR.fatales, bloque: s.fatalesBloque },
|
||||
{ kicker: 'Análisis por tipo', title: 'Con lesionados', color: COLOR.conLes, bloque: s.conLesBloque },
|
||||
{ kicker: 'Análisis por tipo', title: 'Sin lesiones', color: COLOR.sinLes, bloque: s.sinLesBloque },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── Siniestralidad general ── */}
|
||||
<ChartCard
|
||||
kicker="Resumen general"
|
||||
title="Siniestralidad del período"
|
||||
height="auto"
|
||||
contentClassName="min-h-0"
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-5">
|
||||
{[
|
||||
{ label: 'Total siniestros', valor: s.kpis.total, color: COLOR.navy },
|
||||
{ label: 'Siniestros fatales', valor: s.kpis.fatales, color: COLOR.fatales },
|
||||
{ label: 'Con lesionados', valor: s.kpis.conLes, color: COLOR.conLes },
|
||||
{ label: 'Sin lesiones', valor: s.kpis.sinLes, color: COLOR.sinLes },
|
||||
{ label: 'Víctimas fatales', valor: s.kpis.victimas, color: COLOR.red },
|
||||
].map(({ label, valor, color }) => (
|
||||
<div key={label} className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
||||
<p className="text-4xl font-black" style={{ color }}>
|
||||
{typeof valor === 'number' ? valor.toLocaleString('es-AR') : valor}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-opsv-muted">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ChartCard>
|
||||
|
||||
{/* ── Contexto histórico ── */}
|
||||
<ChartCard
|
||||
kicker="Serie histórica"
|
||||
title="Evolución de víctimas fatales"
|
||||
subtitle="Excluye 2020 por restricciones de movilidad COVID-19"
|
||||
height="auto"
|
||||
contentClassName="min-h-0"
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
||||
<p className="text-3xl font-black text-opsv-navy">{anoMaxVictimas.ano}</p>
|
||||
<p className="mt-1 text-sm text-opsv-muted">
|
||||
Año más crítico ({anoMaxVictimas.victimas} víctimas)
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
||||
<p className="text-3xl font-black text-opsv-navy">{anoMinVictimas.ano}</p>
|
||||
<p className="mt-1 text-sm text-opsv-muted">
|
||||
Año más bajo ({anoMinVictimas.victimas} víctimas)
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
||||
<p className="text-3xl font-black text-opsv-navy">
|
||||
{penultimoAno?.victimas ?? '—'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-opsv-muted">
|
||||
Víctimas {penultimoAno?.ano ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
|
||||
<p className="text-3xl font-black" style={{ color: tendenciaColor }}>
|
||||
{tendencia}
|
||||
{pctVariacion !== null && (
|
||||
<span className="ml-1 text-base font-semibold">{pctVariacion}%</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-opsv-muted">
|
||||
Tendencia {penultimoAno?.ano}→{ultimoAno?.ano}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ChartCard>
|
||||
|
||||
{/* ── Fichas por tipo: 3 columnas en desktop ── */}
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
{fichas.map(({ kicker, title, color, bloque }) => (
|
||||
<Ficha
|
||||
key={title}
|
||||
kicker={kicker}
|
||||
title={title}
|
||||
color={color}
|
||||
destacado={{ label: 'Localidad más afectada', valor: bloque.localidad }}
|
||||
datos={[
|
||||
{ label: 'Mes con más siniestros', valor: bloque.mesPico },
|
||||
{ label: 'Franja horaria pico', valor: bloque.franjaPico },
|
||||
{ label: '% urbano', valor: bloque.pctUrbano },
|
||||
{ label: '% rural', valor: bloque.pctRural },
|
||||
{ label: 'Género predominante', valor: bloque.genero },
|
||||
{ label: 'Rango etario más afectado', valor: bloque.rangoEtario },
|
||||
{ label: 'Tipo de involucrado', valor: bloque.tipoInvolucrado },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
|
||||
SecVeranoVivo
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, ResponsiveContainer, ReferenceLine, Cell,
|
||||
PieChart, Pie, Legend,
|
||||
} from 'recharts'
|
||||
import ChartCard from '../components/ui/ChartCard'
|
||||
import { COLOR } from '../utils/colores'
|
||||
import {
|
||||
HISTORICO_VERANO_VIVO, PROMEDIO_HISTORICO_VV, CAMPANAS_VV,
|
||||
filtrarCampanaVV, kpisVV, ruralUrbanoPorCampana,
|
||||
distribucionMensualVV, rankingRutas, rankingLocalidades,
|
||||
tiposSiniestroVV,
|
||||
} from '../utils/calculos'
|
||||
|
||||
|
||||
// ── UTILIDADES DONUT ────────────────────────────────────────
|
||||
const COLORS_F = [
|
||||
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 CustomPieLabel = ({ 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── INSIGHTS ────────────────────────────────────────────────
|
||||
function calcularInsights(campanaActual, kpis, graficoC, graficoD, graficoF) {
|
||||
const insights = []
|
||||
|
||||
// 1. Fatales vs promedio histórico
|
||||
const fatalesActual = kpis.fatales
|
||||
const diff = fatalesActual - PROMEDIO_HISTORICO_VV
|
||||
|
||||
if (fatalesActual === 0) {
|
||||
insights.push({
|
||||
tipo: 'logro',
|
||||
texto: `La campaña ${campanaActual.label} finalizó sin víctimas fatales en rutas y caminos.`,
|
||||
})
|
||||
} else if (diff < 0) {
|
||||
insights.push({
|
||||
tipo: 'logro',
|
||||
texto: `Con ${fatalesActual} víctima${fatalesActual !== 1 ? 's' : ''} fatal${fatalesActual !== 1 ? 'es' : ''} en ruta, la campaña se mantuvo por debajo del promedio histórico de ${PROMEDIO_HISTORICO_VV}.`,
|
||||
})
|
||||
} else if (diff === 0) {
|
||||
insights.push({
|
||||
tipo: 'neutro',
|
||||
texto: `La campaña registró ${fatalesActual} víctimas fatales en ruta, igual al promedio histórico de ${PROMEDIO_HISTORICO_VV}.`,
|
||||
})
|
||||
} else {
|
||||
insights.push({
|
||||
tipo: 'alerta',
|
||||
texto: `La campaña registró ${fatalesActual} víctimas fatales en ruta, superando el promedio histórico de ${PROMEDIO_HISTORICO_VV}.`,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Reducción vs 2015/16 (pico inicial)
|
||||
const fatalesPico = HISTORICO_VERANO_VIVO[0].fatales
|
||||
if (fatalesActual < fatalesPico) {
|
||||
const reduccion = Math.round(((fatalesPico - fatalesActual) / fatalesPico) * 100)
|
||||
insights.push({
|
||||
tipo: 'logro',
|
||||
texto: `Reducción del ${reduccion}% respecto a la primera campaña Verano Vivo (${HISTORICO_VERANO_VIVO[0].campaña}), que registró ${fatalesPico} víctimas fatales en ruta.`,
|
||||
})
|
||||
} else if (fatalesActual === fatalesPico) {
|
||||
insights.push({
|
||||
tipo: 'alerta',
|
||||
texto: `Los fatales en ruta igualaron el máximo histórico de la primera campaña (${HISTORICO_VERANO_VIVO[0].campaña}: ${fatalesPico} víctimas).`,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Tendencia últimas 3 campañas
|
||||
if (HISTORICO_VERANO_VIVO.length >= 3) {
|
||||
const ultimas = HISTORICO_VERANO_VIVO.slice(-3)
|
||||
const [a, b, c] = ultimas.map(x => x.fatales)
|
||||
if (c <= b && b <= a && c < a) {
|
||||
insights.push({
|
||||
tipo: 'logro',
|
||||
texto: `Tendencia a la baja: los últimos 3 períodos registraron ${a}, ${b} y ${c} víctimas fatales en ruta respectivamente.`,
|
||||
})
|
||||
} else if (c >= b && b >= a && c > a) {
|
||||
insights.push({
|
||||
tipo: 'alerta',
|
||||
texto: `Tendencia al alza: los últimos 3 períodos registraron ${a}, ${b} y ${c} víctimas fatales en ruta respectivamente.`,
|
||||
})
|
||||
} else if (a === b && b === c) {
|
||||
insights.push({
|
||||
tipo: 'neutro',
|
||||
texto: `Los últimos 3 períodos mantuvieron estable la cantidad de víctimas fatales en ruta: ${c} por campaña.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Meses sin siniestros fatales en ruta
|
||||
const NOMBRES_MESES = { 12: 'Diciembre', 1: 'Enero', 2: 'Febrero', 3: 'Marzo' }
|
||||
const mesesSinFatales = graficoC
|
||||
.filter(m => m.fatales === 0)
|
||||
.map(m => Object.values(NOMBRES_MESES).find(n => n.startsWith(m.mes)) || m.mes)
|
||||
|
||||
if (mesesSinFatales.length === 4) {
|
||||
insights.push({
|
||||
tipo: 'logro',
|
||||
texto: 'Todos los meses de la campaña transcurrieron sin víctimas fatales en rutas y caminos.',
|
||||
})
|
||||
} else if (mesesSinFatales.length > 0) {
|
||||
const listaMeses = mesesSinFatales.length === 1
|
||||
? mesesSinFatales[0]
|
||||
: mesesSinFatales.slice(0, -1).join(', ') + ' y ' + mesesSinFatales.at(-1)
|
||||
insights.push({
|
||||
tipo: 'logro',
|
||||
texto: `${listaMeses} transcurrió${mesesSinFatales.length > 1 ? 'ron' : ''} sin víctimas fatales en rutas y caminos.`,
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Tipo más frecuente en ruta
|
||||
if (graficoF.length > 0) {
|
||||
const top = graficoF[0]
|
||||
insights.push({
|
||||
tipo: 'dato',
|
||||
texto: `El tipo de siniestro más frecuente en rutas fue "${top.name}" (${top.pct.toFixed(0)}% de los casos).`,
|
||||
})
|
||||
}
|
||||
|
||||
// 6. Ruta con más siniestros
|
||||
if (graficoD.length > 0) {
|
||||
const topRuta = graficoD[0]
|
||||
insights.push({
|
||||
tipo: 'dato',
|
||||
texto: `La Ruta ${topRuta.ruta} concentró la mayor cantidad de siniestros con ${topRuta.total} evento${topRuta.total !== 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
|
||||
return insights
|
||||
}
|
||||
|
||||
|
||||
// ── BLOQUE DE INSIGHTS ──────────────────────────────────────
|
||||
const INSIGHT_CONFIG = {
|
||||
logro: { bg: 'bg-emerald-100 dark:bg-emerald-950/50', border: 'border-emerald-300 dark:border-emerald-700', icon: '✅', label: 'Logro', labelColor: 'text-emerald-800 dark:text-emerald-300' },
|
||||
neutro: { bg: 'bg-slate-100 dark:bg-slate-800/60', border: 'border-slate-300 dark:border-slate-600', icon: '➡️', label: 'Estable', labelColor: 'text-slate-700 dark:text-slate-300' },
|
||||
alerta: { bg: 'bg-red-100 dark:bg-red-950/50', border: 'border-red-300 dark:border-red-700', icon: '⚠️', label: 'Alerta', labelColor: 'text-red-800 dark:text-red-300' },
|
||||
dato: { bg: 'bg-blue-100 dark:bg-blue-950/50', border: 'border-blue-300 dark:border-blue-700', icon: '📊', label: 'Dato', labelColor: 'text-blue-800 dark:text-blue-300' },
|
||||
}
|
||||
|
||||
function BloqueInsights({ insights, campanaLabel }) {
|
||||
if (!insights.length) return null
|
||||
return (
|
||||
<div data-pdf-block className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">
|
||||
Análisis · {campanaLabel}
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl font-black text-opsv-navy dark:text-white">
|
||||
Puntos destacados de la campaña
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{insights.map((insight, i) => {
|
||||
const cfg = INSIGHT_CONFIG[insight.tipo]
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex gap-3 rounded-2xl border p-4 ${cfg.bg} ${cfg.border}`}
|
||||
>
|
||||
<span className="mt-0.5 text-lg leading-none">{cfg.icon}</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`text-sm font-bold uppercase tracking-widest ${cfg.labelColor}`}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
<p className="text-[16px] leading-relaxed text-slate-900 dark:text-slate-100 font-medium">
|
||||
{insight.texto}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── KPI card ────────────────────────────────────────────────
|
||||
function KpiVV({ label, value, color, badge }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-opsv-border bg-white p-4 shadow-sm dark:bg-opsv-surface-dark dark:border-opsv-border-dark">
|
||||
<span className="text-3xl font-black" style={{ color }}>{value}</span>
|
||||
<span className="mt-1 text-xs font-semibold uppercase tracking-widest text-opsv-muted text-center">{label}</span>
|
||||
{badge && (
|
||||
<span className="mt-2 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-slate-500 dark:bg-slate-700 dark:text-slate-300">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── Tooltip genérico ────────────────────────────────────────
|
||||
function CustomTooltip({ active, payload, label }) {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-lg">
|
||||
<p className="mb-1 text-sm font-bold text-slate-800">{label}</p>
|
||||
{payload.map((p) => (
|
||||
<p key={p.name} className="text-xs text-slate-700">
|
||||
<span className="mr-1.5 inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: p.color }} />
|
||||
{p.name}: <strong>{p.value}</strong>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── Bar horizontal stacked ──────────────────────────────────
|
||||
function BarHorizontalStacked({ data, nameKey }) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 16, left: 8, bottom: 4 }}
|
||||
barCategoryGap="30%"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||
<YAxis type="category" dataKey={nameKey} width={130} tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="fatales" name="Fatales" fill={COLOR.fatales} stackId="a" radius={[0,0,0,0]} />
|
||||
<Bar dataKey="conLes" name="Con lesiones" fill={COLOR.conLes} stackId="a" />
|
||||
<Bar dataKey="sinLes" name="Sin lesiones" fill={COLOR.sinLes} stackId="a" radius={[0,4,4,0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── Componente principal ────────────────────────────────────
|
||||
export default function SecVeranoVivo({ siniestros }) {
|
||||
const [campanaIdx, setCampanaIdx] = useState(CAMPANAS_VV.length - 1)
|
||||
const campanaActual = CAMPANAS_VV[campanaIdx]
|
||||
|
||||
const datosActual = useMemo(
|
||||
() => filtrarCampanaVV(siniestros, campanaActual.anoDesde),
|
||||
[siniestros, campanaActual]
|
||||
)
|
||||
const datosPorCampana = useMemo(
|
||||
() => CAMPANAS_VV.map(c => ({ label: c.label, datos: filtrarCampanaVV(siniestros, c.anoDesde) })),
|
||||
[siniestros]
|
||||
)
|
||||
|
||||
const kpis = useMemo(() => kpisVV(datosActual), [datosActual])
|
||||
const graficoB = useMemo(() => ruralUrbanoPorCampana(datosPorCampana), [datosPorCampana])
|
||||
const graficoCRural = useMemo(() => distribucionMensualVV(datosActual, 'rural'), [datosActual])
|
||||
const graficoCUrbano = useMemo(() => distribucionMensualVV(datosActual, 'urbano'), [datosActual])
|
||||
const graficoD = useMemo(() => rankingRutas(datosActual), [datosActual])
|
||||
const graficoE = useMemo(() => rankingLocalidades(datosActual), [datosActual])
|
||||
const graficoF = useMemo(() => agruparConUmbral(tiposSiniestroVV(datosActual), 10), [datosActual])
|
||||
|
||||
// ✅ graficoCRural (no graficoC) — datos de ruta para los insights
|
||||
const insights = useMemo(
|
||||
() => calcularInsights(campanaActual, kpis, graficoCRural, graficoD, graficoF),
|
||||
[campanaActual, kpis, graficoCRural, graficoD, graficoF]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Encabezado + selector de campaña */}
|
||||
<div data-pdf-block className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-muted">Campaña</p>
|
||||
<h2 className="mt-1 text-2xl font-black text-opsv-navy dark:text-white">Verano Vivo</h2>
|
||||
<p className="text-sm text-slate-500">20 de diciembre al 20 de marzo</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{CAMPANAS_VV.map((c, i) => (
|
||||
<button
|
||||
key={c.label}
|
||||
onClick={() => setCampanaIdx(i)}
|
||||
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-all ${
|
||||
i === campanaIdx
|
||||
? 'bg-opsv-navy text-white shadow-sm'
|
||||
: 'bg-white border border-opsv-border text-opsv-muted hover:border-opsv-navy hover:text-opsv-navy dark:bg-transparent dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<div data-pdf-block>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.3em] text-opsv-muted">
|
||||
Rutas y caminos de la provincia · {campanaActual.label}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<KpiVV label="Total siniestros en ruta" value={kpis.total} color={COLOR.navy} />
|
||||
<KpiVV label="Fatales en Ruta" value={kpis.fatales} color={COLOR.fatales} />
|
||||
<KpiVV label="Con lesiones en Ruta" value={kpis.conLes} color={COLOR.conLes} />
|
||||
<KpiVV label="Sin lesiones en Ruta" value={kpis.sinLes} color={COLOR.sinLes} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gráfico A — Serie histórica · ancho completo */}
|
||||
<ChartCard
|
||||
kicker="Serie histórica"
|
||||
title="Evolución de siniestros fatales en ruta"
|
||||
subtitle="Campañas Verano Vivo desde 2015/16. Solo se contabilizan siniestros en Rutas y Caminos. El año 2020/21 fue excluido por restricciones de movilidad COVID-19."
|
||||
height="md"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={HISTORICO_VERANO_VIVO} margin={{ top: 16, right: 24, left: 0, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis dataKey="campaña" tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: '#4A5568' }} domain={[0, 14]} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine
|
||||
y={PROMEDIO_HISTORICO_VV}
|
||||
stroke={COLOR.conLes}
|
||||
strokeDasharray="6 3"
|
||||
label={{ value: `Promedio ${PROMEDIO_HISTORICO_VV}`, position: 'insideTopRight', fontSize: 11, fill: COLOR.conLes }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="fatales"
|
||||
name="Siniestros fatales en ruta"
|
||||
stroke={COLOR.fatales}
|
||||
strokeWidth={2.5}
|
||||
dot={{ r: 5, fill: COLOR.fatales, strokeWidth: 0 }}
|
||||
activeDot={{ r: 7 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
{/* Gráfico B — Comparación histórica · ancho completo */}
|
||||
<ChartCard
|
||||
kicker="Comparación entre campañas"
|
||||
title="Siniestros por zona y severidad"
|
||||
subtitle="Incluye siniestros en rutas/caminos y en zonas urbanas."
|
||||
height="lg"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={graficoB} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="25%" barGap={2}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis dataKey="campaña" tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="ruralFatal" name="Ruta — fatal" fill={COLOR.fatales} radius={[3,3,0,0]} />
|
||||
<Bar dataKey="urbanaFatal" name="Urbano — fatal" fill={COLOR.orange} radius={[3,3,0,0]} />
|
||||
<Bar dataKey="ruralLes" name="Ruta — con lesiones" fill={COLOR.navy} radius={[3,3,0,0]} />
|
||||
<Bar dataKey="urbanaLes" name="Urbano — con lesiones" fill={COLOR.blue} radius={[3,3,0,0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
{/* Gráficos C — Siniestros por mes separados por zona · grid 2 col */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<ChartCard
|
||||
kicker={`Rutas y caminos · ${campanaActual.label}`}
|
||||
title="Siniestros por mes en ruta"
|
||||
subtitle="Solo siniestros ocurridos en rutas y caminos provinciales."
|
||||
height="lg"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={graficoCRural} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="35%">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis dataKey="mes" tick={{ fontSize: 12, fill: '#4A5568' }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="fatales" name="Fatales" fill={COLOR.fatales} stackId="a" />
|
||||
<Bar dataKey="conLes" name="Con lesiones" fill={COLOR.conLes} stackId="a" />
|
||||
<Bar dataKey="sinLes" name="Sin lesiones" fill={COLOR.sinLes} stackId="a" radius={[4,4,0,0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
kicker={`Zona urbana · ${campanaActual.label}`}
|
||||
title="Siniestros por mes en ejido urbano"
|
||||
subtitle="Solo siniestros ocurridos en ejidos urbanos de la provincia."
|
||||
height="lg"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={graficoCUrbano} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="35%">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis dataKey="mes" tick={{ fontSize: 12, fill: '#4A5568' }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="fatales" name="Fatales" fill={COLOR.fatales} stackId="a" />
|
||||
<Bar dataKey="conLes" name="Con lesiones" fill={COLOR.orange} stackId="a" />
|
||||
<Bar dataKey="sinLes" name="Sin lesiones" fill={COLOR.blue} stackId="a" radius={[4,4,0,0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Gráficos D y E — Rutas y Localidades · grid 2 col */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<ChartCard
|
||||
kicker={`Rutas y caminos · ${campanaActual.label}`}
|
||||
title="Rutas con más siniestros"
|
||||
subtitle="Siniestros ocurridos en Rutas de la Provincia."
|
||||
height="lg"
|
||||
>
|
||||
{graficoD.length === 0
|
||||
? <p className="text-sm text-opsv-muted text-center pt-8">Sin datos para esta campaña</p>
|
||||
: <BarHorizontalStacked data={graficoD} nameKey="ruta" />
|
||||
}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
kicker={`Zona urbana · ${campanaActual.label}`}
|
||||
title="Localidades con más siniestros"
|
||||
subtitle="Siniestros ocurridos en Ejidos Urbanos."
|
||||
height="lg"
|
||||
>
|
||||
{graficoE.length === 0
|
||||
? <p className="text-sm text-opsv-muted text-center pt-8">Sin datos para esta campaña</p>
|
||||
: <BarHorizontalStacked data={graficoE} nameKey="localidad" />
|
||||
}
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Gráfico F — Donut tipos · ancho completo */}
|
||||
<ChartCard
|
||||
kicker={`Solo rutas y caminos · ${campanaActual.label}`}
|
||||
title="Tipo de siniestro en rutas y caminos"
|
||||
subtitle="Solo siniestros en zona rural / ruta. Excluye siniestros en zona urbana."
|
||||
height="sm"
|
||||
>
|
||||
<div className="h-[320px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={graficoF}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="60%" cy="50%"
|
||||
outerRadius={95} innerRadius={58}
|
||||
paddingAngle={4}
|
||||
labelLine={false}
|
||||
label={CustomPieLabel}
|
||||
>
|
||||
{graficoF.map((entry, index) => (
|
||||
<Cell key={entry.name} fill={COLORS_F[index % COLORS_F.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
layout="vertical" verticalAlign="bottom" align="right" iconType="circle"
|
||||
formatter={(value) => <span className="text-sm text-opsv-text">{value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartCard>
|
||||
|
||||
{/* Bloque de insights · ancho completo */}
|
||||
<BloqueInsights insights={insights} campanaLabel={campanaActual.label} />
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
// Censo 2022 INDEC — nombres exactos según tabla siniestros campo departamento
|
||||
export const POBLACION_DEPTO = {
|
||||
'DESEADO': 79650,
|
||||
'GUER AIKE': 260879,
|
||||
'LAGO ARGENTINO': 31941,
|
||||
'LAGO BUENOS AIRES': 14978,
|
||||
'MAGALLANES': 7952,
|
||||
'CORPEN AIKE': 4847,
|
||||
'RIO CHICO': 9420,
|
||||
'SANTA CRUZ': 11534,
|
||||
}
|
||||
|
||||
export const POBLACION_DEPARTAMENTOS = POBLACION_DEPTO
|
||||
|
||||
export const MESES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre']
|
||||
|
||||
const PARSE_INT = (value) => {
|
||||
const parsed = parseInt(value, 10)
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
}
|
||||
|
||||
const getMesNombre = (siniestro) => {
|
||||
if (siniestro.mes_nombre) return siniestro.mes_nombre
|
||||
const mes = PARSE_INT(siniestro.mes)
|
||||
return MESES[mes - 1] || 'Sin dato'
|
||||
}
|
||||
|
||||
const getCantidadFallecidos = (siniestro) =>
|
||||
PARSE_INT(siniestro.cantidad_fallecidos ?? siniestro.fallecidos)
|
||||
|
||||
const getCantidadLesionados = (siniestro) =>
|
||||
PARSE_INT(siniestro.cantidad_lesionados ?? siniestro.heridos)
|
||||
|
||||
// ✅ FIX: sentence case para normalizar mayúsculas inconsistentes en la BD
|
||||
const getTipoSiniestro = (siniestro) => {
|
||||
const raw = (siniestro.tipo_siniestro_unico || 'Sin dato')
|
||||
.normalize('NFC')
|
||||
.trim()
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
return raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
const getTipoVia = (siniestro) =>
|
||||
siniestro.tipo_via || siniestro.via_publica || 'Sin dato'
|
||||
|
||||
const getHora = (siniestro) =>
|
||||
siniestro.hora_siniestro || siniestro.siniestro_hora || '00:00'
|
||||
|
||||
export function calcularKPIs(siniestros) {
|
||||
const total = siniestros.length
|
||||
const fatales = siniestros.filter((s) => getCantidadFallecidos(s) > 0).length
|
||||
const conLes = siniestros.filter((s) => getCantidadFallecidos(s) === 0 && getCantidadLesionados(s) > 0).length
|
||||
const sinLes = total - fatales - conLes
|
||||
const victimas = siniestros.reduce((acc, s) => acc + getCantidadFallecidos(s), 0)
|
||||
const lesion = siniestros.reduce((acc, s) => acc + getCantidadLesionados(s), 0)
|
||||
return { total, fatales, conLes, sinLes, victimas, lesion }
|
||||
}
|
||||
|
||||
export function evolucionMensual(siniestros) {
|
||||
const mapa = {}
|
||||
MESES.forEach((m) => { mapa[m] = { mes: m.slice(0, 3), fatales: 0, conLes: 0, sinLes: 0, total: 0 } })
|
||||
|
||||
siniestros.forEach((s) => {
|
||||
const m = getMesNombre(s)
|
||||
if (!mapa[m]) return
|
||||
mapa[m].total += 1
|
||||
const fallecidos = getCantidadFallecidos(s)
|
||||
const lesionados = getCantidadLesionados(s)
|
||||
if (fallecidos > 0) mapa[m].fatales += 1
|
||||
else if (lesionados > 0) mapa[m].conLes += 1
|
||||
else mapa[m].sinLes += 1
|
||||
})
|
||||
|
||||
return MESES.map((m) => mapa[m])
|
||||
}
|
||||
|
||||
export function porDepartamento(siniestros) {
|
||||
const mapa = {}
|
||||
siniestros.forEach((s) => {
|
||||
const d = s.departamento || 'Sin dato'
|
||||
if (!mapa[d]) mapa[d] = { departamento: d, siniestros: 0, victimas: 0 }
|
||||
mapa[d].siniestros += 1
|
||||
mapa[d].victimas += getCantidadFallecidos(s)
|
||||
})
|
||||
|
||||
return Object.values(mapa)
|
||||
.map((d) => ({
|
||||
...d,
|
||||
tasa: POBLACION_DEPTO[d.departamento]
|
||||
? ((d.victimas / POBLACION_DEPTO[d.departamento]) * 100000).toFixed(1)
|
||||
: null,
|
||||
}))
|
||||
.sort((a, b) => b.siniestros - a.siniestros)
|
||||
}
|
||||
|
||||
export function porTipoSiniestro(siniestros) {
|
||||
const mapa = {}
|
||||
siniestros.forEach((s) => {
|
||||
const t = getTipoSiniestro(s)
|
||||
mapa[t] = (mapa[t] || 0) + 1
|
||||
})
|
||||
return Object.entries(mapa)
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
}
|
||||
|
||||
export function porTipoVia(siniestros) {
|
||||
const mapa = {}
|
||||
siniestros.forEach((s) => {
|
||||
const t = getTipoVia(s)
|
||||
mapa[t] = (mapa[t] || 0) + 1
|
||||
})
|
||||
return Object.entries(mapa).map(([name, value]) => ({ name, value }))
|
||||
}
|
||||
|
||||
export function porFranjaHoraria(siniestros) {
|
||||
const franjas = ['00-03','03-06','06-09','09-12','12-15','15-18','18-21','21-24']
|
||||
const mapa = {}
|
||||
franjas.forEach((f) => { mapa[f] = { franja: f, urbano: 0, rural: 0 } })
|
||||
|
||||
siniestros.forEach((s) => {
|
||||
const h = parseInt(getHora(s).split(':')[0], 10)
|
||||
const idx = Math.min(Math.floor(h / 3), 7)
|
||||
const f = franjas[idx]
|
||||
const zona = (s.zona || s.zona_ocurrencia || '').toLowerCase().includes('urban') ? 'urbano' : 'rural'
|
||||
if (mapa[f]) mapa[f][zona] += 1
|
||||
})
|
||||
|
||||
return franjas.map((f) => mapa[f])
|
||||
}
|
||||
|
||||
export function perfilVictimas(personas, involucrados) {
|
||||
const tipoMap = new Map()
|
||||
involucrados.forEach((i) => tipoMap.set(String(i.id_involucrado), i.tipo_involucrado))
|
||||
|
||||
const victimas = personas.filter((p) =>
|
||||
p.estado_ocupante_inicio === 'Fallecido' ||
|
||||
p.estado_ocupante_inicio === 'Herido Grave' ||
|
||||
p.estado_ocupante_inicio === 'Herido Leve'
|
||||
)
|
||||
|
||||
const genero = {}
|
||||
const etario = {}
|
||||
const usuario = {}
|
||||
|
||||
victimas.forEach((p) => {
|
||||
genero[p.genero || 'Sin dato'] = (genero[p.genero || 'Sin dato'] || 0) + 1
|
||||
etario[p.rango_etario || 'Sin dato'] = (etario[p.rango_etario || 'Sin dato'] || 0) + 1
|
||||
const tipo = tipoMap.get(String(p.id_involucrado)) || 'Sin dato'
|
||||
usuario[tipo] = (usuario[tipo] || 0) + 1
|
||||
})
|
||||
|
||||
return {
|
||||
genero: Object.entries(genero).map(([name, value]) => ({ name, value })),
|
||||
etario: Object.entries(etario).map(([name, value]) => ({ name, value })),
|
||||
usuario: Object.entries(usuario).map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value),
|
||||
}
|
||||
}
|
||||
|
||||
const TIPOS_HABITACULO = ['Automóvil','Camioneta','Transporte de Pasajeros','Transporte de Carga','Furgón','Camión']
|
||||
const TIPOS_MOTO = ['Motocicleta','Ciclomotor']
|
||||
const TIPOS_EXCLUIR_CIN = ['Motocicleta','Ciclomotor','Peatón','Bicicleta','Tracción a Sangre']
|
||||
|
||||
const hasCasco = (persona) => {
|
||||
if (persona.uso_casco != null) return persona.uso_casco === 'Sí' || persona.uso_casco === 'Si'
|
||||
return persona.casco === 'Sí' || persona.casco === 'Si'
|
||||
}
|
||||
|
||||
const hasCinturon = (persona) => {
|
||||
if (persona.uso_cinturon != null) return persona.uso_cinturon === 'Sí' || persona.uso_cinturon === 'Si'
|
||||
return persona.cinturon_seguridad === 'Sí' || persona.cinturon_seguridad === 'Si'
|
||||
}
|
||||
|
||||
const hasAirbag = (persona) => {
|
||||
if (persona.uso_airbag != null) return persona.uso_airbag === 'Sí' || persona.uso_airbag === 'Si'
|
||||
return persona.airbag === 'Sí' || persona.airbag === 'Si'
|
||||
}
|
||||
|
||||
export function proteccionPasiva(personas, involucrados) {
|
||||
const tipoMap = new Map()
|
||||
involucrados.forEach((i) => tipoMap.set(String(i.id_involucrado), i.tipo_involucrado))
|
||||
|
||||
const motoristas = personas.filter((p) => TIPOS_MOTO.includes(tipoMap.get(String(p.id_involucrado))))
|
||||
const habitaculo = personas.filter((p) => TIPOS_HABITACULO.includes(tipoMap.get(String(p.id_involucrado))))
|
||||
|
||||
const cascoBase = motoristas.length
|
||||
const cascoUso = motoristas.filter((p) => hasCasco(p)).length
|
||||
|
||||
const cinBase = habitaculo.length
|
||||
const cinUso = habitaculo.filter((p) => hasCinturon(p)).length
|
||||
|
||||
const airbagBase = habitaculo.length
|
||||
const airbagUso = habitaculo.filter((p) => hasAirbag(p)).length
|
||||
|
||||
return {
|
||||
casco: { uso: cascoUso, base: cascoBase, pct: cascoBase ? Math.round((cascoUso / cascoBase) * 100) : null },
|
||||
cinturon: { uso: cinUso, base: cinBase, pct: cinBase ? Math.round((cinUso / cinBase) * 100) : null },
|
||||
airbag: { uso: airbagUso, base: airbagBase, pct: airbagBase ? Math.round((airbagUso / airbagBase) * 100) : null },
|
||||
}
|
||||
}
|
||||
|
||||
export function calcularTablaComparativa(siniestros) {
|
||||
const porDepto = {}
|
||||
|
||||
siniestros.forEach((s) => {
|
||||
const depto = s.departamento || 'Sin datos'
|
||||
if (!porDepto[depto]) {
|
||||
porDepto[depto] = { siniestrosFatales: 0, victimasFatales: 0 }
|
||||
}
|
||||
const fallecidos = getCantidadFallecidos(s)
|
||||
if (fallecidos > 0) {
|
||||
porDepto[depto].siniestrosFatales += 1
|
||||
porDepto[depto].victimasFatales += fallecidos
|
||||
}
|
||||
})
|
||||
|
||||
return Object.entries(porDepto)
|
||||
.map(([departamento, vals]) => {
|
||||
const poblacion = POBLACION_DEPTO[departamento] || null
|
||||
const tasa = poblacion
|
||||
? Number(((vals.victimasFatales / poblacion) * 100000).toFixed(1))
|
||||
: '-'
|
||||
return {
|
||||
departamento,
|
||||
siniestrosFatales: vals.siniestrosFatales,
|
||||
victimasFatales: vals.victimasFatales,
|
||||
tasa,
|
||||
}
|
||||
})
|
||||
.filter((d) => d.siniestrosFatales > 0)
|
||||
.sort((a, b) => b.siniestrosFatales - a.siniestrosFatales)
|
||||
}
|
||||
|
||||
const CAMPOS_RANGO_ETARIO = ['rango_etario', 'grupo_etario', 'grupo_edad', 'edad_rango']
|
||||
const ORDEN_RANGOS = ['0-14', '0-17', '15-24', '18-24', '25-34', '35-44', '45-54', '55-64', '65+', 'Sin dato']
|
||||
|
||||
export function calcularRangoEtario(personas) {
|
||||
if (!personas || personas.length === 0) return []
|
||||
|
||||
const campoRango = CAMPOS_RANGO_ETARIO.find((campo) =>
|
||||
personas.some((p) => p[campo] && p[campo] !== null && p[campo] !== 'Sin dato' && p[campo] !== '')
|
||||
)
|
||||
const agrupado = {}
|
||||
|
||||
if (campoRango) {
|
||||
personas.forEach((p) => {
|
||||
const rango = p[campoRango] || 'Sin dato'
|
||||
agrupado[rango] = (agrupado[rango] || 0) + 1
|
||||
})
|
||||
} else {
|
||||
personas.forEach((p) => {
|
||||
const edad = parseInt(String(p.edad ?? '').trim(), 10)
|
||||
if (isNaN(edad) || edad < 0 || edad > 120) {
|
||||
agrupado['Sin dato'] = (agrupado['Sin dato'] || 0) + 1
|
||||
return
|
||||
}
|
||||
let rango
|
||||
if (edad < 15) rango = '0-14'
|
||||
else if (edad < 25) rango = '15-24'
|
||||
else if (edad < 35) rango = '25-34'
|
||||
else if (edad < 45) rango = '35-44'
|
||||
else if (edad < 55) rango = '45-54'
|
||||
else if (edad < 65) rango = '55-64'
|
||||
else rango = '65+'
|
||||
agrupado[rango] = (agrupado[rango] || 0) + 1
|
||||
})
|
||||
}
|
||||
|
||||
const total = Object.values(agrupado).reduce((a, b) => a + b, 0)
|
||||
const sinDato = agrupado['Sin dato'] || 0
|
||||
if (sinDato / total > 0.9) {
|
||||
console.warn('calcularRangoEtario: >90% sin dato — verificar campo en tabla Personas')
|
||||
}
|
||||
|
||||
return Object.entries(agrupado)
|
||||
.map(([rango, cantidad]) => ({ rango, cantidad }))
|
||||
.filter((d) => d.rango !== 'Sin dato' || d.cantidad / total < 0.5)
|
||||
.sort((a, b) => {
|
||||
const ia = ORDEN_RANGOS.indexOf(a.rango)
|
||||
const ib = ORDEN_RANGOS.indexOf(b.rango)
|
||||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
|
||||
})
|
||||
}
|
||||
|
||||
// ── SÍNTESIS ──────────────────────────────────────────────────
|
||||
|
||||
function topEntry(obj) {
|
||||
return Object.entries(obj).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'Sin dato'
|
||||
}
|
||||
|
||||
function pctZona(siniestros, tipo) {
|
||||
const total = siniestros.length
|
||||
if (!total) return '—'
|
||||
const count = siniestros.filter(s =>
|
||||
(s.zona_ocurrencia || '').toLowerCase().includes(tipo)
|
||||
).length
|
||||
return `${((count / total) * 100).toFixed(0)}%`
|
||||
}
|
||||
|
||||
function mesPico(siniestros) {
|
||||
const mapa = {}
|
||||
siniestros.forEach(s => {
|
||||
const m = getMesNombre(s)
|
||||
mapa[m] = (mapa[m] || 0) + 1
|
||||
})
|
||||
return topEntry(mapa)
|
||||
}
|
||||
|
||||
function franjaHorariaPico(siniestros) {
|
||||
const mapa = {}
|
||||
siniestros.forEach(s => {
|
||||
const h = parseInt(getHora(s).split(':')[0], 10)
|
||||
const inicio = Math.floor(h / 3) * 3
|
||||
const franja = `${String(inicio).padStart(2,'0')}:00-${String(inicio+3).padStart(2,'0')}:00`
|
||||
mapa[franja] = (mapa[franja] || 0) + 1
|
||||
})
|
||||
return topEntry(mapa)
|
||||
}
|
||||
|
||||
function localidadPico(siniestros) {
|
||||
const mapa = {}
|
||||
siniestros.forEach(s => {
|
||||
const loc = s.departamento || s.localidad || 'Sin dato'
|
||||
mapa[loc] = (mapa[loc] || 0) + 1
|
||||
})
|
||||
return topEntry(mapa)
|
||||
}
|
||||
|
||||
function generoPredominante(personas, estadoFiltro) {
|
||||
const filtradas = estadoFiltro
|
||||
? personas.filter(p => estadoFiltro.includes(p.estado_ocupante_inicio))
|
||||
: personas
|
||||
const mapa = {}
|
||||
filtradas.forEach(p => {
|
||||
const g = p.genero || 'Sin dato'
|
||||
mapa[g] = (mapa[g] || 0) + 1
|
||||
})
|
||||
return topEntry(mapa)
|
||||
}
|
||||
|
||||
function rangoEtarioPico(personas, estadoFiltro) {
|
||||
const filtradas = estadoFiltro
|
||||
? personas.filter(p => estadoFiltro.includes(p.estado_ocupante_inicio))
|
||||
: personas
|
||||
const rangos = calcularRangoEtario(filtradas)
|
||||
if (!rangos.length) return 'Sin dato'
|
||||
return rangos.reduce((a, b) => a.cantidad > b.cantidad ? a : b).rango
|
||||
}
|
||||
|
||||
function tipoInvolucradoPico(personas, involucrados, estadoFiltro) {
|
||||
const tipoMap = new Map()
|
||||
involucrados?.forEach(i => tipoMap.set(String(i.id_involucrado), i.tipo_involucrado))
|
||||
const filtradas = estadoFiltro
|
||||
? personas.filter(p => estadoFiltro.includes(p.estado_ocupante_inicio))
|
||||
: personas
|
||||
const mapa = {}
|
||||
filtradas.forEach(p => {
|
||||
const tipo = tipoMap.get(String(p.id_involucrado)) || 'Sin dato'
|
||||
mapa[tipo] = (mapa[tipo] || 0) + 1
|
||||
})
|
||||
return topEntry(mapa)
|
||||
}
|
||||
|
||||
export function calcularSintesis(siniestros, personas = [], involucrados = []) {
|
||||
const kpis = calcularKPIs(siniestros)
|
||||
|
||||
const fatales = siniestros.filter(s => getCantidadFallecidos(s) > 0)
|
||||
const conLes = siniestros.filter(s => getCantidadFallecidos(s) === 0 && getCantidadLesionados(s) > 0)
|
||||
const sinLes = siniestros.filter(s => getCantidadFallecidos(s) === 0 && getCantidadLesionados(s) === 0)
|
||||
|
||||
const bloque = (grupo, estadoPersonas) => ({
|
||||
mesPico: mesPico(grupo),
|
||||
franjaPico: franjaHorariaPico(grupo),
|
||||
pctUrbano: pctZona(grupo, 'urban'),
|
||||
pctRural: pctZona(grupo, 'rural'),
|
||||
localidad: localidadPico(grupo),
|
||||
genero: generoPredominante(personas, estadoPersonas),
|
||||
rangoEtario: rangoEtarioPico(personas, estadoPersonas),
|
||||
tipoInvolucrado: tipoInvolucradoPico(personas, involucrados, estadoPersonas),
|
||||
})
|
||||
|
||||
return {
|
||||
kpis,
|
||||
fatalesBloque: bloque(fatales, ['Fallecido']),
|
||||
conLesBloque: bloque(conLes, ['Herido Grave', 'Herido Leve']),
|
||||
sinLesBloque: bloque(sinLes, null),
|
||||
}
|
||||
}
|
||||
|
||||
// ── VERANO VIVO ───────────────────────────────────────────────
|
||||
|
||||
export const HISTORICO_VERANO_VIVO = [
|
||||
{ campaña: '2015/16', fatales: 11 },
|
||||
{ campaña: '2016/17', fatales: 8 },
|
||||
{ campaña: '2017/18', fatales: 2 },
|
||||
{ campaña: '2018/19', fatales: 6 },
|
||||
{ campaña: '2019/20', fatales: 5 },
|
||||
// 2020/21 excluida — restricciones COVID
|
||||
{ campaña: '2021/22', fatales: 4 },
|
||||
{ campaña: '2022/23', fatales: 3 },
|
||||
{ campaña: '2023/24', fatales: 3 },
|
||||
{ campaña: '2024/25', fatales: 3 },
|
||||
]
|
||||
export const PROMEDIO_HISTORICO_VV = 5.2
|
||||
|
||||
export const CAMPANAS_VV = [
|
||||
{ label: '2022/23', anoDesde: 2022, anoHasta: 2023 },
|
||||
{ label: '2023/24', anoDesde: 2023, anoHasta: 2024 },
|
||||
{ label: '2024/25', anoDesde: 2024, anoHasta: 2025 },
|
||||
]
|
||||
|
||||
export function filtrarCampanaVV(siniestros, anoDesde) {
|
||||
return siniestros.filter((s) => {
|
||||
const dia = PARSE_INT(s.dia)
|
||||
const mes = PARSE_INT(s.mes)
|
||||
const ano = PARSE_INT(s.ano)
|
||||
if (ano === anoDesde && mes === 12 && dia >= 20) return true
|
||||
if (ano === anoDesde + 1 && (mes === 1 || mes === 2)) return true
|
||||
if (ano === anoDesde + 1 && mes === 3 && dia <= 20) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export function kpisVV(siniestros) {
|
||||
const rural = siniestros.filter(s =>
|
||||
(s.zona_ocurrencia || '').toLowerCase().includes('rural')
|
||||
)
|
||||
const total = rural.length
|
||||
const fatales = rural.filter(s => getCantidadFallecidos(s) > 0).length
|
||||
const conLes = rural.filter(s => getCantidadFallecidos(s) === 0 && getCantidadLesionados(s) > 0).length
|
||||
const sinLes = total - fatales - conLes
|
||||
return { total, fatales, conLes, sinLes }
|
||||
}
|
||||
|
||||
export function ruralUrbanoPorCampana(siniestrosPorCampana) {
|
||||
return siniestrosPorCampana.map(({ label, datos }) => {
|
||||
const rural = datos.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural'))
|
||||
const urbana = datos.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('urban'))
|
||||
return {
|
||||
campaña: label,
|
||||
ruralFatal: rural.filter(s => getCantidadFallecidos(s) > 0).length,
|
||||
ruralLes: rural.filter(s => getCantidadFallecidos(s) === 0 && getCantidadLesionados(s) > 0).length,
|
||||
ruralSinLes: rural.filter(s => getCantidadFallecidos(s) === 0 && getCantidadLesionados(s) === 0).length,
|
||||
urbanaFatal: urbana.filter(s => getCantidadFallecidos(s) > 0).length,
|
||||
urbanaLes: urbana.filter(s => getCantidadFallecidos(s) === 0 && getCantidadLesionados(s) > 0).length,
|
||||
urbanaSinLes: urbana.filter(s => getCantidadFallecidos(s) === 0 && getCantidadLesionados(s) === 0).length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const MESES_VV = [
|
||||
{ key: 12, label: 'Dic' },
|
||||
{ key: 1, label: 'Ene' },
|
||||
{ key: 2, label: 'Feb' },
|
||||
{ key: 3, label: 'Mar' },
|
||||
]
|
||||
|
||||
// ANTES — distribucionMensualVV(siniestros)
|
||||
// AHORA — acepta zona opcional: 'rural' | 'urbano' | undefined (todos)
|
||||
|
||||
const esUrbano = (s) =>
|
||||
(s.zona || s.zona_ocurrencia || '').toLowerCase().includes('urban')
|
||||
|
||||
export function distribucionMensualVV(siniestros, zona) {
|
||||
const MESES_VV = ['Diciembre', 'Enero', 'Febrero', 'Marzo']
|
||||
const mapa = {}
|
||||
MESES_VV.forEach((m) => {
|
||||
mapa[m] = { mes: m.slice(0, 3), fatales: 0, conLes: 0, sinLes: 0, total: 0 }
|
||||
})
|
||||
|
||||
const datos = zona === 'rural'
|
||||
? siniestros.filter((s) => !esUrbano(s))
|
||||
: zona === 'urbano'
|
||||
? siniestros.filter((s) => esUrbano(s))
|
||||
: siniestros
|
||||
|
||||
datos.forEach((s) => {
|
||||
const m = getMesNombre(s)
|
||||
if (!mapa[m]) return
|
||||
mapa[m].total += 1
|
||||
const fallecidos = getCantidadFallecidos(s)
|
||||
const lesionados = getCantidadLesionados(s)
|
||||
if (fallecidos > 0) mapa[m].fatales += 1
|
||||
else if (lesionados > 0) mapa[m].conLes += 1
|
||||
else mapa[m].sinLes += 1
|
||||
})
|
||||
|
||||
return MESES_VV.map((m) => mapa[m])
|
||||
}
|
||||
|
||||
export function rankingRutas(siniestros, topN = 8) {
|
||||
const map = {}
|
||||
siniestros
|
||||
.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural'))
|
||||
.forEach(s => {
|
||||
const nombre = (s.nombre_via || '').trim()
|
||||
const tipo = (s.via_publica || '').trim()
|
||||
const ruta = nombre || tipo || 'Sin datos'
|
||||
if (!map[ruta]) map[ruta] = { ruta, tipo, fatales: 0, conLes: 0, sinLes: 0, total: 0 }
|
||||
if (getCantidadFallecidos(s) > 0) map[ruta].fatales++
|
||||
else if (getCantidadLesionados(s) > 0) map[ruta].conLes++
|
||||
else map[ruta].sinLes++
|
||||
map[ruta].total++
|
||||
})
|
||||
return Object.values(map)
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, topN)
|
||||
}
|
||||
|
||||
export function rankingLocalidades(siniestros, topN = 8) {
|
||||
const map = {}
|
||||
siniestros
|
||||
.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('urban'))
|
||||
.forEach(s => {
|
||||
const loc = (s.localidad || 'Sin datos').trim()
|
||||
if (!map[loc]) map[loc] = { localidad: loc, fatales: 0, conLes: 0, sinLes: 0, total: 0 }
|
||||
if (getCantidadFallecidos(s) > 0) map[loc].fatales++
|
||||
else if (getCantidadLesionados(s) > 0) map[loc].conLes++
|
||||
else map[loc].sinLes++
|
||||
map[loc].total++
|
||||
})
|
||||
return Object.values(map)
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, topN)
|
||||
}
|
||||
|
||||
// ✅ FIX: eliminados logs temporales y normalización redundante
|
||||
// (getTipoSiniestro ya aplica sentence case + trim + normalize)
|
||||
export function tiposSiniestroVV(siniestros) {
|
||||
const map = {}
|
||||
siniestros
|
||||
.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural'))
|
||||
.forEach(s => {
|
||||
const tipo = getTipoSiniestro(s)
|
||||
map[tipo] = (map[tipo] || 0) + 1
|
||||
})
|
||||
return Object.entries(map)
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// src/utils/colores.js
|
||||
export const COLOR = {
|
||||
fatales: '#C44228',
|
||||
conLes: '#CD9F2B',
|
||||
sinLes: '#337C58',
|
||||
|
||||
navy: '#252C61',
|
||||
blue: '#80B0DE',
|
||||
orange: '#E8881A',
|
||||
|
||||
red: '#C44228',
|
||||
green: '#337C58',
|
||||
|
||||
SERIE: ['#252C61', '#80B0DE', '#CD9F2B', '#337C58', '#C44228'],
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import html2canvas from 'html2canvas-pro'
|
||||
import jsPDF from 'jspdf'
|
||||
|
||||
export const SECCIONES_EXPORTABLES = [
|
||||
{ id: 'resumen', label: 'Resumen General' },
|
||||
{ id: 'historica', label: 'Serie Histórica Provincial' },
|
||||
{ id: 'fatales', label: 'Siniestros Fatales' },
|
||||
{ id: 'lesionados', label: 'Con Lesionados' },
|
||||
{ id: 'sinlesiones', label: 'Sin Lesiones' },
|
||||
{ id: 'sintesis', label: 'Síntesis' },
|
||||
{ id: 'veranovivo', label: 'Verano Vivo' },
|
||||
]
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = reject
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
|
||||
function drawHeaderFooter(pdf, secLabel, year, pageNum, W, H) {
|
||||
pdf.setFillColor(37, 44, 97)
|
||||
pdf.rect(0, 0, W, 13, 'F')
|
||||
pdf.setTextColor(255, 255, 255)
|
||||
pdf.setFont('helvetica', 'bold')
|
||||
pdf.setFontSize(8)
|
||||
pdf.text(secLabel.toUpperCase(), 10, 8.5)
|
||||
pdf.text(`OPSV · Informe ${year} · Santa Cruz`, W - 10, 8.5, { align: 'right' })
|
||||
pdf.setFillColor(225, 230, 242)
|
||||
pdf.rect(0, H - 8, W, 8, 'F')
|
||||
pdf.setTextColor(90, 100, 135)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.setFontSize(7)
|
||||
pdf.text('Agencia Provincial de Seguridad Vial · Santa Cruz', 10, H - 2.5)
|
||||
pdf.text(`Página ${pageNum}`, W - 10, H - 2.5, { align: 'right' })
|
||||
}
|
||||
|
||||
// ── Captura un elemento individual ───────────────────────────────────────────
|
||||
async function capturarBloque(block) {
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
return html2canvas(block, {
|
||||
scale: 1.5,
|
||||
useCORS: true,
|
||||
backgroundColor: '#F4F6F8',
|
||||
windowWidth: 1280,
|
||||
scrollX: 0,
|
||||
scrollY: -window.scrollY,
|
||||
})
|
||||
}
|
||||
|
||||
export async function exportarPDF({ seccionesIds, year, onProgress }) {
|
||||
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
|
||||
const W = pdf.internal.pageSize.getWidth() // 297mm
|
||||
const H = pdf.internal.pageSize.getHeight() // 210mm
|
||||
const areaW = W - 16 // ancho disponible
|
||||
const areaH = H - 13 - 10 // alto disponible (header 13mm + footer 10mm)
|
||||
const topY = 15 // cursor inicial tras el header
|
||||
const marginX = (W - areaW) / 2
|
||||
const gap = 3 // mm de separación entre bloques
|
||||
|
||||
// ── Portada ───────────────────────────────────────────────────────────────
|
||||
pdf.setFillColor(37, 44, 97)
|
||||
pdf.rect(0, 0, W, H, 'F')
|
||||
|
||||
try {
|
||||
const logoImg = await loadImage('/logo-opsv.png')
|
||||
const logoW = 55
|
||||
const logoH = (logoImg.height / logoImg.width) * logoW
|
||||
pdf.addImage(logoImg, 'PNG', (W - logoW) / 2, 35, logoW, logoH)
|
||||
} catch { /* sin logo */ }
|
||||
|
||||
pdf.setTextColor(255, 255, 255)
|
||||
pdf.setFont('helvetica', 'bold')
|
||||
pdf.setFontSize(22)
|
||||
pdf.text(`Informe de Siniestralidad Vial ${year}`, W / 2, 118, { align: 'center' })
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.setFontSize(13)
|
||||
pdf.setTextColor(180, 195, 230)
|
||||
pdf.text('Observatorio Provincial de Seguridad Vial', W / 2, 132, { align: 'center' })
|
||||
pdf.text('Provincia de Santa Cruz · Argentina', W / 2, 141, { align: 'center' })
|
||||
pdf.setFontSize(9)
|
||||
pdf.setTextColor(130, 150, 190)
|
||||
const fecha = new Date().toLocaleDateString('es-AR', {
|
||||
day: '2-digit', month: 'long', year: 'numeric',
|
||||
})
|
||||
pdf.text(`Generado el ${fecha}`, W / 2, 190, { align: 'center' })
|
||||
|
||||
// ── Secciones ─────────────────────────────────────────────────────────────
|
||||
let pageNum = 1
|
||||
|
||||
for (let i = 0; i < seccionesIds.length; i++) {
|
||||
const secId = seccionesIds[i]
|
||||
const secLabel = SECCIONES_EXPORTABLES.find(s => s.id === secId)?.label ?? secId
|
||||
|
||||
onProgress?.(Math.round(((i + 1) / seccionesIds.length) * 90))
|
||||
|
||||
const el = document.getElementById(`pdf-section-${secId}`)
|
||||
if (!el) continue
|
||||
|
||||
// ✅ Obtener todos los bloques atómicos de esta sección
|
||||
const blocks = Array.from(el.querySelectorAll('[data-pdf-block]'))
|
||||
if (!blocks.length) continue
|
||||
|
||||
// Primera página de la sección
|
||||
pdf.addPage()
|
||||
pageNum++
|
||||
drawHeaderFooter(pdf, secLabel, year, pageNum, W, H)
|
||||
let cursorY = topY
|
||||
|
||||
for (const block of blocks) {
|
||||
const canvas = await capturarBloque(block)
|
||||
const mmPerPx = areaW / canvas.width
|
||||
let drawH = canvas.height * mmPerPx
|
||||
let drawW = areaW
|
||||
|
||||
// Si el bloque es más alto que toda la página, escalar para que entre
|
||||
if (drawH > areaH) {
|
||||
const ratio = areaH / drawH
|
||||
drawH = areaH
|
||||
drawW = areaW * ratio
|
||||
}
|
||||
|
||||
// ✅ Si no cabe en lo que queda de página → nueva página
|
||||
if (cursorY + drawH > topY + areaH) {
|
||||
pdf.addPage()
|
||||
pageNum++
|
||||
drawHeaderFooter(pdf, secLabel, year, pageNum, W, H)
|
||||
cursorY = topY
|
||||
}
|
||||
|
||||
const imgData = canvas.toDataURL('image/jpeg', 0.92)
|
||||
pdf.addImage(imgData, 'JPEG', marginX, cursorY, drawW, drawH)
|
||||
cursorY += drawH + gap
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(100)
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
pdf.save(`OPSV_Informe_Siniestralidad_${year}.pdf`)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user