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:
134
apps/cobranza/src/components/MoraDashboard.tsx
Normal file
134
apps/cobranza/src/components/MoraDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
apps/cobranza/src/components/TimelinePagos.tsx
Normal file
90
apps/cobranza/src/components/TimelinePagos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user