feat(ux): 17 mejoras de experiencia de usuario

Backend (10 endpoints nuevos):
- Recordatorios personales (CRUD + tabla RecordatorioVendedor)
- Lead scoring 0-100 (etapa+recencia+datos)
- Dashboard mora (pagos vencidos, monto, días atraso)
- Timeline de pagos (pagado/vence/futuro)
- Perfil completo contacto (pagos+cotizaciones+docs+score)
- Exportar contactos a CSV

Frontend Mi día:
- Resumen matutino (saludo+KPIs)
- Historial visual lead (timeline)
- Score badge 0-100 (verde/amarillo/rojo)
- Vista kanban (drag & drop)

Frontend Cobranza:
- Dashboard mora (tabla+WhatsApp)
- Timeline pagos visual (nodos coloreados)

Frontend Alta Clientes:
- Checklist documentos visual
- Foto cliente (cámara)

Frontend Landing:
- Perfil contacto completo
- Exportar contactos CSV

Frontend Mi día (extras):
- Comparativa mensual
- KPIs dashboard (leads hoy, ventas, conversión)

806 tests, 5 apps construidas.
This commit is contained in:
2026-09-08 07:42:54 +00:00
parent 5063ffe281
commit db18685872
34 changed files with 2950 additions and 33 deletions

View File

@@ -0,0 +1,134 @@
import { useCallback, useEffect, useState } from 'react'
import { request } from '../services/api'
import { formatoMoneda } from '../lib/formatters'
import KpiCard from './KpiCard'
/** Registro de cliente moroso del endpoint GET /api/reportes/mora */
export interface ClienteMora {
clienteId: string
clienteNombre: string
clienteTelefono: string
unidadCodigo: string
proyecto: string
pagoVencido: string // ISO fecha
monto: number
diasAtraso: number
}
interface MoraResponse {
totalMorosos: number
montoTotalVencido: number
diasPromedio: number
clientes: ClienteMoria[]
}
/** Normaliza la respuesta del API; acepta tanto el shape del API como el local */
type ClienteMoria = ClienteMora
function abrirWhatsApp(telefono: string) {
const limpio = telefono.replace(/[^0-9+]/g, '')
window.open(`https://wa.me/${limpio.startsWith('+') ? limpio.slice(1) : limpio}`, '_blank')
}
export default function MoraDashboard() {
const [datos, setDatos] = useState<MoraResponse | null>(null)
const [cargando, setCargando] = useState(true)
const [error, setError] = useState<string | null>(null)
const cargar = useCallback(async () => {
setCargando(true)
try {
const r = await request<MoraResponse>('/reportes/mora')
setDatos(r)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar el reporte de mora')
} finally {
setCargando(false)
}
}, [])
useEffect(() => { void cargar() }, [cargar])
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]">
<p className="text-sm text-[#94A3B8]">Cargando reporte de mora…</p>
</div>
)
}
if (error) {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900/50 dark:bg-red-950/30">
<p className="text-sm font-medium text-red-700 dark:text-red-400">{error}</p>
<button type="button" onClick={() => void cargar()} className="mt-3 rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-accent-hover">
Reintentar
</button>
</div>
)
}
if (!datos || datos.clientes.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>
)
}
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<KpiCard titulo="Total morosos" valor={datos.totalMorosos} />
<KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} />
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedio} />
</div>
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow 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">Cliente</th>
<th scope="col" className="px-4 py-3">Unidad</th>
<th scope="col" className="px-4 py-3">Proyecto</th>
<th scope="col" className="px-4 py-3">Pago vencido</th>
<th scope="col" className="px-4 py-3 text-right">Monto</th>
<th scope="col" className="px-4 py-3 text-right">Días atraso</th>
<th scope="col" className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-[#E5E7EB]">
{datos.clientes.map((c) => (
<tr key={c.clienteId} 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>
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.proyecto}</td>
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.pagoVencido.slice(0, 10)}</td>
<td className="px-4 py-3 text-right font-medium text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(c.monto)}</td>
<td className="px-4 py-3 text-right">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
c.diasAtraso > 30 ? 'bg-red-100 text-red-700' : c.diasAtraso > 14 ? 'bg-amber-100 text-amber-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{c.diasAtraso} días
</span>
</td>
<td className="px-4 py-3 text-right">
{c.clienteTelefono && (
<button
type="button"
onClick={() => abrirWhatsApp(c.clienteTelefono)}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-green-700"
>
WhatsApp
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
import type { Pago, StatusPago } from '../types'
import { statusPago } from '../lib/cobranza'
/** Color del nodo según estado del pago */
function colorNodo(st: StatusPago): string {
switch (st) {
case 'PAGADO': return 'bg-green-500 border-green-600'
case 'PARCIAL': return 'bg-yellow-400 border-yellow-500'
case 'VENCIDO': return 'bg-red-500 border-red-600'
default: return 'bg-gray-300 border-gray-400 dark:bg-gray-600 dark:border-gray-500'
}
}
function etiqueta(st: StatusPago): string {
switch (st) {
case 'PAGADO': return 'Pagado'
case 'PARCIAL': return 'Parcial'
case 'VENCIDO': return 'Vencido'
default: return 'Pendiente'
}
}
function colorEtiqueta(st: StatusPago): string {
switch (st) {
case 'PAGADO': return 'text-green-700'
case 'PARCIAL': return 'text-yellow-700'
case 'VENCIDO': return 'text-red-700'
default: return 'text-gray-500'
}
}
interface TimelinePagosProps {
pagos: Pago[]
tasaMoraMensual?: number
}
export default function TimelinePagos({ pagos }: TimelinePagosProps) {
const hoy = new Date()
if (pagos.length === 0) {
return <p className="py-4 text-center text-sm text-[#94A3B8]">Sin pagos registrados</p>
}
return (
<div className="overflow-x-auto pb-2">
<div className="flex items-start gap-0 min-w-max">
{pagos.map((pago, idx) => {
const st = statusPago(pago, hoy)
return (
<div key={pago.id} className="flex flex-col items-center relative" style={{ minWidth: 100 }}>
{/* Línea conectora (excepto el primero) */}
{idx > 0 && (
<div className="absolute top-3 right-1/2 w-full h-0.5 bg-gray-200 dark:bg-[#334155] -z-0" style={{ width: 100, right: '50%' }} />
)}
{/* Nodo */}
<div className={`relative z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 ${colorNodo(st)}`}>
{st === 'PAGADO' && (
<svg className="h-3 w-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
{st === 'VENCIDO' && (
<svg className="h-3 w-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</div>
{/* Info debajo */}
<div className="mt-2 text-center px-1">
<p className="text-[10px] font-medium text-[#64748B] dark:text-[#94A3B8]">
#{pago.numPago}
</p>
<p className="text-[10px] text-[#94A3B8]">
{formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))}
</p>
<p className="text-[11px] font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(pago.monto)}
</p>
<p className={`text-[10px] font-semibold ${colorEtiqueta(st)}`}>
{etiqueta(st)}
</p>
</div>
</div>
)
})}
</div>
</div>
)
}