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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user