Files
FRASA-CAS/apps/landing/src/components/CommandPalette.tsx
Consult AS Technology 7d19e0da38 fix(landing): dark mode completo en todos los componentes
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.
2026-09-07 06:57:12 +00:00

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>
)}
</>
)
}