Files
FRASA-CAS/apps/landing/src/components/ClientesMaestroTable.tsx
Consult AS Technology dc43cbe1ce feat(clientes-maestro): modal detalle + fix permisos roles
- Modal con 28 campos agrupados por sección (Datos, Contacto, Crédito, Propiedad, Marketing, Venta)
- Click en fila abre modal
- Dark mode completo
- Agregado 'clientes-maestro' a todos los roles
2026-09-08 02:47:29 +00:00

183 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
onRowClick?: (cliente: ClienteMaestro) => void
}
/** Tabla paginada de clientes maestro con dark mode completo */
export function ClientesMaestroTable({ datos, total, pagina, limite, cargando, onPageChange, onExportar, onRowClick }: 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] ${onRowClick ? "cursor-pointer" : ""}`} onClick={() => onRowClick?.(c)}>
<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>
)
}