diff --git a/api-gateway/src/controllers/apartados.controller.ts b/api-gateway/src/controllers/apartados.controller.ts index 706d62b..324e138 100644 --- a/api-gateway/src/controllers/apartados.controller.ts +++ b/api-gateway/src/controllers/apartados.controller.ts @@ -36,6 +36,18 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response) usuarioId: req.usuarioToken?.sub, 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 }) const detalle = body.motivo ? `${body.estado} — motivo: ${body.motivo}${body.subcategoria ? ` / ${body.subcategoria}` : ''}${body.comentario ? ` (${body.comentario})` : ''}` diff --git a/api-gateway/src/controllers/reportes-datos.controller.ts b/api-gateway/src/controllers/reportes-datos.controller.ts new file mode 100644 index 0000000..1c2b854 --- /dev/null +++ b/api-gateway/src/controllers/reportes-datos.controller.ts @@ -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): { 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 | 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 { + if (!datosJson) return {} + try { + const datos = JSON.parse(datosJson) as Record + const plano: Record = {} + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(), + })), + }) +} diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 6907889..a49e524 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -86,6 +86,18 @@ import { } from '../controllers/ai.controller.js' import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.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 { requireAdmin, @@ -253,6 +265,17 @@ apiRouter.get('/reportes/mi-tablero', miTableroEndpoint) // Métricas de uso (admin/coordinador): leads por usuario, tasa de descarte, actividades 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) apiRouter.get('/sistema/estado', estadoSistema) diff --git a/apps/dashboards/package-lock.json b/apps/dashboards/package-lock.json index 2204687..8018e35 100644 --- a/apps/dashboards/package-lock.json +++ b/apps/dashboards/package-lock.json @@ -14,7 +14,8 @@ "react-dom": "^19.0.0", "react-hot-toast": "^2.6.0", "react-router-dom": "^7.18.3", - "recharts": "^2.15.0" + "recharts": "^2.15.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -1955,6 +1956,15 @@ "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": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2086,6 +2096,19 @@ ], "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": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2122,6 +2145,15 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2142,6 +2174,18 @@ "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": { "version": "4.6.0", "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": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3499,6 +3552,18 @@ "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": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4970,6 +5035,24 @@ "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": { "version": "8.21.3", "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/apps/dashboards/package.json b/apps/dashboards/package.json index 638fce2..17d73d8 100644 --- a/apps/dashboards/package.json +++ b/apps/dashboards/package.json @@ -16,7 +16,8 @@ "react-dom": "^19.0.0", "react-hot-toast": "^2.6.0", "react-router-dom": "^7.18.3", - "recharts": "^2.15.0" + "recharts": "^2.15.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/apps/dashboards/src/components/Reportes.tsx b/apps/dashboards/src/components/Reportes.tsx new file mode 100644 index 0000000..1590479 --- /dev/null +++ b/apps/dashboards/src/components/Reportes.tsx @@ -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 + +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): Record { + const plano: Record = {} + for (const col of def.columnas) { + const v = fila[col.key] + if (col.key === 'fecha' || col.key === 'ultimoCambio' || col.key === 'fechaInicio' || col.key === 'proximoVencimiento' || col.key === 'fechaUltimoCambio' || col.key === 'fechaAlta') { + plano[col.label] = ISO_FECHA(String(v ?? '')) + } else if (['engancheTotal', 'pagado', 'saldo', 'montoVencido', 'precioLista', 'precioAcabados'].includes(col.key)) { + plano[col.label] = MONEDA(typeof v === 'number' ? v : Number(v ?? 0) || null) + } else { + plano[col.label] = (v ?? '') as string + } + } + return plano +} + +export default function Reportes() { + const [reporteId, setReporteId] = useState('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([]) + const [captura, setCaptura] = useState<{ + desarrollos: string[] + campos: Record + 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(def.id, { + desde: desde || undefined, + hasta: hasta || undefined, + desarrollo: desarrollo || undefined, + vendedorId: vendedorId || undefined, + }) + setFilas(filas) + if (def.id === 'leads') { + const resp = (await fetchReporte('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[] }[], + 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)) + + if (def.id === 'leads') { + // Hoja resumen: vendedor × etapa + total + const conteo = new Map>() + const etapas = new Set() + for (const f of filas as FilaLeadReporte[]) { + const porVendedor = conteo.get(f.vendedor) ?? new Map() + 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 = { 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 = { 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() + 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>() + const modelosPorDesarrollo = new Map>() + for (const f of filas as FilaDisponibilidadReporte[]) { + const porEstatus = conteo.get(f.desarrollo) ?? new Map() + porEstatus.set(f.estatus, (porEstatus.get(f.estatus) ?? 0) + 1) + conteo.set(f.desarrollo, porEstatus) + const modelos = modelosPorDesarrollo.get(f.desarrollo) ?? new Set() + 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 = { 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>>() + 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[]>() + 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 = { 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

{error}

+ } + if (esCaptura) { + const totalUnidades = new Set((captura?.filas ?? []).map((f) => `${f.desarrollo}-${f.unidad}`)).size + return ( +
+

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

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

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

+ )} +
+ ) + } + if (filas.length === 0) { + return

Sin datos para el rango seleccionado.

+ } + const visibles = filas.slice(0, 100) + return ( +
+

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

+
+ + + + {def.columnas.map((c) => ( + + ))} + + + + {visibles.map((f, i) => ( + + {def.columnas.map((c) => { + const valor = (f as Record)[c.key] + const esFecha = c.key.toLowerCase().includes('fecha') || c.key === 'ultimoCambio' + return ( + + ) + })} + + ))} + +
+ {c.label} +
+ {esFecha && typeof valor === 'string' && valor !== '' ? valor.slice(0, 10) : String(valor ?? '—')} +
+
+
+ ) + } + + return ( +
+
+
+ {REPORTES.map((r) => ( + + ))} +
+
+ +
+

{def.titulo}

+

{def.descripcion}

+ {!esCaptura && ( +
+ {def.filtroFechas && ( + <> +
+ + 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]" /> +
+
+ + setHasta(e.target.value)} + className="mt-1 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]" /> +
+ + )} + {def.filtroDesarrollo && ( +
+ + +
+ )} + {def.filtroVendedor && vendedores.length > 0 && ( +
+ + +
+ )} + + {!esCaptura && filas.length > 0 && ( + + )} +
+ )} +
+ +
{renderTabla()}
+
+ ) +} diff --git a/apps/dashboards/src/pages/DashboardPage.tsx b/apps/dashboards/src/pages/DashboardPage.tsx index 47ddcbf..a7ad2e3 100644 --- a/apps/dashboards/src/pages/DashboardPage.tsx +++ b/apps/dashboards/src/pages/DashboardPage.tsx @@ -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() { ) : vista === 'mia' ? ( + ) : vista === 'reportes' ? ( + ) : ( )} diff --git a/apps/dashboards/src/services/reportes.ts b/apps/dashboards/src/services/reportes.ts index 910df73..9244133 100644 --- a/apps/dashboards/src/services/reportes.ts +++ b/apps/dashboards/src/services/reportes.ts @@ -30,3 +30,153 @@ export async function fetchResumen(dias?: number): Promise { export async function fetchMiTablero(): Promise { return obtener('/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( + 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 + filas: FilaCapturaReporte[] +}> { + const qs = desarrollo ? `?desarrollo=${encodeURIComponent(desarrollo)}` : '' + return obtener(`/reportes/datos/captura${qs}`) +}