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

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