import { SkeletonList } from "../components/Skeleton" import { useEffect, useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { Bar, BarChart, CartesianGrid, Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts' import type { StatusPlan } from '../types' import { listarPlanes, miosPorDefecto, type ResultadoPlanes } from '../services/repository' import { getSesion } from '../services/auth' import { alCorriente, avancePlan, diasAtraso, parseFechaLocal, proximoVencimiento, round2, saldoPlan, statusPlan, sumarMeses, } from '../lib/cobranza' import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters' import MoraDashboard from "../components/MoraDashboard" import KpiCard from '../components/KpiCard' import SemaforoDot, { semaforoPlan } from '../components/Semaforo' const MESES_CORTOS = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'] const COLORES_PIE = ['#2563EB', '#CBD5E1'] type ColumnaOrden = 'saldo' | 'vencimiento' | 'atraso' | 'avance' const COLUMNAS_ORDEN: ColumnaOrden[] = ['saldo', 'vencimiento', 'atraso', 'avance'] function esColumnaOrden(valor: string | null): valor is ColumnaOrden { return valor !== null && (COLUMNAS_ORDEN as string[]).includes(valor) } function claveMes(fecha: Date): string { return `${fecha.getFullYear()}-${String(fecha.getMonth() + 1).padStart(2, '0')}` } export default function DashboardPage() { const [resultado, setResultado] = useState(null) const [errorCarga, setErrorCarga] = useState(null) // Filtros y ordenamiento persistidos en la URL para que sean compartibles const [searchParams, setSearchParams] = useSearchParams() const busqueda = searchParams.get('q') ?? '' const filtroStatus = (searchParams.get('status') ?? 'TODOS') as StatusPlan | 'TODOS' const filtroProyecto = searchParams.get('proyecto') ?? 'TODOS' const paramOrden = searchParams.get('sort') const orden: ColumnaOrden | null = esColumnaOrden(paramOrden) ? paramOrden : null const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc' // Toggle "Mis planes / Todos": persiste en la URL (?vista=mios|todos); sin // param, los vendedores arrancan en "Mis planes" y el resto en "Todos" const paramVista = searchParams.get('vista') const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol) const hoy = useMemo(() => new Date(), []) useEffect(() => { let activo = true setResultado(null) setErrorCarga(null) listarPlanes(undefined, mios) .then((r) => { if (activo) setResultado(r) }) .catch((e: unknown) => { if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes') }) return () => { activo = false } }, [mios]) const planes = useMemo(() => resultado?.planes ?? [], [resultado]) const actualizarParams = (cambios: Record) => { setSearchParams( (prev) => { const next = new URLSearchParams(prev) for (const [clave, valor] of Object.entries(cambios)) { if (valor === null || valor === '') next.delete(clave) else next.set(clave, valor) } return next }, { replace: true }, ) } const alternarOrden = (columna: ColumnaOrden) => { // Click sobre la columna activa alterna asc/desc; sobre otra, empieza en asc if (orden === columna) { actualizarParams({ sort: columna, dir: dir === 'asc' ? 'desc' : 'asc' }) } else { actualizarParams({ sort: columna, dir: 'asc' }) } } const proyectos = useMemo( () => [...new Set(planes.map((p) => p.proyecto))].sort((a, b) => a.localeCompare(b)), [planes], ) const derivados = useMemo( () => planes.map((plan) => ({ plan, status: statusPlan(plan), saldo: saldoPlan(plan), avance: avancePlan(plan), corriente: alCorriente(plan, hoy), proximo: proximoVencimiento(plan, hoy), atraso: diasAtraso(plan, hoy), semaforo: semaforoPlan(plan, hoy), })), [planes, hoy], ) const activos = derivados.filter((d) => d.status === 'ACTIVO') const alCorrienteCount = activos.filter((d) => d.corriente).length const conAtraso = activos.length - alCorrienteCount const saldoTotal = round2( derivados.filter((d) => d.status !== 'CANCELADO').reduce((acc, d) => acc + d.saldo, 0), ) const mesActual = claveMes(hoy) const cobradoEsteMes = round2( planes .flatMap((p) => p.pagos.flatMap((pg) => pg.aplicaciones)) .filter((a) => a.fechaPago.startsWith(mesActual)) .reduce((acc, a) => acc + a.monto, 0), ) const datosPie = [ { name: 'Al corriente', value: alCorrienteCount }, { name: 'Con atraso', value: conAtraso }, ] const datosBarras = useMemo(() => { const aplicaciones = planes.flatMap((p) => p.pagos.flatMap((pg) => pg.aplicaciones)) const meses: { clave: string; etiqueta: string }[] = [] for (let i = 5; i >= 0; i--) { const f = sumarMeses(new Date(hoy.getFullYear(), hoy.getMonth(), 1), -i) meses.push({ clave: claveMes(f), etiqueta: MESES_CORTOS[f.getMonth()] }) } return meses.map(({ clave, etiqueta }) => ({ mes: etiqueta, cobrado: round2( aplicaciones.filter((a) => a.fechaPago.startsWith(clave)).reduce((acc, a) => acc + a.monto, 0), ), })) }, [planes, hoy]) if (!resultado) { return errorCarga ? (
{errorCarga}
) : ( ) } const filtrados = derivados.filter((d) => { if (filtroStatus !== 'TODOS' && d.status !== filtroStatus) return false if (filtroProyecto !== 'TODOS' && d.plan.proyecto !== filtroProyecto) return false const texto = busqueda.trim().toLowerCase() if (!texto) return true return ( d.plan.clienteNombre.toLowerCase().includes(texto) || d.plan.unidadCodigo.toLowerCase().includes(texto) ) }) const valorOrden = (d: (typeof filtrados)[number]): number => { switch (orden) { case 'saldo': return d.saldo case 'vencimiento': // Planes cubiertos (sin próximo vencimiento) siempre al final return d.proximo ? parseFechaLocal(d.proximo.fechaVencimiento).getTime() : Number.MAX_SAFE_INTEGER case 'atraso': return d.atraso case 'avance': return d.avance default: return 0 } } const ordenados = (() => { if (!orden) return filtrados const factor = dir === 'desc' ? -1 : 1 return [...filtrados].sort((a, b) => (valorOrden(a) - valorOrden(b)) * factor) })() const encabezadoOrdenable = (columna: ColumnaOrden, titulo: string, alineadoDerecha = false) => ( ) return (

Módulo Cobranza — Planes de enganche

Nuevo plan
{/* Aviso discreto cuando los datos no vienen del API */} {resultado.origen === 'local' && (

{resultado.planesSoloLocales ? 'El API no tiene planes registrados. Los planes guardados en este navegador se conservan solo localmente (no se migraron automáticamente).' : 'Modo offline: sin conexión al API; los cambios se guardan solo en este navegador.'}

)} {/* KPIs */}
{/* Gráficas */}

Al corriente vs vencidos

{datosPie.map((_, i) => ( ))}

Cobrado por mes (últimos 6 meses)

`$${(v / 1000).toLocaleString('es-MX')}k`} /> formatoMoneda(Number(v))} cursor={{ fill: '#F8FAFC' }} />
{/* Tarjeta unificada: toolbar de filtros/búsqueda + tabla de planes */}
{/* Filtros */}
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
actualizarParams({ q: e.target.value })} placeholder="Buscar por cliente o unidad…" 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 sm:w-64" />
{/* Tabla de planes */}
{encabezadoOrdenable('avance', '% pagado')} {encabezadoOrdenable('vencimiento', 'Próx. vencimiento')} {encabezadoOrdenable('saldo', 'Saldo', true)} {encabezadoOrdenable('atraso', 'Días atraso', true)} {ordenados.map((d) => ( ))} {ordenados.length === 0 && ( )}
Cliente Unidad ProyectoEstado
{d.plan.clienteNombre} {d.plan.unidadCodigo} {d.plan.proyecto}
{formatoPorcentaje(d.avance)}
{d.proximo ? formatoFechaCorta(d.proximo.fechaVencimiento) : '—'} {formatoMoneda(d.saldo)} {d.atraso > 0 ? {d.atraso} : '0'}
No hay planes que coincidan con el filtro.
) }