feat(reportes): módulo de Reportes con 10 reportes, filtros y descarga Excel
This commit is contained in:
646
apps/dashboards/src/components/Reportes.tsx
Normal file
646
apps/dashboards/src/components/Reportes.tsx
Normal file
@@ -0,0 +1,646 @@
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
fetchReporte,
|
||||
fetchReporteCaptura,
|
||||
type FilaAfluenciaReporte,
|
||||
type FilaCancelacionReporte,
|
||||
type FilaCapturaReporte,
|
||||
type FilaClienteReporte as FilaCliente,
|
||||
type FilaCobranzaReporte,
|
||||
type FilaContactoReporte,
|
||||
type FilaDisponibilidadReporte,
|
||||
type FilaEscrituracionReporte,
|
||||
type FilaLeadReporte,
|
||||
} from '../services/reportes'
|
||||
|
||||
type Fila =
|
||||
| FilaLeadReporte
|
||||
| FilaCancelacionReporte
|
||||
| FilaAfluenciaReporte
|
||||
| FilaCobranzaReporte
|
||||
| FilaDisponibilidadReporte
|
||||
| FilaContactoReporte
|
||||
| FilaCliente
|
||||
| FilaEscrituracionReporte
|
||||
| Record<string, unknown>
|
||||
|
||||
interface DefReporte {
|
||||
id: string
|
||||
titulo: string
|
||||
descripcion: string
|
||||
filtroFechas: boolean
|
||||
filtroDesarrollo: boolean
|
||||
filtroVendedor: boolean
|
||||
columnas: { key: string; label: string }[]
|
||||
}
|
||||
|
||||
const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO']
|
||||
|
||||
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.',
|
||||
filtroFechas: true,
|
||||
filtroDesarrollo: false,
|
||||
filtroVendedor: true,
|
||||
columnas: [
|
||||
{ key: 'vendedor', label: 'Vendedor' },
|
||||
{ key: 'tablero', label: 'Tablero' },
|
||||
{ key: 'nombre', label: 'Nombre' },
|
||||
{ key: 'telefono', label: 'Teléfono' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'origen', label: 'Origen' },
|
||||
{ key: 'desarrollo', label: 'Desarrollo' },
|
||||
{ key: 'etapa', label: 'Etapa' },
|
||||
{ key: 'fecha', label: 'Fecha captura' },
|
||||
{ key: 'ultimoCambio', label: 'Último cambio' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cancelaciones-motivo',
|
||||
titulo: '2. Cancelación (Cliente/Motivo)',
|
||||
descripcion: 'Cancelaciones de todas las etapas: cliente y motivo del descarte.',
|
||||
filtroFechas: true,
|
||||
filtroDesarrollo: false,
|
||||
filtroVendedor: false,
|
||||
columnas: [
|
||||
{ key: 'cliente', label: 'Cliente' },
|
||||
{ key: 'motivo', label: 'Motivo' },
|
||||
{ key: 'submotivo', label: 'Submotivo' },
|
||||
{ key: 'etapa', label: 'Etapa' },
|
||||
{ key: 'tablero', label: 'Tablero' },
|
||||
{ key: 'vendedor', label: 'Vendedor' },
|
||||
{ key: 'fecha', label: 'Fecha' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'afluencias',
|
||||
titulo: '3. Afluencias (por desarrollo)',
|
||||
descripcion: 'Afluencias capturadas, agrupables por desarrollo, con rango de fechas.',
|
||||
filtroFechas: true,
|
||||
filtroDesarrollo: true,
|
||||
filtroVendedor: false,
|
||||
columnas: [
|
||||
{ key: 'nombre', label: 'Nombre' },
|
||||
{ key: 'telefono', label: 'Teléfono' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'origen', label: 'Origen' },
|
||||
{ key: 'desarrollo', label: 'Desarrollo' },
|
||||
{ key: 'modelo', label: 'Modelo' },
|
||||
{ key: 'califica', label: 'Califica' },
|
||||
{ key: 'etapa', label: 'Etapa' },
|
||||
{ key: 'vendedor', label: 'Vendedor' },
|
||||
{ key: 'fecha', label: 'Fecha' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cancelaciones-todas',
|
||||
titulo: '4. Listado de cancelaciones (todas las etapas)',
|
||||
descripcion: 'Listado completo de cancelaciones: leads, oportunidades, apartados y escrituración.',
|
||||
filtroFechas: true,
|
||||
filtroDesarrollo: false,
|
||||
filtroVendedor: false,
|
||||
columnas: [
|
||||
{ key: 'cliente', label: 'Cliente' },
|
||||
{ key: 'telefono', label: 'Teléfono' },
|
||||
{ key: 'motivo', label: 'Motivo' },
|
||||
{ key: 'submotivo', label: 'Submotivo' },
|
||||
{ key: 'etapa', label: 'Etapa' },
|
||||
{ key: 'tablero', label: 'Tablero' },
|
||||
{ key: 'vendedor', label: 'Vendedor' },
|
||||
{ key: 'fecha', label: '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.',
|
||||
filtroFechas: true,
|
||||
filtroDesarrollo: true,
|
||||
filtroVendedor: false,
|
||||
columnas: [
|
||||
{ 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: 'estatus', label: 'Estatus' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'disponibilidad',
|
||||
titulo: '7. Desarrollos / Disponibilidad',
|
||||
descripcion: 'Inventario por desarrollo y modelo: estatus, superficies y precios.',
|
||||
filtroFechas: false,
|
||||
filtroDesarrollo: false,
|
||||
filtroVendedor: false,
|
||||
columnas: [
|
||||
{ key: 'codigo', label: 'Código' },
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contactos',
|
||||
titulo: '8. Contactos (info completa)',
|
||||
descripcion: 'Todos los contactos con su perfilamiento completo de afluencia.',
|
||||
filtroFechas: false,
|
||||
filtroDesarrollo: false,
|
||||
filtroVendedor: false,
|
||||
columnas: [
|
||||
{ key: 'nombre', label: 'Nombre' },
|
||||
{ key: 'telefono', label: 'Teléfono' },
|
||||
{ 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: '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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'clientes',
|
||||
titulo: '9. Clientes (info completa)',
|
||||
descripcion: 'Clientes del alta: datos personales, proyecto, unidad y enkontrol.',
|
||||
filtroFechas: false,
|
||||
filtroDesarrollo: false,
|
||||
filtroVendedor: false,
|
||||
columnas: [
|
||||
{ key: 'nombre', label: 'Nombre' },
|
||||
{ key: 'curp', label: 'CURP' },
|
||||
{ key: 'rfc', label: 'RFC' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'celular', label: 'Celular' },
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'escrituracion',
|
||||
titulo: '10. Escrituración (entrega)',
|
||||
descripcion: 'Clientes en escrituración: estatus y fecha de cada uno.',
|
||||
filtroFechas: false,
|
||||
filtroDesarrollo: false,
|
||||
filtroVendedor: false,
|
||||
columnas: [
|
||||
{ key: 'cliente', label: 'Cliente' },
|
||||
{ key: 'telefono', label: 'Teléfono' },
|
||||
{ key: 'unidad', label: 'Unidad' },
|
||||
{ key: 'estatus', label: 'Estatus' },
|
||||
{ key: 'vendedor', label: 'Vendedor' },
|
||||
{ key: 'fechaUltimoCambio', label: 'Fecha último cambio' },
|
||||
{ key: 'fechaAlta', label: 'Fecha alta' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
return plano
|
||||
}
|
||||
|
||||
export default function Reportes() {
|
||||
const [reporteId, setReporteId] = useState<string>('leads')
|
||||
const def = REPORTES.find((r) => r.id === reporteId) ?? REPORTES[0]!
|
||||
const esCaptura = def.id === 'captura'
|
||||
|
||||
const [desde, setDesde] = useState('')
|
||||
const [hasta, setHasta] = useState('')
|
||||
const [desarrollo, setDesarrollo] = useState('')
|
||||
const [vendedorId, setVendedorId] = useState('')
|
||||
const [vendedores, setVendedores] = useState<{ id: string; nombre: string }[]>([])
|
||||
|
||||
const [filas, setFilas] = useState<Fila[]>([])
|
||||
const [captura, setCaptura] = useState<{
|
||||
desarrollos: string[]
|
||||
campos: Record<string, { campo: string; label: string }[]>
|
||||
filas: FilaCapturaReporte[]
|
||||
} | null>(null)
|
||||
const [consultado, setConsultado] = useState(false)
|
||||
const [cargando, setCargando] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [descargando, setDescargando] = useState(false)
|
||||
|
||||
const consultar = useCallback(async () => {
|
||||
setCargando(true)
|
||||
setError('')
|
||||
setConsultado(true)
|
||||
try {
|
||||
if (esCaptura) {
|
||||
setCaptura(await fetchReporteCaptura(desarrollo || undefined))
|
||||
} else {
|
||||
const { filas } = await fetchReporte<Fila>(def.id, {
|
||||
desde: desde || undefined,
|
||||
hasta: hasta || undefined,
|
||||
desarrollo: desarrollo || undefined,
|
||||
vendedorId: vendedorId || undefined,
|
||||
})
|
||||
setFilas(filas)
|
||||
if (def.id === 'leads') {
|
||||
const resp = (await fetchReporte<FilaLeadReporte>('leads', {})) as {
|
||||
filas: FilaLeadReporte[]
|
||||
vendedores?: { id: string; nombre: string }[]
|
||||
}
|
||||
setVendedores(resp.vendedores ?? [])
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : 'No se pudo consultar el reporte')
|
||||
} finally {
|
||||
setCargando(false)
|
||||
}
|
||||
}, [def.id, esCaptura, desde, hasta, desarrollo, vendedorId])
|
||||
|
||||
useEffect(() => {
|
||||
setFilas([])
|
||||
setCaptura(null)
|
||||
setConsultado(false)
|
||||
setError('')
|
||||
}, [reporteId])
|
||||
|
||||
/** Descarga el workbook con las hojas indicadas */
|
||||
const descargar = async (
|
||||
hojas: { nombre: string; filas: Record<string, string | number | null>[] }[],
|
||||
nombreArchivo: string,
|
||||
) => {
|
||||
setDescargando(true)
|
||||
try {
|
||||
const XLSX = await import('xlsx')
|
||||
const wb = XLSX.utils.book_new()
|
||||
for (const hoja of hojas) {
|
||||
const ws = XLSX.utils.json_to_sheet(hoja.filas.length > 0 ? hoja.filas : [{ Sin: 'datos' }])
|
||||
XLSX.utils.book_append_sheet(wb, ws, hoja.nombre.slice(0, 31))
|
||||
}
|
||||
XLSX.writeFile(wb, nombreArchivo)
|
||||
} catch {
|
||||
setError('No se pudo generar el archivo Excel')
|
||||
} finally {
|
||||
setDescargando(false)
|
||||
}
|
||||
}
|
||||
|
||||
const descargarReporte = async () => {
|
||||
if (esCaptura) return
|
||||
const hoy = new Date().toISOString().slice(0, 10)
|
||||
const nombreArchivo = `reporte-${def.id}-${hoy}.xlsx`
|
||||
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 porVendedor = conteo.get(f.vendedor) ?? new Map<string, number>()
|
||||
porVendedor.set(f.etapa || '(sin etapa)', (porVendedor.get(f.etapa || '(sin etapa)') ?? 0) + 1)
|
||||
conteo.set(f.vendedor, porVendedor)
|
||||
etapas.add(f.etapa || '(sin etapa)')
|
||||
}
|
||||
const etapasLista = [...etapas].sort()
|
||||
const resumen = [...conteo.entries()].map(([vendedor, porEtapa]) => {
|
||||
const f: Record<string, string | number> = { Vendedor: vendedor }
|
||||
let total = 0
|
||||
for (const etapa of etapasLista) {
|
||||
const n = porEtapa.get(etapa) ?? 0
|
||||
f[etapa] = n
|
||||
total += n
|
||||
}
|
||||
f['Total'] = total
|
||||
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)
|
||||
}
|
||||
grandTotal['Total'] = resumen.reduce((a, f) => a + Number(f['Total'] ?? 0), 0)
|
||||
resumen.push(grandTotal)
|
||||
await descargar([
|
||||
{ nombre: 'Resumen por vendedor', filas: resumen },
|
||||
{ nombre: 'Detalle', filas: filasPlanas },
|
||||
], nombreArchivo)
|
||||
return
|
||||
}
|
||||
|
||||
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 resumen = [...porDesarrollo.entries()]
|
||||
.map(([desarrollo, total]) => ({ Desarrollo: desarrollo, Afluencias: total }))
|
||||
.sort((a, b) => b.Afluencias - a.Afluencias)
|
||||
await descargar([
|
||||
{ nombre: 'Por desarrollo', filas: resumen },
|
||||
{ nombre: 'Detalle', filas: filasPlanas },
|
||||
], nombreArchivo)
|
||||
return
|
||||
}
|
||||
|
||||
if (def.id === 'disponibilidad') {
|
||||
const conteo = new Map<string, Map<string, number>>()
|
||||
const modelosPorDesarrollo = new Map<string, Set<string>>()
|
||||
for (const f of filas as FilaDisponibilidadReporte[]) {
|
||||
const porEstatus = conteo.get(f.desarrollo) ?? new Map<string, number>()
|
||||
porEstatus.set(f.estatus, (porEstatus.get(f.estatus) ?? 0) + 1)
|
||||
conteo.set(f.desarrollo, porEstatus)
|
||||
const modelos = modelosPorDesarrollo.get(f.desarrollo) ?? new Set<string>()
|
||||
if (f.modelo !== '') modelos.add(f.modelo)
|
||||
modelosPorDesarrollo.set(f.desarrollo, modelos)
|
||||
}
|
||||
const estatuses = [...new Set((filas as FilaDisponibilidadReporte[]).map((f) => f.estatus))].sort()
|
||||
const resumen = [...conteo.entries()].map(([desarrollo, porEstatus]) => {
|
||||
const f: Record<string, string | number> = { Desarrollo: desarrollo }
|
||||
for (const e of estatuses) f[e] = porEstatus.get(e) ?? 0
|
||||
f['Modelos'] = [...(modelosPorDesarrollo.get(desarrollo) ?? [])].join(', ')
|
||||
return f
|
||||
})
|
||||
await descargar([
|
||||
{ nombre: 'Resumen', filas: resumen },
|
||||
{ nombre: 'Unidades', filas: filasPlanas },
|
||||
], nombreArchivo)
|
||||
return
|
||||
}
|
||||
|
||||
await descargar([{ nombre: 'Reporte', filas: filasPlanas }], nombreArchivo)
|
||||
}
|
||||
|
||||
/** Excel de captura: una hoja por departamento; completo o por desarrollo */
|
||||
const descargarCaptura = async (desarrolloElegido?: string) => {
|
||||
if (!captura) return
|
||||
setDescargando(true)
|
||||
try {
|
||||
const XLSX = await import('xlsx')
|
||||
const wb = XLSX.utils.book_new()
|
||||
const porDesarrollo = new Map<string, Map<string, Map<string, string | null>>>()
|
||||
for (const f of captura.filas) {
|
||||
if (desarrolloElegido && f.desarrollo !== desarrolloElegido) continue
|
||||
const porUnidad = porDesarrollo.get(f.desarrollo) ?? new Map()
|
||||
const campos = porUnidad.get(f.unidad) ?? new Map()
|
||||
campos.set(f.campo, f.valor)
|
||||
porUnidad.set(f.unidad, campos)
|
||||
porDesarrollo.set(f.desarrollo, porUnidad)
|
||||
}
|
||||
const porDept = new Map<string, Record<string, string | null>[]>()
|
||||
for (const [desarrollo, unidades] of porDesarrollo) {
|
||||
for (const [unidad, campos] of unidades) {
|
||||
for (const [departamento, camposDef] of Object.entries(captura.campos)) {
|
||||
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
|
||||
lista.push(fila)
|
||||
porDept.set(hoja, lista)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [nombre, filasHoja] of porDept) {
|
||||
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`)
|
||||
} catch {
|
||||
setError('No se pudo generar el archivo Excel')
|
||||
} finally {
|
||||
setDescargando(false)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
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' : ''}.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-label="Resultados">{renderTabla()}</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import MiTablero from '../components/MiTablero'
|
||||
import TableroGeneral from '../components/TableroGeneral'
|
||||
import Reportes from '../components/Reportes'
|
||||
import type { AccionesTablero } from '../components/comunes'
|
||||
import { esRolConEquipo } from '../lib/mi-tablero'
|
||||
import { getMe, getSesion } from '../services/auth'
|
||||
import DarkModeToggle from '../components/DarkModeToggle'
|
||||
|
||||
type Vista = 'mia' | 'general'
|
||||
type Vista = 'mia' | 'general' | 'reportes'
|
||||
|
||||
const VISTAS: { id: Vista; etiqueta: string; clave: string }[] = [
|
||||
{ id: 'mia', etiqueta: 'Mi tablero', clave: 'mi-tablero' },
|
||||
{ id: 'general', etiqueta: 'Tablero comercial', clave: 'tablero-comercial' },
|
||||
{ id: 'reportes', etiqueta: 'Reportes', clave: 'reportes' },
|
||||
]
|
||||
|
||||
/** Roles que ven el módulo de Reportes (datos de todos los vendedores) */
|
||||
const ROLES_REPORTES = ['admin', 'director_ventas', 'direccion_general']
|
||||
|
||||
/**
|
||||
* Tableros con vista por rol: cada tab se muestra solo si el módulo está
|
||||
* permitido en la matriz de roles del API (GET /auth/me → modulos). Con un
|
||||
@@ -34,7 +39,11 @@ export default function DashboardPage() {
|
||||
.catch(() => {}) // si /me falla se mantiene lo que hubiera
|
||||
}, [])
|
||||
|
||||
const permitidas = VISTAS.filter((v) => permitidos === null || permitidos.includes(v.clave))
|
||||
const permitidas = VISTAS.filter((v) => {
|
||||
// Reportes: por rol (los datos son de todos los vendedores)
|
||||
if (v.id === 'reportes') return ROLES_REPORTES.includes(rol)
|
||||
return permitidos === null || permitidos.includes(v.clave)
|
||||
})
|
||||
const vista: Vista | null =
|
||||
permitidas.length === 0
|
||||
? null
|
||||
@@ -103,6 +112,8 @@ export default function DashboardPage() {
|
||||
</main>
|
||||
) : vista === 'mia' ? (
|
||||
<MiTablero alCambiarAcciones={setAcciones} />
|
||||
) : vista === 'reportes' ? (
|
||||
<Reportes />
|
||||
) : (
|
||||
<TableroGeneral mostrarEquipo={conEquipo} alCambiarAcciones={setAcciones} />
|
||||
)}
|
||||
|
||||
@@ -30,3 +30,153 @@ export async function fetchResumen(dias?: number): Promise<ResumenReportes> {
|
||||
export async function fetchMiTablero(): Promise<MiTablero> {
|
||||
return obtener<MiTablero>('/reportes/mi-tablero')
|
||||
}
|
||||
|
||||
// ─── Datos crudos de los 10 reportes (módulo Reportes) ────────────────
|
||||
|
||||
export interface FilaLeadReporte {
|
||||
id: string
|
||||
tablero: string
|
||||
nombre: string
|
||||
telefono: string
|
||||
email: string
|
||||
origen: string
|
||||
desarrollo: string
|
||||
etapa: string
|
||||
vendedor: string
|
||||
fecha: string
|
||||
ultimoCambio: string
|
||||
}
|
||||
|
||||
export interface FilaCancelacionReporte {
|
||||
id: string
|
||||
cliente: string
|
||||
telefono: string
|
||||
motivo: string
|
||||
submotivo: string
|
||||
etapa: string
|
||||
tablero: string
|
||||
vendedor: string
|
||||
fecha: string
|
||||
}
|
||||
|
||||
export interface FilaAfluenciaReporte {
|
||||
id: string
|
||||
nombre: string
|
||||
telefono: string
|
||||
email: string
|
||||
origen: string
|
||||
desarrollo: string
|
||||
modelo: string
|
||||
etapa: string
|
||||
califica: string
|
||||
vendedor: string
|
||||
fecha: string
|
||||
}
|
||||
|
||||
export interface FilaCapturaReporte {
|
||||
desarrollo: string
|
||||
unidad: string
|
||||
departamento: string
|
||||
campo: string
|
||||
valor: string | null
|
||||
}
|
||||
|
||||
export interface FilaCobranzaReporte {
|
||||
cliente: string
|
||||
unidad: string
|
||||
desarrollo: string
|
||||
fechaInicio: string
|
||||
engancheTotal: number
|
||||
pagado: number
|
||||
saldo: number
|
||||
pagosVencidos: number
|
||||
montoVencido: number
|
||||
proximoVencimiento: string
|
||||
estatus: string
|
||||
}
|
||||
|
||||
export interface FilaDisponibilidadReporte {
|
||||
codigo: string
|
||||
desarrollo: string
|
||||
modelo: string
|
||||
estatus: string
|
||||
supConstruccion: number | null
|
||||
precioLista: number | null
|
||||
precioAcabados: number | null
|
||||
}
|
||||
|
||||
export interface FilaContactoReporte {
|
||||
nombre: string
|
||||
telefono: string
|
||||
email: string
|
||||
origen: string
|
||||
desarrollo: string
|
||||
modelo: string
|
||||
tablero: string
|
||||
etapa: string
|
||||
vendedor: string
|
||||
fecha: string
|
||||
numDependientes: string
|
||||
edad: string
|
||||
estadoCivil: string
|
||||
tipoEmpleo: string
|
||||
ocupacion: string
|
||||
rangoSalarial: string
|
||||
credito: string
|
||||
tipoEconomia: string
|
||||
califica: string
|
||||
loQuierePara: string
|
||||
}
|
||||
|
||||
export interface FilaClienteReporte {
|
||||
nombre: string
|
||||
curp: string
|
||||
rfc: string
|
||||
email: string
|
||||
celular: string
|
||||
proyecto: string
|
||||
unidad: string
|
||||
ekontrol: string
|
||||
estadoCivil: string
|
||||
ocupacion: string
|
||||
rangoSalarial: string
|
||||
empresa: string
|
||||
antiguedad: string
|
||||
ingresoMensual: string
|
||||
capturadoPor: string
|
||||
fecha: string
|
||||
}
|
||||
|
||||
export interface FilaEscrituracionReporte {
|
||||
cliente: string
|
||||
telefono: string
|
||||
unidad: string
|
||||
estatus: string
|
||||
vendedor: string
|
||||
fechaUltimoCambio: string
|
||||
fechaAlta: string
|
||||
}
|
||||
|
||||
/** GET /reportes/datos/:tipo — filas del reporte elegido con sus filtros */
|
||||
export async function fetchReporte<T>(
|
||||
tipo: string,
|
||||
filtros: { desde?: string; hasta?: string; desarrollo?: string; vendedorId?: string } = {},
|
||||
): Promise<{ filas: T[] }> {
|
||||
const params = new URLSearchParams()
|
||||
if (filtros.desde) params.set('desde', filtros.desde)
|
||||
if (filtros.hasta) params.set('hasta', filtros.hasta)
|
||||
if (filtros.desarrollo) params.set('desarrollo', filtros.desarrollo)
|
||||
if (filtros.vendedorId) params.set('vendedorId', filtros.vendedorId)
|
||||
const qs = params.toString()
|
||||
return obtener<{ filas: T[] }>(`/reportes/datos/${tipo}${qs !== '' ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
/** GET /reportes/datos/captura — filas + catálogo de campos por departamento */
|
||||
export async function fetchReporteCaptura(desarrollo?: string): Promise<{
|
||||
desarrollos: string[]
|
||||
campos: Record<string, { campo: string; tipo: string; label: string }[]>
|
||||
filas: FilaCapturaReporte[]
|
||||
}> {
|
||||
const qs = desarrollo ? `?desarrollo=${encodeURIComponent(desarrollo)}` : ''
|
||||
return obtener(`/reportes/datos/captura${qs}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user