Primer commit

This commit is contained in:
unknown
2026-07-14 14:50:48 -06:00
commit 2a1a3d40b7
51 changed files with 5905 additions and 0 deletions

13
.env Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View 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
View File

@@ -0,0 +1,7 @@
<script setup>
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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
View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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,
}
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
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,
})

View 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,
}
}

View 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
View 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
View 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
View 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
View 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
}

View 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: [],
}))
}

View 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),
}))
}

View 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,
}
})
}

View 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),
}
})
}

View 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,
}
})
}

View 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: [],
}
})
}

View 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
View 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
View 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
View 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>

View 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>

View 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
View 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>

View 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
View 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
View 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
View 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>

0
test.db Normal file
View File

8
vite.config.js Normal file
View 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()],
})