14 archivos corregidos: - Body: dark:bg-[#0F172A] dark:text-[#E2E8F0] - Navbar: badges, botones, hamburger - ConfiguracionCuenta: botones, textos - VistaContactos: botones, filas hover - ModalContacto: borders, secciones, badges, items - ConfigPage: botones - ModoTrabajo: botón reconexión - RolesPermisos: botones, headers tabla - Usuarios: empty state - Sistema: labels, toggles - AuditoriaLog: todo (tabla, filtros, paginación) - CommandPalette: botón cerrar 28 tests landing verdes.
209 lines
7.7 KiB
TypeScript
209 lines
7.7 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { Search, X } from 'lucide-react'
|
|
import { API_URL, conAuth, manejar401 } from '../services/auth'
|
|
|
|
/** Elemento de resultado de búsqueda global */
|
|
interface BuscadoItem {
|
|
tipo: 'lead' | 'oportunidad' | 'contacto' | 'unidad'
|
|
id: string
|
|
nombre: string
|
|
detalle: string
|
|
url: string
|
|
}
|
|
|
|
const ETIQUETA_TIPO: Record<BuscadoItem['tipo'], string> = {
|
|
lead: 'Lead',
|
|
oportunidad: 'Oportunidad',
|
|
contacto: 'Contacto',
|
|
unidad: 'Unidad',
|
|
}
|
|
|
|
const COLOR_TIPO: Record<BuscadoItem['tipo'], string> = {
|
|
lead: 'bg-gray-100 text-gray-700',
|
|
oportunidad: 'bg-blue-100 text-blue-700',
|
|
contacto: 'bg-green-100 text-green-700',
|
|
unidad: 'bg-purple-100 text-purple-700',
|
|
}
|
|
|
|
export function CommandPalette() {
|
|
const [abierto, setAbierto] = useState(false)
|
|
const [query, setQuery] = useState('')
|
|
const [resultados, setResultados] = useState<BuscadoItem[]>([])
|
|
const [cargando, setCargando] = useState(false)
|
|
const [indiceSeleccionado, setIndiceSeleccionado] = useState(0)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
// Atajo de teclado Ctrl+K / Cmd+K
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
e.preventDefault()
|
|
setAbierto((v) => !v)
|
|
}
|
|
if (e.key === 'Escape' && abierto) {
|
|
setAbierto(false)
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [abierto])
|
|
|
|
// Focus en input al abrir
|
|
useEffect(() => {
|
|
if (abierto) {
|
|
setTimeout(() => inputRef.current?.focus(), 50)
|
|
} else {
|
|
setQuery('')
|
|
setResultados([])
|
|
setIndiceSeleccionado(0)
|
|
}
|
|
}, [abierto])
|
|
|
|
// Búsqueda con debounce
|
|
useEffect(() => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
if (query.trim().length < 2) {
|
|
setResultados([])
|
|
return
|
|
}
|
|
setCargando(true)
|
|
debounceRef.current = setTimeout(async () => {
|
|
try {
|
|
const resp = await fetch(`${API_URL}/buscar?q=${encodeURIComponent(query.trim())}&limite=20`, conAuth())
|
|
manejar401(resp)
|
|
if (resp.ok) {
|
|
setResultados(await resp.json())
|
|
}
|
|
} catch {
|
|
setResultados([])
|
|
} finally {
|
|
setCargando(false)
|
|
}
|
|
}, 300)
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
}
|
|
}, [query])
|
|
|
|
// Navegación con teclado
|
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
setIndiceSeleccionado((i) => Math.min(i + 1, resultados.length - 1))
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
setIndiceSeleccionado((i) => Math.max(i - 1, 0))
|
|
} else if (e.key === 'Enter' && resultados[indiceSeleccionado]) {
|
|
window.location.href = resultados[indiceSeleccionado].url
|
|
}
|
|
}
|
|
|
|
// Agrupar por tipo
|
|
const agrupados = resultados.reduce<Record<string, BuscadoItem[]>>((acc, item) => {
|
|
const key = item.tipo
|
|
if (!acc[key]) acc[key] = []
|
|
acc[key].push(item)
|
|
return acc
|
|
}, {})
|
|
|
|
return (
|
|
<>
|
|
{/* Botón trigger */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setAbierto(true)}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F1F5F9] hover:text-accent"
|
|
aria-label="Buscar (Ctrl+K)"
|
|
title="Buscar (Ctrl+K)"
|
|
>
|
|
<Search size={16} />
|
|
</button>
|
|
|
|
{/* Modal */}
|
|
{abierto && (
|
|
<div className="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[15vh]" role="dialog" aria-modal="true">
|
|
{/* Backdrop */}
|
|
<div className="fixed inset-0 bg-black/40" onClick={() => setAbierto(false)} />
|
|
|
|
{/* Panel */}
|
|
<div className="relative w-full max-w-lg rounded-2xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B] shadow-2xl overflow-hidden">
|
|
{/* Input */}
|
|
<div className="flex items-center gap-3 border-b border-[#E5E7EB] dark:border-[#334155] px-4 py-3">
|
|
<Search size={18} className="shrink-0 text-[#94A3B8]" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder="Buscar leads, contactos, cotizaciones, unidades…"
|
|
value={query}
|
|
onChange={(e) => { setQuery(e.target.value); setIndiceSeleccionado(0) }}
|
|
onKeyDown={onKeyDown}
|
|
className="flex-1 bg-transparent text-sm text-[#111827] dark:text-[#E2E8F0] placeholder-[#94A3B8] focus:outline-none"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setAbierto(false)}
|
|
className="rounded-md p-1 text-[#94A3B8] transition-colors hover:bg-[#F1F5F9] hover:text-[#64748B] dark:hover:bg-[#334155] dark:hover:text-[#CBD5E1]"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Resultados */}
|
|
<div className="max-h-80 overflow-y-auto p-2">
|
|
{query.trim().length < 2 ? (
|
|
<p className="px-4 py-6 text-center text-xs text-[#94A3B8]">
|
|
Escribe al menos 2 caracteres para buscar
|
|
</p>
|
|
) : cargando ? (
|
|
<p className="px-4 py-6 text-center text-xs text-[#94A3B8]">Buscando…</p>
|
|
) : resultados.length === 0 ? (
|
|
<p className="px-4 py-6 text-center text-xs text-[#94A3B8]">Sin resultados para "{query}"</p>
|
|
) : (
|
|
Object.entries(agrupados).map(([tipo, items]) => (
|
|
<div key={tipo} className="mb-2">
|
|
<p className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-[#94A3B8]">
|
|
{ETIQUETA_TIPO[tipo as BuscadoItem['tipo']]}
|
|
</p>
|
|
{items.map((item) => {
|
|
const idxGlobal = resultados.indexOf(item)
|
|
return (
|
|
<a
|
|
key={`${item.tipo}-${item.id}`}
|
|
href={item.url}
|
|
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-colors ${
|
|
idxGlobal === indiceSeleccionado
|
|
? 'bg-blue-50 dark:bg-blue-900/30'
|
|
: 'hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]'
|
|
}`}
|
|
>
|
|
<span className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${COLOR_TIPO[item.tipo]}`}>
|
|
{ETIQUETA_TIPO[item.tipo]}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</span>
|
|
{item.detalle && (
|
|
<span className="block truncate text-[11px] text-[#94A3B8]">{item.detalle}</span>
|
|
)}
|
|
</span>
|
|
</a>
|
|
)
|
|
})}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center gap-4 border-t border-[#E5E7EB] dark:border-[#334155] px-4 py-2 text-[10px] text-[#94A3B8]">
|
|
<span>↑↓ navegar</span>
|
|
<span>↵ abrir</span>
|
|
<span>esc cerrar</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|