From 59f25adabe2c4aebf230be62b5359e9328d47ad0 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Fri, 5 Dec 2025 11:41:26 -0600 Subject: [PATCH] =?UTF-8?q?Agregar=20secci=C3=B3n=20Contactos=20con=20UTab?= =?UTF-8?q?s=20y=20conexi=C3=B3n=20a=20Metabase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implementar UTabs (Contactos, Aplicaciones, Perfil) en app.vue - Crear componentes ContactsList, ContactsFilters, ContactItem - Agregar server routes para obtener contactos via Metabase API - Sistema de aliases por usuario guardados en archivos JSON - Filtros: nombre (fuzzy search), ID, teléfono, empleado - Click en contacto abre WhatsApp - Estilo glassmorphism consistente con la app --- .env.example | 23 ++ .gitea/workflows/build-and-deploy.yml | 5 + docker-compose.yml | 13 + nuxt4/app/app.vue | 111 +++++- nuxt4/app/components/contacts/ContactItem.vue | 317 ++++++++++++++++++ .../components/contacts/ContactsFilters.vue | 278 +++++++++++++++ .../app/components/contacts/ContactsList.vue | 201 +++++++++++ nuxt4/app/composables/useContacts.ts | 205 +++++++++++ nuxt4/app/composables/useFuzzySearch.ts | 119 +++++++ nuxt4/nuxt.config.ts | 9 + nuxt4/server/api/contacts/aliases.get.ts | 51 +++ nuxt4/server/api/contacts/aliases/[id].put.ts | 86 +++++ nuxt4/server/api/contacts/index.get.ts | 111 ++++++ 13 files changed, 1512 insertions(+), 17 deletions(-) create mode 100644 nuxt4/app/components/contacts/ContactItem.vue create mode 100644 nuxt4/app/components/contacts/ContactsFilters.vue create mode 100644 nuxt4/app/components/contacts/ContactsList.vue create mode 100644 nuxt4/app/composables/useContacts.ts create mode 100644 nuxt4/app/composables/useFuzzySearch.ts create mode 100644 nuxt4/server/api/contacts/aliases.get.ts create mode 100644 nuxt4/server/api/contacts/aliases/[id].put.ts create mode 100644 nuxt4/server/api/contacts/index.get.ts diff --git a/.env.example b/.env.example index 091d454..77bc0f6 100644 --- a/.env.example +++ b/.env.example @@ -70,3 +70,26 @@ NUXT_AUTHENTIK_API_TOKEN=tu-token-de-api-aqui # URL de la API de Authentik (usualmente la misma que NUXT_PUBLIC_AUTHENTIK_URL) NUXT_AUTHENTIK_API_URL=https://authentik.nucleoriofrio.com + +# =========================================== +# METABASE API (para contactos) +# =========================================== +# La sección de Contactos obtiene datos de la tabla Clientes +# a través de Metabase API. +# +# Metabase debe estar en la misma red Docker para usar URL interna. + +# URL de Metabase (interna Docker o pública) +NUXT_METABASE_API_URL=http://metabase:3000 + +# API Key de Metabase (SECRETO) +# Para crear una API Key: +# 1. Ve a Metabase Admin → Settings → Authentication +# 2. Genera una nueva API Key +NUXT_METABASE_API_KEY=tu-metabase-api-key + +# ID de la base de datos en Metabase (facturador supabase = 2) +NUXT_METABASE_DATABASE_ID=2 + +# ID de la tabla Clientes en Metabase +NUXT_METABASE_TABLE_ID=15 diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml index 366ce37..fe7f2bb 100644 --- a/.gitea/workflows/build-and-deploy.yml +++ b/.gitea/workflows/build-and-deploy.yml @@ -17,6 +17,11 @@ jobs: NUXT_AUTHENTIK_API_TOKEN: ${{ secrets.NUXT_AUTHENTIK_API_TOKEN }} NUXT_AUTHENTIK_API_URL: ${{ vars.NUXT_AUTHENTIK_API_URL }} NUXT_PUBLIC_AUTHENTIK_URL: ${{ vars.NUXT_PUBLIC_AUTHENTIK_URL }} + # Metabase API (para contactos) + NUXT_METABASE_API_URL: ${{ vars.NUXT_METABASE_API_URL }} + NUXT_METABASE_API_KEY: ${{ secrets.NUXT_METABASE_API_KEY }} + NUXT_METABASE_DATABASE_ID: ${{ vars.NUXT_METABASE_DATABASE_ID }} + NUXT_METABASE_TABLE_ID: ${{ vars.NUXT_METABASE_TABLE_ID }} steps: - uses: actions/checkout@v3 - uses: docker/setup-buildx-action@v2 diff --git a/docker-compose.yml b/docker-compose.yml index fe70ea9..52d3fac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,15 @@ services: - NUXT_AUTHENTIK_API_TOKEN=${NUXT_AUTHENTIK_API_TOKEN} - NUXT_AUTHENTIK_API_URL=${NUXT_AUTHENTIK_API_URL} - NUXT_PUBLIC_AUTHENTIK_URL=${NUXT_PUBLIC_AUTHENTIK_URL} + # Metabase API Configuration (para contactos) + - NUXT_METABASE_API_URL=${NUXT_METABASE_API_URL:-http://metabase:3000} + - NUXT_METABASE_API_KEY=${NUXT_METABASE_API_KEY} + - NUXT_METABASE_DATABASE_ID=${NUXT_METABASE_DATABASE_ID:-2} + - NUXT_METABASE_TABLE_ID=${NUXT_METABASE_TABLE_ID:-15} + # Directorio de datos persistente + - NUXT_DATA_DIR=/data + volumes: + - perfil-data:/data networks: - principal - traefik-network @@ -53,6 +62,10 @@ services: - "traefik.http.middlewares.${APP_NAME}-cors.headers.accesscontrolmaxage=100" - "traefik.http.middlewares.${APP_NAME}-cors.headers.addvaryheader=true" +volumes: + perfil-data: + name: perfil-data + networks: principal: external: true diff --git a/nuxt4/app/app.vue b/nuxt4/app/app.vue index ec2bb62..2a1c680 100644 --- a/nuxt4/app/app.vue +++ b/nuxt4/app/app.vue @@ -14,15 +14,34 @@
- + - - - + +
+ +
+ + + + + + +
@@ -50,13 +69,43 @@ + + + + diff --git a/nuxt4/app/components/contacts/ContactsFilters.vue b/nuxt4/app/components/contacts/ContactsFilters.vue new file mode 100644 index 0000000..ccffc56 --- /dev/null +++ b/nuxt4/app/components/contacts/ContactsFilters.vue @@ -0,0 +1,278 @@ + + + + + + + diff --git a/nuxt4/app/components/contacts/ContactsList.vue b/nuxt4/app/components/contacts/ContactsList.vue new file mode 100644 index 0000000..1cb3454 --- /dev/null +++ b/nuxt4/app/components/contacts/ContactsList.vue @@ -0,0 +1,201 @@ + + + + + + + diff --git a/nuxt4/app/composables/useContacts.ts b/nuxt4/app/composables/useContacts.ts new file mode 100644 index 0000000..ce4350e --- /dev/null +++ b/nuxt4/app/composables/useContacts.ts @@ -0,0 +1,205 @@ +/** + * Composable para gestión de contactos + * Maneja fetching, filtrado, aliases y acciones + */ +export interface Contact { + id: number + name: string + cedula: number | null + ubicacion: string | null + grupo_estudio: string | null + empleado: boolean + avatar_url: string | null + telefono: string | null + idciat: string | null +} + +export interface ContactFilters { + search: string + id: string + telefono: string + empleado: boolean +} + +export const useContacts = () => { + const { fuzzyMatch, fuzzyScore } = useFuzzySearch() + + // Estado + const contacts = ref([]) + const aliases = ref>({}) + const isLoading = ref(false) + const error = ref(null) + + // Filtros con persistencia en cookie + const filtersCookie = useCookie('contact-filters', { + maxAge: 60 * 60 * 24 * 7, // 1 semana + default: () => ({ + search: '', + id: '', + telefono: '', + empleado: true + }) + }) + + const filters = ref({ ...filtersCookie.value }) + + // Sincronizar filtros con cookie + watch(filters, (newFilters) => { + filtersCookie.value = newFilters + }, { deep: true }) + + /** + * Cargar contactos desde el servidor + */ + const fetchContacts = async () => { + isLoading.value = true + error.value = null + + try { + const params = new URLSearchParams() + params.set('empleado', filters.value.empleado.toString()) + + // Filtro por ID se aplica en servidor si es exacto + if (filters.value.id && !isNaN(parseInt(filters.value.id))) { + params.set('id', filters.value.id) + } + + const data = await $fetch(`/api/contacts?${params.toString()}`) + contacts.value = data + } catch (err: any) { + console.error('Error al cargar contactos:', err) + error.value = err.message || 'Error al cargar contactos' + } finally { + isLoading.value = false + } + } + + /** + * Cargar aliases del usuario + */ + const fetchAliases = async () => { + try { + const data = await $fetch>('/api/contacts/aliases') + aliases.value = data + } catch (err: any) { + console.error('Error al cargar aliases:', err) + // No es crítico, continuar sin aliases + } + } + + /** + * Actualizar alias de un contacto + */ + const updateAlias = async (contactId: number, alias: string): Promise => { + try { + await $fetch(`/api/contacts/aliases/${contactId}`, { + method: 'PUT', + body: { alias } + }) + + // Actualizar estado local + if (alias) { + aliases.value[contactId.toString()] = alias + } else { + delete aliases.value[contactId.toString()] + } + + return true + } catch (err: any) { + console.error('Error al actualizar alias:', err) + return false + } + } + + /** + * Contactos filtrados (búsqueda fuzzy en cliente) + */ + const filteredContacts = computed(() => { + let result = contacts.value + + // Filtro por teléfono (parcial) + if (filters.value.telefono) { + const telFilter = filters.value.telefono.replace(/\D/g, '') + result = result.filter(c => + c.telefono?.replace(/\D/g, '').includes(telFilter) + ) + } + + // Filtro por nombre/alias (fuzzy) + if (filters.value.search) { + result = result + .map(contact => ({ + contact, + score: Math.max( + fuzzyScore(contact.name, filters.value.search), + fuzzyScore(aliases.value[contact.id.toString()] || '', filters.value.search) + ) + })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .map(({ contact }) => contact) + } + + return result + }) + + /** + * Obtener nombre visible (alias o nombre real) + */ + const getDisplayName = (contact: Contact): string => { + return aliases.value[contact.id.toString()] || contact.name + } + + /** + * Verificar si el contacto tiene alias + */ + const hasAlias = (contact: Contact): boolean => { + return !!aliases.value[contact.id.toString()] + } + + /** + * Generar URL de WhatsApp + */ + const getWhatsAppUrl = (telefono: string | null): string | null => { + if (!telefono) return null + const cleanNumber = telefono.replace(/\D/g, '') + if (!cleanNumber) return null + return `https://wa.me/${cleanNumber}` + } + + /** + * Limpiar filtros + */ + const clearFilters = () => { + filters.value = { + search: '', + id: '', + telefono: '', + empleado: true + } + } + + // Watch para recargar cuando cambia el filtro de empleado o ID + watch( + () => [filters.value.empleado, filters.value.id], + () => { + fetchContacts() + } + ) + + return { + contacts, + aliases, + filters, + filteredContacts, + isLoading, + error, + fetchContacts, + fetchAliases, + updateAlias, + getDisplayName, + hasAlias, + getWhatsAppUrl, + clearFilters + } +} diff --git a/nuxt4/app/composables/useFuzzySearch.ts b/nuxt4/app/composables/useFuzzySearch.ts new file mode 100644 index 0000000..a432c63 --- /dev/null +++ b/nuxt4/app/composables/useFuzzySearch.ts @@ -0,0 +1,119 @@ +/** + * Composable para búsqueda fuzzy de texto + * Normaliza acentos y es tolerante a typos básicos + */ +export const useFuzzySearch = () => { + /** + * Normaliza un texto para comparación: + * - Convierte a minúsculas + * - Elimina acentos/diacríticos + * - Elimina espacios extra + */ + const normalize = (text: string): string => { + return text + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim() + } + + /** + * Verifica si el texto contiene la query (fuzzy) + * Retorna true si hay match + */ + const fuzzyMatch = (text: string, query: string): boolean => { + if (!query) return true + if (!text) return false + + const normalizedText = normalize(text) + const normalizedQuery = normalize(query) + + // Match exacto (substring) + if (normalizedText.includes(normalizedQuery)) return true + + // Match por palabras - cada palabra de la query debe coincidir con alguna palabra del texto + const textWords = normalizedText.split(/\s+/) + const queryWords = normalizedQuery.split(/\s+/) + + return queryWords.every(queryWord => + textWords.some(textWord => + textWord.startsWith(queryWord) || textWord.includes(queryWord) + ) + ) + } + + /** + * Calcula un score de relevancia (0-100) + * Mayor score = mejor match + */ + const fuzzyScore = (text: string, query: string): number => { + if (!query) return 100 + if (!text) return 0 + + const normalizedText = normalize(text) + const normalizedQuery = normalize(query) + + // Match exacto completo + if (normalizedText === normalizedQuery) return 100 + + // Match al inicio + if (normalizedText.startsWith(normalizedQuery)) return 90 + + // Match exacto (substring) + if (normalizedText.includes(normalizedQuery)) { + const position = normalizedText.indexOf(normalizedQuery) + return 80 - (position / normalizedText.length) * 20 + } + + // Match por palabras + const textWords = normalizedText.split(/\s+/) + const queryWords = normalizedQuery.split(/\s+/) + + let matchedWords = 0 + let startMatches = 0 + + queryWords.forEach(queryWord => { + const match = textWords.find(textWord => + textWord.startsWith(queryWord) || textWord.includes(queryWord) + ) + if (match) { + matchedWords++ + if (match.startsWith(queryWord)) startMatches++ + } + }) + + if (matchedWords === 0) return 0 + + const matchRatio = matchedWords / queryWords.length + const startBonus = startMatches / queryWords.length * 10 + + return Math.round(matchRatio * 60 + startBonus) + } + + /** + * Filtra y ordena una lista por relevancia de búsqueda + */ + const filterAndSort = ( + items: T[], + query: string, + getText: (item: T) => string + ): T[] => { + if (!query) return items + + return items + .map(item => ({ + item, + score: fuzzyScore(getText(item), query) + })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .map(({ item }) => item) + } + + return { + normalize, + fuzzyMatch, + fuzzyScore, + filterAndSort + } +} diff --git a/nuxt4/nuxt.config.ts b/nuxt4/nuxt.config.ts index 1342961..8237d1c 100644 --- a/nuxt4/nuxt.config.ts +++ b/nuxt4/nuxt.config.ts @@ -19,6 +19,15 @@ export default defineNuxtConfig({ authentikApiToken: process.env.NUXT_AUTHENTIK_API_TOKEN || '', authentikApiUrl: process.env.NUXT_AUTHENTIK_API_URL || 'https://authentik.nucleoriofrio.com', + // Metabase API (para contactos) + metabaseApiUrl: process.env.NUXT_METABASE_API_URL || 'http://metabase:3000', + metabaseApiKey: process.env.NUXT_METABASE_API_KEY || '', + metabaseDatabaseId: parseInt(process.env.NUXT_METABASE_DATABASE_ID || '2'), + metabaseTableId: parseInt(process.env.NUXT_METABASE_TABLE_ID || '15'), + + // Directorio de datos para aliases de contactos + dataDir: process.env.NUXT_DATA_DIR || './data', + // Variables públicas (expuestas al cliente) public: { authentikUrl: process.env.NUXT_PUBLIC_AUTHENTIK_URL || 'https://authentik.nucleoriofrio.com' diff --git a/nuxt4/server/api/contacts/aliases.get.ts b/nuxt4/server/api/contacts/aliases.get.ts new file mode 100644 index 0000000..3dbc680 --- /dev/null +++ b/nuxt4/server/api/contacts/aliases.get.ts @@ -0,0 +1,51 @@ +import { readFile, mkdir } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' + +/** + * API endpoint para obtener los aliases de contactos del usuario actual + * Los aliases se guardan en archivos JSON por usuario + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const headers = getRequestHeaders(event) + + // Verificar autenticación + const uid = headers['x-authentik-uid'] + if (!uid) { + throw createError({ + statusCode: 401, + message: 'Usuario no autenticado' + }) + } + + // Sanitizar UID para usar como nombre de archivo + const safeUid = uid.replace(/[^a-zA-Z0-9-_]/g, '_') + + // Ruta del archivo de aliases + const dataDir = config.dataDir || './data' + const aliasesDir = join(dataDir, 'contact-aliases') + const aliasesFile = join(aliasesDir, `${safeUid}.json`) + + try { + // Crear directorio si no existe + if (!existsSync(aliasesDir)) { + await mkdir(aliasesDir, { recursive: true }) + } + + // Leer archivo de aliases si existe + if (existsSync(aliasesFile)) { + const content = await readFile(aliasesFile, 'utf-8') + return JSON.parse(content) + } + + // Si no existe, retornar objeto vacío + return {} + } catch (error: any) { + console.error('Error al leer aliases:', error) + throw createError({ + statusCode: 500, + message: 'Error al obtener los aliases' + }) + } +}) diff --git a/nuxt4/server/api/contacts/aliases/[id].put.ts b/nuxt4/server/api/contacts/aliases/[id].put.ts new file mode 100644 index 0000000..1ae3f8d --- /dev/null +++ b/nuxt4/server/api/contacts/aliases/[id].put.ts @@ -0,0 +1,86 @@ +import { readFile, writeFile, mkdir } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' + +/** + * API endpoint para actualizar el alias de un contacto + * PUT /api/contacts/aliases/:id + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const headers = getRequestHeaders(event) + + // Verificar autenticación + const uid = headers['x-authentik-uid'] + if (!uid) { + throw createError({ + statusCode: 401, + message: 'Usuario no autenticado' + }) + } + + // Obtener ID del contacto desde la ruta + const contactId = getRouterParam(event, 'id') + if (!contactId) { + throw createError({ + statusCode: 400, + message: 'ID de contacto requerido' + }) + } + + // Leer body + const body = await readBody(event) + const alias = body?.alias?.toString().trim() || '' + + // Validar alias (max 50 caracteres) + if (alias.length > 50) { + throw createError({ + statusCode: 400, + message: 'El alias no puede tener más de 50 caracteres' + }) + } + + // Sanitizar UID para usar como nombre de archivo + const safeUid = uid.replace(/[^a-zA-Z0-9-_]/g, '_') + + // Ruta del archivo de aliases + const dataDir = config.dataDir || './data' + const aliasesDir = join(dataDir, 'contact-aliases') + const aliasesFile = join(aliasesDir, `${safeUid}.json`) + + try { + // Crear directorio si no existe + if (!existsSync(aliasesDir)) { + await mkdir(aliasesDir, { recursive: true }) + } + + // Leer aliases existentes + let aliases: Record = {} + if (existsSync(aliasesFile)) { + const content = await readFile(aliasesFile, 'utf-8') + aliases = JSON.parse(content) + } + + // Actualizar o eliminar alias + if (alias) { + aliases[contactId] = alias + } else { + delete aliases[contactId] + } + + // Guardar archivo + await writeFile(aliasesFile, JSON.stringify(aliases, null, 2), 'utf-8') + + return { + success: true, + contactId, + alias: alias || null + } + } catch (error: any) { + console.error('Error al actualizar alias:', error) + throw createError({ + statusCode: 500, + message: 'Error al actualizar el alias' + }) + } +}) diff --git a/nuxt4/server/api/contacts/index.get.ts b/nuxt4/server/api/contacts/index.get.ts new file mode 100644 index 0000000..1c90e52 --- /dev/null +++ b/nuxt4/server/api/contacts/index.get.ts @@ -0,0 +1,111 @@ +/** + * API endpoint para obtener contactos desde Metabase + * Consulta la tabla Clientes del proyecto facturador en Supabase via Metabase API + */ + +interface Contact { + id: number + name: string + cedula: number | null + ubicacion: string | null + grupo_estudio: string | null + empleado: boolean + avatar_url: string | null + telefono: string | null + idciat: string | null +} + +interface MetabaseResponse { + data: { + cols: Array<{ name: string }> + rows: Array> + } +} + +export default defineEventHandler(async (event): Promise => { + const config = useRuntimeConfig() + const headers = getRequestHeaders(event) + const query = getQuery(event) + + // Verificar autenticación + const uid = headers['x-authentik-uid'] + if (!uid) { + throw createError({ + statusCode: 401, + message: 'Usuario no autenticado' + }) + } + + // Obtener configuración de Metabase + const metabaseUrl = config.metabaseApiUrl as string + const metabaseApiKey = config.metabaseApiKey as string + const databaseId = config.metabaseDatabaseId as number + const tableId = config.metabaseTableId as number + + if (!metabaseApiKey) { + throw createError({ + statusCode: 500, + message: 'API Key de Metabase no configurada' + }) + } + + // Construir filtros para la query + const filters: unknown[] = [] + + // Filtro de empleados (por defecto true) + const empleadoFilter = query.empleado !== 'false' + if (empleadoFilter) { + filters.push(['=', ['field', 'empleado', { 'base-type': 'type/Boolean' }], true]) + } + + // Filtro por ID exacto + if (query.id) { + const idNum = parseInt(query.id as string) + if (!isNaN(idNum)) { + filters.push(['=', ['field', 'id', { 'base-type': 'type/BigInteger' }], idNum]) + } + } + + // Construir la query para Metabase + const metabaseQuery = { + database: databaseId, + type: 'query', + query: { + 'source-table': tableId, + 'order-by': [['asc', ['field', 'id', { 'base-type': 'type/BigInteger' }]]], + filter: filters.length > 0 + ? (filters.length === 1 ? filters[0] : ['and', ...filters]) + : undefined + } + } + + try { + const response = await $fetch(`${metabaseUrl}/api/dataset`, { + method: 'POST', + headers: { + 'X-API-Key': metabaseApiKey, + 'Content-Type': 'application/json' + }, + body: metabaseQuery + }) + + // Mapear columnas a objetos + const cols = response.data.cols.map((col) => col.name) + const contacts: Contact[] = response.data.rows.map((row) => { + const contact: Record = {} + cols.forEach((colName, index) => { + contact[colName] = row[index] + }) + return contact as unknown as Contact + }) + + return contacts + } catch (error: unknown) { + console.error('Error al obtener contactos de Metabase:', error) + const err = error as { statusCode?: number; message?: string } + throw createError({ + statusCode: err.statusCode || 500, + message: err.message || 'Error al obtener los contactos' + }) + } +})