diff --git a/apps/dashboards/src/components/Reportes.test.tsx b/apps/dashboards/src/components/Reportes.test.tsx new file mode 100644 index 0000000..3e2116e --- /dev/null +++ b/apps/dashboards/src/components/Reportes.test.tsx @@ -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 + +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() + 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() + 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() + 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() + fireEvent.click(screen.getByRole('button', { name: /Consultar/i })) + await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy()) + }) +}) diff --git a/apps/dashboards/src/components/Reportes.tsx b/apps/dashboards/src/components/Reportes.tsx index 1590479..c773991 100644 --- a/apps/dashboards/src/components/Reportes.tsx +++ b/apps/dashboards/src/components/Reportes.tsx @@ -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 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, 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): Record { const plano: Record = {} 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('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(def.id, { + const respuesta = await fetchReporte(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('leads', {})) as { + const conVendedores = (await fetchReporte('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)) if (def.id === 'leads') { - // Hoja resumen: vendedor × etapa + total const conteo = new Map>() const etapas = new Set() for (const f of filas as FilaLeadReporte[]) { + const etapa = f.etapa || '(sin etapa)' const porVendedor = conteo.get(f.vendedor) ?? new Map() - 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 = { 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() 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 = { 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

{error}

- } + // ── 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 ( -
-

- {totalUnidades} unidades{captura && captura.desarrollos.length > 0 ? ` en ${captura.desarrollos.length} desarrollo(s)` : ''}. -

-
- -
- - -
-
- {captura && captura.desarrollos.length > 0 && ( -

- El archivo trae una hoja por departamento (Obra, Ventas, Escrituración, Finanzas y Postventa). -

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

Sin datos para el rango seleccionado.

+ 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 ( -
-

- {filas.length} registro(s){filas.length > 100 ? ' — mostrando los primeros 100; la descarga trae todos' : ''}. -

-
- - - - {def.columnas.map((c) => ( - - ))} - - - - {visibles.map((f, i) => ( - - {def.columnas.map((c) => { - const valor = (f as Record)[c.key] - const esFecha = c.key.toLowerCase().includes('fecha') || c.key === 'ultimoCambio' - return ( - - ) - })} - - ))} - -
- {c.label} -
- {esFecha && typeof valor === 'string' && valor !== '' ? valor.slice(0, 10) : String(valor ?? '—')} -
-
-
- ) - } + 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 ( -
-
-
- {REPORTES.map((r) => ( - - ))} -
-
+
+
+ {/* ── Selector de reportes ─────────────────────────────────── */} + -
-

{def.titulo}

-

{def.descripcion}

- {!esCaptura && ( -
- {def.filtroFechas && ( - <> + {/* ── Panel del reporte activo ─────────────────────────────── */} +
+ {/* Encabezado */} +
+
+
+ + +
- - 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]" /> +

+ Reporte {def.numero} · {def.grupo} +

+

{def.titulo}

+

{def.descripcion}

+
+ {!esCaptura && filas.length > 0 && ( + + )} +
+
+ + {/* Filtros */} +
+
+ +

+ {esCaptura ? 'Desarrollo a consultar' : 'Filtros'} +

+ {!esCaptura && filtrosActivos.length > 0 && ( + + {filtrosActivos.join(' · ')} + + )} +
+
+ {esCaptura && (
- - 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]" /> + +
- - )} - {def.filtroDesarrollo && ( -
- - -
- )} - {def.filtroVendedor && vendedores.length > 0 && ( -
- - -
- )} - - {!esCaptura && filas.length > 0 && ( + )} + {!esCaptura && def.filtroFechas && ( + <> +
+ + setDesde(e.target.value)} className={claseInput} /> +
+
+ + setHasta(e.target.value)} className={claseInput} /> +
+ + )} + {!esCaptura && def.filtroDesarrollo && ( +
+ + +
+ )} + {!esCaptura && def.filtroVendedor && vendedores.length > 0 && ( +
+ + +
+ )} +
+
- )} -
- )} -
+ {!esCaptura && (desde || hasta || desarrollo || vendedorId) && ( + + )} + {!consultado && ( +

+ + {esCaptura ? 'Elige el desarrollo (o todos) y carga el expediente.' : 'Elige tus filtros y presiona Consultar.'} +

+ )} +
+
-
{renderTabla()}
-
+ {/* Resultados */} +
+ {cargando && ( +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ ))} +
+ )} + + {!cargando && error !== '' && ( +

+ {error} +

+ )} + + {!cargando && error === '' && esCaptura && consultado && ( +
+ {resumenKpis.map((k) => ( +
+

{k.etiqueta}

+

{k.valor}

+
+ ))} +
+ )} + + {!cargando && error === '' && esCaptura && ( +
+
+ +

+ Descarga del expediente +

+
+
+ +
+
+ +
+ Solo un desarrollo + + Elige el desarrollo y descarga su expediente. + +
+ + +
+
+
+
+
+
+ )} + + {!cargando && error === '' && consultado && !esCaptura && ( + <> + {/* KPIs de la consulta */} +
+ {resumenKpis.map((k) => ( +
+

{k.etiqueta}

+

{k.valor}

+
+ ))} +
+ + {filas.length === 0 ? ( + + ) : ( +
+
+
+ +

+ Detalle +

+
+

+ {filas.length > 100 + ? `Mostrando 100 de ${NUM.format(filas.length)} · el Excel trae todos` + : `${NUM.format(filas.length)} registro(s)`} +

+
+
+ + + + {def.columnas.map((c) => ( + + ))} + + + + {filas.slice(0, 100).map((f, i) => ( + + {def.columnas.map((c) => { + const texto = valorCelda(f as Record, c) + return ( + + ) + })} + + ))} + +
+ {c.label} +
+ {texto} +
+
+
+ )} + + )} + + {!cargando && error === '' && !consultado && ( + + )} +
+
+ + ) }