635 lines
28 KiB
TypeScript
635 lines
28 KiB
TypeScript
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<PlanPago | undefined>(undefined)
|
|
const [cargando, setCargando] = useState(true)
|
|
const [offline, setOffline] = useState(false)
|
|
const [errorCarga, setErrorCarga] = useState<string | null>(null)
|
|
const [pagoAbono, setPagoAbono] = useState<Pago | null>(null)
|
|
const [modalCancelar, setModalCancelar] = useState(false)
|
|
const [cancelando, setCancelando] = useState(false)
|
|
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)
|
|
// 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<string | null>(null)
|
|
const [avisoMora, setAvisoMora] = useState<string | null>(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 <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando...</p>
|
|
}
|
|
|
|
if (errorCarga) {
|
|
return <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
|
}
|
|
|
|
if (!plan) {
|
|
return (
|
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-16 text-center shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
|
<p className="text-[#64748B] dark:text-[#94A3B8]">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)
|
|
// 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 (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<Link to="/" className="text-sm text-accent hover:underline">
|
|
← Volver al dashboard
|
|
</Link>
|
|
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
|
|
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
|
|
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
|
{status === 'SIN_PLAN' ? 'SIN PLAN CAPTURADO' : status}
|
|
</span>
|
|
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
|
|
</p>
|
|
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
Tel. {plan.clienteTelefono || '—'}
|
|
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
|
|
</p>
|
|
</div>
|
|
{proximoPago && (
|
|
<button
|
|
type="button"
|
|
onClick={() => abonarProximoPago(proximoPago)}
|
|
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"
|
|
aria-label="Registrar abono al próximo pago no cubierto"
|
|
>
|
|
Abonar al próximo pago
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{offline && (
|
|
<p className="text-xs text-amber-700">
|
|
Modo offline: sin conexión al API; los cambios se guardan solo en este navegador.
|
|
</p>
|
|
)}
|
|
|
|
{status === 'SIN_PLAN' && (
|
|
<div className="rounded-lg border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800">
|
|
<strong>Sin plan capturado:</strong> 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.
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
|
)}
|
|
|
|
{/* En lg: columna principal (flex-1) + rail derecho sticky de ~340px */}
|
|
<div className="space-y-6 lg:flex lg:items-start lg:gap-6 lg:space-y-0">
|
|
{/* Columna principal */}
|
|
<div className="min-w-0 flex-1 space-y-6">
|
|
{/* Resumen del plan */}
|
|
<section
|
|
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:p-6"
|
|
aria-labelledby="titulo-resumen"
|
|
>
|
|
<h2 id="titulo-resumen" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
Resumen del plan
|
|
</h2>
|
|
<div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">
|
|
<KpiCard plano titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} />
|
|
<KpiCard plano titulo="Pagado" valor={formatoMoneda(pagadoTotal(plan))} />
|
|
<KpiCard plano titulo="Saldo" valor={formatoMoneda(saldo)} />
|
|
<KpiCard plano titulo="% avance" valor={formatoPorcentaje(avance)} />
|
|
<KpiCard
|
|
plano
|
|
titulo="Mensualidad"
|
|
valor={formatoMoneda(plan.mensualidad)}
|
|
detalle={`${plan.numPagos} pagos`}
|
|
/>
|
|
{mora > 0 && (
|
|
<KpiCard
|
|
plano
|
|
titulo="Mora acumulada"
|
|
valor={<span className="text-red-700">{formatoMoneda(mora)}</span>}
|
|
detalle={`Tasa ${tasaMora}% mensual`}
|
|
/>
|
|
)}
|
|
</div>
|
|
{/* Barra de avance */}
|
|
<div className="mt-6">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="font-medium text-[#64748B] dark:text-[#94A3B8]">Avance del plan</span>
|
|
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{formatoPorcentaje(avance)}</span>
|
|
</div>
|
|
<div className="mt-2 h-2 rounded-full bg-[#E5E7EB]">
|
|
<div
|
|
className="h-full rounded-full bg-accent"
|
|
style={{ width: `${Math.min(100, avance * 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Timeline visual de pagos */}
|
|
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" aria-labelledby="titulo-timeline">
|
|
<h2 id="titulo-timeline" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
Línea de pagos
|
|
</h2>
|
|
<div className="mt-4">
|
|
<TimelinePagos pagos={plan.pagos} />
|
|
</div>
|
|
</section>
|
|
|
|
{/* Tabla de pagos */}
|
|
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
|
<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">#</th>
|
|
<th scope="col" className="px-4 py-3">Vencimiento</th>
|
|
<th scope="col" className="px-4 py-3 text-right">Monto</th>
|
|
<th scope="col" className="px-4 py-3 text-right">Pagado</th>
|
|
<th scope="col" className="px-4 py-3 text-right">Restante</th>
|
|
{tasaMora > 0 && <th scope="col" className="px-4 py-3 text-right">Mora</th>}
|
|
<th scope="col" className="px-4 py-3">Status</th>
|
|
<th scope="col" className="px-4 py-3">Abonos</th>
|
|
<th scope="col" className="px-4 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[#E5E7EB]">
|
|
{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 id={`pago-${pago.id}`} className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
<td className="px-4 py-3 font-medium">{pago.numPago}</td>
|
|
<td className="px-4 py-3">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
|
<td className="px-4 py-3 text-right">{formatoMoneda(pago.monto)}</td>
|
|
<td className="px-4 py-3 text-right">{formatoMoneda(pago.montoPagado)}</td>
|
|
<td className="px-4 py-3 text-right font-medium">{formatoMoneda(restante)}</td>
|
|
{tasaMora > 0 && (
|
|
<td className="px-4 py-3 text-right">
|
|
{moraPago > 0 ? (
|
|
<span className="text-red-700">{formatoMoneda(moraPago)}</span>
|
|
) : (
|
|
<span className="text-neutral-400">—</span>
|
|
)}
|
|
</td>
|
|
)}
|
|
<td className="px-4 py-3">
|
|
<BadgeStatus status={st} />
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{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="px-4 py-3 text-right">
|
|
{st !== 'PAGADO' && status === 'ACTIVO' && (
|
|
<button
|
|
onClick={() => setPagoAbono(pago)}
|
|
className="rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
|
>
|
|
Registrar abono
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
{expandido && (
|
|
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
|
|
{/* Detalle del pago vencido */}
|
|
<div className="mb-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
|
|
<div>
|
|
<span className="font-semibold text-[#64748B] dark:text-[#94A3B8]">Vencimiento:</span>{' '}
|
|
<span className="text-[#111827] dark:text-[#E2E8F0]">{formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))}</span>
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold text-[#64748B] dark:text-[#94A3B8]">Monto:</span>{' '}
|
|
<span className="text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(pago.monto)}</span>
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold text-[#64748B] dark:text-[#94A3B8]">Pagado:</span>{' '}
|
|
<span className="text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(pago.montoPagado)}</span>
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold text-[#64748B] dark:text-[#94A3B8]">Restante:</span>{' '}
|
|
<span className={`font-medium ${restante > 0 ? 'text-red-700' : 'text-green-700'}`}>{formatoMoneda(restante)}</span>
|
|
</div>
|
|
</div>
|
|
{/* Historial de abonos */}
|
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
Historial de abonos
|
|
</p>
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-left font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
<th scope="col" className="py-1">Fecha pago</th>
|
|
<th scope="col" className="py-1">Vencimiento</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-[#E5E7EB] dark:border-[#1E293B]">
|
|
<td className="py-1">{formatoFechaCorta(a.fechaPago.slice(0, 10))}</td>
|
|
<td className="py-1 text-[#94A3B8]">{formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))}</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>
|
|
</div>
|
|
|
|
{/* Rail derecho: acciones y bitácora */}
|
|
<aside className="space-y-6 lg:sticky lg:top-20 lg:w-[340px] lg:shrink-0">
|
|
<section
|
|
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
|
|
aria-labelledby="titulo-acciones"
|
|
>
|
|
<h2 id="titulo-acciones" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
Acciones
|
|
</h2>
|
|
<div className="mt-4 space-y-3">
|
|
<button
|
|
onClick={() => descargar(plan)}
|
|
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
>
|
|
Estado de cuenta PDF
|
|
</button>
|
|
{status === 'ACTIVO' && saldo > 0 && (
|
|
<button
|
|
onClick={() => setModalCancelar(true)}
|
|
className="w-full rounded-lg border border-red-600 bg-white px-5 py-2.5 text-sm font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-600 hover:text-white"
|
|
>
|
|
Cancelar plan
|
|
</button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Mora del plan: la tasa vive en el plan y la editan admin/manager/dirección */}
|
|
<section
|
|
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
|
|
aria-labelledby="titulo-mora"
|
|
>
|
|
<h2 id="titulo-mora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
Mora por atraso
|
|
</h2>
|
|
{puedeEditarTasa ? (
|
|
<form
|
|
className="mt-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
void guardarTasaMora()
|
|
}}
|
|
>
|
|
<label htmlFor="tasa-mora" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
|
|
Tasa de mora mensual (%)
|
|
</label>
|
|
<input
|
|
id="tasa-mora"
|
|
type="number"
|
|
min={0}
|
|
max={100}
|
|
step="0.01"
|
|
inputMode="decimal"
|
|
value={tasaInput}
|
|
onChange={(e) => {
|
|
setTasaInput(e.target.value)
|
|
setErrorMora(null)
|
|
setAvisoMora(null)
|
|
}}
|
|
className="mt-1 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
|
|
/>
|
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
La tasa es <strong>mensual en %</strong> y se prorratea por día (tasa ÷ 30) sobre el restante
|
|
de cada pago vencido, incluidos los parciales vencidos. 0 = sin mora.
|
|
</p>
|
|
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
Mora acumulada con esta tasa:{' '}
|
|
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
|
{formatoMoneda(moraConTasaCapturada)}
|
|
</span>
|
|
</p>
|
|
{errorMora && <p className="mt-1 text-xs text-red-700">{errorMora}</p>}
|
|
{avisoMora && <p className="mt-1 text-xs text-green-700">{avisoMora}</p>}
|
|
<div className="mt-4 flex justify-end">
|
|
<button
|
|
type="submit"
|
|
disabled={guardandoMora || !tasaCapturadaValida || tasaSinCambios}
|
|
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 disabled:opacity-40"
|
|
>
|
|
{guardandoMora ? 'Guardando…' : 'Guardar tasa'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<div className="mt-4 space-y-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
<p>
|
|
Tasa: <span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{tasaMora}% mensual</span>
|
|
</p>
|
|
<p>
|
|
Mora acumulada: <span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(mora)}</span>
|
|
</p>
|
|
<p className="text-xs">Solo admin, manager y dirección pueden modificar la tasa.</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Bitácora del plan */}
|
|
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" aria-labelledby="titulo-bitacora">
|
|
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
Bitácora
|
|
</h2>
|
|
{(plan.notas ?? []).length === 0 ? (
|
|
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">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-[#E5E7EB] pl-4">
|
|
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
{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-[#111827] dark:text-[#E2E8F0]">{nota.texto}</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<form
|
|
className="mt-6"
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
void guardarNota()
|
|
}}
|
|
>
|
|
<label
|
|
htmlFor="nota-texto"
|
|
className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
|
>
|
|
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 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"
|
|
/>
|
|
{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="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 disabled:opacity-40"
|
|
>
|
|
Agregar nota
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
</aside>
|
|
</div>
|
|
|
|
{status === 'CANCELADO' && (
|
|
<button
|
|
onClick={() => void navigate('/')}
|
|
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
>
|
|
Volver
|
|
</button>
|
|
)}
|
|
|
|
{pagoAbono && (
|
|
<ModalAbono pago={pagoAbono} onCancelar={() => setPagoAbono(null)} onGuardar={guardarAbono} />
|
|
)}
|
|
|
|
{modalCancelar && plan && (
|
|
<ModalCancelarPlan
|
|
clienteNombre={plan.clienteNombre}
|
|
enviando={cancelando}
|
|
onCancelar={() => setModalCancelar(false)}
|
|
onConfirmar={(motivo, comentario) => void confirmarCancelacion(motivo, comentario)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|