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