import { Fragment, useEffect, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import type { Pago, PlanPago } from '../types' import { actualizarPlanAsync, agregarNotaAsync, cancelarPlanAsync, obtenerPlanPorId, puedeEditarMora, registrarAbonoAsync, } from '../services/repository' import { getSesion } from '../services/auth' import { avancePlan, diasAtraso, moraDePago, pagadoTotal, proximoVencimiento, round2, saldoPlan, statusPago, statusPlan, totalMora, } from '../lib/cobranza' import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters' import { descargar } from '../lib/pdf/estadoCuenta' import { descargarRecibo } from '../lib/pdf/reciboAbono' import { useHoy } from '../lib/useHoy' import BadgeStatus from '../components/BadgeStatus' import KpiCard from '../components/KpiCard' import ModalAbono from '../components/ModalAbono' import ModalCancelarPlan from '../components/ModalCancelarPlan' import TimelinePagos from '../components/TimelinePagos' export default function PlanDetallePage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const [plan, setPlan] = useState(undefined) const [cargando, setCargando] = useState(true) const [offline, setOffline] = useState(false) const [errorCarga, setErrorCarga] = useState(null) const [pagoAbono, setPagoAbono] = useState(null) const [modalCancelar, setModalCancelar] = useState(false) const [cancelando, setCancelando] = useState(false) const [expandidos, setExpandidos] = useState>(new Set()) const [error, setError] = useState(null) const [notaTexto, setNotaTexto] = useState('') const [errorNota, setErrorNota] = useState(null) // Captura de la tasa de mora (vive en el plan, editable por admin/manager/dirección) const [tasaInput, setTasaInput] = useState('0') const [guardandoMora, setGuardandoMora] = useState(false) const [errorMora, setErrorMora] = useState(null) const [avisoMora, setAvisoMora] = useState(null) const puedeEditarTasa = puedeEditarMora(getSesion()?.usuario.rol) // "Hoy" se recalcula al enfocar la ventana (evita atrasos/mora congelados si // la pestaña queda abierta de un día para otro) const hoy = useHoy() useEffect(() => { if (!id) { setCargando(false) return } let activo = true obtenerPlanPorId(id) .then((r) => { if (!activo) return setPlan(r.plan) setOffline(r.origen === 'local') }) .catch((e: unknown) => { if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar el plan') }) .finally(() => { if (activo) setCargando(false) }) return () => { activo = false } }, [id]) // La caja de la tasa se prellena al cargar el plan (o 0 si no tiene). No se // reinicia con cada recarga del plan para no borrar lo que el usuario escribe. useEffect(() => { if (plan) setTasaInput(String(plan.tasaMoraMensual ?? 0)) }, [plan?.id]) if (cargando) { return

Cargando...

} if (errorCarga) { return
{errorCarga}
} if (!plan) { return (

Plan no encontrado.

Volver al dashboard
) } const status = statusPlan(plan) const saldo = saldoPlan(plan) const atraso = diasAtraso(plan, hoy) const tasaMora = plan.tasaMoraMensual ?? 0 const mora = totalMora(plan, hoy) // Mora que resultaría con lo capturado en la caja (vista previa antes de guardar) const tasaCapturada = Number(tasaInput) const tasaCapturadaValida = Number.isFinite(tasaCapturada) && tasaCapturada >= 0 && tasaCapturada <= 100 const moraConTasaCapturada = tasaCapturadaValida ? totalMora({ ...plan, tasaMoraMensual: tasaCapturada }, hoy) : 0 const tasaSinCambios = tasaCapturadaValida && round2(tasaCapturada) === round2(tasaMora) const toggleExpandido = (pagoId: string) => { setExpandidos((prev) => { const next = new Set(prev) if (next.has(pagoId)) next.delete(pagoId) else next.add(pagoId) return next }) } const guardarAbono = async (datos: { monto: number; fechaPago: string; referencia: string }) => { if (!pagoAbono) return try { const r = await registrarAbonoAsync(plan.id, datos, pagoAbono.id) setPlan(r.plan) setOffline(r.origen === 'local') setPagoAbono(null) setError(null) } catch (e) { setError(e instanceof Error ? e.message : 'Error al registrar el abono') } } /** Cancela el plan y registra el motivo (+comentario) en la bitácora */ const confirmarCancelacion = async (motivo: string, comentario: string) => { if (!plan) return setCancelando(true) try { const r = await cancelarPlanAsync(plan.id) setPlan(r.plan) setOffline(r.origen === 'local') setError(null) setCancelando(false) setModalCancelar(false) // El motivo queda asentado en la bitácora (best-effort) const detalle = `Plan cancelado. Motivo: ${motivo}${comentario !== '' ? ` — ${comentario}` : ''}` try { const rn = await agregarNotaAsync(r.plan, detalle) setPlan(rn.plan) } catch { // la cancelación ya se aplicó; la nota es complementaria } } catch (e) { setCancelando(false) setError(e instanceof Error ? e.message : 'Error al cancelar el plan') } } const guardarNota = async () => { try { const r = await agregarNotaAsync(plan, notaTexto) setPlan(r.plan) setOffline(r.origen === 'local') setNotaTexto('') setErrorNota(null) } catch (e) { setErrorNota(e instanceof Error ? e.message : 'Error al guardar la nota') } } /** Guarda la tasa de mora mensual del plan (PATCH /planes/:id) */ const guardarTasaMora = async () => { if (!tasaCapturadaValida) { setErrorMora('Captura una tasa mensual entre 0 y 100 (%)') return } setGuardandoMora(true) setErrorMora(null) setAvisoMora(null) try { const r = await actualizarPlanAsync(plan.id, { tasaMoraMensual: round2(tasaCapturada) }) setPlan(r.plan) setOffline(r.origen === 'local') setAvisoMora( r.origen === 'local' ? 'Sin conexión al API: la tasa se guardó solo en este navegador.' : 'Tasa de mora actualizada.', ) } catch (e) { setErrorMora(e instanceof Error ? e.message : 'Error al guardar la tasa de mora') } finally { setGuardandoMora(false) } } const avance = avancePlan(plan) /** Siguiente pago sin cubrir (futuro próximo o vencido más antiguo) */ const proximoPago = status === 'ACTIVO' ? proximoVencimiento(plan, hoy) : null /** Abonar al próximo pago: hace scroll a su fila y abre el modal prellenado */ const abonarProximoPago = (pago: Pago) => { document.getElementById(`pago-${pago.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }) setPagoAbono(pago) } return (
← Volver al dashboard

{plan.clienteNombre}

{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '} {status === 'SIN_PLAN' ? 'SIN PLAN CAPTURADO' : status} {atraso > 0 && · {atraso} días de atraso}

Tel. {plan.clienteTelefono || '—'} {tasaMora > 0 && · Mora {tasaMora}% mensual}

{proximoPago && ( )}
{offline && (

Modo offline: sin conexión al API; los cambios se guardan solo en este navegador.

)} {status === 'SIN_PLAN' && (
Sin plan capturado: este plan no tiene calendario de pagos (enganche y mensualidades en cero), así que no aparece en “Cobranza del día” ni en el reporte de mora. No es un plan liquidado: hay que capturar su calendario para poder cobrarlo.
)} {error && (
{error}
)} {/* En lg: columna principal (flex-1) + rail derecho sticky de ~340px */}
{/* Columna principal */}
{/* Resumen del plan */}

Resumen del plan

{mora > 0 && ( {formatoMoneda(mora)}} detalle={`Tasa ${tasaMora}% mensual`} /> )}
{/* Barra de avance */}
Avance del plan {formatoPorcentaje(avance)}
{/* Timeline visual de pagos */}

Línea de pagos

{/* Tabla de pagos */}
{tasaMora > 0 && } {plan.pagos.map((pago) => { const st = statusPago(pago, hoy) const restante = round2(pago.monto - pago.montoPagado) const moraPago = tasaMora > 0 ? moraDePago(pago, tasaMora, hoy) : 0 const expandido = expandidos.has(pago.id) return ( {tasaMora > 0 && ( )} {expandido && ( )} ) })}
# Vencimiento Monto Pagado RestanteMoraStatus Abonos
{pago.numPago} {formatoFechaCorta(pago.fechaVencimiento)} {formatoMoneda(pago.monto)} {formatoMoneda(pago.montoPagado)} {formatoMoneda(restante)} {moraPago > 0 ? ( {formatoMoneda(moraPago)} ) : ( — )} {pago.aplicaciones.length > 0 ? ( ) : ( — )} {st !== 'PAGADO' && status === 'ACTIVO' && ( )}
0 ? 9 : 8} className="px-4 py-4"> {/* Detalle del pago vencido */}
Vencimiento:{' '} {formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))}
Monto:{' '} {formatoMoneda(pago.monto)}
Pagado:{' '} {formatoMoneda(pago.montoPagado)}
Restante:{' '} 0 ? 'text-red-700' : 'text-green-700'}`}>{formatoMoneda(restante)}
{/* Historial de abonos */}

Historial de abonos

{pago.aplicaciones.map((a) => ( ))}
Fecha pago Vencimiento Monto Referencia Recibo
{formatoFechaCorta(a.fechaPago.slice(0, 10))} {formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))} {formatoMoneda(a.monto)} {a.referencia || '—'}
{/* Rail derecho: acciones y bitácora */}