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:
2026-09-07 06:04:49 +00:00
parent 388e1da338
commit c77b4c0628
170 changed files with 5802 additions and 637 deletions

View File

@@ -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",
@@ -1811,7 +1813,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/debug": {
@@ -1995,6 +1996,15 @@
"node": ">=6.9.0"
}
},
"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",
@@ -2335,6 +2345,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/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2468,6 +2487,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-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@@ -11,8 +11,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",

View File

@@ -17,17 +17,17 @@ export default function AuthGate({ children }: { children: ReactNode }) {
}
return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<div className="border-b border-[#E5E7EB] bg-white">
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
<div className="border-b border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
<span className="text-xs font-medium text-[#64748B]">
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{sesion.usuario.nombre} · {sesion.usuario.rol}
</span>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setModalPassword(true)}
className="rounded-md px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:text-[#111827]"
className="rounded-md px-2 py-1.5 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-all duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0]"
>
Contraseña
</button>

View File

@@ -9,7 +9,7 @@ function Burbuja({ mensaje }: { mensaje: Mensaje }) {
className={`inline-block whitespace-pre-wrap rounded-2xl px-4 py-3 text-sm leading-relaxed ${
esUsuario
? 'rounded-br-md bg-accent text-white shadow-sm'
: 'rounded-bl-md border border-[#E5E7EB] bg-white text-[#111827] shadow-sm'
: 'rounded-bl-md border border-[#E5E7EB] bg-white text-[#111827] dark:text-[#E2E8F0] shadow-sm'
}`}
>
{mensaje.contenido}

View File

@@ -3,8 +3,8 @@ import { validarConfirmacion, validarPasswordNueva } from '../lib/password'
import { cambiarPassword } from '../services/auth'
const CLASE_INPUT =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563]'
'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 transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
/**
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
@@ -46,7 +46,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
aria-label="Cambiar contraseña"
>
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Cambiar contraseña
</h2>
{exito ? (
@@ -127,7 +127,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
<button
type="button"
onClick={onCerrar}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-3 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Cancelar
</button>

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

View File

@@ -23,12 +23,12 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
return (
<div className="flex min-h-screen items-center justify-center bg-[#F8FAFC] px-4">
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm">
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="mb-10 text-center">
<p className="text-lg font-bold tracking-wide">
FRASA<span className="text-[#F97316]">.</span>
</p>
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Acceso al ecosistema
</p>
</div>
@@ -36,7 +36,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
<div>
<label
htmlFor="login-usuario"
className="mb-2 block text-xs font-medium text-[#4B5563]"
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
>
Usuario
</label>
@@ -48,13 +48,13 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
autoComplete="username"
autoFocus
required
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
className="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 transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
</div>
<div>
<label
htmlFor="login-password"
className="mb-2 block text-xs font-medium text-[#4B5563]"
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
>
Contraseña
</label>
@@ -65,7 +65,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
className="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 transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
</div>
{error && (

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

View File

@@ -1,3 +1,5 @@
@custom-variant dark (&:where(.dark, .dark *));
@import "tailwindcss";
@theme {

View File

@@ -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'
@@ -13,5 +14,6 @@ createRoot(document.getElementById('root')!).render(
<AuthGate>
<ChatPage />
</AuthGate>
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
</StrictMode>,
)

View File

@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import Burbuja from '../components/Burbuja'
import DarkModeToggle from '../components/DarkModeToggle'
import toast from "react-hot-toast"
import { enviarMensaje, esErrorDeRed, obtenerHistorial } from '../services/api'
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
import type { Mensaje } from '../types'
@@ -59,7 +61,7 @@ export default function ChatPage() {
if (esErrorDeRed(err)) {
setSinConexion(true)
} else {
setError(err instanceof Error ? err.message : 'Error al enviar el mensaje')
toast.error('Error al enviar el mensaje'); setError(err instanceof Error ? err.message : 'Error al enviar el mensaje')
}
// el mensaje del usuario se queda visible; puede reintentar
} finally {
@@ -76,29 +78,32 @@ export default function ChatPage() {
}
return (
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827]">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
<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 w-full max-w-3xl items-center justify-between px-4 sm:px-6">
<div className="flex items-center gap-3">
<a
href="/"
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827]"
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827] dark:text-[#E2E8F0]"
>
&larr; Menú
</a>
<span className="text-sm tracking-wide">
<span className="font-bold">FRASA</span>
<span className="text-[#F97316]"> · </span>
<span className="font-light text-[#64748B]">ASISTENTE IA</span>
<span className="font-light text-[#64748B] dark:text-[#94A3B8]">ASISTENTE IA</span>
</span>
</div>
<button
type="button"
onClick={nuevaConversacion}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Nueva conversación
</button>
<div className="flex items-center gap-2">
<DarkModeToggle />
<button
type="button"
onClick={nuevaConversacion}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Nueva conversación
</button>
</div>
</div>
</header>
@@ -117,11 +122,11 @@ export default function ChatPage() {
{mensajes.length === 0 && !escribiendo ? (
<div className="flex flex-1 flex-col items-center justify-center">
<div className="w-full max-w-lg rounded-xl border border-[#E5E7EB] bg-white p-6 text-center shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
<div className="w-full max-w-lg rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] text-center shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Asistente interno
</p>
<p className="mt-3 text-xl font-light leading-relaxed text-[#111827]">
<p className="mt-3 text-xl font-light leading-relaxed text-[#111827] dark:text-[#E2E8F0]">
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de
venta o las políticas de Frasa.
</p>
@@ -131,7 +136,7 @@ export default function ChatPage() {
key={pregunta}
type="button"
onClick={() => setEntrada(pregunta)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-left text-xs font-medium text-[#374151] transition-colors hover:border-accent hover:text-accent"
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-left text-xs font-medium text-[#374151] dark:text-[#CBD5E1] transition-colors hover:border-accent hover:text-accent"
>
{pregunta}
</button>
@@ -149,11 +154,11 @@ export default function ChatPage() {
)}
</main>
<footer className="sticky bottom-0 border-t border-[#E5E7EB] bg-white">
<footer className="sticky bottom-0 border-t border-[#E5E7EB] dark:border-[#1E293B] bg-white">
<div className="relative mx-auto w-full max-w-3xl px-4 sm:px-6">
{escribiendo && (
<div className="pointer-events-none absolute -top-11 left-4 sm:left-6">
<p className="rounded-full border border-[#E5E7EB] bg-white px-4 py-2 text-xs font-medium text-[#64748B] shadow-md">
<p className="rounded-full border border-[#E5E7EB] bg-white px-4 py-2 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] shadow-md">
Escribiendo…
</p>
</div>
@@ -175,7 +180,7 @@ export default function ChatPage() {
onChange={(e) => setEntrada(e.target.value)}
placeholder="Escribe tu pregunta…"
autoComplete="off"
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 py-3 text-sm placeholder:text-[#94A3B8] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 text-sm placeholder:text-[#94A3B8] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
<button
type="submit"