feat(mejoras): 14 mejoras al CRM Frasa
Fase 1 - UI: dark mode, skeletons, toasts Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K Fase 3 - Datos: export Excel, notas apartados, auditoría Fase 4 - Dashboard: gráficas vendedor + métricas uso Fase 5 - Responsive: tablets optimizadas Fase 6 - PWA: IndexedDB offline, push notifications Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos 721 API tests + fronts verdes.
This commit is contained in:
208
apps/landing/src/components/CommandPalette.tsx
Normal file
208
apps/landing/src/components/CommandPalette.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
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]"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user