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:
40
apps/landing/package-lock.json
generated
40
apps/landing/package-lock.json
generated
@@ -9,8 +9,10 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -2173,7 +2175,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
@@ -2494,6 +2495,15 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/goober": {
|
||||
"version": "2.1.19",
|
||||
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz",
|
||||
"integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"csstype": "^3.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -3027,6 +3037,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.41.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.41.0.tgz",
|
||||
"integrity": "sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -3346,6 +3365,23 @@
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hot-toast": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.3",
|
||||
"goober": "^2.1.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16",
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
|
||||
@@ -68,22 +68,22 @@ export default function App() {
|
||||
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
||||
<BannerModoLocal />
|
||||
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-6">
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-4 sm:px-6">
|
||||
<section aria-labelledby="titulo-hero" className="py-12 sm:py-16">
|
||||
<h1
|
||||
id="titulo-hero"
|
||||
className="text-3xl font-semibold tracking-tight text-[#111827] sm:text-4xl"
|
||||
className="text-3xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0] sm:text-4xl"
|
||||
>
|
||||
Ecosistema comercial
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#64748B] sm:text-base">
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8] sm:text-base">
|
||||
Las herramientas del equipo comercial de Frasa Desarrollos en un solo lugar:
|
||||
cotizaciones, alta de clientes, cobranza, tableros y asistente IA.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<nav aria-label="Módulos del ecosistema">
|
||||
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ul className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{MODULOS.filter((modulo) => esModuloVisible(modulo, permitidos)).map((modulo) => (
|
||||
<TarjetaModulo
|
||||
key={modulo.id}
|
||||
@@ -99,9 +99,9 @@ export default function App() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="mt-16 border-t border-[#E5E7EB]">
|
||||
<footer className="mt-16 border-t border-[#E5E7EB] dark:border-[#1E293B] dark:border-[#1E293B]">
|
||||
<div className="mx-auto max-w-[1280px] px-6 py-8">
|
||||
<p className="text-xs text-[#64748B]">Consult (AS) Technology</p>
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Consult (AS) Technology</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { ErrorLogin, getSesion, login, logout, type UsuarioSesion } from '../services/auth'
|
||||
import { getMe, getUsuariosMonday, guardarVinculoMonday, type UsuarioMonday } from '../services/api'
|
||||
|
||||
const labelEstilo = 'block text-xs font-medium text-[#4B5563]'
|
||||
const labelEstilo = 'block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
const inputEstilo =
|
||||
'mt-2 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
|
||||
'mt-2 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm text-[#111827] dark:text-[#E2E8F0] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
|
||||
|
||||
/** 'director_ventas' → 'Director Ventas' (texto discreto del perfil) */
|
||||
function formatearRol(rol: string): string {
|
||||
@@ -106,9 +106,9 @@ export function ConfiguracionCuenta() {
|
||||
<section
|
||||
id="configuracion"
|
||||
aria-labelledby="titulo-cuenta"
|
||||
className="scroll-mt-20 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm sm:p-8"
|
||||
className="scroll-mt-20 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:p-8"
|
||||
>
|
||||
<h2 id="titulo-cuenta" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 id="titulo-cuenta" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Configuración de cuenta
|
||||
</h2>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ConfiguracionCuenta() {
|
||||
onSubmit={(e) => void entrar(e)}
|
||||
className="mt-6 max-w-sm"
|
||||
>
|
||||
<p className="text-sm leading-relaxed text-[#64748B]">
|
||||
<p className="text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
|
||||
Inicia sesión para vincular tu usuario de Monday.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
@@ -163,24 +163,24 @@ export function ConfiguracionCuenta() {
|
||||
</form>
|
||||
) : (
|
||||
<div className="mt-6 max-w-md">
|
||||
{cargandoPerfil && <p className="text-sm text-[#64748B]">Cargando tu perfil…</p>}
|
||||
{cargandoPerfil && <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando tu perfil…</p>}
|
||||
|
||||
{perfil && (
|
||||
<>
|
||||
<p className="text-sm text-[#111827]">
|
||||
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">
|
||||
Sesión: <span className="font-medium">{perfil.nombre}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B]">Rol: {formatearRol(perfil.rol)}</p>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">Rol: {formatearRol(perfil.rol)}</p>
|
||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Vínculo Monday actual:{' '}
|
||||
{perfil.mondayUserId === null ? (
|
||||
<span className="text-[#94A3B8]">sin vincular</span>
|
||||
) : vinculado ? (
|
||||
<span className="text-[#111827]">
|
||||
<span className="text-[#111827] dark:text-[#E2E8F0]">
|
||||
{vinculado.nombre} ({vinculado.email})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[#111827]">ID {perfil.mondayUserId}</span>
|
||||
<span className="text-[#111827] dark:text-[#E2E8F0]">ID {perfil.mondayUserId}</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -192,13 +192,13 @@ export function ConfiguracionCuenta() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={cargarUsuarios}
|
||||
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : usuarios === null ? (
|
||||
<p className="mt-6 text-sm text-[#64748B]">Cargando usuarios de Monday…</p>
|
||||
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando usuarios de Monday…</p>
|
||||
) : (
|
||||
<div className="mt-6">
|
||||
<label htmlFor="cuenta-monday" className={labelEstilo}>
|
||||
@@ -256,7 +256,7 @@ export function ConfiguracionCuenta() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={cerrarSesion}
|
||||
className="mt-8 rounded-lg border border-[#D1D5DB] bg-white px-4 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="mt-8 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
|
||||
32
apps/landing/src/components/DarkModeToggle.tsx
Normal file
32
apps/landing/src/components/DarkModeToggle.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Sun, Moon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
function getStoredTheme(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
try {
|
||||
const stored = localStorage.getItem("frasa_theme")
|
||||
if (stored) return stored === "dark"
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default function DarkModeToggle() {
|
||||
const [dark, setDark] = useState(getStoredTheme)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark)
|
||||
try {
|
||||
localStorage.setItem("frasa_theme", dark ? "dark" : "light")
|
||||
} catch {
|
||||
// localStorage not available (test env)
|
||||
}
|
||||
}, [dark])
|
||||
|
||||
return (
|
||||
<button onClick={() => setDark(!dark)} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#1E293B]" aria-label="Cambiar tema">
|
||||
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -19,8 +19,8 @@ function Dato({ etiqueta, valor }: { etiqueta: string; valor: string }) {
|
||||
if (valor === '') return null
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#64748B]">{etiqueta}</p>
|
||||
<p className="mt-0.5 text-sm text-[#111827]">{valor}</p>
|
||||
<p className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</p>
|
||||
<p className="mt-0.5 text-sm text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,15 +29,15 @@ function ListaRelaciones({ titulo, items }: { titulo: string; items: RelacionCrm
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{items.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-[#E5E7EB] px-3 py-2">
|
||||
<span className="text-sm text-[#111827]">
|
||||
<span className="text-sm text-[#111827] dark:text-[#E2E8F0]">
|
||||
{r.estado || 'Sin estado'}
|
||||
{r.detalle ? <span className="ml-2 text-xs text-[#64748B]">{r.detalle}</span> : null}
|
||||
{r.detalle ? <span className="ml-2 text-xs text-[#64748B] dark:text-[#94A3B8]">{r.detalle}</span> : null}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-[#64748B]">
|
||||
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
|
||||
{r.atendidoPor ?? 'Sin asignar'}
|
||||
</span>
|
||||
</li>
|
||||
@@ -91,10 +91,10 @@ export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
|
||||
if (e.target === e.currentTarget) onCerrar()
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl rounded-2xl border border-[#E5E7EB] bg-white shadow-xl">
|
||||
<div className="w-full max-w-2xl rounded-2xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -xl">
|
||||
<div className="flex items-center justify-between border-b border-[#E5E7EB] px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-[#111827]">{c['nombre'] ?? nombre}</h2>
|
||||
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c['nombre'] ?? nombre}</h2>
|
||||
{c['Estado'] && (
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">{c['Estado']}</span>
|
||||
)}
|
||||
@@ -103,7 +103,7 @@ export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
aria-label="Cerrar"
|
||||
className="rounded-lg px-2 py-1.5 text-sm text-[#64748B] transition-colors hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
className="rounded-lg px-2 py-1.5 text-sm text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F9FAFB] hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -115,12 +115,12 @@ export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!detalle && !error && <p className="text-sm text-[#64748B]">Cargando detalle…</p>}
|
||||
{!detalle && !error && <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando detalle…</p>}
|
||||
|
||||
{detalle && (
|
||||
<>
|
||||
<section aria-label="Datos del contacto">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B]">Datos del contacto</p>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Datos del contacto</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
|
||||
<Dato etiqueta="Teléfono" valor={c['Teléfono'] ?? ''} />
|
||||
<Dato etiqueta="Email" valor={c['Email'] ?? ''} />
|
||||
@@ -138,7 +138,7 @@ export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
|
||||
|
||||
{detalle.propiedad && (
|
||||
<section aria-label="Propiedad">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B]">Propiedad</p>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Propiedad</p>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] p-4">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
|
||||
<Dato etiqueta="Desarrollo" valor={detalle.propiedad.desarrollo} />
|
||||
@@ -159,23 +159,23 @@ export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
|
||||
</section>
|
||||
|
||||
<section aria-label="Historial de atención">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Historial de atención
|
||||
</p>
|
||||
{detalle.historial.length === 0 ? (
|
||||
<p className="text-sm text-[#64748B]">Sin movimientos registrados.</p>
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin movimientos registrados.</p>
|
||||
) : (
|
||||
<ul className="max-h-64 space-y-2 overflow-y-auto">
|
||||
{detalle.historial.map((h, i) => (
|
||||
<li key={i} className="flex items-baseline justify-between gap-3 rounded-lg border border-[#E5E7EB] px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-[#111827]">
|
||||
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">
|
||||
{h.detalle}
|
||||
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600">
|
||||
{h.fuente}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-[#64748B]">{h.usuario ?? 'Automatización'}</p>
|
||||
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">{h.usuario ?? 'Automatización'}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-[#94A3B8]">{formatoFecha(h.fechaISO)}</span>
|
||||
</li>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getSesion, type UsuarioSesion } from '../services/auth'
|
||||
import DarkModeToggle from './DarkModeToggle'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
|
||||
interface Props {
|
||||
usuario?: UsuarioSesion | null
|
||||
@@ -9,6 +11,7 @@ interface Props {
|
||||
/** Barra superior: marca a la izquierda, estado de sesión y acceso a configuración a la derecha */
|
||||
export function Navbar({ usuario, onConfiguraciones }: Props) {
|
||||
const [conSesion, setConSesion] = useState(() => getSesion() !== null)
|
||||
const [menuAbierto, setMenuAbierto] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const actualizar = () => setConSesion(getSesion() !== null)
|
||||
@@ -23,15 +26,19 @@ export function Navbar({ usuario, onConfiguraciones }: Props) {
|
||||
const esAdmin = usuario?.rol === 'admin'
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 max-w-[1280px] items-center justify-between px-6">
|
||||
<span className="text-base font-semibold tracking-tight text-[#111827]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
|
||||
<div className="mx-auto flex h-14 max-w-[1280px] items-center justify-between px-4 sm:px-6">
|
||||
<span className="text-base font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0]">
|
||||
FRASA<span className="text-accent">.</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
{/* Desktop nav */}
|
||||
<div className="hidden items-center gap-4 md:flex">
|
||||
<CommandPalette />
|
||||
<DarkModeToggle />
|
||||
<span
|
||||
className={`hidden items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium sm:inline-flex ${
|
||||
conSesion ? 'bg-green-50 text-green-700' : 'bg-[#F1F5F9] text-[#64748B]'
|
||||
conSesion ? 'bg-green-50 text-green-700' : 'bg-[#F1F5F9] text-[#64748B] dark:text-[#94A3B8]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -45,7 +52,7 @@ export function Navbar({ usuario, onConfiguraciones }: Props) {
|
||||
type="button"
|
||||
onClick={onConfiguraciones}
|
||||
title="Configuraciones"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-[#64748B] transition-colors hover:bg-[#F1F5F9] hover:text-accent"
|
||||
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="Abrir configuraciones"
|
||||
>
|
||||
⚙
|
||||
@@ -53,12 +60,70 @@ export function Navbar({ usuario, onConfiguraciones }: Props) {
|
||||
)}
|
||||
<a
|
||||
href="#configuracion"
|
||||
className="text-sm font-medium text-[#64748B] transition-colors hover:text-accent"
|
||||
className="text-sm font-medium text-[#64748B] dark:text-[#94A3B8] transition-colors hover:text-accent"
|
||||
>
|
||||
Configuración
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuAbierto((v) => !v)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-[#4B5563] dark:text-[#CBD5E1] transition-colors hover:bg-[#F1F5F9] md:hidden"
|
||||
aria-label={menuAbierto ? 'Cerrar menú' : 'Abrir menú'}
|
||||
aria-expanded={menuAbierto}
|
||||
>
|
||||
{menuAbierto ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu panel */}
|
||||
{menuAbierto && (
|
||||
<div className="border-t border-[#E5E7EB] bg-white px-4 py-4 dark:border-[#1E293B] dark:bg-[#1E293B] md:hidden">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CommandPalette />
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium w-fit ${
|
||||
conSesion ? 'bg-green-50 text-green-700' : 'bg-[#F1F5F9] text-[#64748B] dark:text-[#94A3B8]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-1.5 w-1.5 rounded-full ${conSesion ? 'bg-green-500' : 'bg-[#94A3B8]'}`}
|
||||
/>
|
||||
{conSesion ? 'Sesión activa' : 'Sin sesión'}
|
||||
</span>
|
||||
{esAdmin && onConfiguraciones && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onConfiguraciones(); setMenuAbierto(false) }}
|
||||
className="flex items-center gap-2 text-sm font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
⚙ Configuraciones
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="#configuracion"
|
||||
className="text-sm font-medium text-[#64748B] dark:text-[#94A3B8] transition-colors hover:text-accent"
|
||||
onClick={() => setMenuAbierto(false)}
|
||||
>
|
||||
Configuración
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
21
apps/landing/src/components/Skeleton.tsx
Normal file
21
apps/landing/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export default function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-lg bg-[#E2E8F0] dark:bg-[#334155] ${className}`} />
|
||||
}
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<Skeleton className="h-4 w-1/3 mb-3" />
|
||||
<Skeleton className="h-3 w-2/3 mb-2" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonList({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,19 +24,19 @@ export function TarjetaModulo({ modulo, onAbrir }: Props) {
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-sm font-semibold text-accent"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-sm font-semibold text-accent dark:bg-accent/20"
|
||||
>
|
||||
{iniciales}
|
||||
</span>
|
||||
<h2 className="mt-4 text-base font-semibold text-[#111827]">{modulo.nombre}</h2>
|
||||
<p className="mt-1 text-sm leading-relaxed text-[#64748B]">{modulo.descripcion}</p>
|
||||
<h2 className="mt-4 text-base font-semibold text-[#111827] dark:text-[#E2E8F0]">{modulo.nombre}</h2>
|
||||
<p className="mt-1 text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">{modulo.descripcion}</p>
|
||||
<span className="mt-auto pt-6 text-sm font-medium text-accent transition-colors duration-100 ease-out group-hover:text-accent-hover">
|
||||
Entrar <span aria-hidden="true">→</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
const estilo =
|
||||
'group flex h-full w-full flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:shadow-md'
|
||||
'group flex h-full w-full flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:shadow-md dark:border-[#1E293B] dark:bg-[#1E293B]'
|
||||
|
||||
return (
|
||||
<li>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { getContactos, type Contacto } from '../services/api'
|
||||
import { ModalContacto } from './ModalContacto'
|
||||
import { SkeletonList } from "./Skeleton"
|
||||
import toast from "react-hot-toast"
|
||||
|
||||
interface Props {
|
||||
onVolver: () => void
|
||||
@@ -17,34 +19,48 @@ export function VistaContactos({ onVolver }: Props) {
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [seleccionado, setSeleccionado] = useState<Contacto | null>(null)
|
||||
const [busqueda, setBusqueda] = useState('')
|
||||
|
||||
/** Contactos filtrados por texto de búsqueda */
|
||||
const contactosFiltrados = useMemo(() => {
|
||||
if (!contactos) return null
|
||||
if (busqueda.trim() === '') return contactos
|
||||
const q = busqueda.toLowerCase()
|
||||
return contactos.filter(
|
||||
(c) =>
|
||||
c.nombre.toLowerCase().includes(q) ||
|
||||
(c.email && c.email.toLowerCase().includes(q)) ||
|
||||
(c.telefono && c.telefono.includes(q)),
|
||||
)
|
||||
}, [contactos, busqueda])
|
||||
|
||||
const cargar = useCallback(() => {
|
||||
setCargando(true)
|
||||
setError('')
|
||||
getContactos()
|
||||
.then(setContactos)
|
||||
.catch(() => setError('No se pudieron cargar los contactos.'))
|
||||
.catch(() => { toast.error("No se pudieron cargar los contactos."); setError("No se pudieron cargar los contactos."); })
|
||||
.finally(() => setCargando(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => cargar(), [cargar])
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-6 py-12 sm:py-16">
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-4 py-12 sm:px-6 sm:py-16">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVolver}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
← Volver
|
||||
</button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl">Contactos</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0] sm:text-3xl">Contactos</h1>
|
||||
</div>
|
||||
|
||||
<section aria-label="Lista de contactos" className="mt-8">
|
||||
{cargando ? (
|
||||
<p className="text-sm text-[#64748B]">Cargando contactos…</p>
|
||||
<SkeletonList count={5} />
|
||||
) : error ? (
|
||||
<div>
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
@@ -53,35 +69,76 @@ export function VistaContactos({ onVolver }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={cargar}
|
||||
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : !contactos || contactos.length === 0 ? (
|
||||
<p className="text-sm text-[#64748B]">No hay contactos para mostrar.</p>
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">No hay contactos para mostrar.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
{contactos.map((contacto) => (
|
||||
<li key={contacto.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSeleccionado(contacto)}
|
||||
className="flex w-full flex-wrap items-center gap-x-4 gap-y-1 px-6 py-4 text-left transition-colors duration-100 hover:bg-[#F8FAFC]"
|
||||
>
|
||||
<span className="text-sm font-semibold text-[#111827]">{contacto.nombre}</span>
|
||||
{contacto.estado && (
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
|
||||
{contacto.estado}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-[#64748B]">{contacto.telefono || 'Sin teléfono'}</span>
|
||||
<span className="text-sm text-[#64748B]">{contacto.email || 'Sin email'}</span>
|
||||
<span className="ml-auto text-xs font-medium text-accent">Ver detalle →</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre, email o teléfono…"
|
||||
value={busqueda}
|
||||
onChange={(e) => setBusqueda(e.target.value)}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
{contactosFiltrados && contactosFiltrados.length === 0 ? (
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin resultados para “{busqueda}”</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: cards */}
|
||||
<div className="grid gap-3 md:hidden">
|
||||
{contactosFiltrados?.map((contacto) => (
|
||||
<button
|
||||
key={contacto.id}
|
||||
type="button"
|
||||
onClick={() => setSeleccionado(contacto)}
|
||||
className="flex flex-col gap-2 rounded-xl border border-[#E5E7EB] bg-white p-4 text-left shadow-sm transition-colors duration-100 hover:border-accent dark:border-[#1E293B] dark:bg-[#1E293B]"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">{contacto.nombre}</span>
|
||||
{contacto.estado && (
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
|
||||
{contacto.estado}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">{contacto.telefono || 'Sin teléfono'}</span>
|
||||
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">{contacto.email || 'Sin email'}</span>
|
||||
<span className="text-xs font-medium text-accent">Ver detalle →</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Desktop: table-style list */}
|
||||
<ul className="hidden divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] md:block -sm">
|
||||
{contactosFiltrados?.map((contacto) => (
|
||||
<li key={contacto.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSeleccionado(contacto)}
|
||||
className="flex w-full flex-wrap items-center gap-x-4 gap-y-1 px-6 py-4 text-left transition-colors duration-100 hover:bg-[#F8FAFC] dark:bg-[#0F172A]"
|
||||
>
|
||||
<span className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">{contacto.nombre}</span>
|
||||
{contacto.estado && (
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
|
||||
{contacto.estado}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">{contacto.telefono || 'Sin teléfono'}</span>
|
||||
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">{contacto.email || 'Sin email'}</span>
|
||||
<span className="ml-auto text-xs font-medium text-accent">Ver detalle →</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
77
apps/landing/src/components/config/AuditoriaLog.test.tsx
Normal file
77
apps/landing/src/components/config/AuditoriaLog.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { AuditoriaLog } from './AuditoriaLog'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
function crearStorageStub(): Storage {
|
||||
const mapa = new Map<string, string>()
|
||||
return {
|
||||
getItem: (k: string) => mapa.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void mapa.set(k, v),
|
||||
removeItem: (k: string) => void mapa.delete(k),
|
||||
clear: () => mapa.clear(),
|
||||
key: () => null,
|
||||
get length() { return mapa.size },
|
||||
} as Storage
|
||||
}
|
||||
|
||||
const sesionFake = { token: 'jwt-prueba', usuario: { nombre: 'Admin', rol: 'admin', asignacion: null } }
|
||||
|
||||
const respuestaOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
const auditoriaFake = [
|
||||
{ id: 'a1', usuarioId: 'admin', accion: 'LEAD_CREADO', entidad: 'lead', entidadId: 'l1', detalle: 'Lead nuevo', createdAt: '2026-09-01T10:00:00Z' },
|
||||
{ id: 'a2', usuarioId: 'vendedor1', accion: 'ETAPA_CAMBIADA', entidad: 'oportunidad', entidadId: 'o1', detalle: 'Cambiado a Cotizador', createdAt: '2026-09-02T14:00:00Z' },
|
||||
]
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('localStorage', crearStorageStub())
|
||||
localStorage.setItem('frasa_auth', JSON.stringify(sesionFake))
|
||||
fetchMock.mockResolvedValue(respuestaOk(auditoriaFake))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('AuditoriaLog', () => {
|
||||
it('renderiza la tabla con registros de auditoría', async () => {
|
||||
render(<AuditoriaLog />)
|
||||
expect(screen.getByText('Registro de auditoría')).toBeTruthy()
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce())
|
||||
expect(screen.getByText('LEAD_CREADO')).toBeTruthy()
|
||||
expect(screen.getByText('ETAPA_CAMBIADA')).toBeTruthy()
|
||||
// User IDs may appear in multiple cells; verify they exist
|
||||
expect(screen.getAllByText('admin').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('vendedor1').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('muestra "Sin registros" cuando no hay datos', async () => {
|
||||
fetchMock.mockResolvedValue(respuestaOk([]))
|
||||
render(<AuditoriaLog />)
|
||||
expect(await screen.findByText('Sin registros')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('muestra error cuando falla la carga', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
render(<AuditoriaLog />)
|
||||
expect(await screen.findByText(/No se pudieron cargar/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('permite filtrar por entidad', async () => {
|
||||
render(<AuditoriaLog />)
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce())
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Todas'), { target: { value: 'lead' } })
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
|
||||
expect(String(fetchMock.mock.calls[1]?.[0])).toContain('entidad=lead')
|
||||
})
|
||||
})
|
||||
177
apps/landing/src/components/config/AuditoriaLog.tsx
Normal file
177
apps/landing/src/components/config/AuditoriaLog.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { getAuditoria, type AuditoriaItem } from '../../services/config'
|
||||
|
||||
const ENTIDADES = ['', 'lead', 'oportunidad', 'apartado', 'cliente', 'cotizacion']
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function AuditoriaLog() {
|
||||
const [items, setItems] = useState<AuditoriaItem[]>([])
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pagina, setPagina] = useState(1)
|
||||
|
||||
// Filtros
|
||||
const [entidad, setEntidad] = useState('')
|
||||
const [usuario, setUsuario] = useState('')
|
||||
const [fechaDesde, setFechaDesde] = useState('')
|
||||
const [fechaHasta, setFechaHasta] = useState('')
|
||||
const [usuariosLista, setUsuariosLista] = useState<string[]>([])
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setCargando(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await getAuditoria({
|
||||
entidad: entidad || undefined,
|
||||
usuario: usuario || undefined,
|
||||
desde: fechaDesde || undefined,
|
||||
hasta: fechaHasta || undefined,
|
||||
})
|
||||
setItems(data)
|
||||
setPagina(1)
|
||||
// Extraer usuarios únicos para el select
|
||||
const usrs = [...new Set(data.map((i) => i.usuarioId).filter(Boolean))] as string[]
|
||||
setUsuariosLista(usrs)
|
||||
} catch {
|
||||
setError('No se pudieron cargar los registros de auditoría.')
|
||||
} finally {
|
||||
setCargando(false)
|
||||
}
|
||||
}, [entidad, usuario, fechaDesde, fechaHasta])
|
||||
|
||||
useEffect(() => {
|
||||
void cargar()
|
||||
}, [cargar])
|
||||
|
||||
const totalPaginas = Math.max(1, Math.ceil(items.length / PAGE_SIZE))
|
||||
const paginados = items.slice((pagina - 1) * PAGE_SIZE, pagina * PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-semibold text-[#111827]">Registro de auditoría</h2>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="block text-xs text-[#64748B]">
|
||||
Entidad
|
||||
<select
|
||||
value={entidad}
|
||||
onChange={(e) => setEntidad(e.target.value)}
|
||||
className="ml-2 rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs"
|
||||
>
|
||||
{ENTIDADES.map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{e || 'Todas'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-xs text-[#64748B]">
|
||||
Usuario
|
||||
<select
|
||||
value={usuario}
|
||||
onChange={(e) => setUsuario(e.target.value)}
|
||||
className="ml-2 rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{usuariosLista.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-xs text-[#64748B]">
|
||||
Desde
|
||||
<input
|
||||
type="date"
|
||||
value={fechaDesde}
|
||||
onChange={(e) => setFechaDesde(e.target.value)}
|
||||
className="ml-2 rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-[#64748B]">
|
||||
Hasta
|
||||
<input
|
||||
type="date"
|
||||
value={fechaHasta}
|
||||
onChange={(e) => setFechaHasta(e.target.value)}
|
||||
className="ml-2 rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{cargando ? (
|
||||
<p className="py-6 text-center text-xs text-[#94A3B8]">Cargando…</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Tabla */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB]">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F9FAFB]">
|
||||
<th className="px-3 py-2 font-medium text-[#64748B]">Fecha</th>
|
||||
<th className="px-3 py-2 font-medium text-[#64748B]">Usuario</th>
|
||||
<th className="px-3 py-2 font-medium text-[#64748B]">Acción</th>
|
||||
<th className="px-3 py-2 font-medium text-[#64748B]">Entidad</th>
|
||||
<th className="px-3 py-2 font-medium text-[#64748B]">Detalle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginados.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-[#94A3B8]">
|
||||
Sin registros
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{paginados.map((item) => (
|
||||
<tr key={item.id} className="border-b border-[#E5E7EB] last:border-0">
|
||||
<td className="whitespace-nowrap px-3 py-2 text-[#374151]">
|
||||
{new Date(item.createdAt).toLocaleString('es-MX')}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#374151]">{item.usuarioId}</td>
|
||||
<td className="px-3 py-2 text-[#374151]">{item.accion}</td>
|
||||
<td className="px-3 py-2 text-[#374151]">{item.entidad}</td>
|
||||
<td className="max-w-[200px] truncate px-3 py-2 text-[#64748B]">{item.detalle ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Paginación */}
|
||||
{totalPaginas > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pagina <= 1}
|
||||
onClick={() => setPagina((p) => p - 1)}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<span className="text-[#64748B]">
|
||||
Página {pagina} de {totalPaginas}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pagina >= totalPaginas}
|
||||
onClick={() => setPagina((p) => p + 1)}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,14 +3,16 @@ import { ModoTrabajo } from './ModoTrabajo'
|
||||
import { RolesPermisos } from './RolesPermisos'
|
||||
import { Usuarios } from './Usuarios'
|
||||
import { Sistema } from './Sistema'
|
||||
import { AuditoriaLog } from './AuditoriaLog'
|
||||
import { getSesion } from '../../services/auth'
|
||||
|
||||
interface Props {
|
||||
onVolver: () => void
|
||||
}
|
||||
|
||||
type Tab = 'modo' | 'roles' | 'usuarios' | 'sistema'
|
||||
type Tab = 'modo' | 'roles' | 'usuarios' | 'sistema' | 'auditoria'
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
const TABS_BASE: { key: Tab; label: string }[] = [
|
||||
{ key: 'modo', label: 'Modo de trabajo' },
|
||||
{ key: 'roles', label: 'Roles y permisos' },
|
||||
{ key: 'usuarios', label: 'Usuarios' },
|
||||
@@ -19,6 +21,8 @@ const TABS: { key: Tab; label: string }[] = [
|
||||
|
||||
export function ConfigPage({ onVolver }: Props) {
|
||||
const [tab, setTab] = useState<Tab>('modo')
|
||||
const esAdmin = getSesion()?.usuario.rol === 'admin'
|
||||
const tabs = esAdmin ? [...TABS_BASE, { key: 'auditoria' as const, label: 'Auditoría' }] : TABS_BASE
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-6 py-12 sm:py-16">
|
||||
@@ -38,7 +42,7 @@ export function ConfigPage({ onVolver }: Props) {
|
||||
{/* Tabs */}
|
||||
<nav aria-label="Tabs de configuraciones" className="mt-8">
|
||||
<ul className="flex gap-1 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-1">
|
||||
{TABS.map((t) => (
|
||||
{tabs.map((t) => (
|
||||
<li key={t.key}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -62,6 +66,7 @@ export function ConfigPage({ onVolver }: Props) {
|
||||
{tab === 'roles' && <RolesPermisos />}
|
||||
{tab === 'usuarios' && <Usuarios />}
|
||||
{tab === 'sistema' && <Sistema />}
|
||||
{tab === 'auditoria' && esAdmin && <AuditoriaLog />}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import '@fontsource/inter/300.css'
|
||||
import '@fontsource/inter/400.css'
|
||||
import '@fontsource/inter/500.css'
|
||||
@@ -10,5 +11,6 @@ import App from './App'
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -108,6 +108,37 @@ export async function patchUsuarioConfig(
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
}
|
||||
|
||||
// ─── Auditoría ─────────────────────────────────────────────────
|
||||
|
||||
export interface AuditoriaItem {
|
||||
id: string
|
||||
usuarioId: string
|
||||
accion: string
|
||||
entidad: string
|
||||
entidadId: string
|
||||
detalle: string | null
|
||||
createdAt: string // ISO
|
||||
}
|
||||
|
||||
/** GET /auditoria — registros de auditoría con filtros opcionales */
|
||||
export async function getAuditoria(filtros?: {
|
||||
entidad?: string
|
||||
usuario?: string
|
||||
desde?: string
|
||||
hasta?: string
|
||||
}): Promise<AuditoriaItem[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (filtros?.entidad) params.set('entidad', filtros.entidad)
|
||||
if (filtros?.usuario) params.set('usuario', filtros.usuario)
|
||||
if (filtros?.desde) params.set('desde', filtros.desde)
|
||||
if (filtros?.hasta) params.set('hasta', filtros.hasta)
|
||||
const qs = params.toString()
|
||||
const resp = await fetch(`${API_URL}/auditoria${qs ? `?${qs}` : ''}`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as AuditoriaItem[]
|
||||
}
|
||||
|
||||
// ─── Acciones ────────────────────────────────────────────────────
|
||||
|
||||
/** GET /config/acciones — acciones disponibles para el rol del usuario actual */
|
||||
|
||||
Reference in New Issue
Block a user