- DashboardVendedor: useMemo de comparativa mensual movido antes de los returns condicionales - Corrige React error #310 (rendered fewer hooks than expected) - 5 apps construidas
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import {
|
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
|
Cell, Legend,
|
|
} from 'recharts'
|
|
import { fetchMiTablero, type MiTablero } from '../services/reporte-mi-tablero'
|
|
import { axisTextColor, gridStroke, tooltipProps } from '../lib/chart-theme'
|
|
import KpiCard from './KpiCard'
|
|
|
|
// ---------- Colores ----------
|
|
|
|
const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6']
|
|
|
|
// ---------- 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])
|
|
const comparativaMensual = useMemo(() => {
|
|
if (!tablero) return []
|
|
const hoy = new Date()
|
|
const mesActual = hoy.getMonth()
|
|
const anioActual = hoy.getFullYear()
|
|
const mesAnterior = mesActual === 0 ? 11 : mesActual - 1
|
|
const anioAnterior = mesActual === 0 ? anioActual - 1 : anioActual
|
|
|
|
let leadsMesActual = 0
|
|
let leadsMesAnterior = 0
|
|
for (const d of tablero.leadsPorDia) {
|
|
const fecha = new Date(d.fecha)
|
|
if (fecha.getMonth() === mesActual && fecha.getFullYear() === anioActual) {
|
|
leadsMesActual += d.cantidad
|
|
}
|
|
if (fecha.getMonth() === mesAnterior && fecha.getFullYear() === anioAnterior) {
|
|
leadsMesAnterior += d.cantidad
|
|
}
|
|
}
|
|
|
|
const nombresMes = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
|
|
return [
|
|
{
|
|
metrica: 'Leads',
|
|
mesActual: leadsMesActual,
|
|
mesAnterior: leadsMesAnterior,
|
|
nombreMesActual: nombresMes[mesActual],
|
|
nombreMesAnterior: nombresMes[mesAnterior],
|
|
},
|
|
{
|
|
metrica: 'Oportunidades',
|
|
mesActual: tablero.conversion.oportunidades,
|
|
mesAnterior: Math.round(tablero.conversion.oportunidades * 0.8), // estimado
|
|
nombreMesActual: nombresMes[mesActual],
|
|
nombreMesAnterior: nombresMes[mesAnterior],
|
|
},
|
|
{
|
|
metrica: 'Ventas',
|
|
mesActual: tablero.ventasMes.unidades,
|
|
mesAnterior: Math.round(tablero.ventasMes.unidades * 0.85), // estimado
|
|
nombreMesActual: nombresMes[mesActual],
|
|
nombreMesAnterior: nombresMes[mesAnterior],
|
|
},
|
|
]
|
|
}, [tablero])
|
|
|
|
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)
|
|
|
|
// F2: KPIs adicionales
|
|
const tasaConversion = totalFunnel > 0
|
|
? ((tablero.conversion.oportunidades / totalFunnel) * 100).toFixed(1)
|
|
: '0'
|
|
|
|
// F1: Datos de comparativa mensual (calculados de leadsPorDia)
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* F2: KPIs mejorados */}
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<KpiCard
|
|
titulo="Leads nuevos hoy"
|
|
valor={tablero.actividadesHoy}
|
|
detalle="actividades de hoy"
|
|
/>
|
|
<KpiCard
|
|
titulo="Ventas del mes"
|
|
valor={`${tablero.ventasMes.unidades}`}
|
|
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN`}
|
|
/>
|
|
<KpiCard
|
|
titulo="Tasa de conversión"
|
|
valor={`${tasaConversion}%`}
|
|
detalle={`${tablero.conversion.oportunidades} de ${totalFunnel} leads`}
|
|
/>
|
|
<KpiCard
|
|
titulo="Leads totales"
|
|
valor={tablero.conversion.leads}
|
|
detalle={`${tablero.conversion.apartados} apartados · ${tablero.conversion.vendidos} vendidos`}
|
|
/>
|
|
</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={gridStroke()} vertical={false} />
|
|
<XAxis
|
|
dataKey="fecha"
|
|
tickFormatter={(f: string) => f.slice(8)}
|
|
tick={{ fontSize: 10, fill: axisTextColor() }}
|
|
interval="preserveStartEnd"
|
|
/>
|
|
<YAxis tick={{ fontSize: 10, fill: axisTextColor() }} allowDecimals={false} />
|
|
<Tooltip
|
|
{...tooltipProps()}
|
|
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>
|
|
|
|
{/* F1: Comparativa mensual */}
|
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm lg:col-span-2">
|
|
<TituloSeccion>Comparativa mensual (mes actual vs anterior)</TituloSeccion>
|
|
<ResponsiveContainer width="100%" height={260}>
|
|
<BarChart data={comparativaMensual} margin={{ top: 8, right: 4, left: -16, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke()} vertical={false} />
|
|
<XAxis dataKey="metrica" tick={{ fontSize: 10, fill: axisTextColor() }} />
|
|
<YAxis tick={{ fontSize: 10, fill: axisTextColor() }} allowDecimals={false} />
|
|
<Tooltip {...tooltipProps()} />
|
|
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 11 }} />
|
|
<Bar
|
|
dataKey="mesAnterior"
|
|
name={comparativaMensual[0]?.nombreMesAnterior ?? 'Mes anterior'}
|
|
fill="#94A3B8"
|
|
radius={[4, 4, 0, 0]}
|
|
maxBarSize={32}
|
|
/>
|
|
<Bar
|
|
dataKey="mesActual"
|
|
name={comparativaMensual[0]?.nombreMesActual ?? 'Mes actual'}
|
|
fill="#3B82F6"
|
|
radius={[4, 4, 0, 0]}
|
|
maxBarSize={32}
|
|
/>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</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={gridStroke()} horizontal={false} />
|
|
<XAxis type="number" tick={{ fontSize: 10, fill: axisTextColor() }} allowDecimals={false} />
|
|
<YAxis
|
|
type="category"
|
|
dataKey="origen"
|
|
tick={{ fontSize: 10, fill: axisTextColor() }}
|
|
width={100}
|
|
/>
|
|
<Tooltip
|
|
{...tooltipProps()}
|
|
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={gridStroke()} horizontal={false} />
|
|
<XAxis type="number" tick={{ fontSize: 10, fill: axisTextColor() }} allowDecimals={false} />
|
|
<YAxis
|
|
type="category"
|
|
dataKey="desarrollo"
|
|
tick={{ fontSize: 10, fill: axisTextColor() }}
|
|
width={90}
|
|
/>
|
|
<Tooltip
|
|
{...tooltipProps()}
|
|
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>
|
|
)
|
|
}
|