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

@@ -36,6 +36,18 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
usuarioId: req.usuarioToken?.sub, usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId), sombra: !esIdLocal(itemId),
}) })
// Las cancelaciones (apartado y escrituración) guardan el motivo en la fila
// para los reportes de cancelación (Cliente/Motivo).
const esCancelacion = body.estado === 'Cancelacion Apartado' || body.estado === 'Cancelo'
if (esCancelacion && (body.motivo !== undefined || body.subcategoria !== undefined)) {
await prisma.leadCaptura.updateMany({
where: { leadId: itemId },
data: {
...(body.motivo !== undefined ? { motivo: body.motivo } : {}),
...(body.subcategoria !== undefined ? { submotivo: body.subcategoria } : {}),
},
})
}
res.json({ itemId, estado: body.estado }) res.json({ itemId, estado: body.estado })
const detalle = body.motivo const detalle = body.motivo
? `${body.estado} — motivo: ${body.motivo}${body.subcategoria ? ` / ${body.subcategoria}` : ''}${body.comentario ? ` (${body.comentario})` : ''}` ? `${body.estado} — motivo: ${body.motivo}${body.subcategoria ? ` / ${body.subcategoria}` : ''}${body.comentario ? ` (${body.comentario})` : ''}`

View File

@@ -0,0 +1,328 @@
/**
* Datos crudos para el módulo de Reportes del tablero (10 reportes del CRM).
* Devuelven filas planas en JSON; el frontend arma la vista y el Excel.
*
* Acceso: solo admin, director_ventas y direccion_general (ven a todos los
* vendedores). Rango de fechas `desde`/`hasta` en YYYY-MM-DD (inclusive).
*/
import type { Request, Response } from 'express'
import { NextFunction } from 'express'
import { z } from 'zod'
import { prisma } from '../services/prisma.js'
import { AppError } from '../utils/errors.js'
import { CAMPOS_POR_DEPARTAMENTO } from '../config/campos-departamento.js'
import { VOCABULARIO } from '../config/vocabulario.js'
// ── Acceso ────────────────────────────────────────────────────────────
export function requireRolReportes(req: Request, _res: Response, next: NextFunction): void {
const rol = req.usuarioToken?.rol
if (rol !== 'admin' && rol !== 'director_ventas' && rol !== 'direccion_general') {
throw new AppError(403, 'No tienes acceso a los reportes')
}
next()
}
// ── Helpers ───────────────────────────────────────────────────────────
const rangoSchema = z.object({
desde: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
hasta: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
})
/** Rango [desde 00:00, hasta+1d 00:00) en UTC; undefined si no viene */
function rangoFechas(query: Record<string, unknown>): { gte?: Date; lt?: Date } {
const { desde, hasta } = rangoSchema.parse(query)
const rango: { gte?: Date; lt?: Date } = {}
if (desde) rango.gte = new Date(`${desde}T00:00:00.000Z`)
if (hasta) {
const fin = new Date(`${hasta}T00:00:00.000Z`)
fin.setUTCDate(fin.getUTCDate() + 1)
rango.lt = fin
}
return rango
}
const campoDesarrollo = (query: Record<string, unknown>): string | undefined => {
const d = String(query['desarrollo'] ?? '').trim()
return d !== '' ? d : undefined
}
// Etapas de cancelación por tablero (todas las etapas del embudo)
const ETAPAS_CANCELACION = ['Descartado', 'Perdido', 'Cancelacion Apartado', 'Cancelo']
const FASES_ESCRITURACION: readonly string[] = VOCABULARIO.FASES_ESCRITURACION
function perfilDeDatos(datosJson: string | null): Record<string, string> {
if (!datosJson) return {}
try {
const datos = JSON.parse(datosJson) as Record<string, unknown>
const plano: Record<string, string> = {}
for (const [k, v] of Object.entries(datos)) {
if (v == null || typeof v === 'object') continue
plano[k] = String(v)
}
return plano
} catch {
return {}
}
}
// ── 1. Leads (todas las etapas, separados por vendedor y total) ───────
export async function reporteLeads(req: Request, res: Response): Promise<void> {
const rango = rangoFechas(req.query)
const vendedorId = String(req.query['vendedorId'] ?? '').trim()
const filas = await prisma.leadCaptura.findMany({
where: { createdAt: rango, ...(vendedorId !== '' ? { usuarioId: vendedorId } : {}) },
orderBy: [{ usuarioId: 'asc' }, { createdAt: 'asc' }],
})
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
res.json({
vendedores: usuarios
.filter((u) => u.rol === 'vendedor' && u.activo)
.map((u) => ({ id: u.id, nombre: u.nombre })),
filas: filas.map((c) => ({
id: c.leadId,
tablero: c.board,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
etapa: c.etapa,
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
fecha: c.createdAt.toISOString(),
ultimoCambio: c.updatedAt.toISOString(),
})),
})
}
// ── 2 y 4. Cancelaciones (Cliente/Motivo, todas las etapas) ───────────
export async function reporteCancelaciones(req: Request, res: Response): Promise<void> {
const rango = rangoFechas(req.query)
const filas = await prisma.leadCaptura.findMany({
where: { etapa: { in: ETAPAS_CANCELACION }, updatedAt: rango },
orderBy: { updatedAt: 'desc' },
})
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
res.json({
filas: filas.map((c) => ({
id: c.leadId,
cliente: c.nombreLead,
telefono: c.telefono ?? '',
motivo: c.motivo ?? '',
submotivo: c.submotivo ?? '',
etapa: c.etapa,
tablero: c.board,
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
fecha: c.updatedAt.toISOString(),
})),
})
}
// ── 3. Afluencias (por desarrollo, con rango de fechas) ───────────────
export async function reporteAfluencias(req: Request, res: Response): Promise<void> {
const rango = rangoFechas(req.query)
const desarrollo = campoDesarrollo(req.query)
const filas = await prisma.leadCaptura.findMany({
where: { board: 'OPORTUNIDADES', createdAt: rango, ...(desarrollo ? { desarrollo } : {}) },
orderBy: { createdAt: 'asc' },
})
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
res.json({
filas: filas.map((c) => {
const perfil = perfilDeDatos(c.datosJson)
return {
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
modelo: c.modelo ?? '',
etapa: c.etapa,
califica: perfil['califica'] ?? '',
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
fecha: c.createdAt.toISOString(),
}
}),
})
}
// ── 5. Captura por departamento (completo o por desarrollo) ───────────
export async function reporteCaptura(req: Request, res: Response): Promise<void> {
const desarrollo = campoDesarrollo(req.query)
const filas = await prisma.capturaDepartamento.findMany({
where: desarrollo ? { desarrollo } : {},
orderBy: [{ desarrollo: 'asc' }, { unidadCodigo: 'asc' }, { departamento: 'asc' }, { campo: 'asc' }],
})
res.json({
desarrollos: desarrollo ? [desarrollo] : [...new Set(filas.map((f) => f.desarrollo))],
campos: CAMPOS_POR_DEPARTAMENTO,
filas: filas.map((f) => ({
desarrollo: f.desarrollo,
unidad: f.unidadCodigo,
departamento: f.departamento,
campo: f.campo,
valor: f.valor,
})),
})
}
// ── 6. Clientes en cobranza (por desarrollo y fechas) ─────────────────
export async function reporteCobranza(req: Request, res: Response): Promise<void> {
const rango = rangoFechas(req.query)
const desarrollo = campoDesarrollo(req.query)
const planes = await prisma.planPago.findMany({
where: { fechaInicio: rango, ...(desarrollo ? { proyecto: desarrollo } : {}) },
include: { pagos: true },
orderBy: { fechaInicio: 'asc' },
})
const hoy = new Date()
res.json({
filas: planes.map((p) => {
const pagado = p.pagos.reduce((a, x) => a + x.montoPagado, 0)
const saldo = p.pagos.reduce((a, x) => a + (x.monto - x.montoPagado), 0)
const vencidos = p.pagos.filter(
(x) => x.fechaVencimiento < hoy && x.montoPagado < x.monto,
)
const proximo = p.pagos
.filter((x) => x.montoPagado < x.monto && x.fechaVencimiento >= hoy)
.sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0]
return {
cliente: p.clienteNombre,
unidad: p.unidadCodigo,
desarrollo: p.proyecto,
fechaInicio: p.fechaInicio.toISOString(),
engancheTotal: Math.round(p.engancheTotal * 100) / 100,
pagado: Math.round(pagado * 100) / 100,
saldo: Math.round(saldo * 100) / 100,
pagosVencidos: vencidos.length,
montoVencido: Math.round(vencidos.reduce((a, x) => a + (x.monto - x.montoPagado), 0) * 100) / 100,
proximoVencimiento: proximo ? proximo.fechaVencimiento.toISOString() : '',
estatus: p.status === 'CANCELADO' ? 'CANCELADO' : saldo <= 0.005 ? 'LIQUIDADO' : 'ACTIVO',
}
}),
})
}
// ── 7. Desarrollos / disponibilidad (unidades y modelos) ──────────────
export async function reporteDisponibilidad(_req: Request, res: Response): Promise<void> {
const unidades = await prisma.unidad.findMany({
orderBy: [{ desarrollo: 'asc' }, { modelo: 'asc' }, { codigo: 'asc' }],
})
res.json({
filas: unidades.map((u) => ({
codigo: u.codigo,
desarrollo: u.desarrollo,
modelo: u.modelo ?? '',
estatus: u.status,
supConstruccion: u.supConstruccion ?? null,
precioLista: u.precioLista ?? null,
precioAcabados: u.precioAcabados ?? null,
})),
})
}
// ── 8. Contactos (toda su info) ───────────────────────────────────────
export async function reporteContactos(_req: Request, res: Response): Promise<void> {
const filas = await prisma.leadCaptura.findMany({ orderBy: [{ board: 'asc' }, { createdAt: 'desc' }] })
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
res.json({
filas: filas.map((c) => {
const perfil = perfilDeDatos(c.datosJson)
return {
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
modelo: c.modelo ?? '',
tablero: c.board,
etapa: c.etapa,
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
fecha: c.createdAt.toISOString(),
numDependientes: perfil['numDependientes'] ?? '',
edad: perfil['edad'] ?? '',
estadoCivil: perfil['estadoCivil'] ?? '',
tipoEmpleo: perfil['tipoEmpleo'] ?? '',
ocupacion: perfil['ocupacion'] ?? '',
rangoSalarial: perfil['rangoSalarial'] ?? '',
credito: perfil['credito'] ?? '',
tipoEconomia: perfil['tipoEconomia'] ?? '',
califica: perfil['califica'] ?? '',
loQuierePara: perfil['loQuierePara'] ?? '',
}
}),
})
}
// ── 9. Clientes (toda su info) ────────────────────────────────────────
export async function reporteClientes(_req: Request, res: Response): Promise<void> {
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' } })
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
res.json({
filas: clientes.map((c) => {
const perfil = perfilDeDatos(c.payloadJson)
return {
nombre: c.nombreCompleto,
curp: c.curp,
rfc: c.rfc,
email: c.email ?? '',
celular: c.celular ?? '',
proyecto: c.proyecto,
unidad: c.unidadCodigo ?? '',
ekontrol: perfil['ekontrol'] ?? perfil['usuarioEnkontrol'] ?? '',
estadoCivil: perfil['estadoCivil'] ?? '',
ocupacion: perfil['ocupacion'] ?? '',
rangoSalarial: perfil['rangoSalarial'] ?? '',
empresa: perfil['compania'] ?? perfil['empresa'] ?? '',
antiguedad: perfil['antiguedad'] ?? '',
ingresoMensual: perfil['ingresoMensual'] ?? perfil['ingresos'] ?? '',
capturadoPor: c.capturadoPorId ? (nombrePorId.get(c.capturadoPorId) ?? '') : '',
fecha: c.createdAt.toISOString(),
}
}),
})
}
// ── 10. Escrituración (estatus y fecha de cada cliente) ───────────────
export async function reporteEscrituracion(_req: Request, res: Response): Promise<void> {
const filas = await prisma.leadCaptura.findMany({
where: { board: 'APARTADO', etapa: { in: [...FASES_ESCRITURACION] } },
orderBy: { updatedAt: 'desc' },
})
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
// Unidad ligada vía Cliente (igual que Mi día)
const clientes = await prisma.cliente.findMany({
where: { mondayApartadoId: { not: null } },
select: { mondayApartadoId: true, unidadCodigo: true },
})
const unidadPorApartadoId = new Map(
clientes.filter((c) => c.mondayApartadoId !== null).map((c) => [c.mondayApartadoId!, c.unidadCodigo ?? '']),
)
res.json({
filas: filas.map((c) => ({
cliente: c.nombreLead,
telefono: c.telefono ?? '',
unidad: unidadPorApartadoId.get(c.leadId) ?? '',
estatus: c.etapa,
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
fechaUltimoCambio: c.updatedAt.toISOString(),
fechaAlta: c.createdAt.toISOString(),
})),
})
}

View File

@@ -86,6 +86,18 @@ import {
} from '../controllers/ai.controller.js' } from '../controllers/ai.controller.js'
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js' import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
import { resumenReportes, scoringEndpoint, moraEndpoint } from '../controllers/reportes.controller.js' import { resumenReportes, scoringEndpoint, moraEndpoint } from '../controllers/reportes.controller.js'
import {
requireRolReportes,
reporteLeads,
reporteCancelaciones,
reporteAfluencias,
reporteCaptura,
reporteCobranza,
reporteDisponibilidad,
reporteContactos,
reporteClientes,
reporteEscrituracion,
} from '../controllers/reportes-datos.controller.js'
import { estadoSistema } from '../controllers/sistema.controller.js' import { estadoSistema } from '../controllers/sistema.controller.js'
import { import {
requireAdmin, requireAdmin,
@@ -253,6 +265,17 @@ apiRouter.get('/reportes/mi-tablero', miTableroEndpoint)
// Métricas de uso (admin/coordinador): leads por usuario, tasa de descarte, actividades // Métricas de uso (admin/coordinador): leads por usuario, tasa de descarte, actividades
apiRouter.get('/reportes/metricas-uso', requireAdminOrCoordinador, metricasUsoEndpoint) apiRouter.get('/reportes/metricas-uso', requireAdminOrCoordinador, metricasUsoEndpoint)
// Datos crudos para el módulo de Reportes (admin/dirección): los 10 reportes
apiRouter.get('/reportes/datos/leads', requireRolReportes, reporteLeads)
apiRouter.get('/reportes/datos/cancelaciones', requireRolReportes, reporteCancelaciones)
apiRouter.get('/reportes/datos/afluencias', requireRolReportes, reporteAfluencias)
apiRouter.get('/reportes/datos/captura', requireRolReportes, reporteCaptura)
apiRouter.get('/reportes/datos/cobranza', requireRolReportes, reporteCobranza)
apiRouter.get('/reportes/datos/disponibilidad', requireRolReportes, reporteDisponibilidad)
apiRouter.get('/reportes/datos/contactos', requireRolReportes, reporteContactos)
apiRouter.get('/reportes/datos/clientes', requireRolReportes, reporteClientes)
apiRouter.get('/reportes/datos/escrituracion', requireRolReportes, reporteEscrituracion)
// Estado del modo local (banner del front) // Estado del modo local (banner del front)
apiRouter.get('/sistema/estado', estadoSistema) apiRouter.get('/sistema/estado', estadoSistema)

View File

@@ -14,7 +14,8 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-router-dom": "^7.18.3", "react-router-dom": "^7.18.3",
"recharts": "^2.15.0" "recharts": "^2.15.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
@@ -1955,6 +1956,15 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -2086,6 +2096,19 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "5.3.3", "version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@@ -2122,6 +2145,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2142,6 +2174,18 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cssstyle": { "node_modules/cssstyle": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -2521,6 +2565,15 @@
} }
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3499,6 +3552,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackback": { "node_modules/stackback": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -4970,6 +5035,24 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ws": { "node_modules/ws": {
"version": "8.21.3", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
@@ -4992,6 +5075,27 @@
} }
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View File

@@ -16,7 +16,8 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-router-dom": "^7.18.3", "react-router-dom": "^7.18.3",
"recharts": "^2.15.0" "recharts": "^2.15.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",

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 { useEffect, useState } from 'react'
import MiTablero from '../components/MiTablero' import MiTablero from '../components/MiTablero'
import TableroGeneral from '../components/TableroGeneral' import TableroGeneral from '../components/TableroGeneral'
import Reportes from '../components/Reportes'
import type { AccionesTablero } from '../components/comunes' import type { AccionesTablero } from '../components/comunes'
import { esRolConEquipo } from '../lib/mi-tablero' import { esRolConEquipo } from '../lib/mi-tablero'
import { getMe, getSesion } from '../services/auth' import { getMe, getSesion } from '../services/auth'
import DarkModeToggle from '../components/DarkModeToggle' import DarkModeToggle from '../components/DarkModeToggle'
type Vista = 'mia' | 'general' type Vista = 'mia' | 'general' | 'reportes'
const VISTAS: { id: Vista; etiqueta: string; clave: string }[] = [ const VISTAS: { id: Vista; etiqueta: string; clave: string }[] = [
{ id: 'mia', etiqueta: 'Mi tablero', clave: 'mi-tablero' }, { id: 'mia', etiqueta: 'Mi tablero', clave: 'mi-tablero' },
{ id: 'general', etiqueta: 'Tablero comercial', clave: 'tablero-comercial' }, { 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á * 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 * 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 .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 = const vista: Vista | null =
permitidas.length === 0 permitidas.length === 0
? null ? null
@@ -103,6 +112,8 @@ export default function DashboardPage() {
</main> </main>
) : vista === 'mia' ? ( ) : vista === 'mia' ? (
<MiTablero alCambiarAcciones={setAcciones} /> <MiTablero alCambiarAcciones={setAcciones} />
) : vista === 'reportes' ? (
<Reportes />
) : ( ) : (
<TableroGeneral mostrarEquipo={conEquipo} alCambiarAcciones={setAcciones} /> <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> { export async function fetchMiTablero(): Promise<MiTablero> {
return obtener<MiTablero>('/reportes/mi-tablero') 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}`)
}