feat(reportes): módulo de Reportes con 10 reportes, filtros y descarga Excel

This commit is contained in:
2026-09-17 07:18:34 +00:00
parent 31f83b0504
commit 8bcb093fec
8 changed files with 1279 additions and 4 deletions

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

View File

@@ -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} />
)}

View File

@@ -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}`)
}