feat(reportes): rediseno del modulo (sidebar agrupado, KPIs, filtros claros, tabla mejorada y captura con carga)

This commit is contained in:
2026-09-17 09:10:39 +00:00
parent 84582ce6a2
commit 9630893974
2 changed files with 619 additions and 245 deletions

View File

@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import Reportes from './Reportes'
const respuestaOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
/** Cada llamada recibe una Response nueva (el body solo se puede leer una vez) */
const responderCon = (data: unknown) => async () => respuestaOk(data)
const leadsFake = {
vendedores: [{ id: 'u1', nombre: 'Martin Zavala' }],
filas: [
{
id: 'l1',
tablero: 'LEADS',
nombre: 'Juan Pérez',
telefono: '6641234567',
email: 'juan@example.com',
origen: 'PASE',
desarrollo: 'SENDA',
etapa: 'NUEVO',
vendedor: 'Martin Zavala',
fecha: '2026-09-01T10:00:00.000Z',
ultimoCambio: '2026-09-02T10:00:00.000Z',
},
],
}
const capturaFake = {
desarrollos: ['SENDA'],
campos: { OBRA: [{ campo: 'certificacion_gas', label: 'Certificación Gas', tipo: 'boolean' }] },
filas: [{ desarrollo: 'SENDA', unidad: 'SENDA-401-1-1', departamento: 'OBRA', campo: 'certificacion_gas', valor: 'true' }],
}
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
fetchMock.mockImplementation(responderCon(leadsFake))
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('Reportes', () => {
it('lista los 10 reportes agrupados y arranca sin consultar', () => {
render(<Reportes />)
expect(screen.getAllByText('Leads por vendedor y total').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('Escrituración (entrega)').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('Embudo comercial')).toBeTruthy()
expect(screen.getByText(/Elige tus filtros/i)).toBeTruthy() // estado inicial
})
it('consulta el reporte de leads y muestra KPIs y detalle', async () => {
render(<Reportes />)
fireEvent.click(screen.getByRole('button', { name: /Consultar/i }))
await waitFor(() => expect(screen.getByText('Juan Pérez')).toBeTruthy())
expect(screen.getByText('Registros')).toBeTruthy()
expect(screen.getByText('Vendedores')).toBeTruthy()
expect(screen.getByRole('button', { name: /Descargar Excel/i })).toBeTruthy()
})
it('el reporte de captura permite cargar el expediente y ofrece las dos descargas', async () => {
fetchMock.mockImplementation(responderCon(capturaFake))
render(<Reportes />)
fireEvent.click(screen.getByRole('button', { name: /Captura por departamento/i }))
fireEvent.click(screen.getByRole('button', { name: /Cargar expediente/i }))
await waitFor(() => expect(screen.getByText('Archivo completo')).toBeTruthy())
expect(screen.getByText('Solo un desarrollo')).toBeTruthy()
expect(screen.getByText('Unidades')).toBeTruthy()
})
it('muestra el aviso de error del API', async () => {
fetchMock.mockImplementation(async () => new Response('{}', { status: 500 }))
render(<Reportes />)
fireEvent.click(screen.getByRole('button', { name: /Consultar/i }))
await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy())
})
})

View File

@@ -2,13 +2,36 @@
* Módulo de Reportes del tablero: los 10 reportes del CRM con filtros de
* fecha/desarrollo/vendedor y descarga a Excel (SheetJS, import dinámico).
*
* 1. Leads (por vendedor y total, todas las etapas) 6. Clientes en cobranza
* 2. Cancelación (Cliente/Motivo) 7. Desarrollos/Disponibilidad
* 3. Afluencias (por desarrollo) 8. Contactos (info completa)
* 4. Listado de cancelaciones (todas las etapas) 9. Clientes (info completa)
* 5. Captura por departamento (completo o por desarrollo) 10. Escrituración (entrega)
* 1. Leads (por vendedor y total) 6. Clientes en cobranza
* 2. Cancelación (Cliente/Motivo) 7. Desarrollos/Disponibilidad
* 3. Afluencias (por desarrollo) 8. Contactos (info completa)
* 4. Listado de cancelaciones 9. Clientes (info completa)
* 5. Captura por departamento 10. Escrituración (entrega)
*/
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState, type ComponentType } from 'react'
import {
Ban,
Boxes,
Building2,
CircleX,
ClipboardCheck,
ClipboardList,
Contact,
Download,
FileSpreadsheet,
Info,
LoaderCircle,
RefreshCw,
Search,
SlidersHorizontal,
Table2,
TrendingUp,
UserRound,
Users,
Wallet,
type LucideProps,
} from 'lucide-react'
import { EstadoVacio } from './comunes'
import {
fetchReporte,
fetchReporteCaptura,
@@ -36,21 +59,29 @@ type Fila =
interface DefReporte {
id: string
/** Número con el que el cliente pidió el reporte (1-10) */
numero: number
titulo: string
descripcion: string
grupo: 'Embudo comercial' | 'Cobranza' | 'Inventario' | 'Expedientes'
Icono: ComponentType<LucideProps>
filtroFechas: boolean
filtroDesarrollo: boolean
filtroVendedor: boolean
columnas: { key: string; label: string }[]
columnas: { key: string; label: string; tipo?: 'fecha' | 'moneda' | 'numero' }[]
}
const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO']
const GRUPOS: DefReporte['grupo'][] = ['Embudo comercial', 'Cobranza', 'Inventario', 'Expedientes']
const REPORTES: DefReporte[] = [
{
id: 'leads',
titulo: '1. Leads (por vendedor y total)',
descripcion: 'Todos los leads del embudo, separados por vendedor y con total. Todas las etapas.',
numero: 1,
titulo: 'Leads por vendedor y total',
descripcion: 'Todos los leads del embudo con sus etapas, separados por vendedor y con el total general.',
grupo: 'Embudo comercial',
Icono: Users,
filtroFechas: true,
filtroDesarrollo: false,
filtroVendedor: true,
@@ -63,14 +94,17 @@ const REPORTES: DefReporte[] = [
{ key: 'origen', label: 'Origen' },
{ key: 'desarrollo', label: 'Desarrollo' },
{ key: 'etapa', label: 'Etapa' },
{ key: 'fecha', label: 'Fecha captura' },
{ key: 'ultimoCambio', label: 'Último cambio' },
{ key: 'fecha', label: 'Fecha captura', tipo: 'fecha' },
{ key: 'ultimoCambio', label: 'Último cambio', tipo: 'fecha' },
],
},
{
id: 'cancelaciones-motivo',
titulo: '2. Cancelación (Cliente/Motivo)',
descripcion: 'Cancelaciones de todas las etapas: cliente y motivo del descarte.',
numero: 2,
titulo: 'Cancelación (Cliente / Motivo)',
descripcion: 'Cancelaciones de todas las etapas con el motivo y submotivo del descarte.',
grupo: 'Embudo comercial',
Icono: CircleX,
filtroFechas: true,
filtroDesarrollo: false,
filtroVendedor: false,
@@ -81,13 +115,16 @@ const REPORTES: DefReporte[] = [
{ key: 'etapa', label: 'Etapa' },
{ key: 'tablero', label: 'Tablero' },
{ key: 'vendedor', label: 'Vendedor' },
{ key: 'fecha', label: 'Fecha' },
{ key: 'fecha', label: 'Fecha', tipo: 'fecha' },
],
},
{
id: 'afluencias',
titulo: '3. Afluencias (por desarrollo)',
descripcion: 'Afluencias capturadas, agrupables por desarrollo, con rango de fechas.',
numero: 3,
titulo: 'Afluencias por desarrollo',
descripcion: 'Afluencias capturadas con su calificación, agrupables por desarrollo y rango de fechas.',
grupo: 'Embudo comercial',
Icono: TrendingUp,
filtroFechas: true,
filtroDesarrollo: true,
filtroVendedor: false,
@@ -101,13 +138,16 @@ const REPORTES: DefReporte[] = [
{ key: 'califica', label: 'Califica' },
{ key: 'etapa', label: 'Etapa' },
{ key: 'vendedor', label: 'Vendedor' },
{ key: 'fecha', label: 'Fecha' },
{ key: 'fecha', label: 'Fecha', tipo: 'fecha' },
],
},
{
id: 'cancelaciones-todas',
titulo: '4. Listado de cancelaciones (todas las etapas)',
descripcion: 'Listado completo de cancelaciones: leads, oportunidades, apartados y escrituración.',
numero: 4,
titulo: 'Listado de cancelaciones',
descripcion: 'Todas las cancelaciones del sistema: leads, oportunidades, apartados y escrituración.',
grupo: 'Embudo comercial',
Icono: Ban,
filtroFechas: true,
filtroDesarrollo: false,
filtroVendedor: false,
@@ -119,22 +159,16 @@ const REPORTES: DefReporte[] = [
{ key: 'etapa', label: 'Etapa' },
{ key: 'tablero', label: 'Tablero' },
{ key: 'vendedor', label: 'Vendedor' },
{ key: 'fecha', label: 'Fecha' },
{ key: 'fecha', label: 'Fecha', tipo: 'fecha' },
],
},
{
id: 'captura',
titulo: '5. Captura por departamento',
descripcion: 'Descarga del expediente de captura: archivo completo o solo del desarrollo elegido.',
filtroFechas: false,
filtroDesarrollo: true,
filtroVendedor: false,
columnas: [],
},
{
id: 'cobranza',
titulo: '6. Clientes en cobranza',
descripcion: 'Planes de pago con pagado, saldo, vencidos y estatus, por desarrollo y fechas.',
numero: 6,
titulo: 'Clientes en cobranza',
descripcion: 'Planes de pago con lo pagado, el saldo, los vencidos y el próximo vencimiento.',
grupo: 'Cobranza',
Icono: Wallet,
filtroFechas: true,
filtroDesarrollo: true,
filtroVendedor: false,
@@ -142,20 +176,23 @@ const REPORTES: DefReporte[] = [
{ key: 'cliente', label: 'Cliente' },
{ key: 'unidad', label: 'Unidad' },
{ key: 'desarrollo', label: 'Desarrollo' },
{ key: 'fechaInicio', label: 'Fecha inicio' },
{ key: 'engancheTotal', label: 'Plan total' },
{ key: 'pagado', label: 'Pagado' },
{ key: 'saldo', label: 'Saldo' },
{ key: 'pagosVencidos', label: 'Pagos vencidos' },
{ key: 'montoVencido', label: 'Monto vencido' },
{ key: 'proximoVencimiento', label: 'Próximo vencimiento' },
{ key: 'fechaInicio', label: 'Fecha inicio', tipo: 'fecha' },
{ key: 'engancheTotal', label: 'Plan total', tipo: 'moneda' },
{ key: 'pagado', label: 'Pagado', tipo: 'moneda' },
{ key: 'saldo', label: 'Saldo', tipo: 'moneda' },
{ key: 'pagosVencidos', label: 'Vencidos', tipo: 'numero' },
{ key: 'montoVencido', label: 'Monto vencido', tipo: 'moneda' },
{ key: 'proximoVencimiento', label: 'Próximo vencimiento', tipo: 'fecha' },
{ key: 'estatus', label: 'Estatus' },
],
},
{
id: 'disponibilidad',
titulo: '7. Desarrollos / Disponibilidad',
descripcion: 'Inventario por desarrollo y modelo: estatus, superficies y precios.',
numero: 7,
titulo: 'Desarrollos y disponibilidad',
descripcion: 'Inventario por desarrollo y modelo: estatus, superficies y precios de lista.',
grupo: 'Inventario',
Icono: Building2,
filtroFechas: false,
filtroDesarrollo: false,
filtroVendedor: false,
@@ -164,15 +201,30 @@ const REPORTES: DefReporte[] = [
{ key: 'desarrollo', label: 'Desarrollo' },
{ key: 'modelo', label: 'Modelo' },
{ key: 'estatus', label: 'Estatus' },
{ key: 'supConstruccion', label: 'Sup. construcción' },
{ key: 'precioLista', label: 'Precio lista' },
{ key: 'precioAcabados', label: 'Precio c/acabados' },
{ key: 'supConstruccion', label: 'Sup. construcción', tipo: 'numero' },
{ key: 'precioLista', label: 'Precio lista', tipo: 'moneda' },
{ key: 'precioAcabados', label: 'Precio c/acabados', tipo: 'moneda' },
],
},
{
id: 'captura',
numero: 5,
titulo: 'Captura por departamento',
descripcion: 'Expediente de captura por unidad, con una hoja por departamento en el archivo.',
grupo: 'Expedientes',
Icono: ClipboardList,
filtroFechas: false,
filtroDesarrollo: true,
filtroVendedor: false,
columnas: [],
},
{
id: 'contactos',
titulo: '8. Contactos (info completa)',
numero: 8,
titulo: 'Contactos (información completa)',
descripcion: 'Todos los contactos con su perfilamiento completo de afluencia.',
grupo: 'Expedientes',
Icono: Contact,
filtroFechas: false,
filtroDesarrollo: false,
filtroVendedor: false,
@@ -182,27 +234,25 @@ const REPORTES: DefReporte[] = [
{ key: 'email', label: 'Email' },
{ key: 'origen', label: 'Origen' },
{ key: 'desarrollo', label: 'Desarrollo' },
{ key: 'modelo', label: 'Modelo' },
{ key: 'tablero', label: 'Tablero' },
{ key: 'etapa', label: 'Etapa' },
{ key: 'vendedor', label: 'Vendedor' },
{ key: 'numDependientes', label: 'Dependientes' },
{ key: 'edad', label: 'Edad' },
{ key: 'edad', label: 'Edad', tipo: 'numero' },
{ key: 'estadoCivil', label: 'Estado civil' },
{ key: 'tipoEmpleo', label: 'Tipo empleo' },
{ key: 'ocupacion', label: 'Ocupación' },
{ key: 'rangoSalarial', label: 'Rango salarial' },
{ key: 'credito', label: 'Crédito' },
{ key: 'tipoEconomia', label: 'Tipo economía' },
{ key: 'califica', label: 'Califica' },
{ key: 'loQuierePara', label: '¿Para qué?' },
{ key: 'fecha', label: 'Fecha' },
{ key: 'fecha', label: 'Fecha', tipo: 'fecha' },
],
},
{
id: 'clientes',
titulo: '9. Clientes (info completa)',
descripcion: 'Clientes del alta: datos personales, proyecto, unidad y enkontrol.',
numero: 9,
titulo: 'Clientes (información completa)',
descripcion: 'Clientes del alta con sus datos personales, proyecto, unidad y usuario Enkontrol.',
grupo: 'Expedientes',
Icono: UserRound,
filtroFechas: false,
filtroDesarrollo: false,
filtroVendedor: false,
@@ -215,20 +265,19 @@ const REPORTES: DefReporte[] = [
{ key: 'proyecto', label: 'Proyecto' },
{ key: 'unidad', label: 'Unidad' },
{ key: 'ekontrol', label: 'Enkontrol' },
{ key: 'estadoCivil', label: 'Estado civil' },
{ key: 'ocupacion', label: 'Ocupación' },
{ key: 'rangoSalarial', label: 'Rango salarial' },
{ key: 'empresa', label: 'Empresa' },
{ key: 'antiguedad', label: 'Antigüedad' },
{ key: 'ingresoMensual', label: 'Ingreso mensual' },
{ key: 'capturadoPor', label: 'Capturado por' },
{ key: 'fecha', label: 'Fecha alta' },
{ key: 'fecha', label: 'Fecha alta', tipo: 'fecha' },
],
},
{
id: 'escrituracion',
titulo: '10. Escrituración (entrega)',
descripcion: 'Clientes en escrituración: estatus y fecha de cada uno.',
numero: 10,
titulo: 'Escrituración (entrega)',
descripcion: 'Clientes en escrituración con su estatus y las fechas de cada uno.',
grupo: 'Expedientes',
Icono: ClipboardCheck,
filtroFechas: false,
filtroDesarrollo: false,
filtroVendedor: false,
@@ -238,30 +287,45 @@ const REPORTES: DefReporte[] = [
{ key: 'unidad', label: 'Unidad' },
{ key: 'estatus', label: 'Estatus' },
{ key: 'vendedor', label: 'Vendedor' },
{ key: 'fechaUltimoCambio', label: 'Fecha último cambio' },
{ key: 'fechaAlta', label: 'Fecha alta' },
{ key: 'fechaUltimoCambio', label: 'Último cambio', tipo: 'fecha' },
{ key: 'fechaAlta', label: 'Fecha alta', tipo: 'fecha' },
],
},
]
const ISO_FECHA = (iso: string): string => (iso ? iso.slice(0, 10) : '')
const MONEDA = (n: number | null): number | null => (n === null ? null : Math.round(n * 100) / 100)
// ── Formato ──────────────────────────────────────────────────────────
const MXN = new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 })
const NUM = new Intl.NumberFormat('es-MX')
const fechaCorta = (iso: string): string => {
if (!iso) return '—'
const [y, m, d] = iso.slice(0, 10).split('-')
return y && m && d ? `${d}/${m}/${y}` : iso
}
function valorCelda(fila: Record<string, unknown>, col: DefReporte['columnas'][number]): string {
const v = fila[col.key]
if (v === null || v === undefined || v === '') return '—'
if (col.tipo === 'fecha') return fechaCorta(String(v))
if (col.tipo === 'moneda') return MXN.format(Number(v))
if (col.tipo === 'numero') return NUM.format(Number(v))
return String(v)
}
function filaPlana(def: DefReporte, fila: Record<string, unknown>): Record<string, string | number | null> {
const plano: Record<string, string | number | null> = {}
for (const col of def.columnas) {
const v = fila[col.key]
if (col.key === 'fecha' || col.key === 'ultimoCambio' || col.key === 'fechaInicio' || col.key === 'proximoVencimiento' || col.key === 'fechaUltimoCambio' || col.key === 'fechaAlta') {
plano[col.label] = ISO_FECHA(String(v ?? ''))
} else if (['engancheTotal', 'pagado', 'saldo', 'montoVencido', 'precioLista', 'precioAcabados'].includes(col.key)) {
plano[col.label] = MONEDA(typeof v === 'number' ? v : Number(v ?? 0) || null)
} else {
plano[col.label] = (v ?? '') as string
}
if (col.tipo === 'fecha') plano[col.label] = fechaCorta(String(v ?? ''))
else if (col.tipo === 'moneda' || col.tipo === 'numero') plano[col.label] = typeof v === 'number' ? v : Number(v ?? 0) || null
else plano[col.label] = (v ?? '') as string
}
return plano
}
// ── Componente ───────────────────────────────────────────────────────
export default function Reportes() {
const [reporteId, setReporteId] = useState<string>('leads')
const def = REPORTES.find((r) => r.id === reporteId) ?? REPORTES[0]!
@@ -292,19 +356,19 @@ export default function Reportes() {
if (esCaptura) {
setCaptura(await fetchReporteCaptura(desarrollo || undefined))
} else {
const { filas } = await fetchReporte<Fila>(def.id, {
const respuesta = await fetchReporte<Fila>(def.id, {
desde: desde || undefined,
hasta: hasta || undefined,
desarrollo: desarrollo || undefined,
vendedorId: vendedorId || undefined,
})
setFilas(filas)
setFilas(respuesta.filas)
if (def.id === 'leads') {
const resp = (await fetchReporte<FilaLeadReporte>('leads', {})) as {
const conVendedores = (await fetchReporte<FilaLeadReporte>('leads', {})) as {
filas: FilaLeadReporte[]
vendedores?: { id: string; nombre: string }[]
}
setVendedores(resp.vendedores ?? [])
setVendedores(conVendedores.vendedores ?? [])
}
}
} catch (err) {
@@ -349,14 +413,14 @@ export default function Reportes() {
const filasPlanas = filas.map((f) => filaPlana(def, f as Record<string, unknown>))
if (def.id === 'leads') {
// Hoja resumen: vendedor × etapa + total
const conteo = new Map<string, Map<string, number>>()
const etapas = new Set<string>()
for (const f of filas as FilaLeadReporte[]) {
const etapa = f.etapa || '(sin etapa)'
const porVendedor = conteo.get(f.vendedor) ?? new Map<string, number>()
porVendedor.set(f.etapa || '(sin etapa)', (porVendedor.get(f.etapa || '(sin etapa)') ?? 0) + 1)
porVendedor.set(etapa, (porVendedor.get(etapa) ?? 0) + 1)
conteo.set(f.vendedor, porVendedor)
etapas.add(f.etapa || '(sin etapa)')
etapas.add(etapa)
}
const etapasLista = [...etapas].sort()
const resumen = [...conteo.entries()].map(([vendedor, porEtapa]) => {
@@ -371,9 +435,7 @@ export default function Reportes() {
return f
})
const grandTotal: Record<string, string | number> = { Vendedor: 'TOTAL' }
for (const etapa of etapasLista) {
grandTotal[etapa] = resumen.reduce((a, f) => a + Number(f[etapa] ?? 0), 0)
}
for (const etapa of etapasLista) grandTotal[etapa] = resumen.reduce((a, f) => a + Number(f[etapa] ?? 0), 0)
grandTotal['Total'] = resumen.reduce((a, f) => a + Number(f['Total'] ?? 0), 0)
resumen.push(grandTotal)
await descargar([
@@ -386,10 +448,11 @@ export default function Reportes() {
if (def.id === 'afluencias') {
const porDesarrollo = new Map<string, number>()
for (const f of filas as FilaAfluenciaReporte[]) {
porDesarrollo.set(f.desarrollo || '(sin desarrollo)', (porDesarrollo.get(f.desarrollo || '(sin desarrollo)') ?? 0) + 1)
const d = f.desarrollo || '(sin desarrollo)'
porDesarrollo.set(d, (porDesarrollo.get(d) ?? 0) + 1)
}
const resumen = [...porDesarrollo.entries()]
.map(([desarrollo, total]) => ({ Desarrollo: desarrollo, Afluencias: total }))
.map(([d, total]) => ({ Desarrollo: d, Afluencias: total }))
.sort((a, b) => b.Afluencias - a.Afluencias)
await descargar([
{ nombre: 'Por desarrollo', filas: resumen },
@@ -449,7 +512,7 @@ export default function Reportes() {
const hoja = `${desarrollo}-${departamento}`.slice(0, 31)
const lista = porDept.get(hoja) ?? []
const fila: Record<string, string | null> = { Unidad: unidad }
for (const def of camposDef) fila[def.label] = campos.get(def.campo) ?? null
for (const c of camposDef) fila[c.label] = campos.get(c.campo) ?? null
lista.push(fila)
porDept.set(hoja, lista)
}
@@ -459,7 +522,10 @@ export default function Reportes() {
const ws = XLSX.utils.json_to_sheet(filasHoja.length > 0 ? filasHoja : [{ Unidad: null }])
XLSX.utils.book_append_sheet(wb, ws, nombre)
}
XLSX.writeFile(wb, desarrolloElegido ? `captura-${desarrolloElegido}.xlsx` : `captura-completa-${new Date().toISOString().slice(0, 10)}.xlsx`)
XLSX.writeFile(
wb,
desarrolloElegido ? `captura-${desarrolloElegido}.xlsx` : `captura-completa-${new Date().toISOString().slice(0, 10)}.xlsx`,
)
} catch {
setError('No se pudo generar el archivo Excel')
} finally {
@@ -467,180 +533,404 @@ export default function Reportes() {
}
}
const renderTabla = () => {
if (!consultado) return null
if (error !== '') {
return <p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</p>
}
// ── Resumen de resultados (KPIs de la consulta) ────────────────────
const resumenKpis = useMemo(() => {
if (esCaptura) {
const totalUnidades = new Set((captura?.filas ?? []).map((f) => `${f.desarrollo}-${f.unidad}`)).size
return (
<div className="space-y-3">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
{totalUnidades} unidades{captura && captura.desarrollos.length > 0 ? ` en ${captura.desarrollos.length} desarrollo(s)` : ''}.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={descargando || totalUnidades === 0}
onClick={() => void descargarCaptura(undefined)}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{descargando ? 'Generando…' : 'Descargar archivo completo'}
</button>
<div className="flex items-center gap-2">
<select
value={desarrollo}
onChange={(e) => setDesarrollo(e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
aria-label="Desarrollo para la descarga"
>
<option value="">Elegir desarrollo…</option>
{DESARROLLOS.map((d) => (<option key={d} value={d}>{d}</option>))}
</select>
<button
type="button"
disabled={descargando || desarrollo === ''}
onClick={() => void descargarCaptura(desarrollo)}
className="rounded-lg border border-accent px-4 py-2 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:opacity-50"
>
Descargar por desarrollo
</button>
</div>
</div>
{captura && captura.desarrollos.length > 0 && (
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
El archivo trae una hoja por departamento (Obra, Ventas, Escrituración, Finanzas y Postventa).
</p>
)}
</div>
)
const unidades = new Set((captura?.filas ?? []).map((f) => `${f.desarrollo}-${f.unidad}`))
return [
{ etiqueta: 'Unidades', valor: NUM.format(unidades.size) },
{ etiqueta: 'Desarrollos', valor: NUM.format(captura?.desarrollos.length ?? 0) },
{ etiqueta: 'Departamentos', valor: NUM.format(Object.keys(captura?.campos ?? {}).length) },
]
}
if (filas.length === 0) {
return <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin datos para el rango seleccionado.</p>
const kpis = [{ etiqueta: 'Registros', valor: NUM.format(filas.length) }]
if (def.id === 'leads') {
const vendedoresDistintos = new Set((filas as FilaLeadReporte[]).map((f) => f.vendedor))
const etapas = new Set((filas as FilaLeadReporte[]).map((f) => f.etapa))
kpis.push({ etiqueta: 'Vendedores', valor: NUM.format(vendedoresDistintos.size) })
kpis.push({ etiqueta: 'Etapas', valor: NUM.format(etapas.size) })
}
const visibles = filas.slice(0, 100)
return (
<div className="space-y-2">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
{filas.length} registro(s){filas.length > 100 ? ' — mostrando los primeros 100; la descarga trae todos' : ''}.
</p>
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-[#E5E7EB] bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#1E293B]">
{def.columnas.map((c) => (
<th key={c.key} className="whitespace-nowrap px-3 py-2.5 font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{visibles.map((f, i) => (
<tr key={i} className="border-b border-[#E5E7EB] last:border-0 dark:border-[#334155]">
{def.columnas.map((c) => {
const valor = (f as Record<string, unknown>)[c.key]
const esFecha = c.key.toLowerCase().includes('fecha') || c.key === 'ultimoCambio'
return (
<td key={c.key} className="whitespace-nowrap px-3 py-2 text-[#111827] dark:text-[#E2E8F0]">
{esFecha && typeof valor === 'string' && valor !== '' ? valor.slice(0, 10) : String(valor ?? '—')}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
if (def.id === 'cobranza') {
const saldo = (filas as FilaCobranzaReporte[]).reduce((a, f) => a + (f.saldo ?? 0), 0)
const vencidos = (filas as FilaCobranzaReporte[]).reduce((a, f) => a + (f.pagosVencidos ?? 0), 0)
kpis.push({ etiqueta: 'Saldo total', valor: MXN.format(saldo) })
kpis.push({ etiqueta: 'Pagos vencidos', valor: NUM.format(vencidos) })
}
if (def.id === 'disponibilidad') {
const disponibles = (filas as FilaDisponibilidadReporte[]).filter((f) => f.estatus === 'DISPONIBLE').length
kpis.push({ etiqueta: 'Disponibles', valor: NUM.format(disponibles) })
kpis.push({ etiqueta: 'Desarrollos', valor: NUM.format(new Set((filas as FilaDisponibilidadReporte[]).map((f) => f.desarrollo)).size) })
}
if (def.id.startsWith('cancelaciones')) {
const motivos = new Set((filas as FilaCancelacionReporte[]).map((f) => f.motivo || '(sin motivo)'))
kpis.push({ etiqueta: 'Motivos distintos', valor: NUM.format(motivos.size) })
}
return kpis
}, [def.id, esCaptura, filas, captura])
const filtrosActivos = useMemo(() => {
const activos: string[] = []
if (desde) activos.push(`desde ${fechaCorta(desde)}`)
if (hasta) activos.push(`hasta ${fechaCorta(hasta)}`)
if (desarrollo) activos.push(desarrollo)
if (vendedorId) activos.push(vendedores.find((v) => v.id === vendedorId)?.nombre ?? 'vendedor')
return activos
}, [desde, hasta, desarrollo, vendedorId, vendedores])
// ── Render ─────────────────────────────────────────────────────────
const claseInput =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]'
const claseLabel = 'mb-1.5 block text-[11px] font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]'
return (
<div className="space-y-6">
<section aria-label="Elegir reporte">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
{REPORTES.map((r) => (
<button
key={r.id}
type="button"
onClick={() => setReporteId(r.id)}
title={r.descripcion}
className={`rounded-xl border px-3 py-2.5 text-left text-xs font-medium shadow-sm transition-all duration-100 ease-out ${
r.id === reporteId
? 'border-accent bg-accent/5 text-accent'
: 'border-[#E5E7EB] bg-white text-[#374151] hover:border-accent/40 dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#CBD5E1]'
}`}
>
{r.titulo}
</button>
))}
</div>
</section>
<main className="mx-auto w-full max-w-[1500px] px-4 py-8 sm:px-6 lg:px-8">
<div className="grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
{/* ── Selector de reportes ─────────────────────────────────── */}
<aside aria-label="Reportes disponibles" className="lg:sticky lg:top-24 lg:self-start">
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-3 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex items-center gap-2 px-2 pb-2 pt-1">
<FileSpreadsheet className="h-4 w-4 text-accent" aria-hidden />
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Reportes
</h2>
</div>
{GRUPOS.map((grupo) => (
<div key={grupo} className="mt-2">
<p className="px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-[#94A3B8]">
{grupo}
</p>
<ul className="space-y-1">
{REPORTES.filter((r) => r.grupo === grupo).map((r) => {
const activo = r.id === reporteId
const Icono = r.Icono
return (
<li key={r.id}>
<button
type="button"
onClick={() => setReporteId(r.id)}
aria-current={activo}
className={`flex w-full items-start gap-2.5 rounded-xl px-2.5 py-2 text-left transition-all duration-100 ease-out ${
activo
? 'bg-accent/10 text-accent ring-1 ring-accent/30'
: 'text-[#374151] hover:bg-[#F8FAFC] dark:text-[#CBD5E1] dark:hover:bg-[#0F172A]'
}`}
>
<Icono className={`mt-0.5 h-4 w-4 flex-shrink-0 ${activo ? 'text-accent' : 'text-[#94A3B8]'}`} aria-hidden />
<span className="min-w-0">
<span className="flex items-baseline gap-1.5">
<span className="text-[10px] font-bold text-[#94A3B8]">{r.numero}</span>
<span className="text-xs font-medium leading-snug">{r.titulo}</span>
</span>
</span>
</button>
</li>
)
})}
</ul>
</div>
))}
</div>
</aside>
<section aria-label="Filtros" className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<p className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">{def.titulo}</p>
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">{def.descripcion}</p>
{!esCaptura && (
<div className="mt-3 flex flex-wrap items-end gap-3">
{def.filtroFechas && (
<>
{/* ── Panel del reporte activo ─────────────────────────────── */}
<div className="min-w-0 space-y-5">
{/* Encabezado */}
<header className="rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-start gap-3">
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent">
<def.Icono className="h-5 w-5" aria-hidden />
</span>
<div>
<label htmlFor="rep-desde" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Desde</label>
<input id="rep-desde" type="date" value={desde} onChange={(e) => setDesde(e.target.value)}
className="mt-1 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]" />
<p className="text-[10px] font-semibold uppercase tracking-wider text-[#94A3B8]">
Reporte {def.numero} · {def.grupo}
</p>
<h1 className="text-lg font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0]">{def.titulo}</h1>
<p className="mt-0.5 max-w-2xl text-xs text-[#64748B] dark:text-[#94A3B8]">{def.descripcion}</p>
</div>
</div>
{!esCaptura && filas.length > 0 && (
<button
type="button"
onClick={() => void descargarReporte()}
disabled={descargando}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{descargando ? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden /> : <Download className="h-4 w-4" aria-hidden />}
{descargando ? 'Generando…' : 'Descargar Excel'}
</button>
)}
</div>
</header>
{/* Filtros */}
<section
aria-label="Filtros del reporte"
className="rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]"
>
<div className="mb-4 flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4 text-[#64748B] dark:text-[#94A3B8]" aria-hidden />
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
{esCaptura ? 'Desarrollo a consultar' : 'Filtros'}
</h2>
{!esCaptura && filtrosActivos.length > 0 && (
<span className="rounded-full bg-accent/10 px-2 py-0.5 text-[10px] font-medium text-accent">
{filtrosActivos.join(' · ')}
</span>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{esCaptura && (
<div>
<label htmlFor="rep-hasta" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Hasta</label>
<input id="rep-hasta" type="date" value={hasta} onChange={(e) => setHasta(e.target.value)}
className="mt-1 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]" />
<label htmlFor="rep-desarrollo" className={claseLabel}>Desarrollo (opcional)</label>
<select
id="rep-desarrollo"
value={desarrollo}
onChange={(e) => setDesarrollo(e.target.value)}
className={claseInput}
>
<option value="">Todos los desarrollos</option>
{DESARROLLOS.map((d) => (<option key={d} value={d}>{d}</option>))}
</select>
</div>
</>
)}
{def.filtroDesarrollo && (
<div>
<label htmlFor="rep-desarrollo" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Desarrollo</label>
<select id="rep-desarrollo" value={desarrollo} onChange={(e) => setDesarrollo(e.target.value)}
className="mt-1 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]">
<option value="">Todos</option>
{DESARROLLOS.map((d) => (<option key={d} value={d}>{d}</option>))}
</select>
</div>
)}
{def.filtroVendedor && vendedores.length > 0 && (
<div>
<label htmlFor="rep-vendedor" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Vendedor</label>
<select id="rep-vendedor" value={vendedorId} onChange={(e) => setVendedorId(e.target.value)}
className="mt-1 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]">
<option value="">Todos</option>
{vendedores.map((v) => (<option key={v.id} value={v.id}>{v.nombre}</option>))}
</select>
</div>
)}
<button
type="button"
onClick={() => void consultar()}
disabled={cargando}
className="rounded-lg bg-accent px-5 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{cargando ? 'Consultando…' : 'Consultar'}
</button>
{!esCaptura && filas.length > 0 && (
)}
{!esCaptura && def.filtroFechas && (
<>
<div>
<label htmlFor="rep-desde" className={claseLabel}>Desde</label>
<input id="rep-desde" type="date" value={desde} onChange={(e) => setDesde(e.target.value)} className={claseInput} />
</div>
<div>
<label htmlFor="rep-hasta" className={claseLabel}>Hasta</label>
<input id="rep-hasta" type="date" value={hasta} onChange={(e) => setHasta(e.target.value)} className={claseInput} />
</div>
</>
)}
{!esCaptura && def.filtroDesarrollo && (
<div>
<label htmlFor="rep-desarrollo-filtro" className={claseLabel}>Desarrollo</label>
<select id="rep-desarrollo-filtro" value={desarrollo} onChange={(e) => setDesarrollo(e.target.value)} className={claseInput}>
<option value="">Todos</option>
{DESARROLLOS.map((d) => (<option key={d} value={d}>{d}</option>))}
</select>
</div>
)}
{!esCaptura && def.filtroVendedor && vendedores.length > 0 && (
<div>
<label htmlFor="rep-vendedor" className={claseLabel}>Vendedor</label>
<select id="rep-vendedor" value={vendedorId} onChange={(e) => setVendedorId(e.target.value)} className={claseInput}>
<option value="">Todos</option>
{vendedores.map((v) => (<option key={v.id} value={v.id}>{v.nombre}</option>))}
</select>
</div>
)}
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => void descargarReporte()}
disabled={descargando}
className="rounded-lg border border-accent px-4 py-2 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:opacity-50"
onClick={() => void consultar()}
disabled={cargando}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{descargando ? 'Generando…' : 'Descargar Excel'}
{cargando ? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden /> : <Search className="h-4 w-4" aria-hidden />}
{cargando ? 'Consultando…' : esCaptura ? 'Cargar expediente' : 'Consultar'}
</button>
)}
</div>
)}
</section>
{!esCaptura && (desde || hasta || desarrollo || vendedorId) && (
<button
type="button"
onClick={() => {
setDesde('')
setHasta('')
setDesarrollo('')
setVendedorId('')
}}
className="inline-flex items-center gap-1.5 rounded-xl border border-[#D1D5DB] bg-white px-3 py-2.5 text-xs font-medium text-[#374151] transition-colors hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden />
Limpiar filtros
</button>
)}
{!consultado && (
<p className="flex items-center gap-1.5 text-xs text-[#94A3B8]">
<Info className="h-3.5 w-3.5" aria-hidden />
{esCaptura ? 'Elige el desarrollo (o todos) y carga el expediente.' : 'Elige tus filtros y presiona Consultar.'}
</p>
)}
</div>
</section>
<section aria-label="Resultados">{renderTabla()}</section>
</div>
{/* Resultados */}
<section aria-label="Resultados" className="space-y-4">
{cargando && (
<div className="space-y-2 rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="h-8 animate-pulse rounded-lg bg-[#F1F5F9] dark:bg-[#0F172A]" />
))}
</div>
)}
{!cargando && error !== '' && (
<p role="alert" className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
{error}
</p>
)}
{!cargando && error === '' && esCaptura && consultado && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{resumenKpis.map((k) => (
<div key={k.etiqueta} className="rounded-2xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[#94A3B8]">{k.etiqueta}</p>
<p className="mt-1 text-xl font-semibold tabular-nums text-[#111827] dark:text-[#E2E8F0]">{k.valor}</p>
</div>
))}
</div>
)}
{!cargando && error === '' && esCaptura && (
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex items-center gap-2">
<Boxes className="h-4 w-4 text-accent" aria-hidden />
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Descarga del expediente
</h2>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => void descargarCaptura(undefined)}
disabled={descargando || !captura}
className="flex items-start gap-3 rounded-xl border border-[#E5E7EB] p-4 text-left transition-all duration-100 ease-out hover:border-accent hover:bg-accent/5 disabled:opacity-50 dark:border-[#334155]"
>
<FileSpreadsheet className="mt-0.5 h-5 w-5 flex-shrink-0 text-accent" aria-hidden />
<span>
<span className="block text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Archivo completo</span>
<span className="mt-0.5 block text-xs text-[#64748B] dark:text-[#94A3B8]">
Todos los desarrollos, una hoja por departamento.
</span>
</span>
</button>
<div className="rounded-xl border border-[#E5E7EB] p-4 dark:border-[#334155]">
<div className="flex items-start gap-3">
<Building2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-accent" aria-hidden />
<div className="min-w-0 flex-1">
<span className="block text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Solo un desarrollo</span>
<span className="mt-0.5 block text-xs text-[#64748B] dark:text-[#94A3B8]">
Elige el desarrollo y descarga su expediente.
</span>
<div className="mt-3 flex flex-wrap items-center gap-2">
<select
value={desarrollo}
onChange={(e) => setDesarrollo(e.target.value)}
aria-label="Desarrollo para la descarga"
className="min-w-[140px] flex-1 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
<option value="">Elegir desarrollo…</option>
{DESARROLLOS.map((d) => (<option key={d} value={d}>{d}</option>))}
</select>
<button
type="button"
onClick={() => void descargarCaptura(desarrollo)}
disabled={descargando || !captura || desarrollo === ''}
className="inline-flex items-center gap-2 rounded-lg border border-accent px-3 py-2 text-sm font-medium text-accent transition-colors hover:bg-accent hover:text-white disabled:opacity-50"
>
<Download className="h-4 w-4" aria-hidden />
Descargar
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{!cargando && error === '' && consultado && !esCaptura && (
<>
{/* KPIs de la consulta */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{resumenKpis.map((k) => (
<div key={k.etiqueta} className="rounded-2xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[#94A3B8]">{k.etiqueta}</p>
<p className="mt-1 text-xl font-semibold tabular-nums text-[#111827] dark:text-[#E2E8F0]">{k.valor}</p>
</div>
))}
</div>
{filas.length === 0 ? (
<EstadoVacio mensaje="Sin datos para los filtros seleccionados" />
) : (
<div className="overflow-hidden rounded-2xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[#E5E7EB] px-5 py-3 dark:border-[#334155]">
<div className="flex items-center gap-2">
<Table2 className="h-4 w-4 text-[#64748B] dark:text-[#94A3B8]" aria-hidden />
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Detalle
</h2>
</div>
<p className="text-xs text-[#94A3B8]">
{filas.length > 100
? `Mostrando 100 de ${NUM.format(filas.length)} · el Excel trae todos`
: `${NUM.format(filas.length)} registro(s)`}
</p>
</div>
<div className="max-h-[560px] overflow-auto">
<table className="w-full text-left text-xs">
<thead className="sticky top-0 z-10 bg-[#F9FAFB] dark:bg-[#0F172A]">
<tr className="border-b border-[#E5E7EB] dark:border-[#334155]">
{def.columnas.map((c) => (
<th
key={c.key}
scope="col"
className={`whitespace-nowrap px-4 py-3 font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] ${
c.tipo === 'moneda' || c.tipo === 'numero' ? 'text-right' : ''
}`}
>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{filas.slice(0, 100).map((f, i) => (
<tr
key={i}
className={`border-b border-[#F1F5F9] transition-colors last:border-0 hover:bg-accent/5 dark:border-[#1E293B] ${
i % 2 === 1 ? 'bg-[#FCFDFE] dark:bg-[#0F172A]/40' : ''
}`}
>
{def.columnas.map((c) => {
const texto = valorCelda(f as Record<string, unknown>, c)
return (
<td
key={c.key}
title={texto}
className={`whitespace-nowrap px-4 py-2.5 ${
c.tipo === 'moneda' || c.tipo === 'numero'
? 'text-right tabular-nums text-[#111827] dark:text-[#E2E8F0]'
: 'max-w-[260px] truncate text-[#374151] dark:text-[#CBD5E1]'
}`}
>
{texto}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
{!cargando && error === '' && !consultado && (
<EstadoVacio mensaje="Selecciona los filtros y consulta el reporte" />
)}
</section>
</div>
</div>
</main>
)
}