404 lines
16 KiB
TypeScript
404 lines
16 KiB
TypeScript
import { SkeletonList } from "../components/Skeleton"
|
|
import { useMemo } 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 { usePlanesCobranza } from '../lib/usePlanesCobranza'
|
|
import {
|
|
alCorriente,
|
|
avancePlan,
|
|
diasAtraso,
|
|
parseFechaLocal,
|
|
proximoVencimiento,
|
|
round2,
|
|
saldoPlan,
|
|
statusPlan,
|
|
sumarMeses,
|
|
} from '../lib/cobranza'
|
|
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
|
import { useHoy } from '../lib/useHoy'
|
|
import MoraDashboard from "../components/MoraDashboard"
|
|
import KpiCard from '../components/KpiCard'
|
|
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
|
|
import ToggleVista from '../components/ToggleVista'
|
|
|
|
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() {
|
|
// 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 ?vista=): lo comparte con
|
|
// "Cobranza del día" y avisa si el usuario no tiene planes asignados
|
|
const { mios, definirVista, resultado, errorCarga, sinAsignados } = usePlanesCobranza()
|
|
// "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la
|
|
// pestaña queda abierta de un día para otro)
|
|
const hoy = useHoy()
|
|
|
|
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
|
|
const idsPlan = useMemo(() => new Set(planes.map((p) => p.id)), [planes])
|
|
|
|
const actualizarParams = (cambios: Record<string, string | null>) => {
|
|
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 ? (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
|
) : (
|
|
<SkeletonList count={5} />
|
|
)
|
|
}
|
|
|
|
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) => (
|
|
<th scope="col" className={`px-4 py-3 ${alineadoDerecha ? 'text-right' : ''}`}>
|
|
<button
|
|
type="button"
|
|
onClick={() => alternarOrden(columna)}
|
|
aria-label={`Ordenar por ${titulo}`}
|
|
className="inline-flex items-center gap-1 font-semibold uppercase tracking-wider transition-colors duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0]"
|
|
>
|
|
{titulo}
|
|
{orden === columna && <span aria-hidden="true">{dir === 'asc' ? '↑' : '↓'}</span>}
|
|
</button>
|
|
</th>
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Módulo Cobranza — Planes de enganche</h1>
|
|
<Link
|
|
to="/nuevo"
|
|
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
|
>
|
|
Nuevo plan
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Aviso discreto cuando los datos no vienen del API */}
|
|
{resultado.origen === 'local' && (
|
|
<p className="text-xs text-amber-700">
|
|
{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.'}
|
|
</p>
|
|
)}
|
|
{sinAsignados && (
|
|
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
|
No tienes planes asignados a tu usuario; se muestra el listado de <strong>todos</strong> los planes.
|
|
</p>
|
|
)}
|
|
|
|
{/* KPIs */}
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4">
|
|
<KpiCard titulo="Planes activos" valor={activos.length} />
|
|
<KpiCard
|
|
titulo="Al corriente / con atraso"
|
|
valor={`${alCorrienteCount} / ${conAtraso}`}
|
|
detalle="Solo planes activos"
|
|
/>
|
|
<KpiCard titulo="Saldo por cobrar" valor={formatoMoneda(saldoTotal)} />
|
|
<KpiCard titulo="Cobrado este mes" valor={formatoMoneda(cobradoEsteMes)} />
|
|
</div>
|
|
|
|
{/* Gráficas */}
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
|
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
Al corriente vs vencidos
|
|
</h2>
|
|
<ResponsiveContainer width="100%" height={240}>
|
|
<PieChart>
|
|
<Pie
|
|
data={datosPie}
|
|
dataKey="value"
|
|
nameKey="name"
|
|
innerRadius={50}
|
|
outerRadius={80}
|
|
strokeWidth={0}
|
|
label
|
|
>
|
|
{datosPie.map((_, i) => (
|
|
<Cell key={i} fill={COLORES_PIE[i % COLORES_PIE.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip />
|
|
<Legend />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
|
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
Cobrado por mes (últimos 6 meses)
|
|
</h2>
|
|
<ResponsiveContainer width="100%" height={240}>
|
|
<BarChart data={datosBarras}>
|
|
<CartesianGrid stroke="#E5E7EB" strokeDasharray="3 3" vertical={false} />
|
|
<XAxis dataKey="mes" tickLine={false} axisLine={{ stroke: '#E5E7EB' }} />
|
|
<YAxis
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(v: number) => `$${(v / 1000).toLocaleString('es-MX')}k`}
|
|
/>
|
|
<Tooltip formatter={(v) => formatoMoneda(Number(v))} cursor={{ fill: '#F8FAFC' }} />
|
|
<Bar dataKey="cobrado" fill="#2563EB" name="Cobrado" radius={[6, 6, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tarjeta unificada: toolbar de filtros/búsqueda + tabla de planes */}
|
|
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
|
{/* Filtros */}
|
|
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 md:gap-4 md:px-5">
|
|
{/* Toggle Mis planes / Todos (?vista= filtra en el API por el vendedor del token) */}
|
|
<ToggleVista mios={mios} onCambiar={definirVista} />
|
|
<label htmlFor="filtro-busqueda" className="sr-only">
|
|
Buscar por cliente o unidad
|
|
</label>
|
|
<input
|
|
id="filtro-busqueda"
|
|
type="search"
|
|
value={busqueda}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<label htmlFor="filtro-status" className="sr-only">
|
|
Filtrar por status
|
|
</label>
|
|
<select
|
|
id="filtro-status"
|
|
value={filtroStatus}
|
|
onChange={(e) => actualizarParams({ status: e.target.value === 'TODOS' ? null : e.target.value })}
|
|
className="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"
|
|
>
|
|
<option value="TODOS">Todos los status</option>
|
|
<option value="ACTIVO">ACTIVO</option>
|
|
<option value="LIQUIDADO">LIQUIDADO</option>
|
|
<option value="SIN_PLAN">SIN PLAN CAPTURADO</option>
|
|
<option value="CANCELADO">CANCELADO</option>
|
|
</select>
|
|
<label htmlFor="filtro-proyecto" className="sr-only">
|
|
Filtrar por proyecto
|
|
</label>
|
|
<select
|
|
id="filtro-proyecto"
|
|
value={filtroProyecto}
|
|
onChange={(e) => actualizarParams({ proyecto: e.target.value === 'TODOS' ? null : e.target.value })}
|
|
className="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"
|
|
>
|
|
<option value="TODOS">Todos los proyectos</option>
|
|
{proyectos.map((p) => (
|
|
<option key={p} value={p}>
|
|
{p}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Tabla de planes */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
<th scope="col" className="px-4 py-3">Cliente</th>
|
|
<th scope="col" className="px-4 py-3">Teléfono</th>
|
|
<th scope="col" className="px-4 py-3">Unidad</th>
|
|
<th scope="col" className="px-4 py-3">Proyecto</th>
|
|
{encabezadoOrdenable('avance', '% pagado')}
|
|
{encabezadoOrdenable('vencimiento', 'Próx. vencimiento')}
|
|
{encabezadoOrdenable('saldo', 'Saldo', true)}
|
|
{encabezadoOrdenable('atraso', 'Días atraso', true)}
|
|
<th scope="col" className="px-4 py-3">Estado</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[#E5E7EB]">
|
|
{ordenados.map((d) => (
|
|
<tr key={d.plan.id} className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
<td className="px-4 py-3">
|
|
<Link to={`/planes/${d.plan.id}`} className="font-medium text-accent hover:underline">
|
|
{d.plan.clienteNombre}
|
|
</Link>
|
|
</td>
|
|
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{d.plan.clienteTelefono || '—'}</td>
|
|
<td className="px-4 py-3">{d.plan.unidadCodigo}</td>
|
|
<td className="px-4 py-3">{d.plan.proyecto}</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-1.5 w-24 rounded-full bg-[#E5E7EB]">
|
|
<div
|
|
className="h-full rounded-full bg-accent"
|
|
style={{ width: `${Math.min(100, d.avance * 100)}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoPorcentaje(d.avance)}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{d.proximo ? formatoFechaCorta(d.proximo.fechaVencimiento) : '—'}
|
|
</td>
|
|
<td className="px-4 py-3 text-right font-medium">{formatoMoneda(d.saldo)}</td>
|
|
<td className="px-4 py-3 text-right">
|
|
{d.atraso > 0 ? <span className="text-red-700">{d.atraso}</span> : '0'}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<SemaforoDot valor={d.semaforo} />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{ordenados.length === 0 && (
|
|
<tr>
|
|
<td colSpan={9} className="py-8 text-center text-neutral-500">
|
|
No hay planes que coincidan con el filtro.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<section className="mt-8">
|
|
<MoraDashboard alcance={mios ? 'mios' : 'global'} planIds={idsPlan} />
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|