feat(reportes): rediseno del modulo (sidebar agrupado, KPIs, filtros claros, tabla mejorada y captura con carga)
This commit is contained in:
84
apps/dashboards/src/components/Reportes.test.tsx
Normal file
84
apps/dashboards/src/components/Reportes.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
* 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 (todas las etapas) 9. Clientes (info completa)
|
||||
* 5. Captura por departamento (completo o por desarrollo) 10. Escrituración (entrega)
|
||||
* 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,84 +533,385 @@ 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
|
||||
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) },
|
||||
]
|
||||
}
|
||||
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) })
|
||||
}
|
||||
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-3">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
{totalUnidades} unidades{captura && captura.desarrollos.length > 0 ? ` en ${captura.desarrollos.length} desarrollo(s)` : ''}.
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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"
|
||||
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"
|
||||
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]'
|
||||
}`}
|
||||
>
|
||||
{descargando ? 'Generando…' : 'Descargar archivo completo'}
|
||||
<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>
|
||||
|
||||
{/* ── 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>
|
||||
<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-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>
|
||||
)}
|
||||
{!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 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"
|
||||
>
|
||||
{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>
|
||||
{!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>
|
||||
|
||||
{/* 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)}
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Descargar por desarrollo
|
||||
<Download className="h-4 w-4" aria-hidden />
|
||||
Descargar
|
||||
</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>
|
||||
)
|
||||
}
|
||||
if (filas.length === 0) {
|
||||
return <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin datos para el rango seleccionado.</p>
|
||||
}
|
||||
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' : ''}.
|
||||
</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 className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
</div>
|
||||
<div className="max-h-[560px] overflow-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#1E293B]">
|
||||
<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} className="whitespace-nowrap px-3 py-2.5 font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<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>
|
||||
{visibles.map((f, i) => (
|
||||
<tr key={i} className="border-b border-[#E5E7EB] last:border-0 dark:border-[#334155]">
|
||||
{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 valor = (f as Record<string, unknown>)[c.key]
|
||||
const esFecha = c.key.toLowerCase().includes('fecha') || c.key === 'ultimoCambio'
|
||||
const texto = valorCelda(f as Record<string, unknown>, c)
|
||||
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
|
||||
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>
|
||||
)
|
||||
})}
|
||||
@@ -554,93 +921,16 @@ export default function Reportes() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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 && (
|
||||
<>
|
||||
<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]" />
|
||||
</div>
|
||||
<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]" />
|
||||
</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 && (
|
||||
<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"
|
||||
>
|
||||
{descargando ? 'Generando…' : 'Descargar Excel'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!cargando && error === '' && !consultado && (
|
||||
<EstadoVacio mensaje="Selecciona los filtros y consulta el reporte" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-label="Resultados">{renderTabla()}</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user