feat(clientes-maestro): importar y exportar clientes del archivo maestro
- Tabla ClienteMaestro (27 campos, unique numCliente) - Importador Excel: 1535 clientes del Reporte maestro - API: listar/filtros/exportar/detalle/importar - Frontend: tabla paginada, filtros, exportar Excel - Dark mode completo, 52 tests verdes
This commit is contained in:
207
apps/landing/src/components/ClientesMaestroTable.test.tsx
Normal file
207
apps/landing/src/components/ClientesMaestroTable.test.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ClientesMaestroTable } from './ClientesMaestroTable'
|
||||
import type { ClienteMaestro } from '../services/clientes-maestro'
|
||||
|
||||
const DATOS_MOCK: ClienteMaestro[] = [
|
||||
{
|
||||
id: '1', numCliente: 'C001', nombre: 'Juan', apellidoPaterno: 'Perez', apellidoMaterno: 'Lopez',
|
||||
sexo: 'M', telefono: '5512345678', email: 'juan@test.com', estadoCivil: 'Soltero',
|
||||
rangoEdad: '30-40', ocupacion: 'Ingeniero', rangoIngresos: '30k-50k', credito: 'Bancario',
|
||||
institucion: 'BBVA', medioContacto: 'Teléfono', canal: 'Directo', desarrollo: 'SENDA',
|
||||
manzana: '1', lote: '5', interior: '', tipoCasa: 'Casa', etapa: 'Entregado',
|
||||
valorTotal: 1500000, estatus: 'VENDIDO', fechaLiberacion: null, vendedor: 'Carlos Ruiz',
|
||||
vendedorId: 'V001', importadoEn: '2026-09-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2', numCliente: 'C002', nombre: 'Maria', apellidoPaterno: 'Garcia', apellidoMaterno: null,
|
||||
sexo: 'F', telefono: '5598765432', email: null, estadoCivil: 'Casada',
|
||||
rangoEdad: '40-50', ocupacion: null, rangoIngresos: null, credito: null,
|
||||
institucion: null, medioContacto: null, canal: null, desarrollo: 'DIAGONAL',
|
||||
manzana: null, lote: null, interior: null, tipoCasa: null, etapa: null,
|
||||
valorTotal: null, estatus: 'APARTADO', fechaLiberacion: null, vendedor: null,
|
||||
vendedorId: null, importadoEn: '2026-09-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
afterEach(() => { cleanup() })
|
||||
|
||||
describe('ClientesMaestroTable', () => {
|
||||
it('renderiza la tabla con headers', () => {
|
||||
render(
|
||||
<ClientesMaestroTable
|
||||
datos={DATOS_MOCK}
|
||||
total={2}
|
||||
pagina={1}
|
||||
limite={50}
|
||||
cargando={false}
|
||||
onPageChange={vi.fn()}
|
||||
onExportar={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Nombre completo')).toBeTruthy()
|
||||
expect(screen.getByText('Teléfono')).toBeTruthy()
|
||||
expect(screen.getByText('Email')).toBeTruthy()
|
||||
expect(screen.getByText('Desarrollo')).toBeTruthy()
|
||||
expect(screen.getByText('Estatus')).toBeTruthy()
|
||||
expect(screen.getByText('Valor')).toBeTruthy()
|
||||
expect(screen.getByText('Vendedor')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renderiza los datos de clientes', () => {
|
||||
render(
|
||||
<ClientesMaestroTable
|
||||
datos={DATOS_MOCK}
|
||||
total={2}
|
||||
pagina={1}
|
||||
limite={50}
|
||||
cargando={false}
|
||||
onPageChange={vi.fn()}
|
||||
onExportar={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Nombres aparecen en mobile y desktop → getAllByText
|
||||
expect(screen.getAllByText('Juan Perez Lopez').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('Maria Garcia').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('5512345678').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('SENDA').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('DIAGONAL').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('Carlos Ruiz').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('muestra "—" para campos nulos', () => {
|
||||
render(
|
||||
<ClientesMaestroTable
|
||||
datos={DATOS_MOCK}
|
||||
total={2}
|
||||
pagina={1}
|
||||
limite={50}
|
||||
cargando={false}
|
||||
onPageChange={vi.fn()}
|
||||
onExportar={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Maria Garcia no tiene email, teléfono aparece en la tabla
|
||||
const dashCells = screen.getAllByText('—')
|
||||
expect(dashCells.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('muestra skeleton cuando está cargando', () => {
|
||||
render(
|
||||
<ClientesMaestroTable
|
||||
datos={[]}
|
||||
total={0}
|
||||
pagina={1}
|
||||
limite={50}
|
||||
cargando={true}
|
||||
onPageChange={vi.fn()}
|
||||
onExportar={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// 10 skeleton divs
|
||||
expect(screen.queryByText('Nombre completo')).toBeNull()
|
||||
})
|
||||
|
||||
it('muestra "Sin resultados" cuando no hay datos', () => {
|
||||
render(
|
||||
<ClientesMaestroTable
|
||||
datos={[]}
|
||||
total={0}
|
||||
pagina={1}
|
||||
limite={50}
|
||||
cargando={false}
|
||||
onPageChange={vi.fn()}
|
||||
onExportar={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Sin resultados')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('muestra paginación cuando hay más de 1 página', () => {
|
||||
const onPageChange = vi.fn()
|
||||
render(
|
||||
<ClientesMaestroTable
|
||||
datos={DATOS_MOCK}
|
||||
total={100}
|
||||
pagina={1}
|
||||
limite={50}
|
||||
cargando={false}
|
||||
onPageChange={onPageChange}
|
||||
onExportar={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Verificamos que existan elementos de paginación
|
||||
expect(screen.getAllByText('Anterior').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('Siguiente').length).toBeGreaterThanOrEqual(1)
|
||||
// Verificar que el texto "100" aparece como total
|
||||
expect(screen.getAllByText('100').length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
fireEvent.click(screen.getByText('Siguiente'))
|
||||
expect(onPageChange).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('deshabilita Anterior en la primera página', () => {
|
||||
render(
|
||||
<ClientesMaestroTable
|
||||
datos={DATOS_MOCK}
|
||||
total={100}
|
||||
pagina={1}
|
||||
limite={50}
|
||||
cargando={false}
|
||||
onPageChange={vi.fn()}
|
||||
onExportar={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect((screen.getByText('Anterior') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('deshabilita Siguiente en la última página', () => {
|
||||
render(
|
||||
<ClientesMaestroTable
|
||||
datos={DATOS_MOCK}
|
||||
total={100}
|
||||
pagina={2}
|
||||
limite={50}
|
||||
cargando={false}
|
||||
onPageChange={vi.fn()}
|
||||
onExportar={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect((screen.getByText('Siguiente') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('llama onExportar al hacer click en exportar', () => {
|
||||
const onExportar = vi.fn()
|
||||
render(
|
||||
<ClientesMaestroTable
|
||||
datos={DATOS_MOCK}
|
||||
total={2}
|
||||
pagina={1}
|
||||
limite={50}
|
||||
cargando={false}
|
||||
onPageChange={vi.fn()}
|
||||
onExportar={onExportar}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('📥 Exportar Excel'))
|
||||
expect(onExportar).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deshabilita exportar cuando no hay datos', () => {
|
||||
render(
|
||||
<ClientesMaestroTable
|
||||
datos={[]}
|
||||
total={0}
|
||||
pagina={1}
|
||||
limite={50}
|
||||
cargando={false}
|
||||
onPageChange={vi.fn()}
|
||||
onExportar={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// El botón existe pero está deshabilitado cuando total es 0
|
||||
const btn = screen.getByText('📥 Exportar Excel') as HTMLButtonElement
|
||||
expect(btn.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
181
apps/landing/src/components/ClientesMaestroTable.tsx
Normal file
181
apps/landing/src/components/ClientesMaestroTable.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ClienteMaestro } from '../services/clientes-maestro'
|
||||
|
||||
interface Props {
|
||||
datos: ClienteMaestro[]
|
||||
total: number
|
||||
pagina: number
|
||||
limite: number
|
||||
cargando: boolean
|
||||
onPageChange: (pagina: number) => void
|
||||
onExportar: () => void
|
||||
}
|
||||
|
||||
/** Tabla paginada de clientes maestro con dark mode completo */
|
||||
export function ClientesMaestroTable({ datos, total, pagina, limite, cargando, onPageChange, onExportar }: Props) {
|
||||
const totalPaginas = Math.ceil(total / limite)
|
||||
const inicio = (pagina - 1) * limite + 1
|
||||
const fin = Math.min(pagina * limite, total)
|
||||
|
||||
const paginasVisibles = useMemo(() => {
|
||||
const paginas: number[] = []
|
||||
const start = Math.max(1, pagina - 2)
|
||||
const end = Math.min(totalPaginas, pagina + 2)
|
||||
for (let i = start; i <= end; i++) paginas.push(i)
|
||||
return paginas
|
||||
}, [pagina, totalPaginas])
|
||||
|
||||
const formatCurrency = (v: number | null) => {
|
||||
if (v == null) return '—'
|
||||
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(v)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
{total > 0 ? (
|
||||
<>
|
||||
Mostrando <span className="font-medium text-[#111827] dark:text-[#E2E8F0]">{inicio}–{fin}</span> de{' '}
|
||||
<span className="font-medium text-[#111827] dark:text-[#E2E8F0]">{total.toLocaleString()}</span> clientes
|
||||
</>
|
||||
) : (
|
||||
'Sin resultados'
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportar}
|
||||
disabled={total === 0}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-sm font-medium text-[#374151] shadow-sm transition-colors hover:bg-[#F9FAFB] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||
>
|
||||
📥 Exportar Excel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{cargando ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className="h-12 animate-pulse rounded-lg bg-[#F1F5F9] dark:bg-[#1E293B]" />
|
||||
))}
|
||||
</div>
|
||||
) : datos.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-[#64748B] dark:text-[#94A3B8]">No hay clientes para mostrar.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: cards */}
|
||||
<div className="grid gap-3 md:hidden">
|
||||
{datos.map((c) => (
|
||||
<div key={c.id} className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||
{c.nombre} {c.apellidoPaterno} {c.apellidoMaterno ?? ''}
|
||||
</p>
|
||||
{c.estatus && (
|
||||
<span className="shrink-0 rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-accent">
|
||||
{c.estatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">#{c.numCliente}</p>
|
||||
<div className="mt-2 flex flex-col gap-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
{c.telefono && <span>📞 {c.telefono}</span>}
|
||||
{c.email && <span>✉️ {c.email}</span>}
|
||||
{c.desarrollo && <span>🏠 {c.desarrollo}</span>}
|
||||
{c.valorTotal != null && <span>💰 {formatCurrency(c.valorTotal)}</span>}
|
||||
{c.vendedor && <span>👤 {c.vendedor}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] dark:border-[#1E293B] dark:bg-[#0F172A]">
|
||||
<th className="px-4 py-3 font-medium text-[#64748B] dark:text-[#94A3B8]">#</th>
|
||||
<th className="px-4 py-3 font-medium text-[#64748B] dark:text-[#94A3B8]">Nombre completo</th>
|
||||
<th className="px-4 py-3 font-medium text-[#64748B] dark:text-[#94A3B8]">Teléfono</th>
|
||||
<th className="px-4 py-3 font-medium text-[#64748B] dark:text-[#94A3B8]">Email</th>
|
||||
<th className="px-4 py-3 font-medium text-[#64748B] dark:text-[#94A3B8]">Desarrollo</th>
|
||||
<th className="px-4 py-3 font-medium text-[#64748B] dark:text-[#94A3B8]">Estatus</th>
|
||||
<th className="px-4 py-3 font-medium text-[#64748B] dark:text-[#94A3B8] text-right">Valor</th>
|
||||
<th className="px-4 py-3 font-medium text-[#64748B] dark:text-[#94A3B8]">Vendedor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#E5E7EB] dark:divide-[#1E293B]">
|
||||
{datos.map((c) => (
|
||||
<tr key={c.id} className="transition-colors hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]">
|
||||
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.numCliente}</td>
|
||||
<td className="px-4 py-3 font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||
{c.nombre} {c.apellidoPaterno} {c.apellidoMaterno ?? ''}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.telefono ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.email ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.desarrollo ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{c.estatus ? (
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
|
||||
{c.estatus}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[#64748B] dark:text-[#94A3B8]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||
{formatCurrency(c.valorTotal)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.vendedor ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPaginas > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(pagina - 1)}
|
||||
disabled={pagina === 1}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-sm font-medium text-[#374151] shadow-sm transition-colors hover:bg-[#F9FAFB] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
{paginasVisibles[0]! > 1 && <span className="px-2 text-sm text-[#64748B] dark:text-[#94A3B8]">…</span>}
|
||||
{paginasVisibles.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`min-w-[36px] rounded-lg px-3 py-1.5 text-sm font-medium shadow-sm transition-colors ${
|
||||
p === pagina
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-[#D1D5DB] bg-white text-[#374151] hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{paginasVisibles[paginasVisibles.length - 1]! < totalPaginas && (
|
||||
<span className="px-2 text-sm text-[#64748B] dark:text-[#94A3B8]">…</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(pagina + 1)}
|
||||
disabled={pagina === totalPaginas}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-sm font-medium text-[#374151] shadow-sm transition-colors hover:bg-[#F9FAFB] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const INICIALES: Record<string, string> = {
|
||||
'mi-dia': 'MD',
|
||||
contactos: 'CT',
|
||||
captura: 'CA',
|
||||
'clientes-maestro': 'CM',
|
||||
}
|
||||
|
||||
/** Tarjeta de acceso a un módulo del ecosistema */
|
||||
|
||||
Reference in New Issue
Block a user