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