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

@@ -0,0 +1,79 @@
import { useState, type FormEvent } from 'react'
/**
* Motivos de cancelación: mismas categorías del vocabulario CRM FRASA
* (MOTIVOS_NO_COMPRA) que usa el descarte en Mi día; el motivo queda registrado
* en la bitácora del plan.
*/
const MOTIVOS_CANCELACION = ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'] as const
/**
* Modal de cancelación de plan: pide motivo (+ comentario opcional) antes de
* dar de baja el cobro. Sustituye al window.confirm anterior.
*/
export default function ModalCancelarPlan({
clienteNombre,
onCancelar,
onConfirmar,
enviando,
}: {
clienteNombre: string
onCancelar: () => void
onConfirmar: (motivo: string, comentario: string) => void
enviando: boolean
}) {
const [motivo, setMotivo] = useState<string>('Timing')
const [comentario, setComentario] = useState('')
return (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Cancelar plan</p>
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{clienteNombre}</p>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
El saldo pendiente dejará de cobrarse y el plan pasará a CANCELADO.
</p>
<form
className="mt-6 space-y-4"
onSubmit={(e: FormEvent) => {
e.preventDefault()
onConfirmar(motivo, comentario.trim())
}}
>
<div>
<label htmlFor="motivo-cancel-plan" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Motivo</label>
<select
id="motivo-cancel-plan"
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
className="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"
>
{MOTIVOS_CANCELACION.map((m) => (
<option key={m} value={m}>{m === 'Ubicacion' ? 'Ubicación' : m}</option>
))}
</select>
</div>
<div>
<label htmlFor="comentario-cancel-plan" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Comentario (opcional)</label>
<textarea
id="comentario-cancel-plan"
value={comentario}
onChange={(e) => setComentario(e.target.value)}
rows={3}
className="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"
/>
</div>
<div className="flex justify-end gap-3">
<button type="button" onClick={onCancelar} disabled={enviando}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-sm font-medium text-[#374151] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] hover:bg-[#F9FAFB] disabled:opacity-50">
Cancelar
</button>
<button type="submit" disabled={enviando}
className="rounded-lg bg-red-600 px-5 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 disabled:opacity-50">
{enviando ? 'Procesando…' : 'Confirmar cancelación'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { request } from '../services/api'
import { formatoMoneda } from '../lib/formatters'
@@ -29,7 +29,35 @@ function abrirWhatsApp(telefono: string) {
window.open(`https://wa.me/${telefonoParaEnlace(telefono)}`, '_blank')
}
export default function MoraDashboard() {
/** Título del reporte con el alcance visible (toggle "Mis planes / Todos") */
function EncabezadoMora({ alcance }: { alcance: AlcanceMora }) {
return (
<div className="flex items-center gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Reporte de mora
</h2>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
alcance === 'mios' ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-600'
}`}
title={alcance === 'mios' ? 'Solo los planes de tu alcance' : 'Todos los planes del sistema'}
>
{alcance === 'mios' ? 'Mis planes' : 'Global'}
</span>
</div>
)
}
/** Alcance del reporte: los planes cargados en el dashboard ("mios") o todo */
type AlcanceMora = 'mios' | 'global'
interface Props {
alcance?: AlcanceMora
/** ids de los planes del alcance actual (los mismos que lista la tabla de planes) */
planIds?: Set<string>
}
export default function MoraDashboard({ alcance = 'global', planIds }: Props) {
const navigate = useNavigate()
const [datos, setDatos] = useState<MoraResponse | null>(null)
const [cargando, setCargando] = useState(true)
@@ -50,6 +78,22 @@ export default function MoraDashboard() {
useEffect(() => { void cargar() }, [cargar])
// El endpoint /reportes/mora es global: con alcance "mios" se filtra por los
// planes cargados en el dashboard y los KPIs se recalculan sobre esas filas.
const filas = useMemo(() => {
if (!datos) return []
if (alcance !== 'mios' || !planIds) return datos.clientes
return datos.clientes.filter((c) => planIds.has(c.planId))
}, [datos, alcance, planIds])
const resumen = useMemo(
() => ({
total: filas.length,
monto: Math.round(filas.reduce((acc, c) => acc + c.monto, 0) * 100) / 100,
promedio: filas.length > 0 ? Math.round(filas.reduce((acc, c) => acc + c.diasAtraso, 0) / filas.length) : 0,
}),
[filas],
)
if (cargando) {
return (
<div className="flex h-32 items-center justify-center rounded-xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
@@ -69,23 +113,31 @@ export default function MoraDashboard() {
)
}
if (!datos || datos.clientes.length === 0) {
if (!datos || filas.length === 0) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-center dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">No hay clientes con pagos vencidos</p>
<div>
<EncabezadoMora alcance={alcance} />
<div className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-center dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
{alcance === 'mios'
? 'No hay clientes con pagos vencidos en tu alcance'
: 'No hay clientes con pagos vencidos'}
</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
<EncabezadoMora alcance={alcance} />
<div className="grid gap-4 sm:grid-cols-3">
<KpiCard titulo="Total morosos" valor={datos.clientesConMora} />
<KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} />
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedioAtraso} />
<KpiCard titulo="Total morosos" valor={resumen.total} />
<KpiCard titulo="Monto vencido" valor={formatoMoneda(resumen.monto)} />
<KpiCard titulo="Días promedio atraso" valor={resumen.promedio} />
</div>
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B]">
<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]">
@@ -99,7 +151,7 @@ export default function MoraDashboard() {
</tr>
</thead>
<tbody className="divide-y divide-[#E5E7EB]">
{datos.clientes.map((c) => (
{filas.map((c) => (
<tr key={`${c.planId}-${c.pagoVencido}`} className="transition-colors hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]">
<td className="px-4 py-3 font-medium text-[#111827] dark:text-[#E2E8F0]">{c.clienteNombre}</td>
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.unidadCodigo}</td>

View File

@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react'
function esMismoDia(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
/**
* Fecha "hoy" para cálculos de cobranza (atrasos, vencimientos, mora). Se
* recalcula al enfocar la ventana o al volver a la pestaña, de modo que dejar
* la app abierta de un día para otro no muestre atrasos del día congelado.
* Devuelve la misma instancia mientras no cambie el día (render estable).
*/
export function useHoy(): Date {
const [hoy, setHoy] = useState(() => new Date())
useEffect(() => {
const actualizar = () => {
const ahora = new Date()
setHoy((prev) => (esMismoDia(prev, ahora) ? prev : ahora))
}
window.addEventListener('focus', actualizar)
document.addEventListener('visibilitychange', actualizar)
return () => {
window.removeEventListener('focus', actualizar)
document.removeEventListener('visibilitychange', actualizar)
}
}, [])
return hoy
}

View File

@@ -29,6 +29,7 @@ import {
sumarMeses,
} from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
import { useHoy } from '../lib/useHoy'
import MoraDashboard from "../components/MoraDashboard"
import KpiCard from '../components/KpiCard'
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
@@ -62,7 +63,9 @@ export default function DashboardPage() {
// param, los vendedores arrancan en "Mis planes" y el resto en "Todos"
const paramVista = searchParams.get('vista')
const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol)
const hoy = useMemo(() => new Date(), [])
// "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la
// pestaña queda abierta de un día para otro)
const hoy = useHoy()
useEffect(() => {
let activo = true
@@ -81,6 +84,7 @@ export default function DashboardPage() {
}, [mios])
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
const idsPlan = useMemo(() => new Set(planes.map((p) => p.id)), [planes])
const actualizarParams = (cambios: Record<string, string | null>) => {
setSearchParams(
@@ -237,7 +241,7 @@ export default function DashboardPage() {
)}
{/* KPIs */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4 xl:grid-cols-5">
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4">
<KpiCard titulo="Planes activos" valor={activos.length} />
<KpiCard
titulo="Al corriente / con atraso"
@@ -295,7 +299,7 @@ export default function DashboardPage() {
</div>
{/* Tarjeta unificada: toolbar de filtros/búsqueda + tabla de planes */}
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
{/* Filtros */}
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 md:gap-4 md:px-5">
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
@@ -426,7 +430,7 @@ export default function DashboardPage() {
</div>
</div>
<section className="mt-8">
<MoraDashboard />
<MoraDashboard alcance={mios ? 'mios' : 'global'} planIds={idsPlan} />
</section>
</div>
)

View File

@@ -4,13 +4,16 @@ import { Link } from 'react-router-dom'
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
import { useHoy } from '../lib/useHoy'
import KpiCard from '../components/KpiCard'
/** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */
export default function HoyPage() {
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
const [errorCarga, setErrorCarga] = useState<string | null>(null)
const hoy = useMemo(() => new Date(), [])
// "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la
// pestaña queda abierta de un día para otro)
const hoy = useHoy()
useEffect(() => {
let activo = true
@@ -63,7 +66,7 @@ export default function HoyPage() {
<KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} />
</div>
<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]">

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>
)
}