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

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { getContactos, type Contacto } from '../services/api'
import { getContactos, type Contacto, type RespuestaContactos } from '../services/api'
import PerfilContactoCompleto from './PerfilContactoCompleto'
import { SkeletonList } from "./Skeleton"
import toast from "react-hot-toast"
@@ -16,11 +16,12 @@ interface Props {
* información, propiedad, quién lo atiende e historial de atención.
*/
export function VistaContactos({ onVolver }: Props) {
const [contactos, setContactos] = useState<Contacto[] | null>(null)
const [respuesta, setRespuesta] = useState<RespuestaContactos | null>(null)
const [cargando, setCargando] = useState(true)
const [error, setError] = useState('')
const [seleccionado, setSeleccionado] = useState<Contacto | null>(null)
const [busqueda, setBusqueda] = useState('')
const contactos = respuesta?.contactos ?? null
/** Contactos filtrados por texto de búsqueda */
const contactosFiltrados = useMemo(() => {
@@ -35,12 +36,16 @@ export function VistaContactos({ onVolver }: Props) {
)
}, [contactos, busqueda])
const cargar = useCallback(() => {
const cargar = useCallback((pagina = 1) => {
setCargando(true)
setError('')
getContactos()
.then(setContactos)
.catch(() => { toast.error("No se pudieron cargar los contactos."); setError("No se pudieron cargar los contactos."); })
getContactos(pagina)
.then(setRespuesta)
.catch((err: unknown) => {
const mensaje = err instanceof Error ? err.message : 'No se pudieron cargar los contactos.'
toast.error('No se pudieron cargar los contactos.')
setError(mensaje)
})
.finally(() => setCargando(false))
}, [])
@@ -87,11 +92,11 @@ export function VistaContactos({ onVolver }: Props) {
) : error ? (
<div>
<p role="alert" className="text-sm text-red-600">
{error}
Error al cargar contactos: {error}
</p>
<button
type="button"
onClick={cargar}
onClick={() => cargar()}
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
>
Reintentar
@@ -145,7 +150,7 @@ export function VistaContactos({ onVolver }: Props) {
))}
</div>
{/* Desktop: table-style list */}
<ul className="hidden divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] md:block -sm">
<ul className="hidden divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B] md:block">
{contactosFiltrados?.map((contacto) => (
<li key={contacto.id}>
<button
@@ -168,6 +173,32 @@ export function VistaContactos({ onVolver }: Props) {
</ul>
</>
)}
{/* Paginación: el API entrega bloques de máx 100 contactos */}
{respuesta && (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
Página {respuesta.pagina} · máx {respuesta.porPagina} contactos por página
</p>
<div className="flex items-center gap-2">
<button
type="button"
disabled={cargando || respuesta.pagina <= 1}
onClick={() => cargar(respuesta.pagina - 1)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:hover:bg-[#334155] disabled:opacity-40"
>
← Anterior
</button>
<button
type="button"
disabled={cargando || !respuesta.hayMas}
onClick={() => cargar(respuesta.pagina + 1)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:hover:bg-[#334155] disabled:opacity-40"
>
Siguiente →
</button>
</div>
</div>
)}
</>
)}
</section>

View File

@@ -14,7 +14,7 @@ const ESTATUSES = ['', 'VENDIDO', 'APARTADO', 'ESCRITURADO', 'PENDIENTE', 'CANCE
/**
* Página / clientes-maestro: tabla paginada con filtros de búsqueda,
* desarrollo y estatus. Botón exportar genera xlsx desde los datos filtrados.
* desarrollo y estatus. Botón exportar genera CSV desde los datos filtrados.
*/
export function ClientesMaestroPage({ onVolver }: Props) {
const [datos, setDatos] = useState<ClienteMaestro[]>([])
@@ -70,7 +70,7 @@ export function ClientesMaestroPage({ onVolver }: Props) {
estatus: filtroEstatus || undefined,
})
// Generar CSV como fallback (xlsx no está en el bundle del front)
// Generar el archivo CSV desde los datos filtrados
const headers = ['Num Cliente', 'Nombre', 'Apellido Paterno', 'Apellido Materno', 'Sexo', 'Teléfono', 'Email', 'Estado Civil', 'Rango Edad', 'Ocupación', 'Rango Ingresos', 'Crédito', 'Institución', 'Medio Contacto', 'Canal', 'Desarrollo', 'Manzana', 'Lote', 'Interior', 'Tipo Casa', 'Etapa', 'Valor Total', 'Estatus', 'Fecha Liberación', 'Vendedor', 'Vendedor ID']
const rows = datosExport.map((c) => [
c.numCliente, c.nombre, c.apellidoPaterno, c.apellidoMaterno ?? '', c.sexo ?? '',

View File

@@ -17,12 +17,25 @@ export interface Contacto {
updatedAt: string
}
/** GET /contactos — contactos locales (máx 100) */
export async function getContactos(): Promise<Contacto[]> {
const resp = await fetch(`${API_URL}/contactos`, conAuth())
/** Respuesta paginada de GET /contactos */
export interface RespuestaContactos {
contactos: Contacto[]
pagina: number
porPagina: number
hayMas: boolean
}
/** GET /contactos — contactos locales, paginados de 100 en 100 (?pagina=N) */
export async function getContactos(pagina = 1): Promise<RespuestaContactos> {
const resp = await fetch(`${API_URL}/contactos?pagina=${pagina}`, conAuth())
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return (await resp.json()) as Contacto[]
if (!resp.ok) {
// Propagar el mensaje del API (p.ej. error upstream) para diferenciarlo
// del caso "sin contactos"
const body = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `HTTP ${resp.status}`)
}
return (await resp.json()) as RespuestaContactos
}
// ---------- Detalle de contacto (modal del módulo Contactos) ----------

View File

@@ -64,7 +64,7 @@ export async function listarClientesMaestro(filtros: FiltrosClientesMaestro = {}
return (await resp.json()) as ResultadoListado
}
/** GET /api/clientes-maestro/exportar — array de objetos para xlsx */
/** GET /api/clientes-maestro/exportar — array de objetos para generar CSV */
export async function exportarClientesMaestro(filtros: Omit<FiltrosClientesMaestro, 'pagina' | 'limite'> = {}): Promise<ClienteMaestro[]> {
const params = new URLSearchParams()
if (filtros.busqueda) params.set('busqueda', filtros.busqueda)