feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday

Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario
Mi día: modal español real, cita desde modal, score falso eliminado
Cotizador: barra sticky, persistencia local con folio
Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas
Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp
Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado
Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas
Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones

69 archivos, 7 apps construidas
This commit is contained in:
2026-09-11 06:52:37 +00:00
parent 075d47c304
commit 64b01246d6
69 changed files with 1600 additions and 1133 deletions

View File

@@ -1,36 +1,36 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { request } from '../services/api'
import { formatoMoneda } from '../lib/formatters'
import { telefonoParaEnlace } from '../lib/cobranza'
import KpiCard from './KpiCard'
/** Registro de cliente moroso del endpoint GET /api/reportes/mora */
/** Registro de cliente moroso del endpoint GET /api/reportes/mora (mora.service) */
export interface ClienteMora {
clienteId: string
clienteNombre: string
clienteTelefono: string
unidadCodigo: string
proyecto: string
pagoVencido: number // ISO fecha
pagoVencido: number // número de pago vencido
monto: number
diasAtraso: number
telefono: string | null
planId: string
}
interface MoraResponse {
totalMorosos: number
clientesConMora: number
montoTotalVencido: number
diasPromedio: number
clientes: ClienteMoria[]
diasPromedioAtraso: number
clientes: ClienteMora[]
}
/** 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')
// Misma normalización +52 que mi-dia: 10 dígitos → México
window.open(`https://wa.me/${telefonoParaEnlace(telefono)}`, '_blank')
}
export default function MoraDashboard() {
const navigate = useNavigate()
const [datos, setDatos] = useState<MoraResponse | null>(null)
const [cargando, setCargando] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -80,9 +80,9 @@ export default function MoraDashboard() {
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<KpiCard titulo="Total morosos" valor={datos.totalMorosos} />
<KpiCard titulo="Total morosos" valor={datos.clientesConMora} />
<KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} />
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedio} />
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedioAtraso} />
</div>
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B]">
@@ -100,7 +100,7 @@ export default function MoraDashboard() {
</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]">
<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>
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.proyecto}</td>
@@ -114,15 +114,25 @@ export default function MoraDashboard() {
</span>
</td>
<td className="px-4 py-3 text-right">
{c.clienteTelefono && (
<div className="flex items-center justify-end gap-2">
<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"
onClick={() => navigate(`/planes/${c.planId}`)}
className="text-xs font-medium text-accent hover:underline"
aria-label={`Ver plan de ${c.clienteNombre}`}
>
WhatsApp
Ver plan →
</button>
)}
{c.telefono && (
<button
type="button"
onClick={() => c.telefono && abrirWhatsApp(c.telefono)}
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>
)}
</div>
</td>
</tr>
))}

View File

@@ -183,6 +183,16 @@ export interface CobranzaItem {
atraso: number
}
/**
* Normaliza un teléfono a dígitos para enlaces wa.me. Si el número no trae
* lada internacional (10 dígitos), asume México (+52); si ya la trae, la respeta.
*/
export function telefonoParaEnlace(telefono: string): string {
const digitos = telefono.replace(/\D/g, '')
if (digitos.length === 10) return `52${digitos}`
return digitos
}
/**
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos

View File

@@ -370,6 +370,7 @@ export default function DashboardPage() {
<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">Teléfono</th>
<th scope="col" className="px-4 py-3">Unidad</th>
<th scope="col" className="px-4 py-3">Proyecto</th>
{encabezadoOrdenable('avance', '% pagado')}
@@ -387,6 +388,7 @@ export default function DashboardPage() {
{d.plan.clienteNombre}
</Link>
</td>
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{d.plan.clienteTelefono || '—'}</td>
<td className="px-4 py-3">{d.plan.unidadCodigo}</td>
<td className="px-4 py-3">{d.plan.proyecto}</td>
<td className="px-4 py-3">
@@ -414,7 +416,7 @@ export default function DashboardPage() {
))}
{ordenados.length === 0 && (
<tr>
<td colSpan={8} className="py-8 text-center text-neutral-500">
<td colSpan={9} className="py-8 text-center text-neutral-500">
No hay planes que coincidan con el filtro.
</td>
</tr>

View File

@@ -12,6 +12,7 @@ import {
diasAtraso,
moraDePago,
pagadoTotal,
proximoVencimiento,
round2,
saldoPlan,
statusPago,
@@ -136,22 +137,43 @@ export default function PlanDetallePage() {
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>
<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}</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 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}</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 && (
@@ -245,7 +267,7 @@ export default function PlanDetallePage() {
const expandido = expandidos.has(pago.id)
return (
<Fragment key={pago.id}>
<tr className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<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>

View File

@@ -13,6 +13,9 @@ import {
type StorageLike,
} from './repository'
import { ErrorApi } from './api'
// Misma resolución de base que api.ts: el test no depende de la VITE_API_URL local
const API_URL = import.meta.env.VITE_API_URL || '/api'
import type { PlanPago } from '../types'
/** Stub sencillo de localStorage para correr en entorno node */
@@ -120,7 +123,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(plan.tasaMoraMensual).toBeUndefined()
expect(plan).not.toHaveProperty('saldo')
expect(fetchMock).toHaveBeenCalledWith(
'http://192.168.10.123:3000/api/planes',
`${API_URL}/planes`,
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
)
})
@@ -205,7 +208,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(r.plan.id).toBe('api-nuevo')
expect(storage.getItem(CLAVE_STORAGE)).toBeNull()
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes')
expect(url).toBe(`${API_URL}/planes`)
expect(init?.method).toBe('POST')
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
expect(body.clienteNombre).toBe('Cliente Prueba')
@@ -239,7 +242,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(r.origen).toBe('api')
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/abonos')
expect(url).toBe(`${API_URL}/planes/api-1/abonos`)
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
expect(body).toEqual({ pagoId: 'pago-1', fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' })
})
@@ -291,7 +294,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
texto: 'Se habló con el cliente',
})
const [url] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/notas')
expect(url).toBe(`${API_URL}/planes/api-1/notas`)
})
it('agregarNotaAsync cae a localStorage cuando falla la red (conserva la validación local)', async () => {
@@ -317,7 +320,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(r.origen).toBe('api')
expect(r.plan.status).toBe('CANCELADO')
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/cancelar')
expect(url).toBe(`${API_URL}/planes/api-1/cancelar`)
expect(init?.method).toBe('POST')
})
@@ -361,7 +364,7 @@ describe('mis cobranzas (toggle Mis planes / Todos)', () => {
expect(r.origen).toBe('api')
expect(fetchMock).toHaveBeenCalledWith(
'http://192.168.10.123:3000/api/planes?mios=1',
`${API_URL}/planes?mios=1`,
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
)
})