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