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

@@ -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-[1280px] items-center justify-between px-4 py-2 sm:px-6">
<span className="text-xs font-medium text-[#4B5563]">
<span className="text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
{sesion.usuario.nombre} · {sesion.usuario.rol}
</span>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setModalPassword(true)}
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#4B5563] 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-[#4B5563] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827] dark:text-[#E2E8F0]"
>
Contraseña
</button>

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 ? (
@@ -92,7 +92,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
required
className={CLASE_INPUT}
/>
<p className="mt-2 text-xs text-[#64748B]">Mínimo 8 caracteres y un número</p>
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">Mínimo 8 caracteres y un número</p>
</div>
<div>
<label htmlFor="password-confirmar" className={CLASE_LABEL}>
@@ -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-2.5 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-2.5 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

@@ -2,10 +2,10 @@ import type { ReactNode } from 'react'
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-shadow duration-200 hover:shadow-md">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
<p className="mt-2 text-3xl font-light text-[#111827]">{valor}</p>
{detalle && <p className="mt-2 text-xs text-[#4B5563]">{detalle}</p>}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
<p className="mt-2 text-3xl font-light text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
{detalle && <p className="mt-2 text-xs text-[#4B5563] dark:text-[#CBD5E1]">{detalle}</p>}
</div>
)
}

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-widest">
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

@@ -44,9 +44,9 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
return (
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
<div className="mb-8">
<h1 className="text-xl font-semibold text-[#111827]">Mi tablero</h1>
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Mi tablero</h1>
{resumen && (
<p className="mt-1 text-sm text-[#64748B]">
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
</p>
)}
@@ -69,7 +69,7 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
{resumen && (
<>
<section aria-label="Mis indicadores" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<section aria-label="Mis indicadores" className="grid gap-4 grid-cols-2 lg:grid-cols-4">
<KpiCard
titulo="Mis leads activos"
valor={resumen.misLeads.length}

View File

@@ -20,8 +20,8 @@ const DIMENSIONES: { titulo: string; registro: (perfil: ResumenReportes['leads']
function TarjetaDimension({ titulo, datos }: { titulo: string; datos: Record<string, number> }) {
const rebanadas = rebanadasDe(datos)
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm transition-shadow duration-200 hover:shadow-md">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
{rebanadas.length === 0 ? (
<EstadoVacio mensaje="Sin datos" />
) : (

View File

@@ -3,8 +3,8 @@ import { EstadoVacio, TituloSeccion } from './comunes'
import type { ResumenMiDia } from '../lib/mi-tablero'
import { fetchResumenMiDia } from '../services/mi-dia'
const CLASE_CELDA = 'px-4 py-3 text-sm text-[#111827]'
const CLASE_ENCABEZADO = 'bg-[#F8FAFC] px-4 py-2 text-left text-xs font-semibold uppercase text-[#64748B]'
const CLASE_CELDA = 'px-4 py-3 text-sm text-[#111827] dark:text-[#E2E8F0]'
const CLASE_ENCABEZADO = 'bg-[#F8FAFC] px-4 py-2 text-left text-xs font-semibold uppercase text-[#64748B] dark:text-[#94A3B8]'
/**
* Sección "Equipo" (solo coordinador/corporativo/admin): tabla vendedor →
@@ -40,14 +40,14 @@ export default function SeccionEquipo() {
{!error && !equipo && <EstadoVacio mensaje="Sin datos de equipo" />}
{!error && equipo && (
<>
<p className="mb-4 text-xs text-[#4B5563]">
<p className="mb-4 text-xs text-[#4B5563] dark:text-[#CBD5E1]">
Leads nuevos sin asignar:{' '}
<span className="font-semibold text-[#111827]">{equipo.leadsNuevosSinAsignar}</span>
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{equipo.leadsNuevosSinAsignar}</span>
</p>
{equipo.porVendedor.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados al equipo" />
) : (
<table className="w-full overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
<table className="w-full overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
<thead className="border-b border-[#E5E7EB]">
<tr>
<th className={CLASE_ENCABEZADO}>Vendedor</th>
@@ -57,7 +57,7 @@ export default function SeccionEquipo() {
</thead>
<tbody className="divide-y divide-[#E5E7EB]">
{equipo.porVendedor.map((v) => (
<tr key={v.mondayUserId} className="transition-colors duration-100 hover:bg-[#F8FAFC]">
<tr key={v.mondayUserId} className="transition-colors duration-100 hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<td className={CLASE_CELDA}>{v.nombre ?? `Monday #${v.mondayUserId}`}</td>
<td className={CLASE_CELDA}>{v.leadsAsignados}</td>
<td className={CLASE_CELDA}>{v.leadsNuevosSemana}</td>

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

@@ -89,9 +89,9 @@ export default function TableroGeneral({
return (
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
<div className="mb-8">
<h1 className="text-xl font-semibold text-[#111827]">Tablero comercial</h1>
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Tablero comercial</h1>
{resumen && (
<p className="mt-1 text-sm text-[#64748B]">
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
</p>
)}
@@ -108,7 +108,7 @@ export default function TableroGeneral({
{resumen && (
<>
<section aria-label="Indicadores clave" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<section aria-label="Indicadores clave" className="grid gap-4 grid-cols-2 lg:grid-cols-4">
<KpiCard
titulo="Leads totales"
valor={resumen.leads.total}
@@ -151,7 +151,7 @@ export default function TableroGeneral({
/>
</section>
<div className="mt-10 grid gap-6 sm:grid-cols-1 lg:grid-cols-2">
<div className="mt-10 grid gap-6 md:grid-cols-2">
<section aria-label="Inventario por desarrollo" className="lg:col-span-2">
<TarjetaGrafica titulo="Unidades por desarrollo y status">
{unidades.length === 0 ? (

View File

@@ -11,14 +11,14 @@ export type AccionesTablero = { actualizar: () => void; cargando: boolean }
export function EstadoVacio({ mensaje }: { mensaje: string }) {
return (
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white">
<p className="text-xs font-medium uppercase tracking-wider text-[#64748B]">{mensaje}</p>
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white dark:border-[#334155] dark:bg-[#1E293B]">
<p className="text-xs font-medium uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{mensaje}</p>
</div>
)
}
export function TituloSeccion({ children }: { children: string }) {
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{children}</h2>
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{children}</h2>
}
/** Tarjeta contenedora de gráficas: título interno + padding consistente. */
@@ -30,8 +30,8 @@ export function TarjetaGrafica({
children: ReactNode
}) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</h3>
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</h3>
{children}
</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>
<DashboardPage />
</AuthGate>
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
</StrictMode>,
)

View File

@@ -5,6 +5,7 @@ import type { AccionesTablero } from '../components/comunes'
import { esRolConEquipo } from '../lib/mi-tablero'
import { getMe, getSesion } from '../services/auth'
import BannerModoLocal from '../components/BannerModoLocal'
import DarkModeToggle from '../components/DarkModeToggle'
type Vista = 'mia' | 'general'
@@ -45,24 +46,25 @@ export default function DashboardPage() {
return (
<div>
<BannerModoLocal />
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<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 min-h-14 max-w-[1280px] flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-2 sm:px-6">
<div className="flex items-center gap-2.5">
<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="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-xs font-bold text-white">
F
</span>
<span className="text-sm font-semibold text-[#111827]">FRASA Desarrollos</span>
<span className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">FRASA Desarrollos</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 sm:gap-3">
<DarkModeToggle />
{permitidas.length > 1 && (
<div
className="inline-flex rounded-lg border border-[#E5E7EB] bg-[#F1F5F9] p-1"
className="inline-flex rounded-lg border border-[#E5E7EB] bg-[#F1F5F9] p-0.5 sm:p-1"
role="tablist"
aria-label="Vista del tablero"
>
@@ -75,8 +77,8 @@ export default function DashboardPage() {
onClick={() => setElegida(v.id)}
className={
vista === v.id
? 'rounded-md bg-white px-3 py-1.5 text-xs font-medium text-[#111827] shadow-sm transition-all duration-100 ease-out'
: 'rounded-md px-3 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:text-[#111827]'
? 'rounded-md bg-white px-2 py-1.5 text-[10px] font-medium text-[#111827] dark:text-[#E2E8F0] shadow-sm transition-all duration-100 ease-out sm:px-3 sm:text-xs'
: 'rounded-md px-2 py-1.5 text-[10px] font-medium text-[#64748B] dark:text-[#94A3B8] transition-all duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0] sm:px-3 sm:text-xs'
}
>
{v.etiqueta}
@@ -89,7 +91,7 @@ export default function DashboardPage() {
type="button"
onClick={() => acciones?.actualizar()}
disabled={!acciones || acciones.cargando}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50"
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50"
>
{acciones?.cargando ? 'Actualizando…' : 'Actualizar'}
</button>
@@ -99,7 +101,7 @@ export default function DashboardPage() {
</header>
{vista === null ? (
<main className="mx-auto max-w-[1280px] px-4 py-16 sm:px-6">
<p className="text-sm text-[#64748B]">Tu rol no tiene tableros asignados por ahora.</p>
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Tu rol no tiene tableros asignados por ahora.</p>
</main>
) : vista === 'mia' ? (
<MiTablero alCambiarAcciones={setAcciones} />