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:
2026-09-07 09:11:09 +00:00
parent c2a8f4e929
commit 9021733455
18 changed files with 1428 additions and 10 deletions

View 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)
})
})

View 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>
)
}

View File

@@ -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 */