commit 2a1a3d40b75224faa4b3716a4ea2e2cac10d4dba Author: unknown Date: Tue Jul 14 14:50:48 2026 -0600 Primer commit diff --git a/.env b/.env new file mode 100644 index 0000000..2ef74cd --- /dev/null +++ b/.env @@ -0,0 +1,13 @@ +VITE_API_BASE_URL=https://api.leaks.mx +VITE_AUTH_LOGIN_URL=https://api.leaks.mx/usuario/login + +VITE_AUTH_CLIENT_ID=string +VITE_AUTH_CLIENT_SECRET=tu_secreto_real + + + +# Configuracion WebSocket (por defecto replica la del sistema actual) +VITE_WS_HOST=ws.leaks.mx +VITE_WS_PORT=2140 +VITE_WS_PATH=/websocket +VITE_WS_SECURE=true \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..04dbbb5 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +VITE_AUTH_LOGIN_URL=https://fast.roph.one/usuario/login +VITE_AUTH_CLIENT_ID=your-client-id +VITE_AUTH_CLIENT_SECRET=your-client-secret diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..3b450fd --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,73 @@ +================================ +HANDOFF PROYECTO PANEL VUE +Fecha: 2026-07-10 +Ruta local actual: C:/python/test/panel + +1. Resumen ejecutivo +Proyecto migrado a arquitectura de panel admin con Vue 3 + Vite + Tailwind. +Login obligatorio antes de entrar al panel, con guard de rutas. +Integración de WebSocket activa. +Búsqueda principal en Dashboard (3 pestañas: Teléfono, Placa, Nombre). +Monitor WS con logs. +Arquitectura nueva de resultados normalizados en árbol (sesión -> base -> card -> búsquedas hijas). +Exportación de reporte final en JSON y PDF desde selección de cards. +2. Estado funcional implementado +Autenticación: +Login y sesión en localStorage. +Ruta protegida para admin. +Layout: +Navbar, Sidebar, Footer. +Sidebar colapsable y persistente. +Dashboard: +Formulario de búsqueda principal. +Envío por WS con contrato alineado a HomeView. +Resultados: +Store central de resultados. +Adaptadores por fuente/base. +Tablero agrupado por base. +Cards con acciones para búsquedas derivadas. +Reportes: +Selección manual de cards. +Exportación JSON. +Exportación PDF (imprimible). +3.Archivos clave +src/views/DashboardView.vue +src/composables/useSearchResultsStore.js +src/composables/useWsMonitor.js +src/components/WsMonitorPanel.vue +src/components/results/SearchResultBoard.vue +src/components/results/SourceGroup.vue +src/components/results/ResultCard.vue +src/components/results/ChildSearchPanel.vue +src/components/report/ReportBuilderSheet.vue +src/services/wsService.js +src/services/auth.js +src/services/resultAdapters/index.js +src/services/resultAdapters/adapterGeneric.js +src/services/resultAdapters/adapterIne.js +src/services/resultAdapters/adapterActas.js +src/services/resultAdapters/adapterPlacas.js +.env.example + +4. Variables de entorno requeridas +VITE_AUTH_LOGIN_URL +VITE_AUTH_CLIENT_ID +VITE_AUTH_CLIENT_SECRET +VITE_WS_HOST +VITE_WS_PORT +VITE_WS_PATH +VITE_WS_SECURE +5. Punto crítico validado +Se eliminó la inyección de requestId dentro de busqueda.context para respetar contrato de backend WS. +El envío actual de búsqueda usa estructura: +cmd: busqueda +busqueda.query +busqueda.context +Pendientes recomendados +Pendientes recomendados +Cambiar searchLoading para que termine con respuesta real WS y no por timeout. +Agregar validaciones visuales por pestaña de búsqueda. +Paginación o virtualización por grupo cuando haya muchos resultados. +Persistencia opcional de sesiones de resultados. +================================ +FIN HANDOFF \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ab2bd56 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1547 @@ +{ + "name": "panel", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "panel", + "version": "0.0.0", + "dependencies": { + "vue": "^3.5.39", + "vue-router": "^4.6.4", + "vue3-toastify": "^0.2.9" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@vitejs/plugin-vue": "^6.0.7", + "tailwindcss": "^4.3.2", + "vite": "^8.1.1" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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==", + "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.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "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.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "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.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "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==", + "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/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "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/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/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/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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/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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "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==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "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.3.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/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue3-toastify": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/vue3-toastify/-/vue3-toastify-0.2.9.tgz", + "integrity": "sha512-LN2GJGKgjt+C6IIBANp9hCM2A4yc5jC2Kj4YGl1J7ptj4rhThOJOGGlkCg+IxDt7hntNw9n3MG9ifv/AymyxKQ==", + "license": "MIT", + "engines": { + "node": ">=20", + "npm": ">=9.0.0" + }, + "peerDependencies": { + "vue": ">=3.2.0" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f3e74fc --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "panel", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.39", + "vue-router": "^4.6.4", + "vue3-toastify": "^0.2.9" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@vitejs/plugin-vue": "^6.0.7", + "tailwindcss": "^4.3.2", + "vite": "^8.1.1" + } +} 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/src/App.vue b/src/App.vue new file mode 100644 index 0000000..13ee9ea --- /dev/null +++ b/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/src/assets/hero.png differ 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/assets/vue.svg b/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/AdminFooter.vue b/src/components/AdminFooter.vue new file mode 100644 index 0000000..8cde14a --- /dev/null +++ b/src/components/AdminFooter.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/AdminMainBody.vue b/src/components/AdminMainBody.vue new file mode 100644 index 0000000..44f4157 --- /dev/null +++ b/src/components/AdminMainBody.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/AdminNavbar.vue b/src/components/AdminNavbar.vue new file mode 100644 index 0000000..df9dc3e --- /dev/null +++ b/src/components/AdminNavbar.vue @@ -0,0 +1,129 @@ + + + diff --git a/src/components/AdminSidebar.vue b/src/components/AdminSidebar.vue new file mode 100644 index 0000000..0e28d79 --- /dev/null +++ b/src/components/AdminSidebar.vue @@ -0,0 +1,98 @@ + + + diff --git a/src/components/WsMonitorPanel.vue b/src/components/WsMonitorPanel.vue new file mode 100644 index 0000000..f7f7c66 --- /dev/null +++ b/src/components/WsMonitorPanel.vue @@ -0,0 +1,227 @@ + + + diff --git a/src/components/report/ReportBuilderSheet.vue b/src/components/report/ReportBuilderSheet.vue new file mode 100644 index 0000000..7c5522c --- /dev/null +++ b/src/components/report/ReportBuilderSheet.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/components/results/ChildSearchPanel.vue b/src/components/results/ChildSearchPanel.vue new file mode 100644 index 0000000..6aa21e4 --- /dev/null +++ b/src/components/results/ChildSearchPanel.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/components/results/ResultCard.vue b/src/components/results/ResultCard.vue new file mode 100644 index 0000000..3f8b605 --- /dev/null +++ b/src/components/results/ResultCard.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/components/results/SearchResultBoard.vue b/src/components/results/SearchResultBoard.vue new file mode 100644 index 0000000..5df61bc --- /dev/null +++ b/src/components/results/SearchResultBoard.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/components/results/SourceGroup.vue b/src/components/results/SourceGroup.vue new file mode 100644 index 0000000..813cc4a --- /dev/null +++ b/src/components/results/SourceGroup.vue @@ -0,0 +1,176 @@ + + + diff --git a/src/composables/useConfigurationTree.js b/src/composables/useConfigurationTree.js new file mode 100644 index 0000000..ada7afc --- /dev/null +++ b/src/composables/useConfigurationTree.js @@ -0,0 +1,179 @@ +import { computed, ref } from 'vue' // Importa funciones reactivas de Vue para manejar el estado y las propiedades computadas +import { toast } from 'vue3-toastify' +import { fetchSelectedBases, fetchTiposArbol, saveSelectedBases } from '../services/config' // Importa funciones para interactuar con la API de configuración +// Este composable maneja la lógica de carga, validación y guardado de la configuración de tablas y tipos de búsqueda en la aplicación. Proporciona variables reactivas y funciones para interactuar con la interfaz de usuario. + +// Define la función useConfigurationTree que encapsula toda la lógica relacionada con la configuración de tablas y tipos de búsqueda +export function useConfigurationTree() { + // Define variables reactivas para manejar el estado de carga, guardado, errores, éxito, datos del árbol y tablas seleccionadas + const loading = ref(false) + const saving = ref(false) + const error = ref('') + const success = ref('') + const treeData = ref([]) + const selectedTables = ref(new Set()) + // Define propiedades computadas para contar las tablas seleccionadas y determinar si se puede guardar la configuración + const selectedCount = computed(() => selectedTables.value.size) + // Define una propiedad computada para determinar si se puede guardar la configuración (si hay al menos una tabla seleccionada) + const canSave = computed(() => Array.isArray(treeData.value) && treeData.value.length > 0) + + // Define una propiedad computada para validar la configuración actual, asegurando que haya al menos una tabla seleccionada por cada tipo de búsqueda + const validationState = computed(() => { + if (!Array.isArray(treeData.value) || treeData.value.length === 0) { + return { + valid: false, + incompleteTypes: [], + message: 'No hay tipos de busqueda disponibles para guardar configuracion.', + } + } + // Filtra los tipos de búsqueda que no tienen al menos una tabla seleccionada y construye un mensaje de error si es necesario + const incompleteTypes = treeData.value + .filter((tipo) => { + const keys = tipoTableKeys(tipo) + if (keys.length === 0) { + return true + } + return !keys.some((key) => selectedTables.value.has(key)) + }) + .map((tipo) => String(tipo?.nombre || tipo?.tipo_busqueda || 'Tipo sin nombre')) + + // Determina si la configuración es válida (si no hay tipos incompletos) y construye un mensaje de error si es necesario + const valid = incompleteTypes.length === 0 + return { + valid, + incompleteTypes, + message: valid ? '' : `Debes seleccionar al menos una tabla de cada tipo. Faltan: ${incompleteTypes.join(', ')}.`, + } + }) + // Función auxiliar para obtener las claves de las tablas asociadas a un tipo de búsqueda + function tipoTableKeys(tipo) { + if (!Array.isArray(tipo?.tablas)) { + return [] + } + + return tipo.tablas + .map((tabla) => String(tabla?.bd_tbl_nombre || '').trim()) + .filter(Boolean) + } + // Función para verificar si una tabla está seleccionada + function isTableChecked(tableKey) { + return selectedTables.value.has(String(tableKey || '').trim()) + } + // Función para verificar si un tipo de búsqueda tiene todas sus tablas seleccionadas + function isTipoChecked(tipo) { + const keys = tipoTableKeys(tipo) + return keys.length > 0 && keys.every((key) => selectedTables.value.has(key)) + } + // Función para verificar si un tipo de búsqueda tiene algunas, pero no todas, sus tablas seleccionadas + function isTipoIndeterminate(tipo) { + const keys = tipoTableKeys(tipo) + if (keys.length === 0) { + return false + } + + const selected = keys.filter((key) => selectedTables.value.has(key)).length + return selected > 0 && selected < keys.length + } + // Función para alternar la selección de todas las tablas asociadas a un tipo de búsqueda + function toggleTipo(tipo, checked) { + const next = new Set(selectedTables.value) + const keys = tipoTableKeys(tipo) + + keys.forEach((key) => { + if (checked) { + next.add(key) + } else { + next.delete(key) + } + }) + + selectedTables.value = next + } + // Función para alternar la selección de una tabla individual + function toggleTabla(tableKey, checked) { + const normalizedKey = String(tableKey || '').trim() + if (!normalizedKey) { + return + } + + const next = new Set(selectedTables.value) + if (checked) { + next.add(normalizedKey) + } else { + next.delete(normalizedKey) + } + + selectedTables.value = next + } + // Función para cargar la configuración inicial desde la API, manejando el estado de carga y errores + async function loadConfigTree() { + loading.value = true + error.value = '' + success.value = '' + + try { + const [tree, selected] = await Promise.all([ + fetchTiposArbol(), + fetchSelectedBases(), + ]) + + treeData.value = tree + selectedTables.value = new Set(selected) + } catch (err) { + treeData.value = [] + selectedTables.value = new Set() + error.value = err instanceof Error ? err.message : 'Error desconocido al cargar configuracion.' + toast.error(error.value) + } finally { + loading.value = false + } + } + + async function saveConfig() { + if (saving.value) { + return false + } + + error.value = '' + success.value = '' + + // Regla de negocio: al menos una tabla seleccionada por cada tipo. + if (!validationState.value.valid) { + error.value = validationState.value.message + toast.warning(error.value) + return false + } + + saving.value = true + try { + const payload = await saveSelectedBases([...selectedTables.value]) + success.value = `Configuracion guardada con ${payload.bases_seleccionadas.length} tabla(s) seleccionada(s).` + toast.success('Configuracion guardada correctamente.') + return true + } catch (err) { + error.value = err instanceof Error ? err.message : 'Error desconocido al guardar configuracion.' + toast.error(error.value) + return false + } finally { + saving.value = false + } + } + + return { + loading, + saving, + error, + success, + treeData, + selectedCount, + canSave, + validationState, + isTableChecked, + isTipoChecked, + isTipoIndeterminate, + toggleTipo, + toggleTabla, + loadConfigTree, + saveConfig, + } +} diff --git a/src/composables/useSearchResultsStore.js b/src/composables/useSearchResultsStore.js new file mode 100644 index 0000000..d8523ef --- /dev/null +++ b/src/composables/useSearchResultsStore.js @@ -0,0 +1,422 @@ +// Composable para gestionar los resultados de búsqueda y las sesiones de búsqueda. + +import { computed, ref, watch } from 'vue' +import { useWsMonitor } from './useWsMonitor' +import { adaptSearchPayload } from '../services/resultAdapters' + +const sessions = ref([]) +const activeSessionId = ref('') +const pendingRequests = ref({}) +const selectedCardIds = ref([]) + +let bridgeInitialized = false +let sequence = 0 +const processedLogIds = new Set() + +const uid = (prefix) => { + sequence += 1 + return `${prefix}_${Date.now()}_${sequence}` +} + +const hashString = (value) => { + const raw = String(value || '') + let hash = 5381 + for (let i = 0; i < raw.length; i += 1) { + hash = ((hash << 5) + hash) + raw.charCodeAt(i) + hash &= 0xffffffff + } + return `h${Math.abs(hash)}` +} + +const getActiveSession = () => sessions.value.find((item) => item.id === activeSessionId.value) || null + +const activeSession = computed(() => getActiveSession()) + +const selectedSet = computed(() => new Set(selectedCardIds.value)) + +const findSessionById = (sessionId) => sessions.value.find((item) => item.id === sessionId) || null + +const findCardById = (cardId) => { + for (const session of sessions.value) { + const card = session.cardsById[cardId] + if (card) { + return { session, card } + } + } + return { session: null, card: null } +} + +const ensureSourceGroup = (session, sourceKey, sourceLabel) => { + if (session.groupMap[sourceKey]) { + return session.groupMap[sourceKey] + } + + const group = { + id: uid('group'), + sourceKey, + sourceLabel, + status: 'ok', + error: '', + cards: [], + } + + session.groupMap[sourceKey] = group + session.groups.push(group) + return group +} + +const createSession = (rootRequestId, rootQuery, rootContext) => { + const session = { + id: uid('session'), + createdAt: new Date().toISOString(), + rootRequestId, + rootQuery, + rootContext, + groups: [], + groupMap: {}, + cardsById: {}, + } + + sessions.value.unshift(session) + activeSessionId.value = session.id + return session +} + +const pushChildRequestPlaceholder = (parentCardId, requestId, query, context) => { + if (!parentCardId) { + return + } + + const { card } = findCardById(parentCardId) + if (!card) { + return + } + + card.childRequests.unshift({ + requestId, + query, + context, + status: 'loading', + error: '', + resultCardIds: [], + createdAt: new Date().toISOString(), + }) +} +// Actualiza el estado de una solicitud de búsqueda secundaria asociada a un card padre, incluyendo el estado, los IDs de las cards resultantes y cualquier error. +const setChildRequestResult = (parentCardId, requestId, status, resultCardIds = [], error = '') => { + if (!parentCardId) { + return + } + + const { card } = findCardById(parentCardId) + if (!card) { + return + } + + const target = card.childRequests.find((item) => item.requestId === requestId) + if (!target) { + return + } + + target.status = status + target.error = error + target.resultCardIds = resultCardIds +} + + +// Ejecuta una busqueda enviando la query y el context al backend a través del WebSocket +const dispatchSearch = ({ query, context, parentCardId = '', sessionId = '' }) => { + // Genera un ID único para la solicitud de búsqueda + const requestId = uid('req') + // Obtiene la función sendCommand del composable useWsMonitor para enviar comandos al backend + const { sendCommand } = useWsMonitor() + // Busca la sesión correspondiente al sessionId proporcionado, si no se encuentra, intenta buscarla a través del parentCardId. Si aún no se encuentra, crea una nueva sesión con la query y el context proporcionados. + let targetSession = sessionId ? findSessionById(sessionId) : null + if (!targetSession && parentCardId) { + const found = findCardById(parentCardId) + targetSession = found.session + } + // Si no se encuentra ninguna sesión existente, crea una nueva sesión con la query y el context proporcionados. + if (!targetSession) { + targetSession = createSession(requestId, query, context) + } + // Registra la solicitud de búsqueda pendiente en el objeto pendingRequests, asociando el requestId con la sesión, el parentCardId, la query y el context. + pendingRequests.value[requestId] = { + requestId, + sessionId: targetSession.id, + parentCardId, + query, + context, + status: 'pending', + error: '', + createdAt: new Date().toISOString(), + } + // Si se proporciona un parentCardId, agrega un marcador de posición para la solicitud de búsqueda secundaria en el card correspondiente. + pushChildRequestPlaceholder(parentCardId, requestId, query, context) + // Envía el comando 'busqueda' al backend a través del WebSocket, incluyendo la query y el context en el payload. + + sendCommand('busqueda', { + busqueda: { + query, + context, + }, + }) + + return { requestId, sessionId: targetSession.id } +} + +const normalizeMessagePayload = (payload) => { + if (!payload) { + return null + } + if (typeof payload === 'string') { + try { + return JSON.parse(payload) + } catch { + return null + } + } + if (typeof payload === 'object') { + return payload + } + return null +} + +const extractRequestId = (payload) => { + return ( + payload?.requestId + || payload?.context?.requestId + || payload?.meta?.requestId + || payload?.source?.requestId + || '' + ) +} + +const pickLatestRequest = () => { + const entries = Object.values(pendingRequests.value) + .filter((item) => item.status !== 'error') + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + + return entries[0] || null +} + +// Agrega nuevas cards a un grupo de resultados de búsqueda dentro de una sesión, asegurando que no se dupliquen y devolviendo los IDs de las cards agregadas. +const addCardsToGroup = ({ session, group, cards, parentCardId, requestId }) => { + const appendedIds = [] + + for (const card of cards) { + const stableSeed = `${group.sourceKey}:${card.stableKey}` + const stableId = `card_${hashString(stableSeed)}` + + if (session.cardsById[stableId]) { + appendedIds.push(stableId) + continue + } + + const normalized = { + id: stableId, + stableKey: card.stableKey, + sourceKey: group.sourceKey, + sourceLabel: group.sourceLabel, + title: card.title, + fields: Array.isArray(card.fields) ? card.fields : [], + raw: card.raw, + actions: Array.isArray(card.actions) ? card.actions : [], + parentCardId: parentCardId || '', + createdByRequestId: requestId, + childRequests: [], + } + + session.cardsById[stableId] = normalized + group.cards.push(stableId) + appendedIds.push(stableId) + } + + return appendedIds +} + +const handleIncomingSearchPayload = (payload) => { + const requestId = extractRequestId(payload) + const pending = requestId ? pendingRequests.value[requestId] : pickLatestRequest() + + if (!pending) { + return + } + + const session = findSessionById(pending.sessionId) + if (!session) { + return + } + + if (payload?.ok === false) { + const normalized = adaptSearchPayload(payload) + const group = ensureSourceGroup(session, normalized.sourceKey, normalized.sourceLabel) + group.status = 'error' + group.error = String( + payload?.error?.message + || payload?.error?.detail + || payload?.message + || 'Error en respuesta' + ) + + // NOT_FOUND por fuente es esperado en búsquedas multi-base: no cancelar toda la solicitud. + const errorCode = String(payload?.error?.code || '').toUpperCase() + if (errorCode !== 'NOT_FOUND') { + pending.error = group.error + setChildRequestResult(pending.parentCardId, pending.requestId, 'error', [], pending.error) + } + + return + } + + const normalized = adaptSearchPayload(payload) + const group = ensureSourceGroup(session, normalized.sourceKey, normalized.sourceLabel) + + const cardIds = addCardsToGroup({ + session, + group, + cards: normalized.cards, + parentCardId: pending.parentCardId, + requestId: pending.requestId, + }) + + pending.status = 'done' + setChildRequestResult(pending.parentCardId, pending.requestId, 'done', cardIds) +} + +// Inicializa el puente WebSocket para recibir los resultados de búsqueda en tiempo real +const initWsBridge = () => { + if (bridgeInitialized) { + return + } + + bridgeInitialized = true + const { logs } = useWsMonitor() + // Observa los cambios en los logs del WebSocket para procesar los mensajes entrantes + watch( + logs, + (items) => { + for (const item of items) { + if (item.type !== 'incoming') { + continue + } + if (processedLogIds.has(item.id)) { + continue + } + + processedLogIds.add(item.id) + const payload = normalizeMessagePayload(item.payload) + if (!payload) { + continue + } + + const isSearchPayload = Object.prototype.hasOwnProperty.call(payload, 'ok') + && Object.prototype.hasOwnProperty.call(payload, 'source') + && Object.prototype.hasOwnProperty.call(payload, 'data') + + if (isSearchPayload) { + handleIncomingSearchPayload(payload) + } + } + }, + { deep: true } + ) +} + +const toggleCardSelection = (cardId) => { + if (selectedSet.value.has(cardId)) { + selectedCardIds.value = selectedCardIds.value.filter((id) => id !== cardId) + } else { + selectedCardIds.value = [...selectedCardIds.value, cardId] + } +} + +const isCardSelected = (cardId) => selectedSet.value.has(cardId) + +const runCardAction = ({ sessionId, parentCardId, action }) => { + if (!action?.query || !action?.context) { + return + } + dispatchSearch({ + query: action.query, + context: action.context, + parentCardId, + sessionId, + }) +} + +const selectedCards = computed(() => { + const session = activeSession.value + if (!session) { + return [] + } + return selectedCardIds.value.map((id) => session.cardsById[id]).filter(Boolean) +}) + +const exportSelectedAsJson = () => { + const data = { + generatedAt: new Date().toISOString(), + sessionId: activeSessionId.value, + cards: selectedCards.value, + } + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `reporte_resultados_${Date.now()}.json` + link.click() + URL.revokeObjectURL(url) +} + +const escapeHtml = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +const exportSelectedAsPdf = () => { + if (!selectedCards.value.length) { + return + } + + const rows = selectedCards.value + .map((card, index) => { + const fields = card.fields + .map((field) => `
  • ${escapeHtml(field.label)}: ${escapeHtml(field.value)}
  • `) + .join('') + + return `

    ${index + 1}. ${escapeHtml(card.title)}

    Base: ${escapeHtml(card.sourceLabel || card.sourceKey)}

    ` + }) + .join('') + + const html = `Reporte

    Reporte de resultados

    Generado: ${escapeHtml(new Date().toLocaleString())}

    Total de cards: ${selectedCards.value.length}

    ${rows}` + + const win = window.open('', '_blank', 'noopener,noreferrer,width=1100,height=800') + if (!win) { + return + } + + win.document.open() + win.document.write(html) + win.document.close() + win.focus() + win.print() +} + +export const useSearchResultsStore = () => ({ + sessions, + activeSession, + activeSessionId, + pendingRequests, + selectedCardIds, + selectedCards, + initWsBridge, + dispatchSearch, + toggleCardSelection, + isCardSelected, + runCardAction, + exportSelectedAsJson, + exportSelectedAsPdf, +}) diff --git a/src/composables/useWsMonitor.js b/src/composables/useWsMonitor.js new file mode 100644 index 0000000..00a6458 --- /dev/null +++ b/src/composables/useWsMonitor.js @@ -0,0 +1,169 @@ +import { computed, ref } from 'vue' +import { createWsClient } from '../services/wsService' +import { getAuthSession } from '../services/auth' + +const DEFAULT_MAX_HISTORY = 200 +const logs = ref([]) +const connectionStatus = ref('offline') +const connectionDetail = ref('') +const maxHistory = ref(DEFAULT_MAX_HISTORY) +const username = ref('') +const isConnected = computed(() => connectionStatus.value === 'open') + +let client = null + +function stringifyPayload(payload) { + if (typeof payload === 'string') { + return payload + } + try { + return JSON.stringify(payload) + } catch { + return String(payload) + } +} + +const appendLog = (entry) => { + const normalized = { + id: `${Date.now()}_${Math.random().toString(16).slice(2)}`, + timestamp: new Date().toISOString(), + type: entry.type, + label: entry.label, + payload: stringifyPayload(entry.payload), + } + + logs.value.unshift(normalized) + + if (logs.value.length > maxHistory.value) { + logs.value.length = maxHistory.value + } +} + +const ensureClient = () => { + if (client) { + return true + } + + const session = getAuthSession() + const token = session?.access_token + + if (!token) { + appendLog({ + type: 'error', + label: 'No hay token de sesion para conectar WS.', + payload: 'Inicia sesion nuevamente.', + }) + return false + } + + client = createWsClient({ + token, + username: session?.usuario, + onStatus: ({ status, detail }) => { + connectionStatus.value = status + connectionDetail.value = detail + + const isErrorLike = ['error', 'failed'].includes(status) + appendLog({ + type: isErrorLike ? 'error' : 'status', + label: `Estado: ${status}`, + payload: detail || '-', + }) + }, + onSend: ({ message, sent }) => { + appendLog({ + type: 'outgoing', + label: sent ? 'Solicitud enviada' : 'Solicitud en cola', + payload: message, + }) + }, + onMessage: (message) => { + appendLog({ + type: 'incoming', + label: 'Respuesta recibida', + payload: message, + }) + }, + }) + + username.value = client.getUsername() + return true +} + +export function useWsMonitor(initialMaxHistory = DEFAULT_MAX_HISTORY) { + if (initialMaxHistory > maxHistory.value) { + maxHistory.value = initialMaxHistory + } + + const connect = () => { + const ready = ensureClient() + if (!ready) { + return + } + client.connect() + } + + const disconnect = () => { + if (!client) { + connectionStatus.value = 'offline' + connectionDetail.value = '' + return + } + + client.disconnect() + client = null + connectionStatus.value = 'offline' + connectionDetail.value = '' + username.value = '' + } + // Envía un mensaje de texto sin envolver al servidor WebSocket. Si el socket no está abierto, el mensaje se encola para enviarse cuando se abra la conexión. + const sendRaw = (message) => { + if (!ensureClient()) { + return + } + client.sendRaw(message) + } + // Envía un comando al servidor WebSocket. El comando se envía como un objeto JSON con la propiedad "command" y cualquier otra propiedad adicional que se pase en "extra". + const sendCommand = (cmd, extra = {}) => { + // Verifica si el cliente WS está inicializado y listo para enviar comandos. Si no lo está, intenta inicializarlo. + if (!ensureClient()) { + return + } + // en este caso, el payload se envía como un objeto con la propiedad "command" y cualquier otra propiedad adicional que se pase en "extra" + client.sendCommand(cmd, extra) + } + + const clearLogs = () => { + logs.value = [] + } + + const setMaxHistory = (nextValue) => { + const normalized = Number(nextValue) + if (Number.isNaN(normalized) || normalized < 20) { + maxHistory.value = 20 + } else if (normalized > 1000) { + maxHistory.value = 1000 + } else { + maxHistory.value = normalized + } + + if (logs.value.length > maxHistory.value) { + logs.value.length = maxHistory.value + } + } + + return { + logs, + connectionStatus, + connectionDetail, + isConnected, + maxHistory, + username, + connect, + disconnect, + sendRaw, + sendCommand, + clearLogs, + setMaxHistory, + } +} diff --git a/src/layouts/AdminLayout.vue b/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..347c149 --- /dev/null +++ b/src/layouts/AdminLayout.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..85aa48e --- /dev/null +++ b/src/main.js @@ -0,0 +1,15 @@ +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' +import router from './router' +import Vue3Toastify from 'vue3-toastify' +import 'vue3-toastify/dist/index.css' + +createApp(App) + .use(router) + .use(Vue3Toastify, { + autoClose: 3500, + position: 'top-right', + theme: 'colored', + }) + .mount('#app') diff --git a/src/router/index.js b/src/router/index.js new file mode 100644 index 0000000..58379ae --- /dev/null +++ b/src/router/index.js @@ -0,0 +1,98 @@ +import { createRouter, createWebHistory } from 'vue-router' +import AdminLayout from '../layouts/AdminLayout.vue' +import ConfigurationView from '../views/ConfigurationView.vue' +import DashboardView from '../views/DashboardView.vue' +import Misbusquedas from '../views/Misbusquedas.vue' +import BillingView from '../views/BillingView.vue' +import IntegrationsView from '../views/IntegrationsView.vue' +import LoginView from '../views/LoginView.vue' +import ChangePasswordView from '../views/ChangePasswordView.vue' +import { isAuthenticated } from '../services/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + redirect: '/login', + }, + { + path: '/login', + name: 'login', + component: LoginView, + meta: { + guestOnly: true, + }, + }, + { + path: '/admin', + component: AdminLayout, + meta: { + requiresAuth: true, + }, + children: [ + { + path: '', + redirect: '/admin/dashboard', + }, + { + path: 'dashboard', + name: 'dashboard', + component: DashboardView, + }, + { + path: 'configuracion', + name: 'configuracion', + component: ConfigurationView, + }, + { + path: 'misbusquedas', + name: 'misbusquedas', + component: Misbusquedas, + }, + { + path: 'facturacion', + name: 'facturacion', + component: BillingView, + }, + { + path: 'integraciones', + name: 'integraciones', + component: IntegrationsView, + }, + { + path: 'cambiar-contrasena', + name: 'cambiar-contrasena', + component: ChangePasswordView, + }, + ], + }, + { + path: '/:pathMatch(.*)*', + redirect: '/login', + }, + ], +}) + +router.beforeEach((to) => { + const loggedIn = isAuthenticated() + + if (to.meta.requiresAuth && !loggedIn) { + return { + path: '/login', + query: { + redirect: to.fullPath, + }, + } + } + + if (to.meta.guestOnly && loggedIn) { + return { + path: '/admin/dashboard', + } + } + + return true +}) + +export default router diff --git a/src/services/auth.js b/src/services/auth.js new file mode 100644 index 0000000..c23eed5 --- /dev/null +++ b/src/services/auth.js @@ -0,0 +1,322 @@ +const SESSION_STORAGE_KEY = 'auth.session' +const LOGIN_ENDPOINT = import.meta.env.VITE_AUTH_LOGIN_URL ?? 'https://api.leaks.mx/usuario/login' +const REFRESH_ENDPOINT = import.meta.env.VITE_AUTH_REFRESH_URL ?? 'https://api.leaks.mx/usuario/refresh' +const LOGOUT_ENDPOINT = import.meta.env.VITE_AUTH_LOGOUT_URL ?? 'https://api.leaks.mx/usuario/logout' +const LOGOUT_ALL_ENDPOINT = import.meta.env.VITE_AUTH_LOGOUT_ALL_URL ?? 'https://api.leaks.mx/usuario/logout-all' +const PROFILE_ENDPOINT = import.meta.env.VITE_AUTH_PROFILE_URL ?? 'https://api.leaks.mx/usuario/me' +const CHANGE_PASSWORD_ENDPOINT = import.meta.env.VITE_AUTH_CHANGE_PASSWORD_URL ?? 'https://api.leaks.mx/perfil/cambiarPassword' +const EXPIRY_SKEW_SECONDS = 45 + +const AUTH_CLIENT_ID = import.meta.env.VITE_AUTH_CLIENT_ID ?? '' +const AUTH_CLIENT_SECRET = import.meta.env.VITE_AUTH_CLIENT_SECRET ?? '' + +let refreshPromise = null + +const decodeJwtPayload = (token) => { + if (!token || typeof token !== 'string') { + return {} + } + + try { + const parts = token.split('.') + if (parts.length < 2) { + return {} + } + + const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') + const normalized = payload.padEnd(Math.ceil(payload.length / 4) * 4, '=') + return JSON.parse(atob(normalized)) + } catch { + return {} + } +} + +const normalizeSession = (session) => { + if (!session || typeof session !== 'object') { + return null + } + + const accessToken = String(session.access_token || '').trim() + if (!accessToken) { + return null + } + + const payload = decodeJwtPayload(accessToken) + const expFromToken = Number(payload?.exp || 0) + const expiresIn = Number(session.expires_in || 0) + + let expiresAt = Number(session.expires_at || 0) + if (!Number.isFinite(expiresAt) || expiresAt <= 0) { + if (Number.isFinite(expFromToken) && expFromToken > 0) { + expiresAt = expFromToken * 1000 + } else if (Number.isFinite(expiresIn) && expiresIn > 0) { + expiresAt = Date.now() + (expiresIn * 1000) + } else { + expiresAt = Date.now() + (10 * 60 * 1000) + } + } + + return { + ...session, + access_token: accessToken, + idperfil: String(session.idperfil || payload?.idperfil || payload?.sub || ''), + usuario: String(session.usuario || payload?.usuario || ''), + nivel: String(session.nivel || payload?.nivel || ''), + expires_in: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : Math.max(1, Math.floor((expiresAt - Date.now()) / 1000)), + expires_at: expiresAt, + } +} + +const isNearExpiration = (session, skewSeconds = EXPIRY_SKEW_SECONDS) => { + const normalized = normalizeSession(session) + if (!normalized) { + return true + } + return (Date.now() + (skewSeconds * 1000)) >= Number(normalized.expires_at || 0) +} + +export const saveAuthSession = (session) => { + const normalized = normalizeSession(session) + if (!normalized) { + window.localStorage.removeItem(SESSION_STORAGE_KEY) + return + } + window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(normalized)) +} + +export const getAuthSession = () => { + const session = window.localStorage.getItem(SESSION_STORAGE_KEY) + if (!session) { + return null + } + + try { + const parsed = JSON.parse(session) + const normalized = normalizeSession(parsed) + if (!normalized) { + window.localStorage.removeItem(SESSION_STORAGE_KEY) + return null + } + return normalized + } catch { + window.localStorage.removeItem(SESSION_STORAGE_KEY) + return null + } +} + +export const clearAuthSession = () => { + window.localStorage.removeItem(SESSION_STORAGE_KEY) +} + +export const isAuthenticated = () => { + const session = getAuthSession() + if (!session?.access_token) { + return false + } + + if (isNearExpiration(session, 0)) { + clearAuthSession() + return false + } + + return true +} + +export const isTokenExpired = () => { + const session = getAuthSession() + return isNearExpiration(session, 0) +} + +export const loginUser = async ({ username, password }) => { + if (!AUTH_CLIENT_ID || !AUTH_CLIENT_SECRET) { + throw new Error('Faltan variables de entorno VITE_AUTH_CLIENT_ID o VITE_AUTH_CLIENT_SECRET.') + } + + const body = new URLSearchParams({ + grant_type: 'password', + username, + password, + scope: '', + client_id: AUTH_CLIENT_ID, + client_secret: AUTH_CLIENT_SECRET, + }) + + const response = await fetch(LOGIN_ENDPOINT, { + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }) + + const responseData = await response.json().catch(() => null) + + if (!response.ok) { + const message = responseData?.detail || responseData?.message || 'No se pudo iniciar sesion. Verifica tus credenciales.' + throw new Error(message) + } + + if (!responseData?.access_token) { + throw new Error('La API respondio sin access_token.') + } + + const normalized = normalizeSession(responseData) + saveAuthSession(normalized) + return normalized +} + +export const refreshAccessToken = async () => { + if (refreshPromise) { + return refreshPromise + } + + refreshPromise = (async () => { + const response = await fetch(REFRESH_ENDPOINT, { + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }) + + const data = await response.json().catch(() => null) + if (!response.ok || !data?.access_token) { + clearAuthSession() + throw new Error(data?.detail || data?.message || 'No se pudo refrescar la sesion.') + } + + const normalized = normalizeSession(data) + saveAuthSession(normalized) + return normalized + })() + + try { + return await refreshPromise + } finally { + refreshPromise = null + } +} + +export const ensureValidAccessToken = async () => { + const session = getAuthSession() + if (!session?.access_token) { + throw new Error('No hay sesion activa.') + } + + if (!isNearExpiration(session)) { + return session.access_token + } + + const refreshed = await refreshAccessToken() + return refreshed.access_token +} + +export const authenticatedFetch = async (url, options = {}, retry = true) => { + const token = await ensureValidAccessToken() + const headers = new Headers(options?.headers || {}) + headers.set('Authorization', `Bearer ${token}`) + if (!headers.has('Accept')) { + headers.set('Accept', 'application/json') + } + + const response = await fetch(url, { + ...options, + headers, + credentials: options?.credentials ?? 'include', + }) + + if (response.status === 401 && retry) { + try { + await refreshAccessToken() + } catch { + clearAuthSession() + throw new Error('Sesion expirada. Inicia sesion nuevamente.') + } + + return authenticatedFetch(url, options, false) + } + + return response +} + +export const fetchProfile = async () => { + const response = await authenticatedFetch(PROFILE_ENDPOINT, { method: 'GET' }) + if (!response.ok) { + throw new Error(`No se pudo obtener perfil (HTTP ${response.status}).`) + } + return response.json() +} + +export const changePassword = async ({ currentPassword, newPassword, confirmPassword }) => { + const response = await authenticatedFetch(CHANGE_PASSWORD_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + password_o: currentPassword, + password_n: newPassword, + password_confirm: confirmPassword, + }), + }) + + const payload = await response.json().catch(() => null) + if (!response.ok) { + const detail = payload?.detail || payload?.msj || `HTTP ${response.status}` + throw new Error(String(detail)) + } + + const errorCode = Number(payload?.error ?? payload?.eror ?? 0) + if (errorCode !== 0) { + throw new Error(String(payload?.msj || 'No se pudo cambiar la contraseña.')) + } + + return payload +} + +export const logoutUser = async () => { + try { + const session = getAuthSession() + const headers = { + Accept: 'application/json', + } + if (session?.access_token) { + headers.Authorization = `Bearer ${session.access_token}` + } + + await fetch(LOGOUT_ENDPOINT, { + method: 'POST', + credentials: 'include', + headers, + }) + } catch { + // No bloquear logout local si falla logout remoto. + } finally { + clearAuthSession() + } +} + +export const logoutAllSessions = async () => { + const session = getAuthSession() + if (!session?.access_token) { + clearAuthSession() + return + } + + const response = await fetch(LOGOUT_ALL_ENDPOINT, { + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${session.access_token}`, + }, + }) + + if (!response.ok) { + throw new Error(`No se pudo cerrar todas las sesiones (HTTP ${response.status}).`) + } + + clearAuthSession() +} diff --git a/src/services/config.js b/src/services/config.js new file mode 100644 index 0000000..c75d532 --- /dev/null +++ b/src/services/config.js @@ -0,0 +1,93 @@ +import { authenticatedFetch } from './auth' + +const apiBase = (import.meta.env.VITE_API_BASE_URL || 'https://api.leaks.mx').replace(/\/$/, '') + +export const tiposArbolEndpoint = import.meta.env.VITE_TIPOS_ARBOL_ENDPOINT || `${apiBase}/busqueda/tiposArbol` +export const basesEndpoint = import.meta.env.VITE_BASES_ENDPOINT || `${apiBase}/busqueda/bases-seleccionadas` + +function normalizeSelected(payload) { + const sourceCandidates = [ + payload, + payload?.bases_seleccionadas, + payload?.basesSeleccionadas, + payload?.data, + payload?.data?.bases_seleccionadas, + payload?.result?.bases_seleccionadas, + ] + + const source = sourceCandidates.find((item) => Array.isArray(item)) + if (!Array.isArray(source)) { + return [] + } + + return source + .map((entry) => { + if (typeof entry === 'string') { + return entry.trim() + } + if (entry && typeof entry === 'object') { + return String(entry.bd_tbl_nombre || '').trim() + } + return '' + }) + .filter(Boolean) +} + +async function parseErrorDetail(response) { + let detail = `HTTP ${response.status}` + try { + const errorJson = await response.json() + if (errorJson?.detail) { + detail = String(errorJson.detail) + } + } catch { + // Mantener detalle por codigo HTTP. + } + return detail +} + +export async function fetchTiposArbol() { + const response = await authenticatedFetch(tiposArbolEndpoint, { + method: 'GET', + }) + + if (!response.ok) { + throw new Error(`No se pudo cargar tiposArbol (${await parseErrorDetail(response)}).`) + } + + const payload = await response.json() + return Array.isArray(payload) ? payload : [] +} + +export async function fetchSelectedBases() { + const response = await authenticatedFetch(basesEndpoint, { + method: 'GET', + }) + + if (!response.ok) { + throw new Error(`No se pudo cargar la configuracion actual (${await parseErrorDetail(response)}).`) + } + + const payload = await response.json() + return normalizeSelected(payload) +} + +export async function saveSelectedBases(baseKeys) { + const payload = { + bases_seleccionadas: Array.isArray(baseKeys) ? baseKeys : [], + } + + const response = await authenticatedFetch(basesEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + throw new Error(`No se pudo guardar la configuracion (${await parseErrorDetail(response)}).`) + } + + return payload +} diff --git a/src/services/resultAdapters/adapterActas.js b/src/services/resultAdapters/adapterActas.js new file mode 100644 index 0000000..6e2cf01 --- /dev/null +++ b/src/services/resultAdapters/adapterActas.js @@ -0,0 +1,18 @@ +const clean = (value) => String(value || '').trim() + +export const adaptActas = (payload) => { + const rows = Array.isArray(payload?.data) ? payload.data : [] + + return rows.map((row, index) => ({ + stableKey: `ACTAS:${clean(row?.curp) || index}`, + title: `Actas #${index + 1}`, + fields: [ + { label: 'CURP', value: clean(row?.curp) || '—' }, + { label: 'Nombre', value: [row?.nombre, row?.pa_madre, row?.sa_madre].map(clean).filter(Boolean).join(' ') || '—' }, + { label: 'Padre', value: [row?.nombre_padre, row?.pa_padre, row?.sa_padre].map(clean).filter(Boolean).join(' ') || '—' }, + { label: 'Madre', value: [row?.nombre_madre, row?.pa_madre, row?.sa_madre].map(clean).filter(Boolean).join(' ') || '—' }, + ], + raw: row, + actions: [], + })) +} diff --git a/src/services/resultAdapters/adapterGeneric.js b/src/services/resultAdapters/adapterGeneric.js new file mode 100644 index 0000000..2f82ffd --- /dev/null +++ b/src/services/resultAdapters/adapterGeneric.js @@ -0,0 +1,70 @@ +const toDisplay = (value) => { + if (value === null || value === undefined || value === '') return '—' + if (typeof value === 'object') { + try { + return JSON.stringify(value) + } catch { + return String(value) + } + } + return String(value) +} + +const toRows = (payload) => { + if (Array.isArray(payload?.data)) return payload.data + if (payload?.data && typeof payload.data === 'object') return [payload.data] + return [] +} + +const toActions = (row) => { + const curp = String(row?.CURP || row?.curp || '').trim() + const rfc = String(row?.RFC || row?.rfc || '').trim() || (curp ? curp.substring(0, 10) : '') + const actions = [] + + if (rfc) { + actions.push({ + type: 'vehiculos', + label: 'Buscar vehiculos', + query: { rfc }, + context: { + searchType: 'datos_chiapas', + domain: 'chiapas', + resource: 'vehiculos', + operation: 'buscar', + variant: 'mis_vehiculos', + match: 'exacta', + }, + }) + } + + if (curp) { + actions.push({ + type: 'archivos', + label: 'Buscar archivos', + query: { curp }, + context: { + searchType: 'datos_chiapas', + domain: 'chiapas', + resource: 'mis_archivos', + operation: 'buscar', + variant: 'mis_archivos', + match: 'exacta', + }, + }) + } + + return actions +} + +export const adaptGeneric = (payload) => { + const rows = toRows(payload) + const sourceLabel = String(payload?.source?.label || payload?.source?.bd || 'Resultado').trim() + + return rows.map((row, index) => ({ + stableKey: `${sourceLabel}:${index}:${JSON.stringify(row)}`, + title: `${sourceLabel} #${index + 1}`, + fields: Object.entries(row || {}).map(([label, value]) => ({ label, value: toDisplay(value) })), + raw: row, + actions: toActions(row), + })) +} diff --git a/src/services/resultAdapters/adapterIne.js b/src/services/resultAdapters/adapterIne.js new file mode 100644 index 0000000..cf996af --- /dev/null +++ b/src/services/resultAdapters/adapterIne.js @@ -0,0 +1,94 @@ +// Este adaptador transforma los datos de INE para mostrar todos los campos del registro. +const clean = (value) => String(value || '').trim() + +const humanizeObject = (obj) => { + return Object.entries(obj || {}) + .map(([key, value]) => `${key}: ${toDisplay(value)}`) + .join(', ') +} + +const toDisplay = (value) => { + if (value === null || value === undefined || value === '') { + return '—' + } + + if (typeof value === 'boolean') { + return value ? 'Si' : 'No' + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return '—' + } + + return value + .map((entry, index) => { + if (entry && typeof entry === 'object') { + return `${index + 1}) ${humanizeObject(entry)}` + } + return `${index + 1}) ${toDisplay(entry)}` + }) + .join(' | ') + } + + if (typeof value === 'object') { + return humanizeObject(value) + } + + return String(value) +} + +export const adaptIne = (payload) => { + const rows = Array.isArray(payload?.data) ? payload.data : [] + + return rows.map((row, index) => { + const nombre = [row?.Nombre, row?.Papellido, row?.Sapellido].map(clean).filter(Boolean).join(' ') + const curp = clean(row?.Curp) + const rfc = curp ? curp.substring(0, 10) : '' + + const actions = [] + if (rfc) { + actions.push({ + type: 'vehiculos', + label: 'Buscar vehiculos', + query: { rfc }, + context: { + searchType: 'datos_chiapas', + domain: 'chiapas', + resource: 'vehiculos', + operation: 'buscar', + variant: 'mis_vehiculos', + match: 'exacta', + }, + }) + } + + if (curp) { + actions.push({ + type: 'archivos', + label: 'Buscar archivos', + query: { curp }, + context: { + searchType: 'datos_chiapas', + domain: 'chiapas', + resource: 'mis_archivos', + operation: 'buscar', + variant: 'mis_archivos', + match: 'exacta', + }, + }) + } + + return { + stableKey: `INE:${curp || index}`, + title: nombre ? `INE - ${nombre}` : `INE #${index + 1}`, + // Imprimir registro completo del JSON (incluyendo objetos/arreglos anidados). + fields: Object.entries(row || {}).map(([label, value]) => ({ + label, + value: toDisplay(value), + })), + raw: row, + actions, + } + }) +} diff --git a/src/services/resultAdapters/adapterOportunidades.js b/src/services/resultAdapters/adapterOportunidades.js new file mode 100644 index 0000000..882cd0e --- /dev/null +++ b/src/services/resultAdapters/adapterOportunidades.js @@ -0,0 +1,91 @@ +const clean = (value) => String(value || '').trim() + +const toRows = (payload) => { + if (Array.isArray(payload?.data)) { + return payload.data + } + if (payload?.data && typeof payload.data === 'object') { + return [payload.data] + } + return [] +} + +const buildNombreCompleto = (row) => { + return [row?.Nombre, row?.Paterno, row?.Materno] + .map(clean) + .filter(Boolean) + .join(' ') +} + +const toActions = (row) => { + const curp = clean(row?.Curp || row?.CURP || row?.curp) + const actions = [] + + if (curp) { + actions.push({ + type: 'archivos', + label: 'Buscar archivos', + query: { curp }, + context: { + searchType: 'datos_chiapas', + domain: 'chiapas', + resource: 'mis_archivos', + operation: 'buscar', + variant: 'mis_archivos', + match: 'exacta', + }, + }) + } + + return actions +} + +const buildIdFamAction = (row) => { + const idfam = clean(row?.IdFam || row?.idfam) + if (!idfam) { + return null + } + + return { + type: 'idfamilia', + label: 'Buscar por IdFam', + query: { + idfam, + }, + context: { + searchType: 'datos_chiapas', + domain: 'chiapas', + resource: 'oportunidades', + operation: 'buscar', + variant: 'idfamilia', + match: 'exacta', + }, + } +} + +export const adaptOportunidades = (payload) => { + const rows = toRows(payload) + + return rows.map((row, index) => { + const nombreCompleto = buildNombreCompleto(row) + const curp = clean(row?.Curp || row?.CURP || row?.curp) + const idFamAction = buildIdFamAction(row) + + return { + stableKey: `OPORTUNIDADES:${clean(row?.IdFam)}:${clean(row?.IdInt)}:${curp || index}`, + title: nombreCompleto || `Oportunidades #${index + 1}`, + fields: [ + { label: 'IdFam', value: clean(row?.IdFam) || '—', action: idFamAction || undefined }, + { label: 'IdInt', value: clean(row?.IdInt) || '—' }, + { label: 'Nombre', value: clean(row?.Nombre) || '—' }, + { label: 'Paterno', value: clean(row?.Paterno) || '—' }, + { label: 'Materno', value: clean(row?.Materno) || '—' }, + { label: 'Sexo', value: clean(row?.Sexo) || '—' }, + { label: 'Curp', value: curp || '—' }, + { label: 'Fn', value: clean(row?.Fn) || '—' }, + ], + raw: row, + actions: toActions(row), + } + }) +} diff --git a/src/services/resultAdapters/adapterPlacas.js b/src/services/resultAdapters/adapterPlacas.js new file mode 100644 index 0000000..fabecfe --- /dev/null +++ b/src/services/resultAdapters/adapterPlacas.js @@ -0,0 +1,40 @@ +const clean = (value) => String(value || '').trim() + +export const adaptPlacas = (payload) => { + const rows = Array.isArray(payload?.data) ? payload.data : payload?.data ? [payload.data] : [] + + return rows.map((row, index) => { + const rfc = clean(row?.Rfc || row?.rfc) + const actions = [] + + if (rfc) { + actions.push({ + type: 'vehiculos', + label: 'Buscar vehiculos', + query: { rfc }, + context: { + searchType: 'datos_chiapas', + domain: 'chiapas', + resource: 'vehiculos', + operation: 'buscar', + variant: 'mis_vehiculos', + match: 'exacta', + }, + }) + } + + return { + stableKey: `PLACAS:${clean(row?.Placa || row?.placa) || index}`, + title: `Placas #${index + 1}`, + fields: [ + { label: 'Nombre', value: clean(row?.Nombre || row?.nombre) || '—' }, + { label: 'Placa', value: clean(row?.Placa || row?.placa) || '—' }, + { label: 'RFC', value: rfc || '—' }, + { label: 'Serie', value: clean(row?.Serie || row?.serie) || '—' }, + { label: 'Modelo', value: clean(row?.Modelo || row?.modelo) || '—' }, + ], + raw: row, + actions, + } + }) +} diff --git a/src/services/resultAdapters/adapterSaechf.js b/src/services/resultAdapters/adapterSaechf.js new file mode 100644 index 0000000..7c374fa --- /dev/null +++ b/src/services/resultAdapters/adapterSaechf.js @@ -0,0 +1,103 @@ +const toDisplay = (value) => { + if (value === null || value === undefined || value === '') { + return '—' + } + + if (typeof value === 'boolean') { + return value ? 'Si' : 'No' + } + + if (typeof value === 'object') { + try { + return JSON.stringify(value) + } catch { + return String(value) + } + } + + return String(value) +} + +const toRows = (payload) => { + if (Array.isArray(payload?.data)) { + return payload.data + } + if (payload?.data && typeof payload.data === 'object') { + return [payload.data] + } + return [] +} + +const normalizeRender = (payload) => String(payload?.source?.render || '').trim().toLowerCase() + +const targetResourceByRender = (render) => { + if (render === 'saechf_alumnos') { + return 'saechf_tutores' + } + if (render === 'saechf_tutores') { + return 'saechf_alumnos' + } + return '' +} + +const buildMatriculaAction = (matricula, sourceRender, sourceLabel) => { + const targetTb = targetResourceByRender(sourceRender) + if (!matricula || !targetTb) { + return null + } + + return { + type: 'matricula', + label: `Consultar ${targetTb}`, + query: { + tb: targetTb, + uidb: 'string', + mat: matricula, + matricula, + }, + context: { + searchType: 'datos_chiapas', + domain: 'chiapas', + resource: targetTb, + operation: 'buscar', + variant: 'matricula', + match: 'exacta', + source: sourceLabel || sourceRender || 'saechf', + }, + } +} + +export const adaptSaechf = (payload) => { + const rows = toRows(payload) + const sourceLabel = String(payload?.source?.label || payload?.source?.tabla || 'SAECHF').trim() + const sourceRender = normalizeRender(payload) + + return rows.map((row, index) => { + const matricula = String(row?.Matricula || row?.matricula || '').trim() + const fieldAction = buildMatriculaAction(matricula, sourceRender, sourceLabel) + + const fields = Object.entries(row || {}).map(([label, value]) => { + const normalizedLabel = String(label || '').trim().toLowerCase() + if (normalizedLabel === 'matricula' && fieldAction) { + return { + label, + value: toDisplay(value), + action: fieldAction, + } + } + + return { + label, + value: toDisplay(value), + } + }) + + return { + stableKey: `SAECHF:${sourceRender}:${matricula || index}:${JSON.stringify(row)}`, + title: `${sourceLabel} #${index + 1}`, + fields, + raw: row, + actions: [], + } + }) +} diff --git a/src/services/resultAdapters/index.js b/src/services/resultAdapters/index.js new file mode 100644 index 0000000..a5e066e --- /dev/null +++ b/src/services/resultAdapters/index.js @@ -0,0 +1,57 @@ +import { adaptActas } from './adapterActas' +import { adaptGeneric } from './adapterGeneric' +import { adaptIne } from './adapterIne' +import { adaptOportunidades } from './adapterOportunidades' +import { adaptPlacas } from './adapterPlacas' +import { adaptSaechf } from './adapterSaechf' + +const pickSourceLabel = (payload) => String(payload?.source?.label || payload?.source?.bd || 'resultado').trim() +const pickRender = (payload) => String(payload?.source?.render || '').trim().toLowerCase() +const pickSourceGroupKey = (payload, render) => { + const bd = String(payload?.source?.bd || '').trim().toLowerCase() + const tabla = String(payload?.source?.tabla || '').trim().toLowerCase() + const normalizedRender = String(render || '').trim().toLowerCase() + + const pieces = [normalizedRender || 'resultado', bd || 'sin-bd', tabla || 'sin-tabla'] + return pieces.join(':') +} + +const ADAPTER_BY_RENDER = { + // Actas + actas: adaptActas, + actasv2: adaptActas, + + // Placas + placa: adaptPlacas, + placas: adaptPlacas, + + // INE + ine2018: adaptIne, + + // Oportunidades + oportunidades: adaptOportunidades, + + // SAECHF + saechf_alumnos: adaptSaechf, + saechf_tutores: adaptSaechf, + + // Generic/fallback renders from Tornado + generic: adaptGeneric, + chiapas: adaptGeneric, +} + +// Función principal para adaptar el payload de búsqueda a un formato estandarizado +export const adaptSearchPayload = (payload) => { + const sourceLabel = pickSourceLabel(payload) + const render = pickRender(payload) + const byRender = ADAPTER_BY_RENDER[render] || adaptGeneric + const cards = byRender(payload) + const sourceKey = pickSourceGroupKey(payload, render) + + return { + sourceKey, + sourceLabel, + cards, + raw: payload, + } +} diff --git a/src/services/wsService.js b/src/services/wsService.js new file mode 100644 index 0000000..5184869 --- /dev/null +++ b/src/services/wsService.js @@ -0,0 +1,269 @@ +/** + * Convierte un valor de entorno a booleano. + * + * @param {unknown} value Valor recibido (string, boolean, etc.). + * @param {boolean} fallback Valor por defecto cuando value es nulo/vacio. + * @returns {boolean} + */ +function parseBoolean(value, fallback = true) { + if (value === undefined || value === null || value === '') { + return fallback; + } + return String(value).toLowerCase() === 'true'; +} + +/** + * Decodifica el payload de un JWT de forma tolerante a errores. + * + * @param {string} token JWT en formato header.payload.signature. + * @returns {Record} Objeto payload o {} si no se puede decodificar. + */ +function decodeTokenPayload(token) { + try { + const parts = token.split('.'); + if (parts.length < 2) { + return {}; + } + const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const normalized = payload.padEnd(Math.ceil(payload.length / 4) * 4, '='); + return JSON.parse(atob(normalized)); + } catch (err) { + return {}; + } +} + +const wsHost = import.meta.env.VITE_WS_HOST; +const wsPort = Number(import.meta.env.VITE_WS_PORT); +const wsPath = import.meta.env.VITE_WS_PATH; +const wsSecure = parseBoolean(import.meta.env.VITE_WS_SECURE, true); + +const WS_DEFAULTS = { + maxRetries: 5, + retryDelayMs: 3000 +}; + +/** + * Construye la URL final del websocket a partir de variables de entorno. + * + * @returns {string} + */ +function buildWsUrl() { + const protocol = wsSecure ? 'wss' : 'ws'; + return `${protocol}://${wsHost}:${wsPort}${wsPath}`; +} + +/** + * Crea un cliente WS reusable para Vue con reconexion, cola de salida y registro inicial. + * + * Protocolo soportado: + * - Mensaje de registro en apertura: { type: 'reg', username, token } + * - Comandos: { type: 'cmd', cmd, username, token, ...extra } + * + * @param {Object} options + * @param {string} options.token Token Bearer usado para registrar y enviar comandos. + * @param {string} [options.username] Username opcional; si no existe se intenta extraer del JWT. + * @param {(payload:{status:string,detail:string}) => void} [options.onStatus] Callback de estado. + * @param {(message:unknown) => void} [options.onMessage] Callback de mensajes entrantes. + * @param {(payload:{message:string,sent:boolean}) => void} [options.onSend] Callback de mensajes salientes. + * @param {number} [options.maxRetries] Maximo de reintentos de reconexion. + * @param {number} [options.retryDelayMs] Delay entre reintentos en ms. + * @returns {{ + * connect: () => void, + * disconnect: () => void, + * sendRaw: (message:string) => void, + * sendCommand: (cmd:string, extra?:Record) => void, + * getUsername: () => string + * }} API publica del cliente. + */ +export function createWsClient(options) { + const { + token, + username, + onStatus, + onMessage, + onSend, + maxRetries = WS_DEFAULTS.maxRetries, + retryDelayMs = WS_DEFAULTS.retryDelayMs + } = options; + + const payload = decodeTokenPayload(token || ''); + const resolvedUsername = username || payload.usuario || 'vue_client'; + + let socket = null; + let retries = 0; + let reconnectTimer = null; + let forceClose = false; + const outbox = []; + + /** + * Emite cambios de estado para actualizar UI/diagnostico. + * @param {string} status + * @param {string} [detail=''] + */ + function emitStatus(status, detail = '') { + if (onStatus) { + onStatus({ status, detail }); + } + } + + /** + * Vacia la cola local cuando el socket ya esta abierto. + */ + function flushOutbox() { + while (socket && socket.readyState === WebSocket.OPEN && outbox.length > 0) { + const next = outbox.shift(); + socket.send(next); + } + } + + /** + * Envia texto sin envolver; si el socket no esta abierto, se encola. + * @param {string} message + */ + function sendRaw(message) { + if (!message) { + return; + } + + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(message); + if (onSend) { + onSend({ message, sent: true }); + } + return; + } + + outbox.push(message); + if (onSend) { + onSend({ message, sent: false }); + } + } + + /** + * Envia un comando siguiendo el sobre usado por el backend WS. + * @param {string} cmd Nombre del comando. + * @param {Record} [extra={}] Campos extra (por ejemplo busqueda). + */ + function sendCommand(cmd, extra = {}) { + const envelope = { + type: 'cmd', + cmd, + username: resolvedUsername, + token, + ...extra + }; + sendRaw(JSON.stringify(envelope)); + } + + /** + * Envia el mensaje de registro al abrir la conexion. + */ + function registerOnOpen() { + const reg = { + type: 'reg', + username: resolvedUsername, + token + }; + sendRaw(JSON.stringify(reg)); + } + + /** + * Programa reconexion con limite de intentos, salvo cierre manual. + * @param {string} reason + */ + function scheduleReconnect(reason) { + if (forceClose) { + return; + } + + if (retries >= maxRetries) { + emitStatus('failed', reason || 'max_retries'); + return; + } + + retries += 1; + emitStatus('reconnecting', `attempt_${retries}`); + + reconnectTimer = window.setTimeout(() => { + connect(); + }, retryDelayMs); + } + + /** + * Abre la conexion websocket y enlaza handlers de ciclo de vida. + */ + function connect() { + if (!token) { + emitStatus('error', 'missing_token'); + return; + } + + if (socket && (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN)) { + emitStatus('status', socket.readyState === WebSocket.OPEN ? 'already_open' : 'already_connecting'); + return; + } + + if (reconnectTimer) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + forceClose = false; + const url = buildWsUrl(); + emitStatus('connecting', url); + + socket = new WebSocket(url); + + socket.onopen = () => { + retries = 0; + emitStatus('open', url); + registerOnOpen(); + flushOutbox(); + }; + + socket.onmessage = (event) => { + // Intentar parsear JSON, pero si falla, mantener el mensaje crudo. + let parsed = event.data; + try { + parsed = JSON.parse(event.data); + } catch (err) { + // Mantener mensaje crudo si no es JSON. + } + if (onMessage) { + onMessage(parsed); + } + }; + + socket.onerror = () => { + emitStatus('error', 'socket_error'); + }; + + socket.onclose = (event) => { + emitStatus('closed', String(event.code || 'unknown')); + scheduleReconnect(`close_${event.code || 'unknown'}`); + }; + } + + /** + * Cierra la conexion de forma explicita y detiene reintentos pendientes. + */ + function disconnect() { + forceClose = true; + if (reconnectTimer) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (socket && socket.readyState <= WebSocket.OPEN) { + socket.close(); + } + emitStatus('offline'); + } + + return { + connect, + disconnect, + sendRaw, + sendCommand, + getUsername: () => resolvedUsername + }; +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..4c198fa --- /dev/null +++ b/src/style.css @@ -0,0 +1,10 @@ +@import "tailwindcss"; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif; +} diff --git a/src/views/BillingView.vue b/src/views/BillingView.vue new file mode 100644 index 0000000..2a306cb --- /dev/null +++ b/src/views/BillingView.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/views/ChangePasswordView.vue b/src/views/ChangePasswordView.vue new file mode 100644 index 0000000..434754c --- /dev/null +++ b/src/views/ChangePasswordView.vue @@ -0,0 +1,159 @@ + + + diff --git a/src/views/ConfigurationView.vue b/src/views/ConfigurationView.vue new file mode 100644 index 0000000..917067e --- /dev/null +++ b/src/views/ConfigurationView.vue @@ -0,0 +1,127 @@ + + + diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue new file mode 100644 index 0000000..a906e7a --- /dev/null +++ b/src/views/DashboardView.vue @@ -0,0 +1,262 @@ + + + diff --git a/src/views/IntegrationsView.vue b/src/views/IntegrationsView.vue new file mode 100644 index 0000000..59dcbbf --- /dev/null +++ b/src/views/IntegrationsView.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue new file mode 100644 index 0000000..3c80ab8 --- /dev/null +++ b/src/views/LoginView.vue @@ -0,0 +1,101 @@ + + + diff --git a/src/views/Misbusquedas.vue b/src/views/Misbusquedas.vue new file mode 100644 index 0000000..d9aeb59 --- /dev/null +++ b/src/views/Misbusquedas.vue @@ -0,0 +1,344 @@ + + + diff --git a/src/views/UsersView.vue b/src/views/UsersView.vue new file mode 100644 index 0000000..f01fd6a --- /dev/null +++ b/src/views/UsersView.vue @@ -0,0 +1,66 @@ + + + diff --git a/test.db b/test.db new file mode 100644 index 0000000..e69de29 diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..4815a12 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue(), tailwindcss()], +})