Agregar sección Contactos con UTabs y conexión a Metabase
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m37s

- 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
This commit is contained in:
2025-12-05 11:41:26 -06:00
parent 00596bd6df
commit 59f25adabe
13 changed files with 1512 additions and 17 deletions

View File

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

View File

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

View File

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

View File

@@ -14,15 +14,34 @@
<UContainer class="py-8">
<div v-if="isAuthenticated" class="space-y-6">
<!-- Header principal con info del usuario -->
<UserHeader @edit-profile="showProfileForm = true" />
<UserHeader @edit-profile="activeTab = 'perfil'" />
<!-- Formulario de edición de perfil o Lista de aplicaciones -->
<UserProfileForm
v-if="showProfileForm"
:shared-image-url="sharedImageUrl"
@close="handleCloseProfileForm"
<!-- Sistema de Tabs -->
<div class="tabs-container">
<UTabs
v-model="activeTab"
:items="tabItems"
variant="pill"
color="primary"
:content="false"
:ui="{
list: 'tabs-list',
trigger: 'tabs-trigger',
indicator: 'tabs-indicator'
}"
/>
<AuthApplicationsList v-else />
</div>
<!-- Contenido según tab activo -->
<KeepAlive>
<ContactsContactsList v-if="activeTab === 'contactos'" />
<AuthApplicationsList v-else-if="activeTab === 'aplicaciones'" />
<UserProfileForm
v-else-if="activeTab === 'perfil'"
:shared-image-url="sharedImageUrl"
@close="activeTab = 'aplicaciones'"
/>
</KeepAlive>
<!-- Acciones rápidas en footer transparente -->
<div class="quick-actions">
@@ -50,13 +69,43 @@
</template>
<script setup lang="ts">
import type { TabsItem } from '@nuxt/ui'
const { isAuthenticated } = useAuthentik()
const { isNight } = useTheme()
const route = useRoute()
const router = useRouter()
// Estado para mostrar formulario de edición
const showProfileForm = ref(false)
// Tab activo (persistido en cookie)
const tabCookie = useCookie<string>('active-tab', {
maxAge: 60 * 60 * 24 * 7, // 1 semana
default: () => 'contactos'
})
const activeTab = ref(tabCookie.value)
// Sincronizar con cookie
watch(activeTab, (newTab) => {
tabCookie.value = newTab
})
// Definir tabs
const tabItems: TabsItem[] = [
{
label: 'Contactos',
value: 'contactos',
icon: 'i-heroicons-users'
},
{
label: 'Aplicaciones',
value: 'aplicaciones',
icon: 'i-heroicons-squares-2x2'
},
{
label: 'Perfil',
value: 'perfil',
icon: 'i-heroicons-user-circle'
}
]
// URL de imagen compartida (si existe)
const sharedImageUrl = ref<string | null>(null)
@@ -71,19 +120,13 @@ onMounted(() => {
sharedImageUrl.value = `/temp-shared/${sharedFile}`
// Abrir automáticamente el formulario de perfil
showProfileForm.value = true
activeTab.value = 'perfil'
// Limpiar query params de la URL sin recargar
router.replace({ query: {} })
}
})
// Manejar cierre del formulario
const handleCloseProfileForm = () => {
showProfileForm.value = false
sharedImageUrl.value = null
}
// Configurar meta tags para PWA
useHead({
htmlAttrs: {
@@ -115,6 +158,18 @@ useHead({
padding-top: max(2rem, calc(env(titlebar-area-height, 0px) + 1rem));
}
/* Contenedor de tabs con glassmorphism */
.tabs-container {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(20px) saturate(180%);
border-radius: 1rem;
padding: 0.375rem;
box-shadow:
0 4px 16px 0 rgba(31, 38, 135, 0.1),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.quick-actions {
display: flex;
justify-content: center;
@@ -162,6 +217,28 @@ useHead({
<style>
/* Estilos de modo oscuro (sin scoped para que .dark funcione correctamente) */
.dark .tabs-container {
background: rgba(0, 0, 0, 0.2) !important;
box-shadow:
0 4px 16px 0 rgba(0, 0, 0, 0.4),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.05) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
}
/* Personalizar tabs para estilo glassmorphism */
.tabs-container .tabs-list {
background: transparent !important;
}
.tabs-container .tabs-indicator {
background: rgba(var(--color-primary-500), 0.2) !important;
backdrop-filter: blur(10px) !important;
}
.dark .tabs-container .tabs-indicator {
background: rgba(var(--color-primary-500), 0.3) !important;
}
.dark .quick-actions {
background: rgba(0, 0, 0, 0.15) !important;
box-shadow:

View File

@@ -0,0 +1,317 @@
<template>
<div
class="contact-item"
:class="{ 'contact-item-clickable': contact.telefono }"
@click="handleClick"
>
<div class="contact-content">
<!-- Avatar -->
<div class="contact-avatar">
<img
v-if="contact.avatar_url && !avatarError"
:src="contact.avatar_url"
:alt="displayName"
class="avatar-img"
@error="avatarError = true"
/>
<UIcon v-else name="i-heroicons-user-circle" class="avatar-icon" />
</div>
<!-- Info -->
<div class="contact-info">
<div class="contact-name-row">
<span class="contact-name">{{ displayName }}</span>
<UBadge v-if="hasAlias" size="xs" color="primary" variant="soft">
Alias
</UBadge>
</div>
<span v-if="contact.telefono" class="contact-phone">
<UIcon name="i-heroicons-phone" class="phone-icon" />
{{ contact.telefono }}
</span>
<span v-if="contact.ubicacion" class="contact-location">
<UIcon name="i-heroicons-map-pin" class="location-icon" />
{{ contact.ubicacion }}
</span>
</div>
<!-- Acciones -->
<div class="contact-actions" @click.stop>
<UButton
icon="i-heroicons-pencil-square"
variant="ghost"
size="xs"
color="neutral"
@click="openAliasModal"
title="Editar alias"
/>
<UIcon
v-if="contact.telefono"
name="i-heroicons-chat-bubble-left-ellipsis"
class="whatsapp-icon"
/>
</div>
</div>
<!-- Modal para editar alias -->
<UModal v-model:open="showAliasModal">
<template #content>
<div class="alias-modal">
<h3 class="alias-modal-title">Editar alias</h3>
<p class="alias-modal-original">
<span class="label">Nombre original:</span>
{{ contact.name }}
</p>
<UFormField label="Alias personalizado">
<UInput
v-model="newAlias"
placeholder="Escribe un alias..."
maxlength="50"
class="alias-input"
/>
</UFormField>
<p class="alias-modal-hint">
Máximo 50 caracteres. Deja vacío para eliminar el alias.
</p>
<div class="alias-modal-actions">
<UButton variant="ghost" color="neutral" @click="showAliasModal = false">
Cancelar
</UButton>
<UButton :loading="saving" @click="saveAlias">
Guardar
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
import type { Contact } from '~/composables/useContacts'
const props = defineProps<{
contact: Contact
alias?: string
}>()
const emit = defineEmits<{
'update-alias': [{ contactId: number; alias: string }]
'click': []
}>()
const toast = useToast()
// Estado local
const avatarError = ref(false)
const showAliasModal = ref(false)
const newAlias = ref('')
const saving = ref(false)
// Computed
const displayName = computed(() => props.alias || props.contact.name)
const hasAlias = computed(() => !!props.alias)
// Métodos
const handleClick = () => {
if (props.contact.telefono) {
emit('click')
}
}
const openAliasModal = () => {
newAlias.value = props.alias || ''
showAliasModal.value = true
}
const saveAlias = async () => {
saving.value = true
try {
emit('update-alias', {
contactId: props.contact.id,
alias: newAlias.value.trim()
})
showAliasModal.value = false
toast.add({
title: newAlias.value.trim() ? 'Alias guardado' : 'Alias eliminado',
color: 'success'
})
} finally {
saving.value = false
}
}
</script>
<style scoped>
.contact-item {
padding: 1rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(15px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.18);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.contact-item-clickable {
cursor: pointer;
}
.contact-item-clickable:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px 0 rgba(var(--color-primary-500), 0.2);
border-color: rgba(var(--color-primary-500), 0.4);
}
.contact-content {
display: flex;
gap: 1rem;
align-items: center;
}
.contact-avatar {
flex-shrink: 0;
width: 3rem;
height: 3rem;
border-radius: 50%;
background: rgba(var(--color-primary-500), 0.1);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-icon {
width: 2rem;
height: 2rem;
color: rgb(var(--color-primary-500));
}
.contact-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.contact-name-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.contact-name {
font-weight: 600;
color: var(--color-gray-900);
font-size: 0.9375rem;
}
.contact-phone,
.contact-location {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: var(--color-gray-600);
}
.phone-icon,
.location-icon {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}
.contact-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.whatsapp-icon {
width: 1.25rem;
height: 1.25rem;
color: #25D366;
}
/* Modal de alias */
.alias-modal {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.alias-modal-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-gray-900);
margin: 0;
}
.alias-modal-original {
font-size: 0.875rem;
color: var(--color-gray-600);
margin: 0;
}
.alias-modal-original .label {
font-weight: 500;
color: var(--color-gray-700);
}
.alias-input {
width: 100%;
}
.alias-modal-hint {
font-size: 0.75rem;
color: var(--color-gray-500);
margin: 0;
}
.alias-modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 0.5rem;
}
</style>
<style>
/* Modo oscuro */
.dark .contact-item {
background: rgba(0, 0, 0, 0.3) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
}
.dark .contact-item-clickable:hover {
box-shadow: 0 8px 24px 0 rgba(var(--color-primary-500), 0.3) !important;
border-color: rgba(var(--color-primary-500), 0.5) !important;
}
.dark .contact-name {
color: var(--color-gray-100) !important;
}
.dark .contact-phone,
.dark .contact-location {
color: var(--color-gray-400) !important;
}
.dark .alias-modal-title {
color: var(--color-gray-100) !important;
}
.dark .alias-modal-original .label {
color: var(--color-gray-300) !important;
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<div class="filters-section">
<!-- Búsqueda por nombre (fuzzy) -->
<div class="search-input-wrapper">
<UIcon name="i-heroicons-magnifying-glass" class="search-icon" />
<input
v-model="localFilters.search"
type="text"
placeholder="Buscar por nombre..."
class="search-input"
/>
<button
v-if="localFilters.search"
class="search-clear"
title="Limpiar búsqueda"
@click="localFilters.search = ''"
>
<UIcon name="i-heroicons-x-mark" class="w-4 h-4" />
</button>
</div>
<!-- Filtros adicionales -->
<div class="filter-row">
<!-- Filtro por ID -->
<div class="filter-field filter-id">
<UInput
v-model="localFilters.id"
type="number"
placeholder="ID"
size="sm"
/>
</div>
<!-- Filtro por teléfono -->
<div class="filter-field filter-phone">
<UInput
v-model="localFilters.telefono"
placeholder="Teléfono"
size="sm"
/>
</div>
<!-- Checkbox empleados -->
<label class="filter-checkbox">
<UCheckbox v-model="localFilters.empleado" />
<span>Solo empleados</span>
</label>
<!-- Botón limpiar -->
<UButton
v-if="hasActiveFilters"
variant="ghost"
color="neutral"
size="xs"
icon="i-heroicons-x-mark"
@click="clearFilters"
>
Limpiar
</UButton>
</div>
</div>
</template>
<script setup lang="ts">
import type { ContactFilters } from '~/composables/useContacts'
const props = defineProps<{
modelValue: ContactFilters
}>()
const emit = defineEmits<{
'update:modelValue': [ContactFilters]
'clear': []
}>()
const localFilters = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const hasActiveFilters = computed(() => {
return (
localFilters.value.search ||
localFilters.value.id ||
localFilters.value.telefono ||
!localFilters.value.empleado
)
})
const clearFilters = () => {
emit('clear')
}
</script>
<style scoped>
.filters-section {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
width: 100%;
max-width: 400px;
}
.search-icon {
position: absolute;
left: 1rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-400);
pointer-events: none;
z-index: 1;
}
.search-input {
width: 100%;
padding: 0.75rem 3rem 0.75rem 3rem;
font-size: 0.9375rem;
font-weight: 500;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px) saturate(150%);
border: 1px solid rgba(0, 0, 0, 0.08);
color: var(--color-gray-800);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 2px 6px 0 rgba(31, 38, 135, 0.08),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.3);
outline: none;
}
.search-input::placeholder {
color: var(--color-gray-400);
}
.search-input:focus {
background: rgba(255, 255, 255, 0.4);
border-color: rgba(var(--color-primary-500), 0.3);
box-shadow:
0 4px 12px 0 rgba(var(--color-primary-500), 0.15),
0 0 0 3px rgba(var(--color-primary-500), 0.1),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.4);
}
.search-clear {
position: absolute;
right: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
background: rgba(0, 0, 0, 0.05);
border: none;
color: var(--color-gray-500);
cursor: pointer;
transition: all 0.2s ease;
z-index: 1;
}
.search-clear:hover {
background: rgba(0, 0, 0, 0.1);
color: var(--color-gray-700);
transform: scale(1.1);
}
.filter-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.filter-field {
flex-shrink: 0;
}
.filter-id {
width: 80px;
}
.filter-phone {
width: 140px;
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-gray-700);
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border-radius: 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.08);
cursor: pointer;
transition: all 0.2s ease;
}
.filter-checkbox:hover {
background: rgba(255, 255, 255, 0.35);
}
/* Responsive */
@media (max-width: 640px) {
.filter-row {
flex-direction: column;
align-items: stretch;
}
.filter-id,
.filter-phone {
width: 100%;
}
.search-input-wrapper {
max-width: 100%;
}
}
</style>
<style>
/* Modo oscuro */
.dark .search-input {
background: rgba(255, 255, 255, 0.05) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
color: var(--color-gray-200) !important;
box-shadow:
0 2px 6px 0 rgba(0, 0, 0, 0.3),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.05) !important;
}
.dark .search-input::placeholder {
color: var(--color-gray-500) !important;
}
.dark .search-input:focus {
background: rgba(255, 255, 255, 0.08) !important;
border-color: rgba(var(--color-primary-500), 0.5) !important;
box-shadow:
0 4px 12px 0 rgba(var(--color-primary-500), 0.3),
0 0 0 3px rgba(var(--color-primary-500), 0.15),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.08) !important;
}
.dark .search-icon {
color: var(--color-gray-500) !important;
}
.dark .search-clear {
background: rgba(255, 255, 255, 0.05) !important;
color: var(--color-gray-400) !important;
}
.dark .search-clear:hover {
background: rgba(255, 255, 255, 0.1) !important;
color: var(--color-gray-200) !important;
}
.dark .filter-checkbox {
background: rgba(255, 255, 255, 0.05) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
color: var(--color-gray-300) !important;
}
.dark .filter-checkbox:hover {
background: rgba(255, 255, 255, 0.1) !important;
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<div class="contacts-container">
<!-- Header -->
<div class="contacts-header">
<div class="flex items-center justify-between mb-4">
<h2 class="contacts-title">
<UIcon name="i-heroicons-users" class="w-6 h-6" />
Contactos
</h2>
<span v-if="filteredContacts.length > 0" class="contact-counter">
{{ filteredContacts.length }}
</span>
</div>
<!-- Filtros -->
<ContactsContactsFilters
v-model="filters"
@clear="clearFilters"
/>
</div>
<!-- Estados -->
<div v-if="isLoading" class="empty-state">
<UIcon name="i-heroicons-arrow-path" class="w-12 h-12 animate-spin text-primary" />
<p class="mt-4 text-gray-600 dark:text-gray-400">Cargando contactos...</p>
</div>
<div v-else-if="error" class="empty-state">
<UIcon name="i-heroicons-exclamation-triangle" class="w-16 h-16 text-error" />
<p class="mt-4 text-error font-semibold text-lg">Error al cargar contactos</p>
<p class="text-sm text-gray-500 mt-2">{{ error }}</p>
<UButton class="mt-4" variant="soft" @click="fetchContacts">
Reintentar
</UButton>
</div>
<div v-else-if="contacts.length === 0" class="empty-state">
<UIcon name="i-heroicons-user-group" class="w-16 h-16 text-gray-400" />
<p class="mt-4 text-gray-600 dark:text-gray-400 text-lg">No hay contactos disponibles</p>
</div>
<div v-else-if="filteredContacts.length === 0" class="empty-state">
<UIcon name="i-heroicons-funnel" class="w-16 h-16 text-gray-400" />
<p class="mt-4 text-gray-600 dark:text-gray-400 text-lg">No se encontraron contactos</p>
<p class="text-sm text-gray-500 mt-2">Prueba ajustando los filtros</p>
</div>
<!-- Lista de contactos -->
<div v-else class="contacts-list">
<ContactsContactItem
v-for="contact in filteredContacts"
:key="contact.id"
:contact="contact"
:alias="aliases[contact.id.toString()]"
@update-alias="handleUpdateAlias"
@click="handleContactClick(contact)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Contact } from '~/composables/useContacts'
const {
contacts,
aliases,
filters,
filteredContacts,
isLoading,
error,
fetchContacts,
fetchAliases,
updateAlias,
getWhatsAppUrl,
clearFilters
} = useContacts()
// Cargar datos al montar
onMounted(async () => {
await Promise.all([fetchContacts(), fetchAliases()])
})
// Manejar actualización de alias
const handleUpdateAlias = async ({ contactId, alias }: { contactId: number; alias: string }) => {
await updateAlias(contactId, alias)
}
// Click en contacto abre WhatsApp
const handleContactClick = (contact: Contact) => {
const url = getWhatsAppUrl(contact.telefono)
if (url) {
window.open(url, '_blank')
}
}
</script>
<style scoped>
.contacts-container {
background: rgba(255, 255, 255, 0.35);
backdrop-filter: blur(20px) saturate(180%);
border-radius: 1.5rem;
padding: 2rem;
box-shadow:
0 8px 32px 0 rgba(31, 38, 135, 0.15),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.18);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.contacts-header {
margin-bottom: 1.5rem;
}
.contacts-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.75rem;
font-weight: 700;
color: var(--color-gray-900);
margin: 0;
}
.contact-counter {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
height: 2.5rem;
padding: 0 0.75rem;
font-size: 1.125rem;
font-weight: 700;
border-radius: 0.875rem;
background: rgba(var(--color-primary-500), 0.15);
backdrop-filter: blur(10px) saturate(150%);
border: 1px solid rgba(var(--color-primary-500), 0.3);
color: rgb(var(--color-primary-500));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 2px 8px 0 rgba(var(--color-primary-500), 0.2),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.3),
inset 0 -1px 2px 0 rgba(var(--color-primary-500), 0.1);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
}
.contacts-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Responsive */
@media (max-width: 640px) {
.contacts-container {
padding: 1.5rem;
}
.contacts-title {
font-size: 1.5rem;
}
.contacts-list {
gap: 0.5rem;
}
}
</style>
<style>
/* Modo oscuro */
.dark .contacts-container {
background: rgba(0, 0, 0, 0.15) !important;
box-shadow:
0 8px 32px 0 rgba(0, 0, 0, 0.5),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.05) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
}
.dark .contacts-title {
color: var(--color-gray-100) !important;
}
.dark .contact-counter {
background: rgba(var(--color-primary-500), 0.25) !important;
border-color: rgba(var(--color-primary-500), 0.5) !important;
color: rgb(var(--color-primary-400)) !important;
box-shadow:
0 2px 8px 0 rgba(var(--color-primary-500), 0.4),
0 0 0 1px rgba(var(--color-primary-500), 0.3),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.08),
inset 0 -1px 2px 0 rgba(var(--color-primary-500), 0.2) !important;
}
</style>

View File

@@ -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<Contact[]>([])
const aliases = ref<Record<string, string>>({})
const isLoading = ref(false)
const error = ref<string | null>(null)
// Filtros con persistencia en cookie
const filtersCookie = useCookie<ContactFilters>('contact-filters', {
maxAge: 60 * 60 * 24 * 7, // 1 semana
default: () => ({
search: '',
id: '',
telefono: '',
empleado: true
})
})
const filters = ref<ContactFilters>({ ...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<Contact[]>(`/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<Record<string, string>>('/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<boolean> => {
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
}
}

View File

@@ -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 = <T>(
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
}
}

View File

@@ -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'

View File

@@ -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'
})
}
})

View File

@@ -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<string, string> = {}
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'
})
}
})

View File

@@ -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<Array<unknown>>
}
}
export default defineEventHandler(async (event): Promise<Contact[]> => {
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<MetabaseResponse>(`${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<string, unknown> = {}
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'
})
}
})