Files
FRASA-CAS/apps/cobranza/src/pages/PlanDetallePage.tsx
Consult AS Technology ecd4edac02 feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing
- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador,
  combobox de unidades, vigencia, PDF ANEXO A
- Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE,
  checklist de expediente, firma digital, 5 PDFs legales
- Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF,
  bitacora, dashboard con filtros
- Landing (/): entrada al ecosistema
- Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps
- 123 tests vitest
2026-08-21 22:27:36 +00:00

325 lines
14 KiB
TypeScript

import { Fragment, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import type { Pago, PlanPago } from '../types'
import { agregarNota, cancelarPlan, obtenerPlan, registrarAbonoEnPlan } from '../services/repository'
import {
avancePlan,
diasAtraso,
moraDePago,
pagadoTotal,
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 BadgeStatus from '../components/BadgeStatus'
import KpiCard from '../components/KpiCard'
import ModalAbono from '../components/ModalAbono'
export default function PlanDetallePage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [plan, setPlan] = useState<PlanPago | undefined>(() => (id ? obtenerPlan(id) : undefined))
const [pagoAbono, setPagoAbono] = useState<Pago | null>(null)
const [expandidos, setExpandidos] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const [notaTexto, setNotaTexto] = useState('')
const [errorNota, setErrorNota] = useState<string | null>(null)
const hoy = useMemo(() => new Date(), [])
if (!plan) {
return (
<div className="border border-neutral-200 bg-white p-16 text-center">
<p className="text-neutral-500">Plan no encontrado.</p>
<Link to="/" className="mt-4 inline-block text-accent hover:underline">
Volver al dashboard
</Link>
</div>
)
}
const status = statusPlan(plan)
const saldo = saldoPlan(plan)
const atraso = diasAtraso(plan, hoy)
const tasaMora = plan.tasaMoraMensual ?? 0
const mora = totalMora(plan, hoy)
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 = (datos: { monto: number; fechaPago: string; referencia: string }) => {
if (!pagoAbono) return
try {
const actualizado = registrarAbonoEnPlan(plan.id, datos, pagoAbono.id)
setPlan(actualizado)
setPagoAbono(null)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Error al registrar el abono')
}
}
const cancelar = () => {
if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return
setPlan(cancelarPlan(plan.id))
}
const guardarNota = () => {
try {
setPlan(agregarNota(plan.id, notaTexto))
setNotaTexto('')
setErrorNota(null)
} catch (e) {
setErrorNota(e instanceof Error ? e.message : 'Error al guardar la nota')
}
}
return (
<div className="space-y-16">
<div className="flex flex-wrap items-start justify-between gap-6">
<div>
<Link to="/" className="text-sm text-accent hover:underline">
← Volver al dashboard
</Link>
<h1 className="mt-2 text-3xl font-light text-neutral-900">{plan.clienteNombre}</h1>
<p className="mt-1 text-sm text-neutral-500">
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
<span className="font-bold text-neutral-900">{status}</span>
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
</p>
<p className="mt-1 text-sm text-neutral-500">
Tel. {plan.clienteTelefono || '—'}
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => descargar(plan)}
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Estado de cuenta PDF
</button>
{status === 'ACTIVO' && saldo > 0 && (
<button
onClick={cancelar}
className="border border-red-300 bg-white px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-700 hover:text-white"
>
Cancelar plan
</button>
)}
</div>
</div>
{error && (
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{error}</div>
)}
{/* KPIs del plan */}
<div className="grid grid-cols-2 gap-6 lg:grid-cols-5">
<KpiCard titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} />
<KpiCard titulo="Pagado" valor={formatoMoneda(pagadoTotal(plan))} />
<KpiCard titulo="Saldo" valor={formatoMoneda(saldo)} />
<KpiCard titulo="% avance" valor={formatoPorcentaje(avancePlan(plan))} />
<KpiCard titulo="Mensualidad" valor={formatoMoneda(plan.mensualidad)} detalle={`${plan.numPagos} pagos`} />
{mora > 0 && (
<KpiCard
titulo="Mora acumulada"
valor={<span className="text-red-700">{formatoMoneda(mora)}</span>}
detalle={`Tasa ${tasaMora}% mensual`}
/>
)}
</div>
{/* Tabla de pagos */}
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
<th scope="col" className="py-3 pr-4">#</th>
<th scope="col" className="py-3 pr-4">Vencimiento</th>
<th scope="col" className="py-3 pr-4 text-right">Monto</th>
<th scope="col" className="py-3 pr-4 text-right">Pagado</th>
<th scope="col" className="py-3 pr-4 text-right">Restante</th>
{tasaMora > 0 && <th scope="col" className="py-3 pr-4 text-right">Mora</th>}
<th scope="col" className="py-3 pr-4">Status</th>
<th scope="col" className="py-3 pr-4">Abonos</th>
<th scope="col" className="py-3" />
</tr>
</thead>
<tbody>
{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 (
<Fragment key={pago.id}>
<tr className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50">
<td className="py-3 pr-4 font-medium">{pago.numPago}</td>
<td className="py-3 pr-4">{formatoFechaCorta(pago.fechaVencimiento)}</td>
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.monto)}</td>
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.montoPagado)}</td>
<td className="py-3 pr-4 text-right font-medium">{formatoMoneda(restante)}</td>
{tasaMora > 0 && (
<td className="py-3 pr-4 text-right">
{moraPago > 0 ? (
<span className="text-red-700">{formatoMoneda(moraPago)}</span>
) : (
<span className="text-neutral-400">—</span>
)}
</td>
)}
<td className="py-3 pr-4">
<BadgeStatus status={st} />
</td>
<td className="py-3 pr-4">
{pago.aplicaciones.length > 0 ? (
<button
onClick={() => toggleExpandido(pago.id)}
className="text-accent hover:underline"
>
{expandido ? 'Ocultar' : `Ver (${pago.aplicaciones.length})`}
</button>
) : (
<span className="text-neutral-400">—</span>
)}
</td>
<td className="py-3 text-right">
{st !== 'PAGADO' && status === 'ACTIVO' && (
<button
onClick={() => setPagoAbono(pago)}
className="bg-accent px-4 py-2 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
>
Registrar abono
</button>
)}
</td>
</tr>
{expandido && (
<tr className="border-b border-neutral-200 bg-neutral-50">
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
<table className="w-full text-xs">
<thead>
<tr className="text-left font-bold uppercase tracking-widest text-neutral-500">
<th scope="col" className="py-1">Fecha</th>
<th scope="col" className="py-1 text-right">Monto</th>
<th scope="col" className="py-1">Referencia</th>
<th scope="col" className="py-1 text-right">Recibo</th>
</tr>
</thead>
<tbody>
{pago.aplicaciones.map((a) => (
<tr key={a.id} className="border-t border-neutral-200">
<td className="py-1">{formatoFechaCorta(a.fechaPago)}</td>
<td className="py-1 text-right">{formatoMoneda(a.monto)}</td>
<td className="py-1">{a.referencia}</td>
<td className="py-1 text-right">
<button
onClick={() => descargarRecibo(plan, pago, a)}
aria-label={`Descargar recibo PDF del abono ${a.referencia}`}
className="text-accent hover:underline"
>
Recibo PDF
</button>
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
{/* Bitácora del plan */}
<section className="border border-neutral-200 bg-white p-8" aria-labelledby="titulo-bitacora">
<h2 id="titulo-bitacora" className="text-xs font-bold uppercase tracking-widest text-neutral-500">
Bitácora
</h2>
{(plan.notas ?? []).length === 0 ? (
<p className="mt-6 text-sm text-neutral-500">Sin notas registradas.</p>
) : (
<ul className="mt-6 space-y-4">
{[...(plan.notas ?? [])]
.sort((a, b) => b.fecha.localeCompare(a.fecha))
.map((nota) => (
<li key={nota.id} className="border-l-2 border-neutral-200 pl-4">
<p className="text-xs text-neutral-500">
{new Date(nota.fecha).toLocaleString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
<p className="mt-1 text-sm text-neutral-900">{nota.texto}</p>
</li>
))}
</ul>
)}
<form
className="mt-8"
onSubmit={(e) => {
e.preventDefault()
guardarNota()
}}
>
<label
htmlFor="nota-texto"
className="block text-xs font-bold uppercase tracking-widest text-neutral-500"
>
Nueva nota
</label>
<textarea
id="nota-texto"
value={notaTexto}
onChange={(e) => setNotaTexto(e.target.value)}
rows={3}
placeholder="Gestión realizada, acuerdos con el cliente…"
className="mt-1 w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
/>
{errorNota && <p className="mt-1 text-xs text-red-700">{errorNota}</p>}
<div className="mt-4 flex justify-end">
<button
type="submit"
disabled={notaTexto.trim().length < 3}
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
>
Agregar nota
</button>
</div>
</form>
</section>
{status === 'CANCELADO' && (
<button
onClick={() => void navigate('/')}
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Volver
</button>
)}
{pagoAbono && (
<ModalAbono pago={pagoAbono} onCancelar={() => setPagoAbono(null)} onGuardar={guardarAbono} />
)}
</div>
)
}