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
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:
23
.env.example
23
.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<AuthApplicationsList v-else />
|
||||
<!-- 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'
|
||||
}"
|
||||
/>
|
||||
</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:
|
||||
|
||||
317
nuxt4/app/components/contacts/ContactItem.vue
Normal file
317
nuxt4/app/components/contacts/ContactItem.vue
Normal 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>
|
||||
278
nuxt4/app/components/contacts/ContactsFilters.vue
Normal file
278
nuxt4/app/components/contacts/ContactsFilters.vue
Normal 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>
|
||||
201
nuxt4/app/components/contacts/ContactsList.vue
Normal file
201
nuxt4/app/components/contacts/ContactsList.vue
Normal 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>
|
||||
205
nuxt4/app/composables/useContacts.ts
Normal file
205
nuxt4/app/composables/useContacts.ts
Normal 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
|
||||
}
|
||||
}
|
||||
119
nuxt4/app/composables/useFuzzySearch.ts
Normal file
119
nuxt4/app/composables/useFuzzySearch.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
51
nuxt4/server/api/contacts/aliases.get.ts
Normal file
51
nuxt4/server/api/contacts/aliases.get.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
86
nuxt4/server/api/contacts/aliases/[id].put.ts
Normal file
86
nuxt4/server/api/contacts/aliases/[id].put.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
111
nuxt4/server/api/contacts/index.get.ts
Normal file
111
nuxt4/server/api/contacts/index.get.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user