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

@@ -0,0 +1,220 @@
import { useCallback, useEffect, useState } from 'react'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
Cell,
} from 'recharts'
import { fetchMiTablero, type MiTablero } from '../services/reporte-mi-tablero'
import KpiCard from './KpiCard'
// ---------- Colores ----------
const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6']
const LIGHT_TEXT = '#64748B'
const GRID_LIGHT = '#E5E7EB'
// ---------- Sub-componentes ----------
function TituloSeccion({ children }: { children: string }) {
return (
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
{children}
</h3>
)
}
function SkeletonChart() {
return (
<div className="flex h-[280px] items-center justify-center rounded-xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
<p className="text-sm text-[#94A3B8]">Cargando…</p>
</div>
)
}
function FunnelBar({ label, value, total }: { label: string; value: number; total: number }) {
const pct = total > 0 ? (value / total) * 100 : 0
return (
<div className="flex items-center gap-3">
<span className="w-28 shrink-0 text-xs font-medium text-[#374151] dark:text-[#CBD5E1]">{label}</span>
<div className="relative h-6 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-[#334155]">
<div
className="absolute inset-y-0 left-0 rounded-full bg-accent transition-all duration-500"
style={{ width: `${pct}%` }}
/>
<span className="absolute inset-0 flex items-center justify-center text-xs font-semibold text-[#111827] dark:text-white">
{value}
</span>
</div>
<span className="w-10 text-right text-xs text-[#94A3B8]">{pct.toFixed(0)}%</span>
</div>
)
}
// ---------- Componente principal ----------
export default function DashboardVendedor() {
const [tablero, setTablero] = useState<MiTablero | null>(null)
const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true)
const cargar = useCallback(async () => {
setCargando(true)
try {
setTablero(await fetchMiTablero())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar tu tablero')
} finally {
setCargando(false)
}
}, [])
useEffect(() => { void cargar() }, [cargar])
if (cargando && !tablero) return <SkeletonChart />
if (error) {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900/50 dark:bg-red-950/30">
<p className="text-sm font-medium text-red-700 dark:text-red-400">{error}</p>
<button
type="button"
onClick={() => void cargar()}
className="mt-3 rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-accent-hover"
>
Reintentar
</button>
</div>
)
}
if (!tablero) return null
const totalFunnel = tablero.conversion.leads || 1
// Datos para gráfica de origen (solo top 6)
const datosOrigen = tablero.porOrigen.slice(0, 6)
// Datos para ranking de desarrollos
const datosDesarrollo = tablero.porDesarrollo.slice(0, 5)
return (
<div className="space-y-6">
{/* KPIs */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
titulo="Ventas del mes"
valor={`${tablero.ventasMes.unidades}`}
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN`}
/>
<KpiCard
titulo="Actividades de hoy"
valor={tablero.actividadesHoy}
detalle={`${tablero.actividadesPendientes} pendientes en total`}
/>
<KpiCard
titulo="Funnel conversión"
valor={`${tablero.conversion.vendidos}`}
detalle={`${tablero.conversion.leads} leads → ${tablero.conversion.oportunidades} ops → ${tablero.conversion.vendidos} vendidos`}
/>
<KpiCard
titulo="Leads totales"
valor={tablero.conversion.leads}
detalle={`${tablero.conversion.apartados} apartados`}
/>
</div>
{/* Gráficas */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Leads por día (barras) */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<TituloSeccion>Leads por día (últimos 30 días)</TituloSeccion>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={tablero.leadsPorDia} margin={{ top: 8, right: 4, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID_LIGHT} vertical={false} />
<XAxis
dataKey="fecha"
tickFormatter={(f: string) => f.slice(8)}
tick={{ fontSize: 10, fill: LIGHT_TEXT }}
interval="preserveStartEnd"
/>
<YAxis tick={{ fontSize: 10, fill: LIGHT_TEXT }} allowDecimals={false} />
<Tooltip
contentStyle={{ fontSize: 12, borderRadius: 8 }}
labelFormatter={(f: unknown) => `Fecha: ${String(f)}`}
formatter={(value: unknown) => [`${Number(value)} leads`, "Leads"]}
/>
<Bar dataKey="cantidad" fill="#3B82F6" radius={[4, 4, 0, 0]} maxBarSize={20} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Funnel de conversión */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<TituloSeccion>Funnel de conversión</TituloSeccion>
<div className="mt-2 space-y-3">
<FunnelBar label="Leads" value={tablero.conversion.leads} total={totalFunnel} />
<FunnelBar label="Oportunidades" value={tablero.conversion.oportunidades} total={totalFunnel} />
<FunnelBar label="Apartados" value={tablero.conversion.apartados} total={totalFunnel} />
<FunnelBar label="Vendidos" value={tablero.conversion.vendidos} total={totalFunnel} />
</div>
</div>
{/* Rendimiento por origen (barras horizontales) */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<TituloSeccion>Rendimiento por origen</TituloSeccion>
{datosOrigen.length === 0 ? (
<p className="mt-8 text-center text-sm text-[#94A3B8]">Sin datos de origen</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={datosOrigen} layout="vertical" margin={{ top: 8, right: 16, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID_LIGHT} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: LIGHT_TEXT }} allowDecimals={false} />
<YAxis
type="category"
dataKey="origen"
tick={{ fontSize: 10, fill: LIGHT_TEXT }}
width={100}
/>
<Tooltip
contentStyle={{ fontSize: 12, borderRadius: 8 }}
formatter={(value: unknown, name: unknown) => [`${Number(value)}`, String(name) === "total" ? "Total" : "Convertidos"]}
/>
<Bar dataKey="total" fill="#3B82F6" radius={[0, 4, 4, 0]} maxBarSize={18} />
<Bar dataKey="convertidos" fill="#10B981" radius={[0, 4, 4, 0]} maxBarSize={18} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Ranking de desarrollos (barras horizontales) */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<TituloSeccion>Ranking de desarrollos</TituloSeccion>
{datosDesarrollo.length === 0 ? (
<p className="mt-8 text-center text-sm text-[#94A3B8]">Sin ventas registradas</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={datosDesarrollo} layout="vertical" margin={{ top: 8, right: 16, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID_LIGHT} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: LIGHT_TEXT }} allowDecimals={false} />
<YAxis
type="category"
dataKey="desarrollo"
tick={{ fontSize: 10, fill: LIGHT_TEXT }}
width={90}
/>
<Tooltip
contentStyle={{ fontSize: 12, borderRadius: 8 }}
formatter={(value: unknown) => [`${Number(value)} ventas`, "Ventas"]}
/>
<Bar dataKey="ventas" fill="#8B5CF6" radius={[0, 4, 4, 0]} maxBarSize={18}>
{datosDesarrollo.map((_, idx) => (
<Cell key={idx} fill={COLORS[idx % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
)
}