commit ca7b159657035af568d6c419bec9f1237a5c0c83 Author: vforchino Date: Wed Apr 29 13:39:09 2026 -0300 Primer commit — OPSV Dashboard de siniestralidad vial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9cd2cf1 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8525d2a --- /dev/null +++ b/README.md @@ -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 +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* diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/eslint.config.js @@ -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_]' }], + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..41c0925 --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + opsv-dashboard + + +
+ + + diff --git a/opsv-arquitectura-v2 (1).html b/opsv-arquitectura-v2 (1).html new file mode 100644 index 0000000..92447dc --- /dev/null +++ b/opsv-arquitectura-v2 (1).html @@ -0,0 +1,888 @@ + + + + + +OPSV — Plan de Arquitectura v2.0 + + + + + + + + +
+
Especificación Técnica
+

OPSV Dashboard v2.0
Plan de Arquitectura

+

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.

+
+ Versión 2.0 + Fecha Abril 2026 + Stack React + Supabase + Vercel + Modalidad Solo developer · Claude Code +
+
+ +
+ + +
+
1
+

Decisiones de arquitectura

+
+ +
+
+
⚛️
+

Frontend

+

React + Vite — mismo entorno que usaste en el dashboard anterior. Claude Code lo maneja perfectamente.

+ React 18 + Vite 5 +
+
+
🗄️
+

Backend + Auth

+

Supabase: base de datos PostgreSQL, autenticación email/password, storage para los CSV. Todo gratuito.

+ Supabase Free Tier +
+
+
🚀
+

Hosting

+

Vercel: deploy automático desde GitHub, HTTPS incluido, dominio gratuito bajo vercel.app.

+ Vercel Hobby (gratis) +
+
+
🎨
+

UI + Gráficos

+

Tailwind CSS para estilos. Recharts para visualizaciones (mismo que ya usás). Lucide para iconos.

+ Tailwind + Recharts +
+
+ +
+ ¿Por qué no GitHub Pages? 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. +
+ + +
+
2
+

Stack tecnológico completo

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CapaTecnologíaPara qué sirveCosto
UI FrameworkReact 18 + Vite + Interfaz del dashboard y panel admin +
Familiar para Claude Code, ecosistema enorme
+
Gratis
EstilosTailwind CSS v3 + Diseño responsivo, paleta institucional OPSV +
Claude Code genera Tailwind excelente
+
Gratis
GráficosRecharts + Todos los gráficos del dashboard actual +
Mismo que el dashboard anterior → migración directa
+
Gratis
Base de datosSupabase PostgreSQL + Config del dashboard, textos editables, roles, metadatos de CSV +
500MB gratis, más que suficiente para configuración
+
Gratis
AutenticaciónSupabase Auth + Login email/contraseña con roles (superadmin, admin, editor) +
Email confirmado + recupero de contraseña incluidos
+
Gratis
Storage CSVSupabase Storage + Almacenamiento de los 3 archivos CSV mensuales +
1GB gratis, historial de versiones posible
+
Gratis
HostingVercel + Publicación del sitio, deploy automático desde GitHub +
Cada push = nueva versión publicada en segundos
+
Gratis
RepositorioGitHub + Control de versiones, integración con Vercel +
Todo el código centralizado, historial completo
+
Gratis
+ + +
+
3
+

Diagrama de arquitectura

+
+ +
+ + + + + + + + USUARIOS + FRONTEND · VERCEL + BACKEND · SUPABASE + + + + + 👤 + Usuario Público + Sin login requerido + + Acceso al dashboard + + + + + 🔐 + Administrador + Login email/password + + superadmin | admin + + editor + + + + + + + + + + + Dashboard Público + / · sin autenticación + + React + Recharts + + + + Panel Admin + /admin · ruta protegida + Upload CSV · Editar textos + + React Router + Auth guard + + + + Login / Auth + /login · email + password + + + + GitHub Repository + Push → Vercel auto-deploy + Variables de entorno Supabase + en Vercel (VITE_SUPABASE_URL) + + + + + + + + + + + + + Supabase Auth + JWT · Roles · Email confirm + + user_roles table + + + + PostgreSQL DB + dashboard_config + user_roles · datasets + Textos · Chart config + + Row Level Security + + + + Supabase Storage + siniestros.csv + personas.csv · involucrados.csv + + + REST API + SDK Client + + + Free tier · 500MB DB + 1GB Storage + Respaldo automático · SSL incluido + +
+ + +
+
4
+

Roles de usuario y permisos

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RolVer dashboardSubir CSVEditar textos/resumenConfig gráficosGestionar usuariosAcceso
Superadmin✓ Crear/borrar adminsLogin requerido
AdminLogin requerido
Editor✓ Solo textosLogin requerido
Público✓ Solo lecturaSin login · acceso libre
+ + +
+
5
+

Esquema de base de datos (Supabase)

+
+ +
+
+
user_roles
+
    +
  • iduuid PK
  • +
  • user_iduuid FK auth
  • +
  • roleenum
  • +
  • created_attimestamp
  • +
+
+
+
datasets
+
    +
  • iduuid PK
  • +
  • typetext
  • +
  • yearinteger
  • +
  • monthinteger
  • +
  • storage_pathtext
  • +
  • uploaded_byuuid FK
  • +
  • created_attimestamp
  • +
+
+
+
dashboard_config
+
    +
  • iduuid PK
  • +
  • keytext unique
  • +
  • valuejsonb
  • +
  • updated_byuuid FK
  • +
  • updated_attimestamp
  • +
+
+
+
resumen_content
+
    +
  • iduuid PK
  • +
  • yearinteger
  • +
  • monthinteger
  • +
  • titletext
  • +
  • bodytext (md)
  • +
  • publishedboolean
  • +
  • created_byuuid FK
  • +
+
+
+
chart_config
+
    +
  • iduuid PK
  • +
  • chart_keytext unique
  • +
  • titletext
  • +
  • chart_typeenum
  • +
  • color_palettejsonb
  • +
  • visibleboolean
  • +
  • updated_attimestamp
  • +
+
+
+ +
+ Row Level Security (RLS) — 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. +
+ + +
+
6
+

Estructura del proyecto

+
+ +
+opsv-dashboard/ +├── src/ +│ ├── components/ # Componentes reutilizables +│ │ ├── charts/ # BarChart, LineChart, etc. +│ │ ├── ui/ # Button, Card, Modal, Input +│ │ └── layout/ # Navbar, Sidebar, Footer +│ │ +│ ├── pages/ # Páginas / rutas +│ │ ├── Dashboard.jsx # / → público +│ │ ├── Login.jsx # /login +│ │ └── admin/ +│ │ ├── AdminPanel.jsx # /admin → panel principal +│ │ ├── UploadCSV.jsx # /admin/upload +│ │ ├── EditResumen.jsx # /admin/resumen +│ │ ├── EditCharts.jsx # /admin/charts +│ │ └── ManageUsers.jsx # /admin/users (superadmin) +│ │ +│ ├── hooks/ # Custom hooks +│ │ ├── useAuth.js # Estado de sesión Supabase +│ │ ├── useCSVData.js # Carga y parseo de CSV +│ │ └── useDashConfig.js # Config editable desde DB +│ │ +│ ├── lib/ +│ │ └── supabase.js # Cliente Supabase (1 línea) +│ │ +│ ├── utils/ +│ │ └── parseCSV.js # Lógica de procesamiento CSV +│ │ +│ └── App.jsx # Rutas + Auth provider +│ +├── .env.local # VITE_SUPABASE_URL + ANON_KEY (no subir a GitHub) +├── .env.example # Template sin valores reales (sí subir) +├── vercel.json # Configuración de Vercel (SPA redirect) +├── tailwind.config.js # Paleta institucional OPSV #252C61 +└── vite.config.js +
+ + +
+
7
+

Fases de desarrollo

+
+ +
+
+
+ Fase + 1 + 2–3 sem +
+
+

Fundación del proyecto

+
    +
  • Crear proyecto en Supabase (auth + storage + DB)
  • +
  • Scaffold React + Vite con Tailwind y paleta OPSV
  • +
  • Sistema de autenticación: login, logout, sesión persistente
  • +
  • Ruta protegida /admin con auth guard
  • +
  • Conectar Vercel ↔ GitHub (deploy automático)
  • +
+
Sitio online vacío con login funcional y deploy automático
+
+
+ +
+
+
Fase
+
2
+
3–4 sem
+
+
+

Dashboard público

+
    +
  • Upload manual de los 3 CSV desde panel admin
  • +
  • Parser CSV en el cliente (Papa Parse)
  • +
  • Migrar todos los gráficos actuales (Recharts)
  • +
  • Filtros por año y mes funcionales
  • +
  • Sección Resumen con contenido desde DB
  • +
  • Diseño responsivo institucional
  • +
+
Dashboard público funcional idéntico al actual, pero con datos dinámicos
+
+
+ +
+
+
Fase
+
3
+
3–4 sem
+
+
+

Panel administrador

+
    +
  • Subida de CSV mensuales (3 archivos a la vez)
  • +
  • Editor de texto enriquecido para sección Resumen
  • +
  • Editor de títulos y configuración de gráficos
  • +
  • Selector de tipo de gráfico (barra/línea/área) por visualización
  • +
  • Selector de colores con paleta institucional
  • +
  • Vista previa antes de publicar
  • +
+
Panel admin completo con edición en tiempo real y previsualización
+
+
+ +
+
+
Fase
+
4
+
2–3 sem
+
+
+

Escalabilidad y pulido

+
    +
  • Gestión de múltiples usuarios con roles (admin, editor)
  • +
  • Historial de versiones de CSV por mes/año
  • +
  • Exportación PDF desde el panel admin
  • +
  • Notificaciones de carga exitosa / errores
  • +
  • Pruebas finales y documentación básica
  • +
+
Sistema production-ready con multi-usuario y gestión de versiones
+
+
+
+ +
+ Estimación realista para una persona con Claude Code: 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. +
+ +
+ +
+ OPSV — Observatorio Provincial de Seguridad Vial · Santa Cruz  |  + Arquitectura v2.0 · Abril 2026  |  + Stack: React + Supabase + Vercel +
+ + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..78e874c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3831 @@ +{ + "name": "opsv-dashboard", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opsv-dashboard", + "version": "0.0.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@supabase/auth-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.0.tgz", + "integrity": "sha512-6zAanO6c+6gpHOlt5Lb9TlBBkJdZiUWkWCJKAxzkywBDcwaHlLJKXnjQGX6GyVCyKRR1e7sTq4re/yRTH6U/9A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.0.tgz", + "integrity": "sha512-YrneV2NjskUkkmkZ2Jt2n3elBgbWzV4Y1M9MM370z2Zd5ZPFqFbY8KIoPwuNjtAGE9YrpKBxnbZqeF07BiN9Og==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz", + "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.0.tgz", + "integrity": "sha512-rC3sRxYdPZymkp2CZR1MiNQgbOleD01bGsW8VxEKRR5nMkLZ1NgAS1QTQf78Wh30czFyk505ZYr9Od8/mWT2TA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.0.tgz", + "integrity": "sha512-gcPtXzZ6izyyBVf2of7K3dEt8CScPJn8VcSlQq6oWL9QoE1kqfQl0oFrOMHd5qrcADewxI7OxxosLB8W4XqtIQ==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.0", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.0.tgz", + "integrity": "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.0.tgz", + "integrity": "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.103.0", + "@supabase/functions-js": "2.103.0", + "@supabase/postgrest-js": "2.103.0", + "@supabase/realtime-js": "2.103.0", + "@supabase/storage-js": "2.103.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", + "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/html2canvas-pro": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-2.0.2.tgz", + "integrity": "sha512-9G/t0XgCZWonLwL0JwI7su6NdbOPUY7Ur4Ihpp8+XMaW9ibA2nDXF181Jr6tm94k8lX6sthpaXB3XqEnsMd5Cw==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", + "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", + "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..320d167 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logo-opsv.png b/public/logo-opsv.png new file mode 100644 index 0000000..8caf2b3 Binary files /dev/null and b/public/logo-opsv.png differ diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/src/App.css @@ -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); + } +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..a73ddc4 --- /dev/null +++ b/src/App.jsx @@ -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 ( + + + } /> + } /> + + + + } /> + } /> + + + ) +} \ No newline at end of file diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/components/DebugPanel.jsx b/src/components/DebugPanel.jsx new file mode 100644 index 0000000..14d71e7 --- /dev/null +++ b/src/components/DebugPanel.jsx @@ -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 ⚠ Error: {error} + if (n === 0) return ⚠ 0 registros — ver checklist + return ✓ {n.toLocaleString('es-AR')} registros +} + +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 ( +
+ + + {visible && ( +
+ + {/* Resultados de consultas */} +
+ Resultado de consultas — año {debug.año_consultado}, mes {debug.mes_consultado} +
+ + {['siniestros', 'involucrados', 'personas'].map(k => { + const v = debug[k] + if (!v) return null + return ( +
+ + {v.tabla} + + +
+ ) + })} + + {/* Checklist de ayuda cuando hay 0 registros */} + {hayError && ( +
+
+ 📋 Checklist cuando una tabla devuelve 0 registros +
+
    +
  1. + Verificar RLS (Row Level Security) +
    + En Supabase → Table Editor → seleccioná la tabla → "RLS disabled" o + ejecutá en SQL Editor: + + {`-- 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);`} + +
  2. +
  3. + Verificar nombre exacto de la tabla +
    + + "Involucrados" + y{' '} + + "Personas" + {' '} + con mayúscula inicial (case-sensitive en Postgres) +
  4. +
  5. + Verificar que el año coincide +
    + Los datos del CSV tienen{' '} + ano = 2025. + El filtro actual es{' '} + + ano = {debug.año_consultado} + +
  6. +
  7. + Verificar en SQL Editor directamente + + {`SELECT COUNT(*) FROM "Involucrados" WHERE ano = 2025;\nSELECT COUNT(*) FROM "Personas" WHERE ano = 2025;`} + +
  8. +
+
+ )} + +
+ )} +
+ ) +} diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..42ea6b2 --- /dev/null +++ b/src/components/ProtectedRoute.jsx @@ -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
Cargando...
+ } + + if (!user) { + return + } + + // Para admin y superadmin + if (requiredRole === 'admin' && !isAdmin) { + return + } + + // Si en el futuro usás requiredRole="superadmin" + if (requiredRole === 'superadmin' && !isSuperAdmin) { + return + } + + return children +} \ No newline at end of file diff --git a/src/components/charts/DistribucionLocalidad.jsx b/src/components/charts/DistribucionLocalidad.jsx new file mode 100644 index 0000000..02d43db --- /dev/null +++ b/src/components/charts/DistribucionLocalidad.jsx @@ -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 ( +
+ + +
+ {data.map((item, i) => { + const pct = (item.total / maxVal) * 100 + + return ( +
+
+ {item.localidad} +
+ +
+
+ {pct > 20 && ( + + {item.total} + + )} +
+
+ + {pct <= 20 && ( +
+ {item.total} +
+ )} +
+ ) + })} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/charts/DonutGravedad.jsx b/src/components/charts/DonutGravedad.jsx new file mode 100644 index 0000000..1a527f2 --- /dev/null +++ b/src/components/charts/DonutGravedad.jsx @@ -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 ( +
+
+
+

Gravedad por categoría

+

Distribución de siniestros

+
+
+ +
+ + + + {data.map((entry) => ( + + ))} + + + + +
+ {kpis.total} + Total +
+
+ +
+ {data.map((item) => ( +
+
+ + {item.name} +
+
{item.value}
+
+ ))} +
+
+ ) +} diff --git a/src/components/charts/EstadoOcupante.jsx b/src/components/charts/EstadoOcupante.jsx new file mode 100644 index 0000000..ec7e4ba --- /dev/null +++ b/src/components/charts/EstadoOcupante.jsx @@ -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 ( +
+

{label}

+

+ {v.toLocaleString('es-AR')} personas ({total ? ((v/total)*100).toFixed(1) : 0}%) +

+
+ ) + } + + return ( +
+
+ 🏥 Estado del ocupante +
+
+ Estado al inicio del siniestro — {total.toLocaleString('es-AR')} personas +
+ + {/* KPI chips */} +
+ {inicio.map(e => ( +
+
{e.icon}
+
+ {e.inicio.toLocaleString('es-AR')} +
+
+ {e.label} +
+ + {total ? ((e.inicio/total)*100).toFixed(1) : 0}% + +
+
+ ))} +
+ + + + + + + } cursor={{ fill: '#ffffff08' }} /> + + {barData.map(d => )} + v > 0 ? v.toLocaleString('es-AR') : ''} + /> + + + + + {/* Tabla de evolución: inicio vs final */} +
+
+ Comparación inicio vs. final del siniestro +
+ + + + + + + + + + + {inicio.map(e => { + const delta = e.final - e.inicio + return ( + + + + + + + ) + })} + +
EstadoInicioFinalΔ
+ {e.icon} {e.key} + + {e.inicio.toLocaleString('es-AR')} + + {e.final.toLocaleString('es-AR')} + 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 : '—'} +
+
+
+ ) +} diff --git a/src/components/charts/FranjaHoraria.jsx b/src/components/charts/FranjaHoraria.jsx new file mode 100644 index 0000000..d0673c0 --- /dev/null +++ b/src/components/charts/FranjaHoraria.jsx @@ -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 ( +
+

+ {label} +

+ + {payload.map((p, i) => ( +

+ + {p.name === 'urbano' ? 'Urbano' : 'Rural'}: + {' '} + {p.value} siniestros +

+ ))} +
+ ) + } + + 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 ( +
+ Sin datos de franja horaria disponibles +
+ ) + } + + return ( +
+ +
+ + + + + + + + + + } + /> + + ( + + {value === 'urbano' ? 'Urbano' : 'Rural'} + + )} + /> + + {tieneUrbano && ( + + )} + + {tieneRural && ( + + )} + + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/charts/GeneroPersonas.jsx b/src/components/charts/GeneroPersonas.jsx new file mode 100644 index 0000000..9417283 --- /dev/null +++ b/src/components/charts/GeneroPersonas.jsx @@ -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 ( + + {(percent * 100).toFixed(0)}% + + ) +} + +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 ( +
+
+ 👤 Género +
+
+ Distribución por género — {personas.length.toLocaleString('es-AR')} personas +
+ + {/* Mini KPIs */} +
+ {data.map(d => ( +
+
+ {d.value.toLocaleString('es-AR')} +
+
{d.name}
+
+ ))} +
+ + + + + {data.map(entry => ( + + ))} + + [v.toLocaleString('es-AR'), n]} + contentStyle={{ background: '#12131A', border: `1px solid ${C.border}`, borderRadius: 8 }} + labelStyle={{ color: C.texto }} itemStyle={{ color: C.muted }} + /> + {v}} + iconType="circle" + /> + + +
+ ) +} diff --git a/src/components/charts/GravedadSiniestro.jsx b/src/components/charts/GravedadSiniestro.jsx new file mode 100644 index 0000000..4932766 --- /dev/null +++ b/src/components/charts/GravedadSiniestro.jsx @@ -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 ( +
+

Gravedad por Mes

+ + + + + + + + + + + + +
+ ) +} diff --git a/src/components/charts/PerfilVictimas.jsx b/src/components/charts/PerfilVictimas.jsx new file mode 100644 index 0000000..706eb1e --- /dev/null +++ b/src/components/charts/PerfilVictimas.jsx @@ -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 ( + + {`${(percent * 100).toFixed(0)}%`} + + ) +} + +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 ( +
+ +
+
+
+ Género +
+
+ + + + {generoData.map((entry, index) => ( + + ))} + + + + + ( + {value} + )} + /> + + +
+
+ +
+
+ Rango etario +
+
+ + + + + + + Math.ceil(dataMax * 1.1)]} + /> + + + + + + +
+
+ +
+
+ Tipo de usuario +
+
+ + + + + + + + + + + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/charts/PorLocalidad.jsx b/src/components/charts/PorLocalidad.jsx new file mode 100644 index 0000000..95068ea --- /dev/null +++ b/src/components/charts/PorLocalidad.jsx @@ -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 ( +
+ +
+ + + + + + + + + + + + {data.map((entry) => ( + + ))} + + + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/charts/PorTipoSiniestro.jsx b/src/components/charts/PorTipoSiniestro.jsx new file mode 100644 index 0000000..c756dfd --- /dev/null +++ b/src/components/charts/PorTipoSiniestro.jsx @@ -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 ( + + {`${pct.toFixed(0)}%`} + + ) +} + +export default function PorTipoSiniestro({ siniestros }) { + const { tooltipBg, tooltipBorder, tooltipLabel, tickColor } = useChartTheme() + const data = agruparConUmbral(porTipoSiniestro(siniestros), 10) + + return ( +
+ +
+ + + + {data.map((entry, index) => ( + + ))} + + + + + ( + {value} + )} + /> + + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/charts/ProteccionPersonas.jsx b/src/components/charts/ProteccionPersonas.jsx new file mode 100644 index 0000000..cb1b043 --- /dev/null +++ b/src/components/charts/ProteccionPersonas.jsx @@ -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 ( +
+
+
+ {icon && {icon}} + + {titulo} + +
+
+ base: {base.toLocaleString('es-AR')} {baseLabel} +
+
+ + {base > 0 ? ( + <> +
+ {si > 0 && ( +
+ )} + {no > 0 && ( +
+ )} + {sd > 0 && ( +
+ )} +
+ +
+ {[ + { 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) => ( +
+ + {item.v.toLocaleString('es-AR')} + + + {` ${item.label} (${pct(item.v, base)})`} + +
+ ))} +
+ + ) : ( +
+ Sin datos para esta categoría en el período seleccionado +
+ )} +
+ ) +} + +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 ( +
+
+ + Prueba de alcohol + + + base: {total.toLocaleString('es-AR')} personas totales + +
+ +
+ {items.map((item) => ( +
+
+ {item.v.toLocaleString('es-AR')} +
+
+ {item.label} +
+ + {pct(item.v, total)} + +
+
+ ))} +
+ + {realizadas > 0 && ( +
+ Tasa de positividad sobre pruebas realizadas ( + {realizadas.toLocaleString('es-AR')} + ): + {' '} + 0 ? ROJO : VERDE }} + > + {((pos / realizadas) * 100).toFixed(1)}% + +
+ )} +
+ ) +} + +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 ( +
+ + + {sinMapa && ( +
+ ⚠ 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 involucrados. +
+ )} + +
+ + + + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/charts/RangoEtario.jsx b/src/components/charts/RangoEtario.jsx new file mode 100644 index 0000000..a22d145 --- /dev/null +++ b/src/components/charts/RangoEtario.jsx @@ -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 ( +
+

{label}

+

+ {payload[0].value} Personas +

+
+ ) + } + return null +} + +export default function RangoEtario({ personas = [] }) { + const data = calcularRangoEtario(personas) + + if (!data.length) { + return ( +
+ Sin datos de rango etario + Verificar campo en tabla Personas +
+ ) + } + + return ( +
+
+

Perfil etario

+

Distribucion por edad

+
+ + + + + + } cursor={{ fill: '#F3F4F6' }} /> + + {data.map((_, i) => ( + + ))} + + + +
+ ) +} diff --git a/src/components/charts/RolPersona.jsx b/src/components/charts/RolPersona.jsx new file mode 100644 index 0000000..29dd729 --- /dev/null +++ b/src/components/charts/RolPersona.jsx @@ -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 ( +
+

{label}

+ {payload.map(p => ( +

+ {p.name}: {p.value.toLocaleString('es-AR')} +

+ ))} +

+ Total: {payload.reduce((a, p) => a + p.value, 0).toLocaleString('es-AR')} + {' '}({((payload.reduce((a,p)=>a+p.value,0)/total)*100).toFixed(1)}%) +

+
+ ) + } + + return ( +
+
+ 🎭 Rol en el siniestro +
+
+ Conductor / Acompañante / Peatón — desglosado por género +
+ + {/* KPI chips por rol */} +
+ {ROLES.map(r => ( +
+
{r.icon}
+
+ {(totales[r.key] || 0).toLocaleString('es-AR')} +
+
+ {r.key} + + {' '}({total ? ((totales[r.key]/total)*100).toFixed(0) : 0}%) + +
+
+ ))} +
+ + + + + + + } cursor={{ fill: '#ffffff08' }} /> + {v}} /> + + + + + +
+ ) +} diff --git a/src/components/charts/SerieHistorica.jsx b/src/components/charts/SerieHistorica.jsx new file mode 100644 index 0000000..3cd1b22 --- /dev/null +++ b/src/components/charts/SerieHistorica.jsx @@ -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 ( +
+

+ {label} +

+ + {row?.siniestros != null && ( +

+ Siniestros fatales:{' '} + + {row.siniestros} + +

+ )} + + {row?.victimas != null && ( +

+ Víctimas fatales:{' '} + + {row.victimas} + +

+ )} + + {payload[0].value != null && ( +

+ Tasa:{' '} + + {payload[0].value} + {' '} + c/100k hab. +

+ )} +
+ ) + } + 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 ( +
+
+

+ Serie histórica +

+

+ Tasa de mortalidad vial +

+

+ Víctimas fatales cada 100.000 habitantes. Provincia de Santa Cruz, {primerAnio}–{ultimoAnio}. +

+
+ +
+ + + + + + + } + /> + + + + +
+ +
+

+ 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. +

+

+ Nota: 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. +

+
+
+ ) +} \ No newline at end of file diff --git a/src/components/charts/SiniestrosPorMes.jsx b/src/components/charts/SiniestrosPorMes.jsx new file mode 100644 index 0000000..779dfbd --- /dev/null +++ b/src/components/charts/SiniestrosPorMes.jsx @@ -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 ( +
+ +
+ + + + + + + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/charts/TipoInvolucrado.jsx b/src/components/charts/TipoInvolucrado.jsx new file mode 100644 index 0000000..5abfeba --- /dev/null +++ b/src/components/charts/TipoInvolucrado.jsx @@ -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 ( +
+

+ Tipo de involucrado +

+

+ Vehículos y peatones +

+

+ {d.full}: {d.value.toLocaleString('es-AR')} ( + {((d.value / total) * 100).toFixed(1)}%) +

+
+ ) + } + + return ( +
+
+ {total.toLocaleString('es-AR')} vehículos y peatones registrados +
+ + {/* KPI chips — top 4 */} +
+ {barData.slice(0, 4).map((d) => ( +
+
{d.icon}
+
+ {d.value.toLocaleString('es-AR')} +
+
+ {d.name} +
+ + {((d.value / total) * 100).toFixed(1)}% + +
+
+ ))} +
+ +
+ + + + + + + + + } + cursor={{ fill: 'rgba(148,163,184,0.08)' }} + /> + + + {barData.map((d) => ( + + ))} + + v > 0 ? v.toLocaleString('es-AR') : '' + } + /> + + + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/charts/TipoSiniestro.jsx b/src/components/charts/TipoSiniestro.jsx new file mode 100644 index 0000000..b18498e --- /dev/null +++ b/src/components/charts/TipoSiniestro.jsx @@ -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 ( + + {`${pct.toFixed(0)}%`} + + ) +} + +const CustomTooltip = ({ active, payload }) => { + if (active && payload?.length) { + const d = payload[0].payload + return ( +
+

{d.name}

+

Cantidad: {d.value}

+

Porcentaje: {d.pct.toFixed(1)}%

+
+ ) + } + return null +} + +export default function TipoSiniestro({ siniestros = [] }) { + const data = agruparPorUmbral(siniestros, 10) + + if (!data.length) { + return ( +
+ Sin datos disponibles +
+ ) + } + + return ( +
+
+

Tipo de Siniestro

+

Distribucion por tipo

+
+ + + + {data.map((_, i) => ( + + ))} + + } /> + ( + {value} + )} + /> + + +
+ ) +} diff --git a/src/components/charts/TipoVia.jsx b/src/components/charts/TipoVia.jsx new file mode 100644 index 0000000..1d60297 --- /dev/null +++ b/src/components/charts/TipoVia.jsx @@ -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 ( +
+

Tipo de Vía

+ + + + + + [val, 'Siniestros']} /> + + {data.map((_, i) => )} + + + +
+ ) +} diff --git a/src/components/charts/ZonaOcurrencia.jsx b/src/components/charts/ZonaOcurrencia.jsx new file mode 100644 index 0000000..61918a6 --- /dev/null +++ b/src/components/charts/ZonaOcurrencia.jsx @@ -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 ( + + {`${(percent * 100).toFixed(1)}%`} + + ) + } + + return ( +
+ + + + + {data.map((entry, i) => ( + + ))} + + + [ + `${val} (${total ? ((val / total) * 100).toFixed(1) : 0}%)`, + name, + ]} + /> + + ( + {value} + )} + /> + + +
+ ) +} \ No newline at end of file diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx new file mode 100644 index 0000000..5604ec9 --- /dev/null +++ b/src/components/layout/Sidebar.jsx @@ -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 ( + + ) +} \ No newline at end of file diff --git a/src/components/layout/Topbar.jsx b/src/components/layout/Topbar.jsx new file mode 100644 index 0000000..07f59bb --- /dev/null +++ b/src/components/layout/Topbar.jsx @@ -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 ( +
+
+
+

OPSV

+

{title}

+

{subtitle}

+
+ +
+ + {/* ── Selector de año ── */} + 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 ── */} +
+ + + {openFiltro && ( +
+
+

Filtrar por período

+

+ Seleccioná un rango mensual para acotar los siniestros analizados. +

+
+ +
+ + +
+ + {errorRango && ( +
+ {errorRango} +
+ )} + +
+ + +
+
+ )} +
+ + {/* ── Filtro por departamento (oculto en Verano Vivo) ── */} + {seccion !== 'veranovivo' !== 'historica'&& ( + ({ value: d, label: d }))} + placeholder="Todos los deptos." + /> + )} + + {/* ── Filtro por localidad (oculto en Verano Vivo, deshabilitado sin depto.) ── */} + {seccion !== 'veranovivo' !== 'historica' && ( + ({ value: l, label: l }))} + placeholder="Todas las localidades" + placeholderEmpty="Seleccioná un depto." + disabled={!departamentoFiltro} + /> + )} + + + {/* ── Descargar PDF ── */} + + + + + {/* ── Contador ── */} +
+ {siniestrosCount ?? 0} siniestros cargados +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/ChartCard.jsx b/src/components/ui/ChartCard.jsx new file mode 100644 index 0000000..dac7a07 --- /dev/null +++ b/src/components/ui/ChartCard.jsx @@ -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 ( +
+ {(kicker || title || subtitle) && ( +
+ {kicker && ( +

+ {kicker} +

+ )} + {title && ( +

+ {title} +

+ )} + {subtitle && ( +

+ {subtitle} +

+ )} +
+ )} +
+ {children} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/ErrorBanner.jsx b/src/components/ui/ErrorBanner.jsx new file mode 100644 index 0000000..73c3f0e --- /dev/null +++ b/src/components/ui/ErrorBanner.jsx @@ -0,0 +1,10 @@ +export default function ErrorBanner({ message }) { + if (!message) return null + + return ( +
+
Error al cargar datos
+

{message}

+
+ ) +} diff --git a/src/components/ui/FilterSelect.jsx b/src/components/ui/FilterSelect.jsx new file mode 100644 index 0000000..e9a3921 --- /dev/null +++ b/src/components/ui/FilterSelect.jsx @@ -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 ( +
+ {Icon && } + + {/* Trigger */} + + + {/* Botón limpiar */} + {value && !disabled && ( + + )} + + {/* Dropdown */} + {open && !disabled && ( +
    + + {/* Opción vacía */} +
  • + +
  • + + {options.length === 0 && ( +
  • + Sin opciones disponibles +
  • + )} + + {options.map(opt => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/components/ui/KPICard.jsx b/src/components/ui/KPICard.jsx new file mode 100644 index 0000000..bdd0e8a --- /dev/null +++ b/src/components/ui/KPICard.jsx @@ -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 ( +
+
+
+
+ {formattedValue} + {unit ? ( + + {' '} + {unit} + + ) : null} +
+ +

+ {label} +

+
+ + {variation ? ( +
+ {variation} +
+ ) : null} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/LoadingSpinner.jsx b/src/components/ui/LoadingSpinner.jsx new file mode 100644 index 0000000..46784fb --- /dev/null +++ b/src/components/ui/LoadingSpinner.jsx @@ -0,0 +1,9 @@ +export default function LoadingSpinner() { + return ( +
+
+
Cargando datos...
+

Por favor espera mientras se carga la información de Supabase.

+
+ ) +} diff --git a/src/components/ui/PdfExportModal.jsx b/src/components/ui/PdfExportModal.jsx new file mode 100644 index 0000000..186b641 --- /dev/null +++ b/src/components/ui/PdfExportModal.jsx @@ -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 ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ + {/* Header */} +
+
+

+ Exportar a PDF +

+

+ Seleccioná las secciones a incluir en el informe {year}. +

+
+ {!generando && ( + + )} +
+ + {/* Seleccionar todas */} + + + {/* Checkboxes */} +
+ {SECCIONES_EXPORTABLES.map(sec => ( + + ))} +
+ + {/* Barra de progreso */} + {generando && ( +
+
+ + Generando PDF... + + + {progreso}% + +
+
+
+
+
+ )} + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Botones */} +
+ + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/ThemeToggle.jsx b/src/components/ui/ThemeToggle.jsx new file mode 100644 index 0000000..4ae2045 --- /dev/null +++ b/src/components/ui/ThemeToggle.jsx @@ -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 ( + + ) +} diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.jsx new file mode 100644 index 0000000..45c657a --- /dev/null +++ b/src/hooks/useAuth.jsx @@ -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 ( + + {children} + + ) +} + +export function useAuth() { + const context = useContext(AuthContext) + if (!context) throw new Error('useAuth debe usarse dentro de ') + return context +} \ No newline at end of file diff --git a/src/hooks/useChartTheme.js b/src/hooks/useChartTheme.js new file mode 100644 index 0000000..f9a92dc --- /dev/null +++ b/src/hooks/useChartTheme.js @@ -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 + } +} \ No newline at end of file diff --git a/src/hooks/useData.js b/src/hooks/useData.js new file mode 100644 index 0000000..91e33f3 --- /dev/null +++ b/src/hooks/useData.js @@ -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 } +} \ No newline at end of file diff --git a/src/hooks/useSiniestralidad.js b/src/hooks/useSiniestralidad.js new file mode 100644 index 0000000..112161b --- /dev/null +++ b/src/hooks/useSiniestralidad.js @@ -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 } +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..8b6c9ee --- /dev/null +++ b/src/index.css @@ -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; +} \ No newline at end of file diff --git a/src/lib/supabase.js b/src/lib/supabase.js new file mode 100644 index 0000000..dcd2ba7 --- /dev/null +++ b/src/lib/supabase.js @@ -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 + } +}) \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..de60394 --- /dev/null +++ b/src/main.jsx @@ -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( + + + + + +) diff --git a/src/pages/Admin.jsx b/src/pages/Admin.jsx new file mode 100644 index 0000000..eddf6cb --- /dev/null +++ b/src/pages/Admin.jsx @@ -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 ( +
+
Cargando...
+
+ ) + + // Header reutilizable para ambas vistas + const Header = ({ titulo, onVolver }) => ( +
+
+
+ {onVolver && ( + <> + + | + + )} +

{titulo}

+
+ +
+
+

{user?.email}

+

{role}

+
+ +
+
+
+ ) + + if (vista === 'importar') return ( +
+
setVista('panel')} /> + +
+ ) + + return ( +
+
+ +
+ {/* Tarjetas de stats */} +
+
+
+
+

Siniestros

+

+ {stats.siniestros.toLocaleString('es-AR')} +

+
+ +
+
+ +
+
+
+

Personas

+

+ {stats.personas.toLocaleString('es-AR')} +

+
+ +
+
+ +
+
+
+

Involucrados

+

+ {stats.involucrados.toLocaleString('es-AR')} +

+
+ +
+
+
+ + {/* Funcionalidades */} +
+

+ + Funcionalidades disponibles +

+
+
+

📊 Dashboard

+

+ Visualiza todas las secciones de análisis de siniestralidad vial +

+ + Ir al dashboard → + +
+ +
setVista('importar')} + className="p-4 bg-opsv-bg rounded-2xl border border-opsv-border cursor-pointer hover:border-opsv-blue hover:shadow-sm transition" + > +

📤 Importar datos

+

+ Carga nuevos datos CSV desde la base de siniestros +

+ + Abrir importador → + +
+ + {isSuperAdmin && ( +
+

⚙️ Configuración

+

+ Ajusta parámetros del dashboard (próximamente) +

+
+ )} + +
+

📥 Exportar reportes

+

+ Descarga informes en PDF (próximamente) +

+
+
+
+ +
+

OPSV Dashboard v1.0 — Agencia Provincial de Seguridad Vial, Santa Cruz

+

Para soporte: admin@opsv.sc.gov.ar

+
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx new file mode 100644 index 0000000..7034885 --- /dev/null +++ b/src/pages/Dashboard.jsx @@ -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 ( +
+
{title}
+

{description}

+

+ Este espacio está reservado para la próxima fase de implementación. +

+
+ ) +} + + +function AdminFooter() { + const { user, isAdmin } = useAuth() + const navigate = useNavigate() + + return ( +
+

+ OPSV Dashboard — Agencia Provincial de Seguridad Vial, Santa Cruz +

+ +
+ ) +} + + +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 ( +
+ + +
+ setModalPdf(true)} + /> + +
+ {error && } + {loading ? ( + + ) : seccion === 'resumen' ? ( + + ) : seccion === 'historica' ? ( + + ) : seccion === 'fatales' ? ( + + ) : seccion === 'lesionados' ? ( + + ) : seccion === 'sinlesiones' ? ( + + ) : seccion === 'sintesis' ? ( + + ) : seccion === 'veranovivo' ? ( + + ) : ( + + )} + +
+ {/* ── Contenedor oculto para captura PDF ─────────────────────────── */} + + {/* ────────────────────────────────────────────────────────────────── */} + +
+ + {/* Modal PDF */} + {modalPdf && ( + setModalPdf(false)} + /> + )} +
+ ) +} + \ No newline at end of file diff --git a/src/pages/ImportarCSV.jsx b/src/pages/ImportarCSV.jsx new file mode 100644 index 0000000..a637f97 --- /dev/null +++ b/src/pages/ImportarCSV.jsx @@ -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 ( +
+
+

Importar datos CSV

+

+ Cargá los tres archivos. Las columnas extra del CSV se ignoran automáticamente. +

+
+ +
+

📋 Orden recomendado:

+
    +
  1. siniestros — tabla principal (id_feu). Se calculan dia_semana y es_fin_semana automáticamente.
  2. +
  3. Involucrados — referencia id_feu + id_involucrado
  4. +
  5. Personas — referencia id_feu + id_involucrado + id_persona
  6. +
+
+ + {TABLAS.map(tabla => { + const est = archivos[tabla] + const prog = progreso[tabla] + const porcentaje = prog?.total ? Math.round((prog.procesados / prog.total) * 100) : 0 + + return ( +
+
+
+
+ {est.cargado + ? + : est.valido + ? + : } +
+
+

{tabla}

+

+ {est.nombre + ? `${est.rows.length} filas · ${est.headers.length} columnas en CSV` + : `${ESQUEMAS[tabla].length} columnas requeridas en Supabase`} +

+
+
+ +
+ {est.valido && !est.cargado && ( + + )} + + + + handleFile(tabla, e)} + /> + + {est.rows.length > 0 && ( + + )} +
+
+ + {prog?.estado === 'importando' && ( +
+
+
+
+

+ {prog.procesados} / {prog.total} registros +

+
+ )} + + {est.cargado && est.resultado && ( +
+
+ {est.resultado.insertados} insertados + {est.resultado.errores > 0 && ⚠️ {est.resultado.errores} con error} +
+
+ )} + + {est.nombre && !est.cargado && ( +
+ {est.columnasFaltantes.length > 0 && ( +
+

+ Columnas faltantes ({est.columnasFaltantes.length}): +

+

{est.columnasFaltantes.join(', ')}

+
+ )} + + {est.columnasExtra.length > 0 && ( +
+

+ Columnas extra en CSV (se ignoran automáticamente): +

+

{est.columnasExtra.join(', ')}

+
+ )} + + {est.valido && ( +
+ Todas las columnas requeridas presentes +
+ )} +
+ )} + + {est.expandido && est.rows.length > 0 && ( +
+ + + + {est.headers.slice(0, 8).map(h => ( + + ))} + {est.headers.length > 8 && ( + + )} + + + + {est.rows.slice(0, 5).map((row, i) => ( + + {est.headers.slice(0, 8).map(h => ( + + ))} + {est.headers.length > 8 && } + + ))} + +
+ {h} + +{est.headers.length - 8} más
+ {row[h] ?? '-'} + ...
+ + {est.rows.length > 5 && ( +

+ Mostrando 5 de {est.rows.length} filas +

+ )} +
+ )} +
+ ) + })} + + {algunoValido && ( + + )} + + {TABLAS.every(t => archivos[t].cargado) && ( +
+ +

¡Importación completada!

+

Los tres archivos fueron procesados correctamente.

+
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx new file mode 100644 index 0000000..a8f4178 --- /dev/null +++ b/src/pages/Login.jsx @@ -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 ( +
+
+
+
+
+ +
+
+
+
OPSV
+
+ Agencia Provincial de Seguridad Vial +
+
Santa Cruz, Argentina
+
+ +
+ {error && ( +
+ +

{error}

+
+ )} + +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+ +
+

Panel administrativo reservado para personal autorizado

+

+ Para soporte, contactá a:{' '} + admin@opsv.sc.gov.ar +

+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/SecFatales.jsx b/src/pages/SecFatales.jsx new file mode 100644 index 0000000..02e2b56 --- /dev/null +++ b/src/pages/SecFatales.jsx @@ -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 ( +
+ {/* KPIs */} +
+ + + + +
+ + {/* Evolución y tipo de siniestro */} +
+ + + + + + + +
+ + {/* Franja horaria */} + + + + + {/* Distribución territorial */} +
+ + + + + + + +
+ + {/* Perfil de víctimas */} + + + + + {/* Seguridad pasiva */} + + + +
+ ) +} \ No newline at end of file diff --git a/src/pages/SecHistorica.jsx b/src/pages/SecHistorica.jsx new file mode 100644 index 0000000..b5d0a7d --- /dev/null +++ b/src/pages/SecHistorica.jsx @@ -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 ( +
+ {/* KPIs históricas */} +
+ + + + + +
+ + {/* Gráfico de tasa + barras de víctimas */} +
+ +
+
+

+ Víctimas fatales +

+

+ Evolución anual +

+
+
+ + + + + + + + + +
+
+
+ + {/* Distribución por tipo, franja y localidad */} +
+ + + +
+ + {/* Tabla histórica */} +
+
+

+ Tabla histórica +

+

+ Histórico de siniestros +

+
+
+ + + + + + + + + + + {dataTabla.map((row) => ( + r.ano === yearNum) + ? 'bg-blue-500/5 font-semibold' + : '' + }`} + > + + + + + + ))} + +
+ Año + + Siniestros + + Víctimas + + Tasa +
+ {row.ano} + + {row.siniestros} + + {row.victimas ?? '—'} + {row.tasa ?? '—'}
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/SecLesionados.jsx b/src/pages/SecLesionados.jsx new file mode 100644 index 0000000..5420f6e --- /dev/null +++ b/src/pages/SecLesionados.jsx @@ -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 ( +
+ {/* KPIs */} +
+ + + + +
+ + {/* Evolución y tipo de siniestro */} +
+ + + + + + + +
+ + {/* Franja horaria */} + + + + + {/* Distribución territorial */} +
+ + + + + + + +
+ + {/* Perfil de víctimas */} + + + + + {/* Seguridad pasiva */} + + + +
+ ) +} \ No newline at end of file diff --git a/src/pages/SecResumen.jsx b/src/pages/SecResumen.jsx new file mode 100644 index 0000000..453af9e --- /dev/null +++ b/src/pages/SecResumen.jsx @@ -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 ( +
+
+ + + + + +
+ +
+ + +
+ +
+ + +
+
+

+ Comparativa por departamento +

+

+ Siniestros fatales por departamento +

+
+ + {tablaData.length === 0 ? ( +

+ Sin siniestros fatales registrados para el periodo seleccionado. +

+ ) : ( +
+ + + + + + + + + + + + {tablaData.map((row, i) => ( + + + + + + + ))} + +
+ Departamento + + Siniestros fatales + + Víctimas fatales + + Tasa c/100k Hab. +
+ {row.departamento} + + {row.siniestrosFatales} + + {row.victimasFatales} + + {row.tasa !== null && row.tasa !== '-' ? row.tasa : '—'} +
+
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/SecSinLesiones.jsx b/src/pages/SecSinLesiones.jsx new file mode 100644 index 0000000..7cde7fd --- /dev/null +++ b/src/pages/SecSinLesiones.jsx @@ -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 ( +
+ {/* KPIs */} +
+ + + +
+ + {/* Evolución y tipo */} +
+ + + + + + + +
+ + {/* Franja horaria */} + + + + + {/* Distribución territorial */} +
+ + + + + + + +
+ + {/* Perfil de involucrados */} + + + +
+ ) +} \ No newline at end of file diff --git a/src/pages/SecSintesis.jsx b/src/pages/SecSintesis.jsx new file mode 100644 index 0000000..a1635df --- /dev/null +++ b/src/pages/SecSintesis.jsx @@ -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 ( + +
+ + {/* Dato destacado */} +
+

+ {destacado.label} +

+

+ {destacado.valor ?? '—'} +

+
+ + {/* Grilla de datos secundarios */} +
+ {datos.map(({ label, valor }) => ( +
+

{label}

+

{valor ?? '—'}

+
+ ))} +
+ +
+
+ ) +} + +// ── 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 ( +
+ + {/* ── Siniestralidad general ── */} + +
+ {[ + { 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 }) => ( +
+

+ {typeof valor === 'number' ? valor.toLocaleString('es-AR') : valor} +

+

{label}

+
+ ))} +
+
+ + {/* ── Contexto histórico ── */} + +
+
+

{anoMaxVictimas.ano}

+

+ Año más crítico ({anoMaxVictimas.victimas} víctimas) +

+
+
+

{anoMinVictimas.ano}

+

+ Año más bajo ({anoMinVictimas.victimas} víctimas) +

+
+
+

+ {penultimoAno?.victimas ?? '—'} +

+

+ Víctimas {penultimoAno?.ano ?? '—'} +

+
+
+

+ {tendencia} + {pctVariacion !== null && ( + {pctVariacion}% + )} +

+

+ Tendencia {penultimoAno?.ano}→{ultimoAno?.ano} +

+
+
+
+ + {/* ── Fichas por tipo: 3 columnas en desktop ── */} +
+ {fichas.map(({ kicker, title, color, bloque }) => ( + + ))} +
+ +
+ ) +} \ No newline at end of file diff --git a/src/pages/SecVeranoVivo.jsx b/src/pages/SecVeranoVivo.jsx new file mode 100644 index 0000000..39795cd --- /dev/null +++ b/src/pages/SecVeranoVivo.jsx @@ -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 ( + + {`${pct.toFixed(0)}%`} + + ) +} + + +// ── 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 ( +
+
+

+ Análisis · {campanaLabel} +

+

+ Puntos destacados de la campaña +

+
+
+ {insights.map((insight, i) => { + const cfg = INSIGHT_CONFIG[insight.tipo] + return ( +
+ {cfg.icon} +
+ + {cfg.label} + +

+ {insight.texto} +

+
+
+ ) + })} +
+
+ ) +} + + +// ── KPI card ──────────────────────────────────────────────── +function KpiVV({ label, value, color, badge }) { + return ( +
+ {value} + {label} + {badge && ( + + {badge} + + )} +
+ ) +} + + +// ── Tooltip genérico ──────────────────────────────────────── +function CustomTooltip({ active, payload, label }) { + if (!active || !payload?.length) return null + return ( +
+

{label}

+ {payload.map((p) => ( +

+ + {p.name}: {p.value} +

+ ))} +
+ ) +} + + +// ── Bar horizontal stacked ────────────────────────────────── +function BarHorizontalStacked({ data, nameKey }) { + return ( + + + + + + } /> + + + + + + + ) +} + + +// ── 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 ( +
+ + {/* Encabezado + selector de campaña */} +
+
+

Campaña

+

Verano Vivo

+

20 de diciembre al 20 de marzo

+
+
+ {CAMPANAS_VV.map((c, i) => ( + + ))} +
+
+ + {/* KPIs */} +
+

+ Rutas y caminos de la provincia · {campanaActual.label} +

+
+ + + + +
+
+ + {/* Gráfico A — Serie histórica · ancho completo */} + + + + + + + } /> + + + + + + + {/* Gráfico B — Comparación histórica · ancho completo */} + + + + + + + } /> + + + + + + + + + + {/* Gráficos C — Siniestros por mes separados por zona · grid 2 col */} +
+ + + + + + + } /> + + + + + + + + + + + + + + + } /> + + + + + + + +
+ + {/* Gráficos D y E — Rutas y Localidades · grid 2 col */} +
+ + {graficoD.length === 0 + ?

Sin datos para esta campaña

+ : + } +
+ + + {graficoE.length === 0 + ?

Sin datos para esta campaña

+ : + } +
+
+ + {/* Gráfico F — Donut tipos · ancho completo */} + +
+ + + + {graficoF.map((entry, index) => ( + + ))} + + } /> + {value}} + /> + + +
+
+ + {/* Bloque de insights · ancho completo */} + + +
+ ) +} \ No newline at end of file diff --git a/src/utils/calculos.js b/src/utils/calculos.js new file mode 100644 index 0000000..3701b90 --- /dev/null +++ b/src/utils/calculos.js @@ -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) +} \ No newline at end of file diff --git a/src/utils/colores.js b/src/utils/colores.js new file mode 100644 index 0000000..d0ab38c --- /dev/null +++ b/src/utils/colores.js @@ -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'], +} \ No newline at end of file diff --git a/src/utils/exportPdf.js b/src/utils/exportPdf.js new file mode 100644 index 0000000..233e828 --- /dev/null +++ b/src/utils/exportPdf.js @@ -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`) +} \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..11f4779 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,5 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'], + darkMode: 'class', +} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..4a78695 --- /dev/null +++ b/vite.config.js @@ -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(), + ], +})