feat(mejoras): 22 mejoras UX en todos los módulos

Mi día: cancelación con motivo, conteo escrituración real, cita sin retroceder, calendario completo
Cobranza: MoraDashboard respeta alcance, hoy dinámico, cancelar con motivo
Landing: contactos paginados, error vs vacío diferenciado
Dashboards: KPI renombrado, drill-down planes activos
Chat: historial recarga, preguntas rotativas, error solo toast
Transversal: shadow-sm corrupto corregido en 9 archivos

553 API tests + 7 apps construidas
This commit is contained in:
2026-09-11 12:48:47 +00:00
parent 89d95eb53b
commit 1ba7aa49da
28 changed files with 518 additions and 160 deletions

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState } from 'react'
import { Fragment, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import type { Pago, PlanPago } from '../types'
import {
@@ -22,9 +22,11 @@ import {
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() {
@@ -35,11 +37,15 @@ export default function PlanDetallePage() {
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)
const hoy = useMemo(() => new Date(), [])
// "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) {
@@ -74,7 +80,7 @@ export default function PlanDetallePage() {
if (!plan) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p- dark:border-[#1E293B] dark:bg-[#1E293B] 16 text-center shadow-sm">
<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
@@ -111,14 +117,27 @@ export default function PlanDetallePage() {
}
}
const cancelar = async () => {
if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return
/** 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')
}
}
@@ -244,7 +263,7 @@ export default function PlanDetallePage() {
</section>
{/* Tabla de pagos */}
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
<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]">
@@ -393,7 +412,7 @@ export default function PlanDetallePage() {
</button>
{status === 'ACTIVO' && saldo > 0 && (
<button
onClick={cancelar}
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
@@ -477,6 +496,15 @@ export default function PlanDetallePage() {
{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>
)
}