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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' } }),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user