- 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
325 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|