Primer commit
This commit is contained in:
13
.env
Normal file
13
.env
Normal file
@@ -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
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -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
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -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?
|
||||
73
HANDOFF.md
Normal file
73
HANDOFF.md
Normal file
@@ -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
|
||||
5
README.md
Normal file
5
README.md
Normal file
@@ -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 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1547
package-lock.json
generated
Normal file
1547
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
7
src/App.vue
Normal file
7
src/App.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
27
src/components/AdminFooter.vue
Normal file
27
src/components/AdminFooter.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
isSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const year = ref(new Date().getFullYear())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer
|
||||
:class="[
|
||||
'flex items-center justify-between border-t border-slate-200 bg-slate-50 px-6 py-4 text-xs text-slate-400 transition-[margin] duration-300',
|
||||
isSidebarOpen ? 'md:ml-64' : 'md:ml-0',
|
||||
]"
|
||||
>
|
||||
<p>© {{ year }} Hacienda Chiapas. Todos los derechos reservados.</p>
|
||||
<nav class="flex items-center gap-4">
|
||||
<a href="#" class="transition hover:text-slate-600">Soporte</a>
|
||||
<a href="#" class="transition hover:text-slate-600">Documentacion</a>
|
||||
</nav>
|
||||
</footer>
|
||||
</template>
|
||||
23
src/components/AdminMainBody.vue
Normal file
23
src/components/AdminMainBody.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
isSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen bg-slate-50 p-6 pt-24 transition-[margin] duration-300',
|
||||
isSidebarOpen ? 'md:ml-64' : 'md:ml-0',
|
||||
]"
|
||||
>
|
||||
<slot name="after-content" />
|
||||
</main>
|
||||
</template>
|
||||
129
src/components/AdminNavbar.vue
Normal file
129
src/components/AdminNavbar.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAuthSession } from '../services/auth'
|
||||
|
||||
defineProps({
|
||||
isSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle-sidebar', 'logout'])
|
||||
const router = useRouter()
|
||||
|
||||
const isUserMenuOpen = ref(false)
|
||||
const alertCount = ref(3)
|
||||
|
||||
const session = getAuthSession()
|
||||
const username = computed(() => {
|
||||
const value = String(session?.usuario || '').trim()
|
||||
return value || 'Usuario'
|
||||
})
|
||||
|
||||
const userInitials = computed(() => {
|
||||
const compact = username.value.replace(/\s+/g, '')
|
||||
return compact.slice(0, 2).toUpperCase() || 'US'
|
||||
})
|
||||
|
||||
const toggleUserMenu = () => {
|
||||
isUserMenuOpen.value = !isUserMenuOpen.value
|
||||
}
|
||||
|
||||
const goToChangePassword = () => {
|
||||
isUserMenuOpen.value = false
|
||||
router.push('/admin/cambiar-contrasena')
|
||||
}
|
||||
|
||||
const goToMisBusquedas = () => {
|
||||
isUserMenuOpen.value = false
|
||||
router.push('/admin/misbusquedas')
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="fixed inset-x-0 top-0 z-40 h-16 border-b border-slate-200 bg-white/95 backdrop-blur">
|
||||
<div class="flex h-full items-center justify-between px-4 md:px-6">
|
||||
<div class="flex w-full max-w-md items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('toggle-sidebar')"
|
||||
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-600 transition hover:bg-slate-100 hover:text-slate-900"
|
||||
aria-label="Mostrar u ocultar menu lateral"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="ml-4 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="relative rounded-full bg-slate-100 p-2 text-slate-600 transition hover:bg-slate-200 hover:text-slate-800"
|
||||
aria-label="Notificaciones"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M12 3a6 6 0 0 0-6 6v2.764c0 .637-.166 1.263-.48 1.818L4.5 15.5h15l-1.02-1.918a3.999 3.999 0 0 1-.48-1.818V9a6 6 0 0 0-6-6Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9.75 18a2.25 2.25 0 0 0 4.5 0" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span
|
||||
v-if="alertCount > 0"
|
||||
class="absolute -right-0.5 -top-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-semibold text-white"
|
||||
>
|
||||
{{ alertCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleUserMenu"
|
||||
class="flex items-center gap-2 rounded-full border border-slate-200 bg-white p-1 pr-3 text-sm text-slate-700 transition hover:border-slate-300"
|
||||
aria-label="Menú de usuario"
|
||||
>
|
||||
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-xs font-bold text-white">
|
||||
{{ userInitials }}
|
||||
</span>
|
||||
<span class="hidden sm:inline">{{ username }}</span>
|
||||
<svg class="h-4 w-4 text-slate-500" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path d="m5 7 5 5 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isUserMenuOpen"
|
||||
class="absolute right-0 mt-2 w-52 rounded-xl border border-slate-200 bg-white p-2 shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-700 hover:bg-slate-100"
|
||||
@click="goToChangePassword"
|
||||
>
|
||||
Cambiar Contrasena
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-700 hover:bg-slate-100"
|
||||
@click="goToMisBusquedas"
|
||||
>
|
||||
Mis Busquedas
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('logout')"
|
||||
class="mt-1 flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-rose-600 hover:bg-rose-50"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
98
src/components/AdminSidebar.vue
Normal file
98
src/components/AdminSidebar.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
to: '/admin/dashboard',
|
||||
icon: 'M3 3h8v8H3V3Zm10 0h8v5h-8V3ZM3 13h5v8H3v-8Zm7 3h11v5H10v-5Z',
|
||||
},
|
||||
{
|
||||
name: 'Configuracion',
|
||||
to: '/admin/configuracion',
|
||||
icon: 'M4 17h16M7 13V7m5 10V4m5 13v-8',
|
||||
},
|
||||
{
|
||||
name: 'Mis busquedas',
|
||||
to: '/admin/misbusquedas',
|
||||
icon: 'M16 19a4 4 0 0 0-8 0m4-8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm8 8a3 3 0 0 0-6 0m3-5a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM4 19a3 3 0 0 1 6 0m-3-5a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z',
|
||||
},
|
||||
{
|
||||
name: 'Facturacion',
|
||||
to: '/admin/facturacion',
|
||||
icon: 'M5 4h14v16l-3-2-3 2-3-2-3 2-2-1.33V4Zm4 4h6m-6 4h6',
|
||||
},
|
||||
{
|
||||
name: 'Integraciones',
|
||||
to: '/admin/integraciones',
|
||||
icon: 'M7 7h10v10H7V7Zm-4 4h4m10 0h4M11 3v4m0 10v4',
|
||||
},
|
||||
]
|
||||
|
||||
const closeOnMobile = () => {
|
||||
if (window.matchMedia('(max-width: 767px)').matches) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="isOpen"
|
||||
type="button"
|
||||
class="fixed inset-0 top-16 z-20 bg-slate-900/30 md:hidden"
|
||||
aria-label="Cerrar menu lateral"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
<aside
|
||||
:class="[
|
||||
'fixed left-0 top-16 z-30 flex h-[calc(100vh-4rem)] w-64 flex-col border-r border-slate-200 bg-white transition-transform duration-300',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
]"
|
||||
>
|
||||
<div class="border-b border-slate-200 px-6 py-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Control Center</p>
|
||||
<h1 class="mt-1 text-lg font-semibold text-slate-900">Admin Panel</h1>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 overflow-y-auto px-3 py-4">
|
||||
<ul class="space-y-1.5">
|
||||
<li v-for="item in navItems" :key="item.name">
|
||||
<RouterLink :to="item.to" custom v-slot="{ href, navigate, isActive }">
|
||||
<a
|
||||
:href="href"
|
||||
@click="(event) => { navigate(event); closeOnMobile() }"
|
||||
:class="[
|
||||
'group flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition',
|
||||
isActive
|
||||
? 'bg-indigo-50 text-indigo-600 font-semibold'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||
]"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
:d="item.icon"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ item.name }}</span>
|
||||
</a>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
227
src/components/WsMonitorPanel.vue
Normal file
227
src/components/WsMonitorPanel.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useWsMonitor } from '../composables/useWsMonitor'
|
||||
|
||||
const {
|
||||
logs,
|
||||
connectionStatus,
|
||||
connectionDetail,
|
||||
isConnected,
|
||||
maxHistory,
|
||||
username,
|
||||
connect,
|
||||
disconnect,
|
||||
sendCommand,
|
||||
clearLogs,
|
||||
setMaxHistory,
|
||||
} = useWsMonitor(250)
|
||||
|
||||
const filterType = ref('all')
|
||||
|
||||
const commandForm = reactive({
|
||||
cmd: 'buscar',
|
||||
extraJson: '{"query":"folio:123"}',
|
||||
})
|
||||
|
||||
const parseExtra = () => {
|
||||
const raw = commandForm.extraJson.trim()
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
if (filterType.value === 'all') {
|
||||
return logs.value
|
||||
}
|
||||
return logs.value.filter((item) => item.type === filterType.value)
|
||||
})
|
||||
|
||||
const statusTone = computed(() => {
|
||||
if (connectionStatus.value === 'open') {
|
||||
return 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
}
|
||||
if (connectionStatus.value === 'reconnecting' || connectionStatus.value === 'connecting') {
|
||||
return 'bg-amber-50 text-amber-700 border-amber-200'
|
||||
}
|
||||
if (connectionStatus.value === 'error' || connectionStatus.value === 'failed') {
|
||||
return 'bg-rose-50 text-rose-700 border-rose-200'
|
||||
}
|
||||
return 'bg-slate-100 text-slate-700 border-slate-200'
|
||||
})
|
||||
|
||||
const submitCommand = () => {
|
||||
const cmd = commandForm.cmd.trim()
|
||||
if (!cmd) {
|
||||
return
|
||||
}
|
||||
|
||||
const extra = parseExtra()
|
||||
if (extra === null) {
|
||||
sendCommand('invalid_payload', { error: 'extraJson no es JSON valido' })
|
||||
return
|
||||
}
|
||||
|
||||
sendCommand(cmd, extra)
|
||||
}
|
||||
|
||||
const updateHistoryLimit = (event) => {
|
||||
setMaxHistory(Number(event.target.value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mt-8 rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<header class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Monitor WebSocket</h3>
|
||||
<p class="mt-1 text-xs text-slate-400">Solicitudes salientes y respuestas entrantes en tiempo real.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="['rounded-full border px-2.5 py-1 text-xs font-semibold', statusTone]">
|
||||
{{ connectionStatus }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="connect"
|
||||
class="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-700"
|
||||
>
|
||||
Conectar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="disconnect"
|
||||
class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
Desconectar
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 border-b border-slate-200 bg-slate-50/70 px-5 py-4 md:grid-cols-4">
|
||||
<div class="rounded-lg border border-slate-200 bg-white px-3 py-2">
|
||||
<p class="text-[11px] uppercase tracking-wide text-slate-400">Usuario WS</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-800">{{ username || '-' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-white px-3 py-2 md:col-span-2">
|
||||
<p class="text-[11px] uppercase tracking-wide text-slate-400">Detalle estado</p>
|
||||
<p class="mt-1 truncate text-sm text-slate-700">{{ connectionDetail || '-' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-white px-3 py-2">
|
||||
<p class="text-[11px] uppercase tracking-wide text-slate-400">Conectado</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-800">{{ isConnected ? 'Si' : 'No' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 border-b border-slate-200 px-5 py-4 lg:grid-cols-5">
|
||||
<div class="lg:col-span-2">
|
||||
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Comando</label>
|
||||
<input
|
||||
v-model="commandForm.cmd"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
placeholder="buscar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Payload JSON extra</label>
|
||||
<input
|
||||
v-model="commandForm.extraJson"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
placeholder='{"query":"folio:123"}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
@click="submitCommand"
|
||||
class="w-full rounded-lg bg-violet-600 px-3 py-2 text-sm font-semibold text-white transition hover:bg-violet-700"
|
||||
>
|
||||
Enviar comando
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 border-b border-slate-200 px-5 py-3">
|
||||
<label class="text-xs font-medium uppercase tracking-wide text-slate-500">Filtro</label>
|
||||
<select
|
||||
v-model="filterType"
|
||||
class="rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs text-slate-700 outline-none focus:border-indigo-300"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="incoming">Entrantes</option>
|
||||
<option value="outgoing">Salientes</option>
|
||||
<option value="status">Estado</option>
|
||||
<option value="error">Errores</option>
|
||||
</select>
|
||||
|
||||
<label class="ml-1 text-xs font-medium uppercase tracking-wide text-slate-500">Limite</label>
|
||||
<input
|
||||
:value="maxHistory"
|
||||
type="number"
|
||||
min="20"
|
||||
max="1000"
|
||||
@change="updateHistoryLimit"
|
||||
class="w-24 rounded-lg border border-slate-200 bg-white px-2 py-1.5 text-xs text-slate-700 outline-none focus:border-indigo-300"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="clearLogs"
|
||||
class="ml-auto rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
Limpiar logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[420px] overflow-auto">
|
||||
<table class="table-auto w-full min-w-[820px] text-left text-xs">
|
||||
<thead class="sticky top-0 bg-slate-50 text-slate-500">
|
||||
<tr class="border-b border-slate-200 uppercase tracking-wide">
|
||||
<th class="px-4 py-2.5 font-semibold">Hora</th>
|
||||
<th class="px-4 py-2.5 font-semibold">Tipo</th>
|
||||
<th class="px-4 py-2.5 font-semibold">Etiqueta</th>
|
||||
<th class="px-4 py-2.5 font-semibold">Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in filteredLogs" :key="item.id" class="border-b border-slate-100 align-top hover:bg-slate-50/70">
|
||||
<td class="px-4 py-2 text-slate-500">{{ new Date(item.timestamp).toLocaleTimeString() }}</td>
|
||||
<td class="px-4 py-2">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex rounded-full px-2 py-0.5 font-semibold',
|
||||
item.type === 'incoming'
|
||||
? 'bg-emerald-50 text-emerald-700'
|
||||
: item.type === 'outgoing'
|
||||
? 'bg-indigo-50 text-indigo-700'
|
||||
: item.type === 'error'
|
||||
? 'bg-rose-50 text-rose-700'
|
||||
: 'bg-slate-100 text-slate-700',
|
||||
]"
|
||||
>
|
||||
{{ item.type }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-slate-700">{{ item.label }}</td>
|
||||
<td class="px-4 py-2 font-mono text-[11px] text-slate-600">{{ item.payload }}</td>
|
||||
</tr>
|
||||
<tr v-if="filteredLogs.length === 0">
|
||||
<td colspan="4" class="px-4 py-8 text-center text-xs text-slate-400">Sin eventos para el filtro seleccionado.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
48
src/components/report/ReportBuilderSheet.vue
Normal file
48
src/components/report/ReportBuilderSheet.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useSearchResultsStore } from '../../composables/useSearchResultsStore'
|
||||
|
||||
const store = useSearchResultsStore()
|
||||
const selectedCards = computed(() => store.selectedCards.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mt-8 rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<header class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Reporte final</h3>
|
||||
<p class="mt-1 text-xs text-slate-400">Selecciona cards relevantes y exporta trazabilidad en JSON o PDF.</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ selectedCards.length }} seleccionadas</span>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-slate-200 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="selectedCards.length === 0"
|
||||
@click="store.exportSelectedAsJson()"
|
||||
class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Exportar JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="selectedCards.length === 0"
|
||||
@click="store.exportSelectedAsPdf()"
|
||||
class="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Exportar PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="max-h-72 overflow-auto p-4">
|
||||
<li v-for="card in selectedCards" :key="card.id" class="mb-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-700 last:mb-0">
|
||||
<p class="font-semibold text-slate-800">{{ card.title }}</p>
|
||||
<p class="mt-0.5 text-slate-500">{{ card.sourceLabel || card.sourceKey }}</p>
|
||||
</li>
|
||||
<li v-if="selectedCards.length === 0" class="rounded-lg border border-dashed border-slate-300 bg-slate-50 px-3 py-8 text-center text-xs text-slate-500">
|
||||
Aun no hay cards seleccionadas para reporte.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
63
src/components/results/ChildSearchPanel.vue
Normal file
63
src/components/results/ChildSearchPanel.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useSearchResultsStore } from '../../composables/useSearchResultsStore'
|
||||
|
||||
const props = defineProps({
|
||||
card: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const store = useSearchResultsStore()
|
||||
const activeSession = store.activeSession
|
||||
|
||||
const resolveCard = (cardId) => activeSession.value?.cardsById?.[cardId]
|
||||
|
||||
const childRequests = computed(() => props.card.childRequests || [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="childRequests.length" class="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<h6 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">Busquedas derivadas</h6>
|
||||
|
||||
<div v-for="child in childRequests" :key="child.requestId" class="mb-2 rounded-lg border border-slate-200 bg-white p-2.5 last:mb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-xs font-semibold text-slate-700">{{ child.context?.variant || child.context?.resource || child.context?.searchType || 'Busqueda hija' }}</p>
|
||||
<span
|
||||
:class="[
|
||||
'rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
||||
child.status === 'done'
|
||||
? 'bg-emerald-50 text-emerald-700'
|
||||
: child.status === 'error'
|
||||
? 'bg-rose-50 text-rose-700'
|
||||
: 'bg-amber-50 text-amber-700',
|
||||
]"
|
||||
>
|
||||
{{ child.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="child.error" class="mt-1 text-[11px] text-rose-700">{{ child.error }}</p>
|
||||
|
||||
<div v-if="child.resultCardIds?.length" class="mt-2 space-y-2">
|
||||
<article
|
||||
v-for="resultId in child.resultCardIds"
|
||||
:key="resultId"
|
||||
class="rounded-md border border-slate-200 bg-slate-50 px-2.5 py-2"
|
||||
>
|
||||
<p class="text-xs font-semibold text-slate-700">{{ resolveCard(resultId)?.title || resultId }}</p>
|
||||
<ul class="mt-1 space-y-1">
|
||||
<li
|
||||
v-for="field in (resolveCard(resultId)?.fields || []).slice(0, 5)"
|
||||
:key="`${resultId}_${field.label}`"
|
||||
class="text-[11px] text-slate-600"
|
||||
>
|
||||
<span class="font-semibold">{{ field.label }}:</span> {{ field.value }}
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
90
src/components/results/ResultCard.vue
Normal file
90
src/components/results/ResultCard.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useSearchResultsStore } from '../../composables/useSearchResultsStore'
|
||||
import ChildSearchPanel from './ChildSearchPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
card: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
sessionId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const store = useSearchResultsStore()
|
||||
const selected = computed(() => store.isCardSelected(props.card.id))
|
||||
|
||||
const toggleSelection = () => {
|
||||
store.toggleCardSelection(props.card.id)
|
||||
}
|
||||
|
||||
const runAction = (action) => {
|
||||
store.runCardAction({
|
||||
sessionId: props.sessionId,
|
||||
parentCardId: props.card.id,
|
||||
action,
|
||||
})
|
||||
}
|
||||
|
||||
const runFieldAction = (field) => {
|
||||
if (!field?.action) {
|
||||
return
|
||||
}
|
||||
runAction(field.action)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<header class="mb-3 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-slate-900">{{ card.title }}</h5>
|
||||
<p class="mt-0.5 text-xs text-slate-400">{{ card.sourceLabel || card.sourceKey }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleSelection"
|
||||
:class="[
|
||||
'rounded-lg border px-2 py-1 text-[11px] font-semibold transition',
|
||||
selected
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50',
|
||||
]"
|
||||
>
|
||||
{{ selected ? 'Seleccionada' : 'Seleccionar' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<ul class="space-y-1.5">
|
||||
<li v-for="field in card.fields" :key="`${card.id}_${field.label}`" class="text-xs text-slate-700">
|
||||
<span class="font-semibold text-slate-500">{{ field.label }}:</span>
|
||||
<button
|
||||
v-if="field.action"
|
||||
type="button"
|
||||
class="ml-1 inline-flex cursor-pointer items-center rounded px-1 py-0.5 text-left font-semibold text-indigo-700 underline decoration-dotted underline-offset-2 transition hover:text-indigo-900"
|
||||
@click="runFieldAction(field)"
|
||||
>
|
||||
{{ field.value }}
|
||||
</button>
|
||||
<span v-else class="ml-1">{{ field.value }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="card.actions?.length" class="mt-3 flex flex-wrap gap-2 border-t border-slate-100 pt-3">
|
||||
<button
|
||||
v-for="action in card.actions"
|
||||
:key="`${card.id}_${action.type}`"
|
||||
type="button"
|
||||
@click="runAction(action)"
|
||||
class="rounded-lg bg-violet-600 px-2.5 py-1.5 text-xs font-semibold text-white transition hover:bg-violet-700"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ChildSearchPanel :card="card" />
|
||||
</article>
|
||||
</template>
|
||||
36
src/components/results/SearchResultBoard.vue
Normal file
36
src/components/results/SearchResultBoard.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useSearchResultsStore } from '../../composables/useSearchResultsStore'
|
||||
import SourceGroup from './SourceGroup.vue'
|
||||
|
||||
const store = useSearchResultsStore()
|
||||
const session = computed(() => store.activeSession.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mt-8">
|
||||
<header class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Resultados normalizados</h3>
|
||||
<p class="mt-1 text-xs text-slate-400">Vista agrupada por base de datos con arbol de busquedas derivadas.</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">
|
||||
{{ session?.groups?.length || 0 }} bases
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="!session || !session.groups.length" class="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-10 text-center text-sm text-slate-500">
|
||||
Sin resultados todavia. Ejecuta una busqueda para iniciar una sesion.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<SourceGroup
|
||||
v-for="group in session.groups"
|
||||
:key="group.id"
|
||||
:group="group"
|
||||
:session-id="session.id"
|
||||
:cards-map="session.cardsById"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
176
src/components/results/SourceGroup.vue
Normal file
176
src/components/results/SourceGroup.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useSearchResultsStore } from '../../composables/useSearchResultsStore'
|
||||
import ResultCard from './ResultCard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
group: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
sessionId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
cardsMap: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const store = useSearchResultsStore()
|
||||
|
||||
const isOportunidadesGroup = computed(() => String(props.group?.sourceKey || '').startsWith('oportunidades:'))
|
||||
|
||||
const rowCards = computed(() => {
|
||||
return props.group.cards
|
||||
.map((cardId) => props.cardsMap[cardId])
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
const fieldValue = (card, label) => {
|
||||
const field = (card?.fields || []).find((entry) => String(entry?.label || '').toLowerCase() === String(label).toLowerCase())
|
||||
if (!field || field.value === undefined || field.value === null || String(field.value).trim() === '') {
|
||||
return '—'
|
||||
}
|
||||
return String(field.value)
|
||||
}
|
||||
|
||||
const fieldByLabel = (card, label) => {
|
||||
return (card?.fields || []).find((entry) => String(entry?.label || '').toLowerCase() === String(label).toLowerCase()) || null
|
||||
}
|
||||
|
||||
const runFieldAction = (card, label) => {
|
||||
const field = fieldByLabel(card, label)
|
||||
if (!field?.action) {
|
||||
return
|
||||
}
|
||||
|
||||
store.runCardAction({
|
||||
sessionId: props.sessionId,
|
||||
parentCardId: card.id,
|
||||
action: field.action,
|
||||
})
|
||||
}
|
||||
|
||||
const childRequests = (card) => Array.isArray(card?.childRequests) ? card.childRequests : []
|
||||
|
||||
const resolveResultCard = (resultId) => props.cardsMap?.[resultId] || null
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<header class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-slate-900">{{ group.sourceLabel }}</h4>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Base: {{ group.sourceKey }}</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ group.cards.length }} resultados</span>
|
||||
</header>
|
||||
|
||||
<div v-if="isOportunidadesGroup" class="max-h-[32rem] overflow-auto p-4">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-semibold">IdFam</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">IdInt</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">Nombre</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">Paterno</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">Materno</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">Sexo</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">Curp</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">Fn</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 text-slate-700">
|
||||
<template v-for="card in rowCards" :key="card.id">
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-3 py-2">
|
||||
<button
|
||||
v-if="fieldByLabel(card, 'IdFam')?.action"
|
||||
type="button"
|
||||
class="inline-flex cursor-pointer items-center rounded px-1 py-0.5 text-left font-semibold text-indigo-700 underline decoration-dotted underline-offset-2 transition hover:text-indigo-900"
|
||||
@click="runFieldAction(card, 'IdFam')"
|
||||
>
|
||||
{{ fieldValue(card, 'IdFam') }}
|
||||
</button>
|
||||
<span v-else>{{ fieldValue(card, 'IdFam') }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2">{{ fieldValue(card, 'IdInt') }}</td>
|
||||
<td class="px-3 py-2">{{ fieldValue(card, 'Nombre') }}</td>
|
||||
<td class="px-3 py-2">{{ fieldValue(card, 'Paterno') }}</td>
|
||||
<td class="px-3 py-2">{{ fieldValue(card, 'Materno') }}</td>
|
||||
<td class="px-3 py-2">{{ fieldValue(card, 'Sexo') }}</td>
|
||||
<td class="px-3 py-2">{{ fieldValue(card, 'Curp') }}</td>
|
||||
<td class="px-3 py-2">{{ fieldValue(card, 'Fn') }}</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="childRequests(card).length" class="bg-slate-50/70">
|
||||
<td colspan="8" class="px-3 py-3">
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-2.5">
|
||||
<p class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">Busquedas derivadas</p>
|
||||
|
||||
<div
|
||||
v-for="child in childRequests(card)"
|
||||
:key="child.requestId"
|
||||
class="mb-2 rounded-md border border-slate-200 bg-slate-50 p-2 last:mb-0"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-xs font-semibold text-slate-700">
|
||||
{{ child.context?.variant || child.context?.resource || 'busqueda_hija' }}
|
||||
</p>
|
||||
<span
|
||||
:class="[
|
||||
'rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
||||
child.status === 'done'
|
||||
? 'bg-emerald-50 text-emerald-700'
|
||||
: child.status === 'error'
|
||||
? 'bg-rose-50 text-rose-700'
|
||||
: 'bg-amber-50 text-amber-700',
|
||||
]"
|
||||
>
|
||||
{{ child.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="child.error" class="mt-1 text-[11px] text-rose-700">{{ child.error }}</p>
|
||||
|
||||
<div v-if="child.resultCardIds?.length" class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<article
|
||||
v-for="resultId in child.resultCardIds"
|
||||
:key="resultId"
|
||||
class="rounded-md border border-slate-200 bg-white px-2 py-1.5"
|
||||
>
|
||||
<p class="text-xs font-semibold text-slate-700">{{ resolveResultCard(resultId)?.title || resultId }}</p>
|
||||
<ul class="mt-1 space-y-0.5">
|
||||
<li
|
||||
v-for="field in (resolveResultCard(resultId)?.fields || []).slice(0, 4)"
|
||||
:key="`${resultId}_${field.label}`"
|
||||
class="text-[11px] text-slate-600"
|
||||
>
|
||||
<span class="font-semibold">{{ field.label }}:</span> {{ field.value }}
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[32rem] overflow-y-auto p-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<ResultCard
|
||||
v-for="cardId in group.cards"
|
||||
:key="cardId"
|
||||
:card="cardsMap[cardId]"
|
||||
:session-id="sessionId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
179
src/composables/useConfigurationTree.js
Normal file
179
src/composables/useConfigurationTree.js
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
422
src/composables/useSearchResultsStore.js
Normal file
422
src/composables/useSearchResultsStore.js
Normal file
@@ -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, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const exportSelectedAsPdf = () => {
|
||||
if (!selectedCards.value.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const rows = selectedCards.value
|
||||
.map((card, index) => {
|
||||
const fields = card.fields
|
||||
.map((field) => `<li><strong>${escapeHtml(field.label)}:</strong> ${escapeHtml(field.value)}</li>`)
|
||||
.join('')
|
||||
|
||||
return `<section class="card"><h2>${index + 1}. ${escapeHtml(card.title)}</h2><p class="meta">Base: ${escapeHtml(card.sourceLabel || card.sourceKey)}</p><ul>${fields}</ul></section>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const html = `<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8"/><title>Reporte</title><style>body{font-family:Segoe UI,Arial,sans-serif;margin:24px;color:#0f172a}.header{border-bottom:2px solid #1e293b;margin-bottom:16px;padding-bottom:10px}.card{border:1px solid #cbd5e1;padding:12px;margin-bottom:10px}.meta{color:#475569;font-size:12px;margin:0 0 8px}ul{margin:0;padding-left:16px}li{margin-bottom:4px}</style></head><body><header class="header"><h1>Reporte de resultados</h1><p>Generado: ${escapeHtml(new Date().toLocaleString())}</p><p>Total de cards: ${selectedCards.value.length}</p></header>${rows}</body></html>`
|
||||
|
||||
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,
|
||||
})
|
||||
169
src/composables/useWsMonitor.js
Normal file
169
src/composables/useWsMonitor.js
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
57
src/layouts/AdminLayout.vue
Normal file
57
src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminNavbar from '../components/AdminNavbar.vue'
|
||||
import AdminSidebar from '../components/AdminSidebar.vue'
|
||||
import AdminFooter from '../components/AdminFooter.vue'
|
||||
import { logoutUser } from '../services/auth'
|
||||
import { useWsMonitor } from '../composables/useWsMonitor'
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = 'admin.sidebar.open'
|
||||
const isSidebarOpen = ref(true)
|
||||
const router = useRouter()
|
||||
const { connect, disconnect } = useWsMonitor()
|
||||
|
||||
onMounted(() => {
|
||||
const storedValue = window.localStorage.getItem(SIDEBAR_STORAGE_KEY)
|
||||
if (storedValue !== null) {
|
||||
isSidebarOpen.value = storedValue === 'true'
|
||||
}
|
||||
|
||||
connect()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
watch(isSidebarOpen, (value) => {
|
||||
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(value))
|
||||
})
|
||||
|
||||
const toggleSidebar = () => {
|
||||
isSidebarOpen.value = !isSidebarOpen.value
|
||||
}
|
||||
|
||||
const closeSidebar = () => {
|
||||
isSidebarOpen.value = false
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
disconnect()
|
||||
await logoutUser()
|
||||
router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-100 text-slate-900">
|
||||
<AdminNavbar :is-sidebar-open="isSidebarOpen" @toggle-sidebar="toggleSidebar" @logout="handleLogout" />
|
||||
<AdminSidebar :is-open="isSidebarOpen" @close="closeSidebar" />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" :is-sidebar-open="isSidebarOpen" />
|
||||
</RouterView>
|
||||
<AdminFooter :is-sidebar-open="isSidebarOpen" />
|
||||
</div>
|
||||
</template>
|
||||
15
src/main.js
Normal file
15
src/main.js
Normal file
@@ -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')
|
||||
98
src/router/index.js
Normal file
98
src/router/index.js
Normal file
@@ -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
|
||||
322
src/services/auth.js
Normal file
322
src/services/auth.js
Normal file
@@ -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()
|
||||
}
|
||||
93
src/services/config.js
Normal file
93
src/services/config.js
Normal file
@@ -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
|
||||
}
|
||||
18
src/services/resultAdapters/adapterActas.js
Normal file
18
src/services/resultAdapters/adapterActas.js
Normal file
@@ -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: [],
|
||||
}))
|
||||
}
|
||||
70
src/services/resultAdapters/adapterGeneric.js
Normal file
70
src/services/resultAdapters/adapterGeneric.js
Normal file
@@ -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),
|
||||
}))
|
||||
}
|
||||
94
src/services/resultAdapters/adapterIne.js
Normal file
94
src/services/resultAdapters/adapterIne.js
Normal file
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
91
src/services/resultAdapters/adapterOportunidades.js
Normal file
91
src/services/resultAdapters/adapterOportunidades.js
Normal file
@@ -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),
|
||||
}
|
||||
})
|
||||
}
|
||||
40
src/services/resultAdapters/adapterPlacas.js
Normal file
40
src/services/resultAdapters/adapterPlacas.js
Normal file
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
103
src/services/resultAdapters/adapterSaechf.js
Normal file
103
src/services/resultAdapters/adapterSaechf.js
Normal file
@@ -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: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
57
src/services/resultAdapters/index.js
Normal file
57
src/services/resultAdapters/index.js
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
269
src/services/wsService.js
Normal file
269
src/services/wsService.js
Normal file
@@ -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<string, unknown>} 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<string, unknown>) => 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<string, unknown>} [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
|
||||
};
|
||||
}
|
||||
10
src/style.css
Normal file
10
src/style.css
Normal file
@@ -0,0 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Plus Jakarta Sans", "Segoe UI", sans-serif;
|
||||
}
|
||||
34
src/views/BillingView.vue
Normal file
34
src/views/BillingView.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
isSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const summary = [
|
||||
{ title: 'Facturas emitidas', value: '324' },
|
||||
{ title: 'Pendientes de pago', value: '18' },
|
||||
{ title: 'Monto facturado', value: '$481,920' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen bg-slate-50 p-6 pt-24 transition-[margin] duration-300',
|
||||
isSidebarOpen ? 'md:ml-64' : 'md:ml-0',
|
||||
]"
|
||||
>
|
||||
<section class="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||
<article
|
||||
v-for="card in summary"
|
||||
:key="card.title"
|
||||
class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm"
|
||||
>
|
||||
<p class="text-sm text-slate-500">{{ card.title }}</p>
|
||||
<p class="mt-2 text-3xl font-semibold tracking-tight text-slate-900">{{ card.value }}</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
159
src/views/ChangePasswordView.vue
Normal file
159
src/views/ChangePasswordView.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { toast } from 'vue3-toastify'
|
||||
import { changePassword } from '../services/auth'
|
||||
|
||||
defineProps({
|
||||
isSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const PASSWORD_POLICY_REGEX = /^(?=.*[A-Z])(?=.*\d).{9,}$/
|
||||
|
||||
const validateForm = () => {
|
||||
if (!form.currentPassword || !form.newPassword || !form.confirmPassword) {
|
||||
return 'Completa todos los campos.'
|
||||
}
|
||||
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
return 'La nueva contraseña y su confirmación no coinciden.'
|
||||
}
|
||||
|
||||
if (!PASSWORD_POLICY_REGEX.test(form.newPassword)) {
|
||||
return 'La nueva contraseña debe tener al menos 9 caracteres, una mayúscula y un número.'
|
||||
}
|
||||
|
||||
if (form.currentPassword === form.newPassword) {
|
||||
return 'La nueva contraseña debe ser diferente a la contraseña actual.'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const clearForm = () => {
|
||||
form.currentPassword = ''
|
||||
form.newPassword = ''
|
||||
form.confirmPassword = ''
|
||||
}
|
||||
|
||||
const submitChange = async () => {
|
||||
if (isSubmitting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
const validationError = validateForm()
|
||||
if (validationError) {
|
||||
errorMessage.value = validationError
|
||||
toast.warning(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await changePassword({
|
||||
currentPassword: form.currentPassword,
|
||||
newPassword: form.newPassword,
|
||||
confirmPassword: form.confirmPassword,
|
||||
})
|
||||
|
||||
toast.success('Contrasena actualizada correctamente.')
|
||||
clearForm()
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : 'No se pudo cambiar la contrasena.'
|
||||
toast.error(errorMessage.value)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen bg-slate-50 p-6 pt-24 transition-[margin] duration-300',
|
||||
isSidebarOpen ? 'md:ml-64' : 'md:ml-0',
|
||||
]"
|
||||
>
|
||||
<section class="mx-auto max-w-2xl rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<header class="border-b border-slate-200 px-5 py-4">
|
||||
<h1 class="text-lg font-semibold text-slate-800">Cambiar contrasena</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Ingresa tu contrasena actual y define una nueva con reglas de seguridad.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form class="space-y-4 px-5 py-5" @submit.prevent="submitChange">
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-500">Contrasena actual</span>
|
||||
<input
|
||||
v-model="form.currentPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
placeholder="********"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-500">Nueva contrasena</span>
|
||||
<input
|
||||
v-model="form.newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
placeholder="********"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-500">Confirmar nueva contrasena</span>
|
||||
<input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
placeholder="********"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
Reglas: la nueva contrasena debe tener al menos 9 caracteres, una mayuscula y un numero.
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
class="inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<span v-if="isSubmitting">Actualizando...</span>
|
||||
<span v-else>Guardar contrasena</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-100"
|
||||
@click="clearForm"
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
127
src/views/ConfigurationView.vue
Normal file
127
src/views/ConfigurationView.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
|
||||
// este archivo es parte del panel de administración y permite configurar la visibilidad de tablas y tipos de búsqueda en la aplicación. Utiliza un composable para manejar la lógica de carga, validación y guardado de la configuración.
|
||||
import { onMounted } from 'vue' // Importa la función onMounted de Vue para ejecutar código cuando el componente se monta
|
||||
import { useConfigurationTree } from '../composables/useConfigurationTree' // Importa un composable personalizado que maneja la lógica de la configuración de tablas y tipos de búsqueda
|
||||
|
||||
// Define las propiedades que el componente puede recibir
|
||||
defineProps({
|
||||
isSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Desestructura las funciones y variables reactivas proporcionadas por el composable useConfigurationTree
|
||||
const {
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
success,
|
||||
treeData,
|
||||
selectedCount,
|
||||
canSave,
|
||||
validationState,
|
||||
isTableChecked,
|
||||
isTipoChecked,
|
||||
isTipoIndeterminate,
|
||||
toggleTipo,
|
||||
toggleTabla,
|
||||
loadConfigTree,
|
||||
saveConfig,
|
||||
} = useConfigurationTree()
|
||||
|
||||
// Llama a la función loadConfigTree cuando el componente se monta para cargar la configuración inicial
|
||||
onMounted(() => {
|
||||
loadConfigTree()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen bg-slate-50 p-6 pt-24 transition-[margin] duration-300',
|
||||
isSidebarOpen ? 'md:ml-64' : 'md:ml-0',
|
||||
]"
|
||||
>
|
||||
<section class="mx-auto max-w-5xl rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<header class="border-b border-slate-200 px-5 py-4">
|
||||
<h1 class="text-lg font-semibold text-slate-800">Configuracion de tablas</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Selecciona tipos de busqueda o tablas individuales.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="space-y-4 px-5 py-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 disabled:opacity-60"
|
||||
:disabled="loading"
|
||||
@click="loadConfigTree"
|
||||
>
|
||||
<span v-if="loading">Cargando...</span>
|
||||
<span v-else>Recargar</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700">{{ error }}</p>
|
||||
<p v-if="success" class="rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{{ success }}</p>
|
||||
<p v-if="!validationState.valid && treeData.length > 0" class="rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
{{ validationState.message }}
|
||||
</p>
|
||||
|
||||
<p v-if="loading" class="text-sm text-slate-500">Consultando configuracion...</p>
|
||||
|
||||
<ul v-else-if="treeData.length > 0" class="space-y-4">
|
||||
<li
|
||||
v-for="(tipo, index) in treeData"
|
||||
:key="String(tipo?.tipo_busqueda || `${tipo?.nombre || 'tipo'}-${index}`)"
|
||||
class="rounded-lg border border-slate-200 p-4"
|
||||
>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isTipoChecked(tipo)"
|
||||
:indeterminate.prop="isTipoIndeterminate(tipo)"
|
||||
@change="toggleTipo(tipo, $event.target.checked)"
|
||||
/>
|
||||
<span class="font-semibold text-slate-800">{{ tipo?.nombre || 'Tipo de busqueda' }}</span>
|
||||
</label>
|
||||
|
||||
<ul class="mt-3 space-y-2 pl-6">
|
||||
<li
|
||||
v-for="tabla in (Array.isArray(tipo?.tablas) ? tipo.tablas : [])"
|
||||
:key="`${String(tipo?.tipo_busqueda || 'tipo')}-${String(tabla?.bd_tbl_nombre || '')}`"
|
||||
>
|
||||
<label class="flex items-center gap-2 text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isTableChecked(tabla?.bd_tbl_nombre)"
|
||||
@change="toggleTabla(tabla?.bd_tbl_nombre, $event.target.checked)"
|
||||
/>
|
||||
<span>{{ tabla?.tbl_nombre_publico || tabla?.bd_tbl_nombre || 'Tabla sin nombre' }}</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else class="text-sm text-slate-500">Sin datos para mostrar.</p>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-4">
|
||||
<span class="text-sm text-slate-600">Tablas seleccionadas: {{ selectedCount }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="saving || !canSave || loading"
|
||||
@click="saveConfig"
|
||||
>
|
||||
<span v-if="saving">Guardando...</span>
|
||||
<span v-else>Guardar configuracion</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
262
src/views/DashboardView.vue
Normal file
262
src/views/DashboardView.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import AdminMainBody from '../components/AdminMainBody.vue'
|
||||
import WsMonitorPanel from '../components/WsMonitorPanel.vue'
|
||||
import SearchResultBoard from '../components/results/SearchResultBoard.vue'
|
||||
import ReportBuilderSheet from '../components/report/ReportBuilderSheet.vue'
|
||||
import { useSearchResultsStore } from '../composables/useSearchResultsStore'
|
||||
|
||||
// es usado para recibir la prop isSidebarOpen desde el componente padre
|
||||
defineProps({
|
||||
isSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const searchStore = useSearchResultsStore()
|
||||
|
||||
onMounted(() => {
|
||||
searchStore.initWsBridge()
|
||||
})
|
||||
|
||||
const searchType = ref('nombre')
|
||||
|
||||
const searchPhone = ref('')
|
||||
const searchPlate = ref('')
|
||||
const searchName = ref('')
|
||||
const searchPaterno = ref('')
|
||||
const searchMaterno = ref('')
|
||||
const personScope = ref('chiapas')
|
||||
const searchMatchType = ref('completa')
|
||||
const searchLoading = ref(false)
|
||||
|
||||
// Limpia los campos del formulario de busqueda y reinicia los valores a sus estados iniciales
|
||||
const clearSearchForm = () => {
|
||||
searchPhone.value = ''
|
||||
searchPlate.value = ''
|
||||
searchName.value = ''
|
||||
searchPaterno.value = ''
|
||||
searchMaterno.value = ''
|
||||
personScope.value = 'chiapas'
|
||||
searchMatchType.value = 'completa'
|
||||
}
|
||||
|
||||
// Construye el objeto envelope que contiene la query y el context para la busqueda
|
||||
const buildBusquedaEnvelope = (query, context) => ({
|
||||
query,
|
||||
context,
|
||||
})
|
||||
|
||||
|
||||
|
||||
// Ejecuta la busqueda principal dependiendo del tipo de busqueda seleccionado
|
||||
const runSearch = () => {
|
||||
|
||||
// Evita que se ejecute otra busqueda si ya hay una en curso
|
||||
if (searchLoading.value) {
|
||||
return
|
||||
}
|
||||
// define un objeto query y un objeto context que se enviarán a la acción dispatchSearch del store
|
||||
const query = {}
|
||||
const context = {
|
||||
operation: 'buscar',
|
||||
match: searchMatchType.value === 'parcial' ? 'parcial' : 'exacta',
|
||||
}
|
||||
// dependiendo del tipo de busqueda, se agregan diferentes propiedades al objeto query y se actualiza el objeto context
|
||||
if (searchType.value === 'telefono') {
|
||||
// si el tipo de busqueda es telefono, se agrega la propiedad telefono al objeto query y se actualiza el objeto context
|
||||
query.telefono = searchPhone.value.trim()
|
||||
context.searchType = 'telefono'
|
||||
} else if (searchType.value === 'placa') {
|
||||
query.placa = searchPlate.value.trim().toUpperCase()
|
||||
context.searchType = 'datos_chiapas'
|
||||
context.domain = 'chiapas'
|
||||
context.resource = 'vehiculos'
|
||||
context.variant = 'placa_chiapas'
|
||||
} else {
|
||||
query.nombre = searchName.value.trim().toUpperCase()
|
||||
query.paterno = searchPaterno.value.trim().toUpperCase()
|
||||
query.materno = searchMaterno.value.trim().toUpperCase()
|
||||
context.searchType = personScope.value === 'nacional' ? 'nombre_nacional' : 'datos_chiapas'
|
||||
context.domain = personScope.value === 'nacional' ? 'nacional' : 'chiapas'
|
||||
}
|
||||
|
||||
const envelope = buildBusquedaEnvelope(query, context)
|
||||
console.log('Ejecutando busqueda con envelope:', envelope)
|
||||
|
||||
searchLoading.value = true
|
||||
// dispatchSearch es una acción del store que se encarga de enviar la query y el context al backend para ejecutar la busqueda
|
||||
searchStore.dispatchSearch({
|
||||
query: envelope.query,
|
||||
context: envelope.context,
|
||||
})
|
||||
// se simula un tiempo de espera para mostrar el estado de carga mientras se ejecuta la busqueda
|
||||
window.setTimeout(() => {
|
||||
searchLoading.value = false
|
||||
}, 350)
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminMainBody :is-sidebar-open="isSidebarOpen">
|
||||
<template #after-content>
|
||||
<section class="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<header class="border-b border-slate-200 px-5 py-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Busqueda principal</h3>
|
||||
<p class="mt-1 text-xs text-slate-400">Selecciona un tipo de busqueda y ejecuta la consulta desde el dashboard.</p>
|
||||
</header>
|
||||
|
||||
<div class="border-b border-slate-200 px-5 py-3">
|
||||
<div class="inline-flex rounded-xl border border-slate-200 bg-slate-50 p-1">
|
||||
<button
|
||||
type="button"
|
||||
@click="searchType = 'telefono'"
|
||||
:class="[
|
||||
'rounded-lg px-3 py-1.5 text-xs font-semibold transition',
|
||||
searchType === 'telefono'
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-slate-600 hover:bg-slate-100',
|
||||
]"
|
||||
>
|
||||
Telefono
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="searchType = 'placa'"
|
||||
:class="[
|
||||
'rounded-lg px-3 py-1.5 text-xs font-semibold transition',
|
||||
searchType === 'placa'
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-slate-600 hover:bg-slate-100',
|
||||
]"
|
||||
>
|
||||
Placa
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="searchType = 'nombre'"
|
||||
:class="[
|
||||
'rounded-lg px-3 py-1.5 text-xs font-semibold transition',
|
||||
searchType === 'nombre'
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-slate-600 hover:bg-slate-100',
|
||||
]"
|
||||
>
|
||||
Nombre
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4 px-5 py-4" @submit.prevent="runSearch">
|
||||
<fieldset v-if="searchType === 'telefono'" class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Busqueda por Telefono</legend>
|
||||
<label class="block md:col-span-2">
|
||||
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Telefono</span>
|
||||
<input
|
||||
v-model="searchPhone"
|
||||
type="text"
|
||||
placeholder="9610000000"
|
||||
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-else-if="searchType === 'placa'" class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Busqueda por Placa</legend>
|
||||
<label class="block md:col-span-2">
|
||||
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Placa</span>
|
||||
<input
|
||||
v-model="searchPlate"
|
||||
type="text"
|
||||
placeholder="ABC1234"
|
||||
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm uppercase text-slate-800 outline-none transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-else class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500 lg:col-span-5">Busqueda por Nombre</legend>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Nombre</span>
|
||||
<input
|
||||
v-model="searchName"
|
||||
type="text"
|
||||
placeholder="NOMBRE"
|
||||
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm uppercase text-slate-800 outline-none transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Paterno</span>
|
||||
<input
|
||||
v-model="searchPaterno"
|
||||
type="text"
|
||||
placeholder="PATERNO"
|
||||
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm uppercase text-slate-800 outline-none transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Materno</span>
|
||||
<input
|
||||
v-model="searchMaterno"
|
||||
type="text"
|
||||
placeholder="MATERNO"
|
||||
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm uppercase text-slate-800 outline-none transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Estado</span>
|
||||
<select
|
||||
v-model="personScope"
|
||||
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
>
|
||||
<option value="chiapas">CHIAPAS</option>
|
||||
<option value="nacional">NACIONAL</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Tipo de busqueda</span>
|
||||
<select
|
||||
v-model="searchMatchType"
|
||||
class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
>
|
||||
<option value="completa">Exacta</option>
|
||||
<option value="parcial">Parcial</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 pt-1">
|
||||
<button
|
||||
class="inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
:disabled="searchLoading"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="!searchLoading">Iniciar Busqueda</span>
|
||||
<span v-else>Consultando...</span>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-100"
|
||||
type="button"
|
||||
@click="clearSearchForm"
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<SearchResultBoard />
|
||||
<ReportBuilderSheet />
|
||||
<WsMonitorPanel />
|
||||
</template>
|
||||
</AdminMainBody>
|
||||
</template>
|
||||
46
src/views/IntegrationsView.vue
Normal file
46
src/views/IntegrationsView.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
isSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const services = [
|
||||
{ name: 'API Fiscal', status: 'Conectado' },
|
||||
{ name: 'Webhook Pagos', status: 'Conectado' },
|
||||
{ name: 'Correo transaccional', status: 'Sincronizando' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen bg-slate-50 p-6 pt-24 transition-[margin] duration-300',
|
||||
isSidebarOpen ? 'md:ml-64' : 'md:ml-0',
|
||||
]"
|
||||
>
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-2xl font-semibold tracking-tight text-slate-900">Integraciones</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Estado de conectores y servicios externos.</p>
|
||||
|
||||
<ul class="mt-5 space-y-3">
|
||||
<li
|
||||
v-for="service in services"
|
||||
:key="service.name"
|
||||
class="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"
|
||||
>
|
||||
<span class="font-medium text-slate-800">{{ service.name }}</span>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex rounded-full px-2.5 py-1 text-xs font-semibold',
|
||||
service.status === 'Conectado' ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700',
|
||||
]"
|
||||
>
|
||||
{{ service.status }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
101
src/views/LoginView.vue
Normal file
101
src/views/LoginView.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { loginUser } from '../services/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (isSubmitting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
if (!form.username || !form.password) {
|
||||
errorMessage.value = 'Ingresa usuario y contrasena para continuar.'
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await loginUser({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
})
|
||||
const redirectPath = typeof route.query.redirect === 'string' ? route.query.redirect : '/admin/dashboard'
|
||||
router.replace(redirectPath)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : 'Error inesperado al iniciar sesion.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-gradient-to-br from-slate-100 via-slate-50 to-indigo-50 px-4 py-8">
|
||||
<div class="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-5xl items-center justify-center">
|
||||
<section class="grid w-full overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl md:grid-cols-2">
|
||||
<div class="hidden bg-slate-900 p-10 text-slate-100 md:flex md:flex-col md:justify-between">
|
||||
<div>
|
||||
<p class="inline-flex rounded-full border border-slate-700 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-indigo-300">Acceso seguro</p>
|
||||
<h1 class="mt-6 text-4xl font-semibold leading-tight">Panel administrativo</h1>
|
||||
<p class="mt-3 text-sm text-slate-300">Autenticate para consultar analitica, usuarios y operaciones.</p>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400">Hacienda Chiapas · plataforma interna</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 sm:p-10">
|
||||
<h2 class="text-2xl font-semibold tracking-tight text-slate-900">Iniciar sesion</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Ingresa tus credenciales para entrar al panel.</p>
|
||||
|
||||
<form class="mt-8 space-y-4" @submit.prevent="onSubmit">
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-sm font-medium text-slate-700">Usuario</span>
|
||||
<input
|
||||
v-model.trim="form.username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-800 outline-none transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
placeholder="usuario@dominio.com"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-sm font-medium text-slate-700">Contrasena</span>
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-800 outline-none transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p v-if="errorMessage" class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
class="inline-flex w-full items-center justify-center rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{{ isSubmitting ? 'Validando...' : 'Entrar al panel' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
344
src/views/Misbusquedas.vue
Normal file
344
src/views/Misbusquedas.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<script setup>
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { toast } from 'vue3-toastify'
|
||||
import { authenticatedFetch } from '../services/auth'
|
||||
const MISBUSQUEDAS_ENDPOINT = import.meta.env.VITE_AUTH_PROFILE_URL ?? 'https://api.leaks.mx/perfil/misbusquedas'
|
||||
|
||||
defineProps({
|
||||
isSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const profile = ref(null);
|
||||
const searches = ref([]);
|
||||
const searchesLoading = ref(false);
|
||||
const searchesError = ref('');
|
||||
const currentPage = ref(1);
|
||||
const perPage = ref(20);
|
||||
const totalPages = ref(1);
|
||||
const totalItems = ref(0);
|
||||
const filterTipo = ref('');
|
||||
const PER_PAGE_OPTIONS = [10, 20, 50];
|
||||
|
||||
// parsear el concepto de la busqueda
|
||||
function parseConcepto(raw) {
|
||||
// si no hay concepto, retornar un guion
|
||||
if (!raw) {
|
||||
return '—';
|
||||
}
|
||||
// si el concepto es un objeto, convertirlo a string
|
||||
if (typeof raw === 'object') {
|
||||
return JSON.stringify(raw);
|
||||
}
|
||||
// si el concepto es un string, intentar parsearlo como JSON
|
||||
const text = String(raw);
|
||||
// si no es JSON, retornar el string original
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
// si es un objeto, convertirlo a string con formato
|
||||
return JSON.stringify(parsed, null, 0);
|
||||
} catch (err) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
// darle formato a la fecha
|
||||
function formatFecha(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value || '—');
|
||||
}
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
// filtrar busquedas por tipo
|
||||
const filteredSearches = computed(() => {
|
||||
if (!filterTipo.value) {
|
||||
return searches.value;
|
||||
}
|
||||
return searches.value.filter((item) =>
|
||||
String(item.tipo || '').toLowerCase().includes(filterTipo.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// obtener los tipos disponibles de busquedas
|
||||
const availableTipos = computed(() => {
|
||||
const tipos = new Set(searches.value.map((item) => item.tipo || '').filter(Boolean));
|
||||
return Array.from(tipos).sort();
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const misbusquedas = async (page = 1, perPage = 20) => {
|
||||
const url = `${MISBUSQUEDAS_ENDPOINT}?page=${page}&per_page=${perPage}`
|
||||
const response = await authenticatedFetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error al obtener mis busquedas: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data
|
||||
}
|
||||
|
||||
async function cargarBusquedas(page = 1) {
|
||||
searchesLoading.value = true;
|
||||
searchesError.value = '';
|
||||
|
||||
try {
|
||||
const data = await misbusquedas(page, perPage.value);
|
||||
searches.value = Array.isArray(data?.items) ? data.items : [];
|
||||
currentPage.value = Number(data?.page || page);
|
||||
perPage.value = Number(data?.per_page || perPage.value);
|
||||
totalPages.value = Math.max(1, Number(data?.total_pages || 1));
|
||||
totalItems.value = Number(data?.total || searches.value.length || 0);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : 'Error desconocido';
|
||||
searchesError.value = `No fue posible cargar busquedas recientes (${detail}).`;
|
||||
} finally {
|
||||
searchesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// paginacion
|
||||
function goPrevPage() {
|
||||
if (currentPage.value <= 1 || searchesLoading.value) {
|
||||
return;
|
||||
}
|
||||
cargarBusquedas(currentPage.value - 1);
|
||||
}
|
||||
|
||||
function goNextPage() {
|
||||
if (currentPage.value >= totalPages.value || searchesLoading.value) {
|
||||
return;
|
||||
}
|
||||
cargarBusquedas(currentPage.value + 1);
|
||||
}
|
||||
|
||||
function onPerPageChange() {
|
||||
filterTipo.value = '';
|
||||
cargarBusquedas(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await cargarBusquedas(1);
|
||||
});
|
||||
//console.log(await misbusquedas());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen bg-slate-50 p-6 pt-24 transition-[margin] duration-300',
|
||||
isSidebarOpen ? 'md:ml-64' : 'md:ml-0',
|
||||
]"
|
||||
>
|
||||
<div class="min-h-screen bg-slate-100 p-6">
|
||||
<section class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="border-b border-slate-200 px-6 py-5">
|
||||
<p class="font-mono text-xs uppercase tracking-widest text-sky-600">
|
||||
root@makima:~$ mis-busquedas
|
||||
</p>
|
||||
|
||||
<h2 class="mt-2 text-2xl font-bold text-slate-800">
|
||||
BÚSQUEDAS RECIENTES
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{
|
||||
searchesLoading
|
||||
? 'Cargando resultados...'
|
||||
: `Total: ${totalItems} | Página ${currentPage} de ${totalPages}`
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="flex flex-col gap-4 border-b border-slate-200 bg-slate-50 px-6 py-4 md:flex-row md:items-end md:justify-between">
|
||||
|
||||
<div class="flex flex-col gap-4 md:flex-row">
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">
|
||||
Tipo
|
||||
</label>
|
||||
|
||||
<select
|
||||
v-model="filterTipo"
|
||||
:disabled="searchesLoading"
|
||||
class="w-56 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
|
||||
<option
|
||||
v-for="tipo in availableTipos"
|
||||
:key="tipo"
|
||||
:value="tipo"
|
||||
>
|
||||
{{ tipo }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">
|
||||
Por página
|
||||
</label>
|
||||
|
||||
<select
|
||||
v-model="perPage"
|
||||
:disabled="searchesLoading"
|
||||
@change="onPerPageChange"
|
||||
class="w-40 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
<option
|
||||
v-for="n in PER_PAGE_OPTIONS"
|
||||
:key="n"
|
||||
:value="n"
|
||||
>
|
||||
{{ n }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div
|
||||
v-if="searchesError"
|
||||
class="m-6 rounded-lg border border-red-300 bg-red-50 p-4 text-red-700"
|
||||
>
|
||||
{{ searchesError }}
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
<div
|
||||
v-else
|
||||
class="overflow-x-auto"
|
||||
>
|
||||
|
||||
<table
|
||||
v-if="filteredSearches.length"
|
||||
class="min-w-full"
|
||||
>
|
||||
<thead class="bg-slate-100">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold uppercase text-slate-600">
|
||||
Tipo
|
||||
</th>
|
||||
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold uppercase text-slate-600">
|
||||
Concepto
|
||||
</th>
|
||||
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold uppercase text-slate-600">
|
||||
Fecha
|
||||
</th>
|
||||
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold uppercase text-slate-600">
|
||||
Estado
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
|
||||
<tr
|
||||
v-for="(item, idx) in filteredSearches"
|
||||
:key="`search-${idx}-${item.fecha}`"
|
||||
class="transition hover:bg-sky-50"
|
||||
>
|
||||
<td class="px-6 py-4 text-sm text-slate-700">
|
||||
{{ item.tipo || '—' }}
|
||||
</td>
|
||||
|
||||
<td class="max-w-xl px-6 py-4">
|
||||
<pre class="whitespace-pre-wrap break-words font-mono text-xs text-slate-700">
|
||||
{{ parseConcepto(item.concepto) }}
|
||||
</pre>
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 text-sm text-slate-600">
|
||||
{{ formatFecha(item.fecha) }}
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex rounded-full px-3 py-1 text-xs font-semibold',
|
||||
Number(item.estado) === 1
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
]"
|
||||
>
|
||||
{{ Number(item.estado) === 1 ? 'OK' : 'N/A' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
v-else-if="!searchesLoading"
|
||||
class="py-12 text-center text-slate-500"
|
||||
>
|
||||
No hay búsquedas recientes.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
v-if="!searchesError"
|
||||
class="flex flex-col items-center justify-between gap-4 border-t border-slate-200 bg-slate-50 px-6 py-4 md:flex-row"
|
||||
>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="searchesLoading || currentPage <= 1"
|
||||
@click="goPrevPage"
|
||||
class="rounded-lg border border-slate-300 bg-white px-5 py-2 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-100 disabled:opacity-40"
|
||||
>
|
||||
← Página anterior
|
||||
</button>
|
||||
|
||||
<span class="text-sm font-medium text-slate-600">
|
||||
Página {{ currentPage }} de {{ totalPages }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="searchesLoading || currentPage >= totalPages"
|
||||
@click="goNextPage"
|
||||
class="rounded-lg bg-sky-600 px-5 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-700 disabled:opacity-40"
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
66
src/views/UsersView.vue
Normal file
66
src/views/UsersView.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
isSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const users = [
|
||||
{ name: 'Andrea Flores', role: 'Administradora', status: 'Activo' },
|
||||
{ name: 'Carlos Mendez', role: 'Supervisor', status: 'Activo' },
|
||||
{ name: 'Lucia Perez', role: 'Auditoria', status: 'Pendiente' },
|
||||
{ name: 'Jorge Ruiz', role: 'Operador', status: 'Inactivo' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
:class="[
|
||||
'min-h-screen bg-slate-50 p-6 pt-24 transition-[margin] duration-300',
|
||||
isSidebarOpen ? 'md:ml-64' : 'md:ml-0',
|
||||
]"
|
||||
>
|
||||
<section class="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<header class="flex items-center justify-between border-b border-slate-200 px-5 py-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold tracking-tight text-slate-900">Usuarios</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Gestion de cuentas y permisos del panel.</p>
|
||||
</div>
|
||||
<button type="button" class="rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700">Nuevo usuario</button>
|
||||
</header>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto w-full min-w-[640px] text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<th class="px-5 py-3 font-semibold">Nombre</th>
|
||||
<th class="px-5 py-3 font-semibold">Rol</th>
|
||||
<th class="px-5 py-3 font-semibold">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.name" class="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td class="px-5 py-3 font-medium text-slate-800">{{ user.name }}</td>
|
||||
<td class="px-5 py-3 text-slate-600">{{ user.role }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold',
|
||||
user.status === 'Activo'
|
||||
? 'bg-emerald-50 text-emerald-700'
|
||||
: user.status === 'Pendiente'
|
||||
? 'bg-amber-50 text-amber-700'
|
||||
: 'bg-slate-100 text-slate-600',
|
||||
]"
|
||||
>
|
||||
{{ user.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
8
vite.config.js
Normal file
8
vite.config.js
Normal file
@@ -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()],
|
||||
})
|
||||
Reference in New Issue
Block a user