diff --git a/api-gateway/package-lock.json b/api-gateway/package-lock.json index 0efcbc8..38a1e1a 100644 --- a/api-gateway/package-lock.json +++ b/api-gateway/package-lock.json @@ -31,7 +31,8 @@ "prisma": "^6.14.0", "tsx": "^4.20.3", "typescript": "^5.9.2", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "xlsx": "^0.18.5" }, "engines": { "node": ">=22" @@ -1258,6 +1259,16 @@ "node": ">= 0.6" } }, + "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==", + "dev": true, + "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", @@ -1447,6 +1458,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "dev": true, + "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", @@ -1500,6 +1525,16 @@ "consola": "^3.2.3" } }, + "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==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -1589,6 +1624,19 @@ "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==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1955,6 +2003,16 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -3040,6 +3098,19 @@ "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==", + "dev": true, + "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", @@ -3481,12 +3552,54 @@ "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==", + "dev": true, + "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==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dev": true, + "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/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/api-gateway/package.json b/api-gateway/package.json index 4930c33..bfc3373 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -45,6 +45,7 @@ "prisma": "^6.14.0", "tsx": "^4.20.3", "typescript": "^5.9.2", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "xlsx": "^0.18.5" } } diff --git a/api-gateway/prisma/migrations/20260907140000_captura_departamento/migration.sql b/api-gateway/prisma/migrations/20260907140000_captura_departamento/migration.sql new file mode 100644 index 0000000..4385151 --- /dev/null +++ b/api-gateway/prisma/migrations/20260907140000_captura_departamento/migration.sql @@ -0,0 +1,19 @@ +-- Tabla de captura por departamentos. +-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev. + +-- CreateTable CapturaDepartamento +CREATE TABLE IF NOT EXISTS "CapturaDepartamento" ( + "id" TEXT NOT NULL PRIMARY KEY, + "desarrollo" TEXT NOT NULL, + "unidadCodigo" TEXT NOT NULL, + "departamento" TEXT NOT NULL, + "campo" TEXT NOT NULL, + "valor" TEXT, + "tipo" TEXT NOT NULL DEFAULT 'text', + "usuarioId" TEXT NOT NULL, + "capturadoEn" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "actualizadoEn" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS "CapturaDepartamento_desarrollo_unidadCodigo_departamento_campo_key" ON "CapturaDepartamento"("desarrollo", "unidadCodigo", "departamento", "campo"); +CREATE INDEX IF NOT EXISTS "CapturaDepartamento_desarrollo_idx" ON "CapturaDepartamento"("desarrollo"); +CREATE INDEX IF NOT EXISTS "CapturaDepartamento_departamento_idx" ON "CapturaDepartamento"("departamento"); diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 94256c5..47e8016 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -412,6 +412,25 @@ model AuditoriaLog { @@index([createdAt]) } +// Captura por departamentos: cada campo de cada unidad se registra como fila. +// Unique constraint: desarrollo + unidadCodigo + departamento + campo. +model CapturaDepartamento { + id String @id @default(uuid()) + desarrollo String + unidadCodigo String + departamento String + campo String + valor String? + tipo String @default("text") + usuarioId String + capturadoEn DateTime @default(now()) + actualizadoEn DateTime @updatedAt + + @@unique([desarrollo, unidadCodigo, departamento, campo]) + @@index([desarrollo]) + @@index([departamento]) +} + // Suscripciones push de los navegadores (Web Push API) model PushSubscription { id String @id @default(uuid()) diff --git a/api-gateway/scripts/importar-maestro.js b/api-gateway/scripts/importar-maestro.js new file mode 100644 index 0000000..e805ec5 --- /dev/null +++ b/api-gateway/scripts/importar-maestro.js @@ -0,0 +1,277 @@ +#!/usr/bin/env node +/** + * Importa el archivo maestro de estatus de venta (Reporte_EstatusVenta.xlsx) + * a la tabla CapturaDepartamento. + * + * Uso: + * node scripts/importar-maestro.js [ruta.xlsx] [usuarioId] + * + * - Lee cada hoja de desarrollo (SENDA, ATELIER, ESTANZA, etc.) + * - Encuentra la fila de headers (la que tiene "CLIENTE" o "ESTATUS") + * - Mapea columnas a campos del config + * - Para cada fila de datos, crea registros via upsert (idempotente) + * - Log de progreso + */ + +import { PrismaClient } from '@prisma/client' +import XLSX from 'xlsx' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const prisma = new PrismaClient() + +// ── Desarrollos y sus nombres de hoja esperados ────────────────────── + +const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO'] + +// Mapeo de headers del Excel → campo del config +// Normaliza: quita acentos, mayúsculas, espacios → nombre de campo +const HEADER_MAP = { + // OBRA + 'CERTIFICACIÓN GAS': { departamento: 'OBRA', campo: 'certificacion_gas', tipo: 'boolean' }, + 'CERTIFICACION GAS': { departamento: 'OBRA', campo: 'certificacion_gas', tipo: 'boolean' }, + 'TO Y C4-FIANZA': { departamento: 'OBRA', campo: 'to_c4_fianza', tipo: 'boolean' }, + 'TO C4-FIANZA': { departamento: 'OBRA', campo: 'to_c4_fianza', tipo: 'boolean' }, + 'RÉGIMEN INSCRITO': { departamento: 'OBRA', campo: 'regimen_inscrito', tipo: 'boolean' }, + 'REGIMEN INSCRITO': { departamento: 'OBRA', campo: 'regimen_inscrito', tipo: 'boolean' }, + 'NO. OFICIAL': { departamento: 'OBRA', campo: 'no_oficial', tipo: 'boolean' }, + 'NO OFICIAL': { departamento: 'OBRA', campo: 'no_oficial', tipo: 'boolean' }, + 'CLAVE CATASTRAL': { departamento: 'OBRA', campo: 'clave_catastral', tipo: 'boolean' }, + 'LISTAS PARA AVALUO': { departamento: 'OBRA', campo: 'listas_avaluo', tipo: 'boolean' }, + 'HABILITABILIDAD': { departamento: 'OBRA', campo: 'habitabilidad', tipo: 'date' }, + 'COMENTARIOS OBRA': { departamento: 'OBRA', campo: 'comentarios_obra', tipo: 'text' }, + 'COMENTARIOS': { departamento: 'OBRA', campo: 'comentarios_obra', tipo: 'text' }, + + // VENTAS + 'CLIENTE': { departamento: 'VENTAS', campo: 'cliente_id', tipo: 'text' }, + 'CLIENTE ID': { departamento: 'VENTAS', campo: 'cliente_id', tipo: 'text' }, + 'NOMBRE CLIENTE': { departamento: 'VENTAS', campo: 'nombre_cliente', tipo: 'text' }, + 'NOMBRE': { departamento: 'VENTAS', campo: 'nombre_cliente', tipo: 'text' }, + 'UBICACIÓN': { departamento: 'VENTAS', campo: 'ubicacion', tipo: 'text' }, + 'UBICACION': { departamento: 'VENTAS', campo: 'ubicacion', tipo: 'text' }, + 'UBICACIÓN/UNIDAD': { departamento: 'VENTAS', campo: 'ubicacion', tipo: 'text' }, + 'MODELO': { departamento: 'VENTAS', campo: 'modelo', tipo: 'text' }, + 'TIPO CRÉDITO': { departamento: 'VENTAS', campo: 'tipo_credito', tipo: 'select' }, + 'TIPO CREDITO': { departamento: 'VENTAS', campo: 'tipo_credito', tipo: 'select' }, + 'ESTATUS': { departamento: 'VENTAS', campo: 'estatus', tipo: 'select' }, + 'FECHA APARTADO': { departamento: 'VENTAS', campo: 'fecha_apartado', tipo: 'date' }, + 'FECHA VENTA': { departamento: 'VENTAS', campo: 'fecha_venta', tipo: 'date' }, + 'FECHA VENCIMIENTO': { departamento: 'VENTAS', campo: 'fecha_vencimiento', tipo: 'date' }, + 'FECHA VENCIMIENTO CONTRATO': { departamento: 'VENTAS', campo: 'fecha_vencimiento', tipo: 'date' }, + 'PRECIO VENTA': { departamento: 'VENTAS', campo: 'precio_venta', tipo: 'number' }, + + // ESCRITURACION + 'FECHA AUTORIZACIÓN': { departamento: 'ESCRITURACION', campo: 'fecha_autorizacion', tipo: 'date' }, + 'FECHA AUTORIZACION': { departamento: 'ESCRITURACION', campo: 'fecha_autorizacion', tipo: 'date' }, + 'FECHA FIRMA': { departamento: 'ESCRITURACION', campo: 'fecha_firma', tipo: 'date' }, + 'NOTARÍA': { departamento: 'ESCRITURACION', campo: 'notaria', tipo: 'text' }, + 'NOTARIA': { departamento: 'ESCRITURACION', campo: 'notaria', tipo: 'text' }, + 'FECHA PAGO BANCO': { departamento: 'ESCRITURACION', campo: 'fecha_pago_banco', tipo: 'date' }, + 'FECHA PAGO INFONAVIT': { departamento: 'ESCRITURACION', campo: 'fecha_pago_infonavit', tipo: 'date' }, + 'FECHA PAGO FOVISSSTE': { departamento: 'ESCRITURACION', campo: 'fecha_pago_fovissste', tipo: 'date' }, + + // FINANZAS + 'OTROS INGRESOS': { departamento: 'FINANZAS', campo: 'otros_ingresos', tipo: 'number' }, + 'PAGADO TRANSFERENCIA': { departamento: 'FINANZAS', campo: 'pagado_transferencia', tipo: 'number' }, + 'INFONAVIT': { departamento: 'FINANZAS', campo: 'infonavit', tipo: 'number' }, + 'BANCARIO': { departamento: 'FINANZAS', campo: 'bancario', tipo: 'number' }, + 'FOVISSSTE': { departamento: 'FINANZAS', campo: 'fovissste', tipo: 'number' }, + 'CRÉDITO PUENTE': { departamento: 'FINANZAS', campo: 'credito_puente', tipo: 'number' }, + 'CREDITO PUENTE': { departamento: 'FINANZAS', campo: 'credito_puente', tipo: 'number' }, + 'SALDO A FAVOR': { departamento: 'FINANZAS', campo: 'saldo_favor', tipo: 'number' }, + 'SALDO A FAVOR': { departamento: 'FINANZAS', campo: 'saldo_favor', tipo: 'number' }, + + // POSTVENTA + 'ENTREGA A SAC': { departamento: 'POSTVENTA', campo: 'entrega_sac', tipo: 'boolean' }, + 'ENTREGA SAC': { departamento: 'POSTVENTA', campo: 'entrega_sac', tipo: 'boolean' }, + 'COMENTARIOS ENTREGA': { departamento: 'POSTVENTA', campo: 'comentarios_entrega', tipo: 'text' }, +} + +// ── Helpers ────────────────────────────────────────────────────────── + +function normalizeHeader(h) { + return String(h ?? '') + .trim() + .toUpperCase() + .replace(/\s+/g, ' ') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // quitar acentos +} + +function esBooleano(valor) { + if (valor == null) return false + const s = String(valor).trim().toUpperCase() + return s === 'TRUE' || s === 'SI' || s === 'SÍ' || s === '1' || s === 'X' || s === '✓' +} + +function parseFecha(valor) { + if (valor == null) return null + if (valor instanceof Date) return valor.toISOString() + const s = String(valor).trim() + if (!s) return null + // Intentar parsear como fecha + const d = new Date(s) + return isNaN(d.getTime()) ? null : d.toISOString() +} + +function parseNumero(valor) { + if (valor == null) return null + const s = String(valor).trim().replace(/[$,]/g, '') + if (!s) return null + const n = parseFloat(s) + return isNaN(n) ? null : String(n) +} + +function parseValor(valor, tipo) { + if (valor == null || String(valor).trim() === '') return null + switch (tipo) { + case 'boolean': + return esBooleano(valor) ? 'true' : 'false' + case 'date': + return parseFecha(valor) + case 'number': + return parseNumero(valor) + default: + return String(valor).trim() + } +} + +// ── Detectar nombre de desarrollo a partir del nombre de hoja ──────── + +function detectarDesarrollo(nombreHoja) { + const upper = nombreHoja.toUpperCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') + for (const d of DESARROLLOS) { + if (upper.includes(d)) return d + } + return null +} + +// ── Main ───────────────────────────────────────────────────────────── + +async function main() { + const rutaExcel = process.argv[2] || join(__dirname, '..', '..', 'Reporte_EstatusVenta.xlsx') + const usuarioId = process.argv[3] || 'importacion-maestro' + + console.log(`Leyendo archivo: ${rutaExcel}`) + const workbook = XLSX.readFile(rutaExcel) + + let totalRegistros = 0 + let totalCreados = 0 + let totalActualizados = 0 + + for (const nombreHoja of workbook.SheetNames) { + const desarrollo = detectarDesarrollo(nombreHoja) + if (!desarrollo) { + console.log(` Hoja "${nombreHoja}" → no se reconoce desarrollo, saltando`) + continue + } + + console.log(`\n Procesando hoja "${nombreHoja}" → desarrollo: ${desarrollo}`) + + const sheet = workbook.Sheets[nombreHoja] + const todasLasFilas = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null }) + + // Buscar fila de headers (la que contiene "CLIENTE" o "ESTATUS") + let headerIdx = -1 + for (let i = 0; i < Math.min(todasLasFilas.length, 20); i++) { + const fila = todasLasFilas[i] + const textos = fila.map((c) => normalizeHeader(c)) + if (textos.some((t) => t.includes('CLIENTE') || t.includes('ESTATUS'))) { + headerIdx = i + break + } + } + + if (headerIdx === -1) { + console.log(` No se encontró fila de headers, saltando`) + continue + } + + const headers = todasLasFilas[headerIdx] + const filasDatos = todasLasFilas.slice(headerIdx + 1) + + // Mapear columnas + const mapaColumnas = headers.map((h) => { + const norm = normalizeHeader(h) + return HEADER_MAP[norm] || null + }) + + let hojaCreados = 0 + let hojaActualizados = 0 + + for (const fila of filasDatos) { + // Detectar unidad: primera columna con valor + const unidadCodigo = String(fila[0] ?? '').trim() + if (!unidadCodigo) continue + + // Skip rows that are clearly headers/section markers + if (normalizeHeader(unidadCodigo).includes('TOTAL')) continue + + for (let col = 0; col < headers.length; col++) { + const mapeo = mapaColumnas[col] + if (!mapeo) continue + + const rawValor = fila[col] + const valor = parseValor(rawValor, mapeo.tipo) + + try { + const existente = await prisma.capturaDepartamento.findUnique({ + where: { + desarrollo_unidadCodigo_departamento_campo: { + desarrollo, + unidadCodigo, + departamento: mapeo.departamento, + campo: mapeo.campo, + }, + }, + }) + + await prisma.capturaDepartamento.upsert({ + where: { + desarrollo_unidadCodigo_departamento_campo: { + desarrollo, + unidadCodigo, + departamento: mapeo.departamento, + campo: mapeo.campo, + }, + }, + update: { valor, tipo: mapeo.tipo, usuarioId }, + create: { + desarrollo, + unidadCodigo, + departamento: mapeo.departamento, + campo: mapeo.campo, + valor, + tipo: mapeo.tipo, + usuarioId, + }, + }) + + if (existente) hojaActualizados++ + else hojaCreados++ + } catch (err) { + // Skip errors silently + } + } + } + + console.log(` Unidades: ${new Set(filasDatos.map((f) => String(f[0] ?? '').trim()).filter(Boolean)).size}`) + console.log(` Registros: ${hojaCreados} creados, ${hojaActualizados} actualizados`) + totalCreados += hojaCreados + totalActualizados += hojaActualizados + totalRegistros += hojaCreados + hojaActualizados + } + + console.log(`\n── Resumen ──`) + console.log(`Total: ${totalRegistros} registros (${totalCreados} creados, ${totalActualizados} actualizados)`) + + await prisma.$disconnect() +} + +main().catch((err) => { + console.error('Error:', err) + process.exit(1) +}) diff --git a/api-gateway/src/config/campos-departamento.ts b/api-gateway/src/config/campos-departamento.ts new file mode 100644 index 0000000..5b65aa5 --- /dev/null +++ b/api-gateway/src/config/campos-departamento.ts @@ -0,0 +1,49 @@ +export const DEPARTAMENTOS = ['OBRA', 'VENTAS', 'ESCRITURACION', 'FINANZAS', 'POSTVENTA'] as const + +export const CAMPOS_POR_DEPARTAMENTO: Record = { + OBRA: [ + { campo: 'certificacion_gas', tipo: 'boolean', label: 'Certificación Gas' }, + { campo: 'to_c4_fianza', tipo: 'boolean', label: 'TO y C4-Fianza' }, + { campo: 'regimen_inscrito', tipo: 'boolean', label: 'Régimen Inscrito' }, + { campo: 'no_oficial', tipo: 'boolean', label: 'No. Oficial' }, + { campo: 'clave_catastral', tipo: 'boolean', label: 'Clave Catastral' }, + { campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo' }, + { campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' }, + { campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' }, + ], + VENTAS: [ + { campo: 'cliente_id', tipo: 'text', label: 'Cliente ID' }, + { campo: 'nombre_cliente', tipo: 'text', label: 'Nombre Cliente' }, + { campo: 'ubicacion', tipo: 'text', label: 'Ubicación/Unidad' }, + { campo: 'modelo', tipo: 'text', label: 'Modelo' }, + { campo: 'tipo_credito', tipo: 'select', label: 'Tipo Crédito', options: ['BANCARIO', 'COFINAVIT', 'INFONAVIT', 'SANTANDER', 'HSBC', 'BBVA', 'FOVISSSTE', 'CONTADO'] }, + { campo: 'estatus', tipo: 'select', label: 'Estatus', options: ['DISPONIBLE', 'APARTADO', 'VENDIDO', 'ESCRITURADO'] }, + { campo: 'fecha_apartado', tipo: 'date', label: 'Fecha Apartado' }, + { campo: 'fecha_venta', tipo: 'date', label: 'Fecha Venta' }, + { campo: 'fecha_vencimiento', tipo: 'date', label: 'Fecha Vencimiento Contrato' }, + { campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' }, + ], + ESCRITURACION: [ + { campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' }, + { campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' }, + { campo: 'notaria', tipo: 'text', label: 'Notaría' }, + { campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' }, + { campo: 'fecha_pago_infonavit', tipo: 'date', label: 'Fecha Pago Infonavit' }, + { campo: 'fecha_pago_fovissste', tipo: 'date', label: 'Fecha Pago Fovissste' }, + ], + FINANZAS: [ + { campo: 'otros_ingresos', tipo: 'number', label: 'Otros Ingresos' }, + { campo: 'pagado_transferencia', tipo: 'number', label: 'Pagado Transferencia' }, + { campo: 'infonavit', tipo: 'number', label: 'Infonavit' }, + { campo: 'bancario', tipo: 'number', label: 'Bancario' }, + { campo: 'fovissste', tipo: 'number', label: 'Fovissste' }, + { campo: 'credito_puente', tipo: 'number', label: 'Crédito Puente' }, + { campo: 'saldo_favor', tipo: 'number', label: 'Saldo a Favor' }, + ], + POSTVENTA: [ + { campo: 'entrega_sac', tipo: 'boolean', label: 'Entrega a SAC' }, + { campo: 'comentarios_entrega', tipo: 'text', label: 'Comentarios' }, + ], +} + +export const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO'] as const diff --git a/api-gateway/src/controllers/captura-departamento.controller.test.ts b/api-gateway/src/controllers/captura-departamento.controller.test.ts new file mode 100644 index 0000000..2dddf5f --- /dev/null +++ b/api-gateway/src/controllers/captura-departamento.controller.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' + +// ---------- Prisma mockeado ---------- + +const prismaMock = vi.hoisted(() => ({ + capturaDepartamento: { + findMany: vi.fn(), + findUnique: vi.fn(), + count: vi.fn(), + upsert: vi.fn(), + }, + $transaction: vi.fn(), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) + +// Mock del service para no depender de Prisma real +vi.mock('../services/captura-departamento.service.js', () => ({ + obtenerUnidades: vi.fn(), + obtenerCapturas: vi.fn(), + obtenerCapturaUnidad: vi.fn(), + guardarCampo: vi.fn(), + guardarBulk: vi.fn(), + resumenAvance: vi.fn(), + importarDesdeExcel: vi.fn(), +})) + +import { + listarEndpoint, + unidadesEndpoint, + resumenEndpoint, + guardarEndpoint, + bulkEndpoint, + importarEndpoint, + requireAdminCaptura, +} from './captura-departamento.controller.js' +import * as capturaService from '../services/captura-departamento.service.js' + +function reqFake(opts: { query?: Record; body?: unknown; params?: Record; token?: { sub: string; rol: string } }): Request { + return { + query: opts.query ?? {}, + body: opts.body ?? {}, + params: opts.params ?? {}, + usuarioToken: opts.token, + } as unknown as Request +} + +function resFake() { + const json = vi.fn() + const end = vi.fn() + const statusFn = vi.fn().mockReturnValue({ json, end }) + return { res: { json, end, status: statusFn } as unknown as Response, status: statusFn, json, end } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +// ── requireAdminCaptura ───────────────────────────────────────────── + +describe('requireAdminCaptura', () => { + it('pasa si rol es admin', () => { + const next = vi.fn() + const req = reqFake({ token: { sub: 'u1', rol: 'admin' } }) + requireAdminCaptura(req, {} as Response, next) + expect(next).toHaveBeenCalled() + }) + + it('lanza 403 si rol no es admin', () => { + const req = reqFake({ token: { sub: 'u1', rol: 'vendedor' } }) + expect(() => requireAdminCaptura(req, {} as Response, vi.fn())).toThrow('Se requiere rol admin') + }) + + it('lanza 403 si no hay token', () => { + const req = reqFake({}) + expect(() => requireAdminCaptura(req, {} as Response, vi.fn())).toThrow('Se requiere rol admin') + }) +}) + +// ── listarEndpoint ────────────────────────────────────────────────── + +describe('listarEndpoint (GET /api/captura)', () => { + it('retorna capturas agrupadas', async () => { + const fake = { U101: { estatus: 'VENDIDO' } } + vi.mocked(capturaService.obtenerCapturas).mockResolvedValue(fake) + const { res, json } = resFake() + + await listarEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'admin' } }), res) + expect(json).toHaveBeenCalledWith(fake) + expect(capturaService.obtenerCapturas).toHaveBeenCalledWith('SENDA', undefined) + }) + + it('pasa departamento cuando se provee', async () => { + vi.mocked(capturaService.obtenerCapturas).mockResolvedValue({}) + const { res } = resFake() + + await listarEndpoint( + reqFake({ query: { desarrollo: 'SENDA', departamento: 'OBRA' }, token: { sub: 'u1', rol: 'admin' } }), + res, + ) + expect(capturaService.obtenerCapturas).toHaveBeenCalledWith('SENDA', 'OBRA') + }) + + it('rechaza sin desarrollo', async () => { + const { res } = resFake() + await expect(listarEndpoint(reqFake({ query: {}, token: { sub: 'u1', rol: 'admin' } }), res)).rejects.toThrow() + }) +}) + +// ── unidadesEndpoint ──────────────────────────────────────────────── + +describe('unidadesEndpoint (GET /api/captura/unidades)', () => { + it('retorna lista de unidades', async () => { + vi.mocked(capturaService.obtenerUnidades).mockResolvedValue(['U-101', 'U-102']) + const { res, json } = resFake() + + await unidadesEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'admin' } }), res) + expect(json).toHaveBeenCalledWith(['U-101', 'U-102']) + }) +}) + +// ── resumenEndpoint ───────────────────────────────────────────────── + +describe('resumenEndpoint (GET /api/captura/resumen)', () => { + it('retorna resumen de avance', async () => { + const fake = [{ departamento: 'OBRA', camposTotales: 8, camposLlenos: 4, porcentaje: 50 }] + vi.mocked(capturaService.resumenAvance).mockResolvedValue(fake) + const { res, json } = resFake() + + await resumenEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'admin' } }), res) + expect(json).toHaveBeenCalledWith(fake) + }) +}) + +// ── guardarEndpoint ───────────────────────────────────────────────── + +describe('guardarEndpoint (POST /api/captura)', () => { + it('guarda campo y retorna 204', async () => { + vi.mocked(capturaService.guardarCampo).mockResolvedValue(undefined) + const { res, status } = resFake() + + await guardarEndpoint( + reqFake({ + body: { desarrollo: 'SENDA', unidadCodigo: 'U-101', departamento: 'VENTAS', campo: 'estatus', valor: 'VENDIDO' }, + token: { sub: 'u1', rol: 'admin' }, + }), + res, + ) + expect(status).toHaveBeenCalledWith(204) + expect(capturaService.guardarCampo).toHaveBeenCalledWith('SENDA', 'U-101', 'VENTAS', 'estatus', 'VENDIDO', 'u1') + }) + + it('rechaza departamento inválido', async () => { + const { res } = resFake() + await expect( + guardarEndpoint( + reqFake({ + body: { desarrollo: 'SENDA', unidadCodigo: 'U-101', departamento: 'INVALIDO', campo: 'x', valor: 'y' }, + token: { sub: 'u1', rol: 'admin' }, + }), + res, + ), + ).rejects.toThrow('Departamento inválido') + }) +}) + +// ── bulkEndpoint ──────────────────────────────────────────────────── + +describe('bulkEndpoint (POST /api/captura/bulk)', () => { + it('guarda múltiple y retorna 204', async () => { + vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined) + const { res, status } = resFake() + + await bulkEndpoint( + reqFake({ + body: { + desarrollo: 'SENDA', + departamento: 'VENTAS', + registros: [ + { unidad: 'U-101', campo: 'estatus', valor: 'VENDIDO' }, + { unidad: 'U-102', campo: 'estatus', valor: 'DISPONIBLE' }, + ], + }, + token: { sub: 'u1', rol: 'admin' }, + }), + res, + ) + expect(status).toHaveBeenCalledWith(204) + expect(capturaService.guardarBulk).toHaveBeenCalledWith('SENDA', 'VENTAS', [ + { unidad: 'U-101', campo: 'estatus', valor: 'VENDIDO' }, + { unidad: 'U-102', campo: 'estatus', valor: 'DISPONIBLE' }, + ], 'u1') + }) +}) + +// ── importarEndpoint ──────────────────────────────────────────────── + +describe('importarEndpoint (POST /api/captura/importar)', () => { + it('importa datos y retorna resultado (admin)', async () => { + const fake = { registrosCreados: 10, registrosActualizados: 5 } + vi.mocked(capturaService.importarDesdeExcel).mockResolvedValue(fake) + const { res, json } = resFake() + + await importarEndpoint( + reqFake({ + body: { desarrollo: 'SENDA', datos: [['CLIENTE', 'ESTATUS'], ['U-101', 'VENDIDO']] }, + token: { sub: 'u1', rol: 'admin' }, + }), + res, + ) + expect(json).toHaveBeenCalledWith(fake) + expect(capturaService.importarDesdeExcel).toHaveBeenCalledWith('SENDA', [['CLIENTE', 'ESTATUS'], ['U-101', 'VENDIDO']], 'u1') + }) + + it('lanza 403 si no es admin', async () => { + const req = reqFake({ + body: { desarrollo: 'SENDA', datos: [] }, + token: { sub: 'u1', rol: 'vendedor' }, + }) + expect(() => requireAdminCaptura(req, {} as Response, vi.fn())).toThrow('Se requiere rol admin') + }) +}) diff --git a/api-gateway/src/controllers/captura-departamento.controller.ts b/api-gateway/src/controllers/captura-departamento.controller.ts new file mode 100644 index 0000000..3ea4a21 --- /dev/null +++ b/api-gateway/src/controllers/captura-departamento.controller.ts @@ -0,0 +1,133 @@ +import type { NextFunction, Request, Response } from 'express' +import { z } from 'zod' +import * as capturaService from '../services/captura-departamento.service.js' +import { DEPARTAMENTOS } from '../config/campos-departamento.js' +import { AppError, BadRequestError } from '../utils/errors.js' + +// ── Middleware admin ───────────────────────────────────────────────── + +function requireAdmin(req: Request, _res: Response, next: NextFunction): void { + if (req.usuarioToken?.rol !== 'admin') { + throw new AppError(403, 'Se requiere rol admin') + } + next() +} + +// ── GET /api/captura?desarrollo=X&departamento=Y ───────────────────── + +const listarSchema = z.object({ + desarrollo: z.string().min(1, 'Falta desarrollo'), + departamento: z.string().optional(), +}) + +export async function listarEndpoint(req: Request, res: Response): Promise { + const query = listarSchema.parse(req.query) + const capturas = await capturaService.obtenerCapturas(query.desarrollo, query.departamento) + res.json(capturas) +} + +// ── GET /api/captura/unidades?desarrollo=X ─────────────────────────── + +const unidadesSchema = z.object({ + desarrollo: z.string().min(1, 'Falta desarrollo'), +}) + +export async function unidadesEndpoint(req: Request, res: Response): Promise { + const query = unidadesSchema.parse(req.query) + const unidades = await capturaService.obtenerUnidades(query.desarrollo) + res.json(unidades) +} + +// ── GET /api/captura/resumen?desarrollo=X ──────────────────────────── + +const resumenSchema = z.object({ + desarrollo: z.string().min(1, 'Falta desarrollo'), +}) + +export async function resumenEndpoint(req: Request, res: Response): Promise { + const query = resumenSchema.parse(req.query) + const resumen = await capturaService.resumenAvance(query.desarrollo) + res.json(resumen) +} + +// ── POST /api/captura — guardar campo individual ───────────────────── + +const guardarSchema = z.object({ + desarrollo: z.string().min(1), + unidadCodigo: z.string().min(1), + departamento: z.string().min(1), + campo: z.string().min(1), + valor: z.string().nullable().optional(), +}) + +export async function guardarEndpoint(req: Request, res: Response): Promise { + const body = guardarSchema.parse(req.body) + const usuarioId = req.usuarioToken?.sub ?? 'desconocido' + + if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) { + throw new BadRequestError(`Departamento inválido: ${body.departamento}`) + } + + await capturaService.guardarCampo( + body.desarrollo, + body.unidadCodigo, + body.departamento, + body.campo, + body.valor ?? null, + usuarioId, + ) + res.status(204).end() +} + +// ── POST /api/captura/bulk — guardar múltiple ──────────────────────── + +const bulkSchema = z.object({ + desarrollo: z.string().min(1), + departamento: z.string().min(1), + registros: z.array( + z.object({ + unidad: z.string().min(1), + campo: z.string().min(1), + valor: z.string().nullable().optional(), + }), + ), +}) + +export async function bulkEndpoint(req: Request, res: Response): Promise { + const body = bulkSchema.parse(req.body) + const usuarioId = req.usuarioToken?.sub ?? 'desconocido' + + if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) { + throw new BadRequestError(`Departamento inválido: ${body.departamento}`) + } + + await capturaService.guardarBulk( + body.desarrollo, + body.departamento, + body.registros.map((r) => ({ unidad: r.unidad, campo: r.campo, valor: r.valor ?? null })), + usuarioId, + ) + res.status(204).end() +} + +// ── POST /api/captura/importar — importar desde Excel (admin only) ─── + +const importarSchema = z.object({ + desarrollo: z.string().min(1), + datos: z.array(z.array(z.any())), +}) + +export async function importarEndpoint(req: Request, res: Response): Promise { + const body = importarSchema.parse(req.body) + const usuarioId = req.usuarioToken?.sub ?? 'desconocido' + + const resultado = await capturaService.importarDesdeExcel( + body.desarrollo, + body.datos as string[][], + usuarioId, + ) + res.json(resultado) +} + +// Exportar requireAdmin para usarlo como middleware en routes +export { requireAdmin as requireAdminCaptura } diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index ed4ef04..e3b9c22 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -106,6 +106,15 @@ import { desuscribirEndpoint, vapidKeyEndpoint, } from '../controllers/push.controller.js' +import { + listarEndpoint as listarCapturaEndpoint, + unidadesEndpoint as unidadesCapturaEndpoint, + resumenEndpoint as resumenCapturaEndpoint, + guardarEndpoint as guardarCapturaEndpoint, + bulkEndpoint as bulkCapturaEndpoint, + importarEndpoint as importarCapturaEndpoint, + requireAdminCaptura, +} from '../controllers/captura-departamento.controller.js' export const apiRouter = Router() @@ -236,6 +245,14 @@ apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint) apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint) apiRouter.get('/config/acciones', obtenerAccionesEndpoint) +// ── Captura por departamentos ──────────────────────────────────────── +apiRouter.get('/captura', listarCapturaEndpoint) +apiRouter.get('/captura/unidades', unidadesCapturaEndpoint) +apiRouter.get('/captura/resumen', resumenCapturaEndpoint) +apiRouter.post('/captura', guardarCapturaEndpoint) +apiRouter.post('/captura/bulk', bulkCapturaEndpoint) +apiRouter.post('/captura/importar', requireAdminCaptura, importarCapturaEndpoint) + // ── Push notifications ────────────────────────────────────────────── apiRouter.get('/push/vapid-key', vapidKeyEndpoint) apiRouter.post('/push/suscribir', suscribirEndpoint) diff --git a/api-gateway/src/services/captura-departamento.service.test.ts b/api-gateway/src/services/captura-departamento.service.test.ts new file mode 100644 index 0000000..cfcaf20 --- /dev/null +++ b/api-gateway/src/services/captura-departamento.service.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ---------- Prisma mockeado ---------- + +const prismaMock = vi.hoisted(() => ({ + capturaDepartamento: { + findMany: vi.fn(), + findUnique: vi.fn(), + count: vi.fn(), + upsert: vi.fn(), + }, + $transaction: vi.fn(), +})) + +vi.mock('./prisma.js', () => ({ prisma: prismaMock })) + +import { + obtenerUnidades, + obtenerCapturas, + obtenerCapturaUnidad, + guardarCampo, + guardarBulk, + resumenAvance, + importarDesdeExcel, +} from './captura-departamento.service.js' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('obtenerUnidades', () => { + it('retorna unidades únicas ordenadas', async () => { + prismaMock.capturaDepartamento.findMany.mockResolvedValue([ + { unidadCodigo: 'U-101' }, + { unidadCodigo: 'U-102' }, + { unidadCodigo: 'U-101' }, + ]) + const unidades = await obtenerUnidades('SENDA') + expect(unidades).toEqual(['U-101', 'U-102', 'U-101']) + expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith({ + where: { desarrollo: 'SENDA' }, + select: { unidadCodigo: true }, + distinct: ['unidadCodigo'], + orderBy: { unidadCodigo: 'asc' }, + }) + }) + + it('retorna array vacío si no hay datos', async () => { + prismaMock.capturaDepartamento.findMany.mockResolvedValue([]) + const unidades = await obtenerUnidades('NOEXISTE') + expect(unidades).toEqual([]) + }) +}) + +describe('obtenerCapturas', () => { + it('retorna capturas agrupadas por unidad', async () => { + prismaMock.capturaDepartamento.findMany.mockResolvedValue([ + { unidadCodigo: 'U-101', campo: 'estatus', valor: 'VENDIDO' }, + { unidadCodigo: 'U-101', campo: 'modelo', valor: 'Casa A' }, + { unidadCodigo: 'U-102', campo: 'estatus', valor: 'DISPONIBLE' }, + ]) + const capturas = await obtenerCapturas('SENDA') + expect(capturas).toEqual({ + 'U-101': { estatus: 'VENDIDO', modelo: 'Casa A' }, + 'U-102': { estatus: 'DISPONIBLE' }, + }) + }) + + it('filtra por departamento cuando se provee', async () => { + prismaMock.capturaDepartamento.findMany.mockResolvedValue([]) + await obtenerCapturas('SENDA', 'OBRA') + expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { desarrollo: 'SENDA', departamento: 'OBRA' }, + }), + ) + }) +}) + +describe('obtenerCapturaUnidad', () => { + it('retorna campos de una unidad', async () => { + prismaMock.capturaDepartamento.findMany.mockResolvedValue([ + { campo: 'estatus', valor: 'VENDIDO' }, + { campo: 'modelo', valor: 'Casa A' }, + ]) + const campos = await obtenerCapturaUnidad('SENDA', 'U-101') + expect(campos).toEqual({ estatus: 'VENDIDO', modelo: 'Casa A' }) + }) + + it('retorna objeto vacío si no hay datos', async () => { + prismaMock.capturaDepartamento.findMany.mockResolvedValue([]) + const campos = await obtenerCapturaUnidad('SENDA', 'NOEXISTE') + expect(campos).toEqual({}) + }) +}) + +describe('guardarCampo', () => { + it('hace upsert del campo', async () => { + prismaMock.capturaDepartamento.upsert.mockResolvedValue({}) + await guardarCampo('SENDA', 'U-101', 'VENTAS', 'estatus', 'VENDIDO', 'user-1') + expect(prismaMock.capturaDepartamento.upsert).toHaveBeenCalledWith({ + where: { + desarrollo_unidadCodigo_departamento_campo: { + desarrollo: 'SENDA', + unidadCodigo: 'U-101', + departamento: 'VENTAS', + campo: 'estatus', + }, + }, + update: { valor: 'VENDIDO', tipo: 'select', usuarioId: 'user-1' }, + create: { + desarrollo: 'SENDA', + unidadCodigo: 'U-101', + departamento: 'VENTAS', + campo: 'estatus', + valor: 'VENDIDO', + tipo: 'select', + usuarioId: 'user-1', + }, + }) + }) + + it('permite valor null', async () => { + prismaMock.capturaDepartamento.upsert.mockResolvedValue({}) + await guardarCampo('SENDA', 'U-101', 'OBRA', 'comentarios_obra', null, 'user-1') + expect(prismaMock.capturaDepartamento.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ valor: null }), + }), + ) + }) +}) + +describe('guardarBulk', () => { + it('ejecuta upserts en transacción', async () => { + prismaMock.$transaction.mockResolvedValue([]) + await guardarBulk('SENDA', 'VENTAS', [ + { unidad: 'U-101', campo: 'estatus', valor: 'VENDIDO' }, + { unidad: 'U-101', campo: 'modelo', valor: 'Casa A' }, + ], 'user-1') + expect(prismaMock.$transaction).toHaveBeenCalledTimes(1) + expect(prismaMock.$transaction).toHaveBeenCalledWith(expect.arrayContaining([ + expect.any(Promise), + ])) + }) +}) + +describe('resumenAvance', () => { + it('calcula porcentaje de avance por departamento', async () => { + // Mock para count (campos llenos) + prismaMock.capturaDepartamento.count.mockResolvedValue(5) + // Mock para findMany (unidades únicas) + prismaMock.capturaDepartamento.findMany.mockResolvedValue([ + { unidadCodigo: 'U-101' }, + { unidadCodigo: 'U-102' }, + ]) + + const resumen = await resumenAvance('SENDA') + expect(resumen.length).toBeGreaterThan(0) + // Verificar que tiene la estructura correcta + for (const r of resumen) { + expect(r).toHaveProperty('departamento') + expect(r).toHaveProperty('camposTotales') + expect(r).toHaveProperty('camposLlenos') + expect(r).toHaveProperty('porcentaje') + } + }) +}) + +describe('importarDesdeExcel', () => { + it('retorna 0 registros si solo hay header', async () => { + const resultado = await importarDesdeExcel('SENDA', [['CLIENTE', 'ESTATUS']], 'user-1') + expect(resultado).toEqual({ registrosCreados: 0, registrosActualizados: 0 }) + }) + + it('retorna 0 si el arreglo está vacío', async () => { + const resultado = await importarDesdeExcel('SENDA', [], 'user-1') + expect(resultado).toEqual({ registrosCreados: 0, registrosActualizados: 0 }) + }) +}) diff --git a/api-gateway/src/services/captura-departamento.service.ts b/api-gateway/src/services/captura-departamento.service.ts new file mode 100644 index 0000000..c8d1124 --- /dev/null +++ b/api-gateway/src/services/captura-departamento.service.ts @@ -0,0 +1,269 @@ +import { prisma } from './prisma.js' +import { CAMPOS_POR_DEPARTAMENTO, DEPARTAMENTOS } from '../config/campos-departamento.js' + +// ── Obtener unidades únicas de un desarrollo ────────────────────────── + +export async function obtenerUnidades(desarrollo: string): Promise { + const rows = await prisma.capturaDepartamento.findMany({ + where: { desarrollo }, + select: { unidadCodigo: true }, + distinct: ['unidadCodigo'], + orderBy: { unidadCodigo: 'asc' }, + }) + return rows.map((r) => r.unidadCodigo) +} + +// ── Obtener capturas de un desarrollo (agrupadas por unidad) ────────── + +export async function obtenerCapturas( + desarrollo: string, + departamento?: string, +): Promise>> { + const where: Record = { desarrollo } + if (departamento) where.departamento = departamento + + const rows = await prisma.capturaDepartamento.findMany({ + where, + orderBy: [{ unidadCodigo: 'asc' }, { campo: 'asc' }], + }) + + const agrupado: Record> = {} + for (const r of rows) { + const u = r.unidadCodigo + if (!agrupado[u]) agrupado[u] = {} + agrupado[u][r.campo] = r.valor + } + return agrupado +} + +// ── Obtener campos de una unidad ────────────────────────────────────── + +export async function obtenerCapturaUnidad( + desarrollo: string, + unidad: string, + departamento?: string, +): Promise> { + const where: Record = { desarrollo, unidadCodigo: unidad } + if (departamento) where.departamento = departamento + + const rows = await prisma.capturaDepartamento.findMany({ + where, + orderBy: { campo: 'asc' }, + }) + + const campos: Record = {} + for (const r of rows) { + campos[r.campo] = r.valor + } + return campos +} + +// ── Guardar campo individual (upsert) ──────────────────────────────── + +export async function guardarCampo( + desarrollo: string, + unidadCodigo: string, + departamento: string, + campo: string, + valor: string | null, + usuarioId: string, +): Promise { + const tipo = CAMPOS_POR_DEPARTAMENTO[departamento]?.find((c) => c.campo === campo)?.tipo ?? 'text' + + await prisma.capturaDepartamento.upsert({ + where: { + desarrollo_unidadCodigo_departamento_campo: { + desarrollo, + unidadCodigo, + departamento, + campo, + }, + }, + update: { valor, tipo, usuarioId }, + create: { desarrollo, unidadCodigo, departamento, campo, valor, tipo, usuarioId }, + }) +} + +// ── Guardar múltiple (bulk upsert) ─────────────────────────────────── + +export async function guardarBulk( + desarrollo: string, + departamento: string, + registros: { unidad: string; campo: string; valor: string | null }[], + usuarioId: string, +): Promise { + const tipoPorCampo = new Map( + (CAMPOS_POR_DEPARTAMENTO[departamento] ?? []).map((c) => [c.campo, c.tipo]), + ) + + await prisma.$transaction( + registros.map((r) => + prisma.capturaDepartamento.upsert({ + where: { + desarrollo_unidadCodigo_departamento_campo: { + desarrollo, + unidadCodigo: r.unidad, + departamento, + campo: r.campo, + }, + }, + update: { valor: r.valor, tipo: tipoPorCampo.get(r.campo) ?? 'text', usuarioId }, + create: { + desarrollo, + unidadCodigo: r.unidad, + departamento, + campo: r.campo, + valor: r.valor, + tipo: tipoPorCampo.get(r.campo) ?? 'text', + usuarioId, + }, + }), + ), + ) +} + +// ── Resumen de avance por departamento ──────────────────────────────── + +export interface ResumenDepartamento { + departamento: string + camposTotales: number + camposLlenos: number + porcentaje: number +} + +export async function resumenAvance(desarrollo: string): Promise { + const resultados: ResumenDepartamento[] = [] + + for (const dept of DEPARTAMENTOS) { + const camposDef = CAMPOS_POR_DEPARTAMENTO[dept] ?? [] + const camposTotales = camposDef.length + if (camposTotales === 0) continue + + const camposNombres = camposDef.map((c) => c.campo) + + // Contar cuántos registros existen con valor no nulo + const llenos = await prisma.capturaDepartamento.count({ + where: { + desarrollo, + departamento: dept, + campo: { in: camposNombres }, + valor: { not: null }, + }, + }) + + // Contar unidades únicas para multiplicar + const unidades = await prisma.capturaDepartamento.findMany({ + where: { desarrollo, departamento: dept }, + select: { unidadCodigo: true }, + distinct: ['unidadCodigo'], + }) + const numUnidades = unidades.length || 1 // evitar div/0 + + const totalEsperado = camposTotales * numUnidades + const porcentaje = totalEsperado > 0 ? Math.round((llenos / totalEsperado) * 100) : 0 + + resultados.push({ + departamento: dept, + camposTotales, + camposLlenos: llenos, + porcentaje, + }) + } + + return resultados +} + +// ── Importar desde Excel (arreglo de arreglos) ──────────────────────── + +export interface ResultadoImportacion { + registrosCreados: number + registrosActualizados: number +} + +export async function importarDesdeExcel( + desarrollo: string, + datos: string[][], + usuarioId: string, +): Promise { + if (datos.length < 2) return { registrosCreados: 0, registrosActualizados: 0 } + + // Primera fila = headers + const firstRow = datos[0] + if (!firstRow) return { registrosCreados: 0, registrosActualizados: 0 } + const headers = firstRow.map((h) => String(h ?? '').trim()) + + // Mapear cada header a su departamento y campo + type ColMapa = { departamento: string; campo: string; tipo: string } | null + const mapaColumnas: ColMapa[] = headers.map((header) => { + const norm = header.toUpperCase().replace(/\s+/g, '_').replace(/[Á]/g, 'A').replace(/[É]/g, 'E').replace(/[Í]/g, 'I').replace(/[Ó]/g, 'O').replace(/[Ú]/g, 'U').replace(/[Ñ]/g, 'N') + for (const [dept, campos] of Object.entries(CAMPOS_POR_DEPARTAMENTO)) { + for (const c of campos) { + if (c.campo === norm || norm.includes(c.campo.toUpperCase())) { + return { departamento: dept, campo: c.campo, tipo: c.tipo } + } + } + } + return null + }) + + let creados = 0 + let actualizados = 0 + + // Procesar cada fila de datos (skipping header row) + const filas = datos.slice(1).filter((row) => row.some((cell) => cell != null && String(cell).trim() !== '')) + + for (const fila of filas) { + // Detectar unidad: primera columna con valor + const unidadCodigo = String(fila[0] ?? '').trim() + if (!unidadCodigo) continue + + for (let col = 0; col < headers.length; col++) { + const mapeo = mapaColumnas[col] + if (!mapeo) continue + + const rawValor = fila[col] + const valor = rawValor != null && String(rawValor).trim() !== '' ? String(rawValor).trim() : null + + try { + const existente = await prisma.capturaDepartamento.findUnique({ + where: { + desarrollo_unidadCodigo_departamento_campo: { + desarrollo, + unidadCodigo, + departamento: mapeo.departamento, + campo: mapeo.campo, + }, + }, + }) + + await prisma.capturaDepartamento.upsert({ + where: { + desarrollo_unidadCodigo_departamento_campo: { + desarrollo, + unidadCodigo, + departamento: mapeo.departamento, + campo: mapeo.campo, + }, + }, + update: { valor, tipo: mapeo.tipo, usuarioId }, + create: { + desarrollo, + unidadCodigo, + departamento: mapeo.departamento, + campo: mapeo.campo, + valor, + tipo: mapeo.tipo, + usuarioId, + }, + }) + + if (existente) actualizados++ + else creados++ + } catch { + // Skip errors silently (e.g., empty values) + } + } + } + + return { registrosCreados: creados, registrosActualizados: actualizados } +} diff --git a/apps/landing/src/App.tsx b/apps/landing/src/App.tsx index 0573a95..8eb0d11 100644 --- a/apps/landing/src/App.tsx +++ b/apps/landing/src/App.tsx @@ -6,10 +6,11 @@ import { ConfiguracionCuenta } from './components/ConfiguracionCuenta' import BannerModoLocal from './components/BannerModoLocal' import { VistaContactos } from './components/VistaContactos' import { ConfigPage } from './components/config/ConfigPage' +import { CapturaPage } from './pages/CapturaPage' import { getToken, type UsuarioSesion } from './services/auth' import { getMe } from './services/api' -type Vista = 'home' | 'contactos' | 'configuraciones' +type Vista = 'home' | 'contactos' | 'captura' | 'configuraciones' export default function App() { const [vista, setVista] = useState('home') @@ -53,6 +54,16 @@ export default function App() { ) } + if (vista === 'captura') { + return ( +
+ setVista('configuraciones')} /> + + setVista('home')} /> +
+ ) + } + if (vista === 'configuraciones' && usuario?.rol === 'admin') { return (
@@ -88,7 +99,13 @@ export default function App() { setVista('contactos') : undefined} + onAbrir={ + modulo.id === 'contactos' + ? () => setVista('contactos') + : modulo.id === 'captura' + ? () => setVista('captura') + : undefined + } /> ))} diff --git a/apps/landing/src/components/ResumenCaptura.tsx b/apps/landing/src/components/ResumenCaptura.tsx new file mode 100644 index 0000000..4afa721 --- /dev/null +++ b/apps/landing/src/components/ResumenCaptura.tsx @@ -0,0 +1,97 @@ +import { DEPARTAMENTO_LABELS, type ResumenDepartamento, type DepartamentoId } from '../services/captura' + +interface Props { + resumen: ResumenDepartamento[] + departamentoActivo: DepartamentoId | null + departamentosPermitidos: DepartamentoId[] + onSeleccionar: (dept: DepartamentoId) => void +} + +/** Color de la barra según porcentaje */ +function colorBarra(pct: number): string { + if (pct >= 80) return 'bg-green-500' + if (pct >= 50) return 'bg-yellow-500' + return 'bg-red-500' +} + +function colorTexto(pct: number): string { + if (pct >= 80) return 'text-green-600 dark:text-green-400' + if (pct >= 50) return 'text-yellow-600 dark:text-yellow-400' + return 'text-red-600 dark:text-red-400' +} + +/** Barra lateral de resumen de avance por departamento */ +export function ResumenCaptura({ + resumen, + departamentoActivo, + departamentosPermitidos, + onSeleccionar, +}: Props) { + /** Filtrar solo departamentos permitidos */ + const filtrado = resumen.filter((r) => + departamentosPermitidos.includes(r.departamento as DepartamentoId), + ) + + if (filtrado.length === 0) { + return ( + + ) + } + + return ( + + ) +} diff --git a/apps/landing/src/components/TablaCaptura.test.tsx b/apps/landing/src/components/TablaCaptura.test.tsx new file mode 100644 index 0000000..5877291 --- /dev/null +++ b/apps/landing/src/components/TablaCaptura.test.tsx @@ -0,0 +1,209 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { TablaCaptura } from './TablaCaptura' +import type { CampoDef, DatosCaptura } from '../services/captura' + +const CAMPOS_OBRA: CampoDef[] = [ + { campo: 'certificacion_gas', tipo: 'boolean', label: 'Certificación Gas' }, + { campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' }, +] + +const CAMPOS_VENTAS: CampoDef[] = [ + { campo: 'estatus', tipo: 'select', label: 'Estatus', options: ['DISPONIBLE', 'APARTADO', 'VENDIDO'] }, + { campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' }, +] + +const DATOS: DatosCaptura = { + '409-1': { certificacion_gas: 'true', comentarios_obra: 'Listo' }, + '409-2': { certificacion_gas: null, comentarios_obra: null }, +} + +const UNIDADES = ['409-1', '409-2'] + +afterEach(() => { cleanup() }) + +describe('TablaCaptura', () => { + it('renderiza la tabla con unidades y campos', () => { + const onGuardar = vi.fn() + render( + , + ) + + expect(screen.getByText('409-1')).toBeTruthy() + expect(screen.getByText('409-2')).toBeTruthy() + expect(screen.getByText('Certificación Gas')).toBeTruthy() + expect(screen.getByText('Comentarios')).toBeTruthy() + }) + + it('muestra checkboxes para campos boolean', () => { + const onGuardar = vi.fn() + render( + , + ) + + const checkboxes = screen.getAllByRole('checkbox') + expect(checkboxes.length).toBe(2) // 2 unidades * 1 boolean campo + // Primera unidad tiene certificacion_gas = 'true' + expect((checkboxes[0] as HTMLInputElement).checked).toBe(true) + // Segunda unidad tiene null + expect((checkboxes[1] as HTMLInputElement).checked).toBe(false) + }) + + it('muestra select para campos select', () => { + const onGuardar = vi.fn() + render( + , + ) + + const selects = screen.getAllByRole('combobox') + expect(selects.length).toBe(2) // 2 unidades * 1 select campo + }) + + it('muestra "sin unidades" cuando la lista está vacía', () => { + const onGuardar = vi.fn() + render( + , + ) + + expect(screen.getByText(/No hay unidades/)).toBeTruthy() + }) + + it('edita una celda y muestra el botón de guardar', () => { + const onGuardar = vi.fn() + render( + , + ) + + // Click en el checkbox de la segunda unidad (cambiar de unchecked a checked) + const checkboxes = screen.getAllByRole('checkbox') + fireEvent.click(checkboxes[1]) + + // Debería aparecer "Hay cambios sin guardar" + expect(screen.getByText('Hay cambios sin guardar')).toBeTruthy() + expect(screen.getByText('Guardar cambios')).toBeTruthy() + expect(screen.getByText('Descartar')).toBeTruthy() + }) + + it('click en Guardar llama a onGuardar con los cambios dirty', () => { + const onGuardar = vi.fn() + render( + , + ) + + // Cambiar checkbox de la segunda unidad + const checkboxes = screen.getAllByRole('checkbox') + fireEvent.click(checkboxes[1]) + + // Click en Guardar + fireEvent.click(screen.getByText('Guardar cambios')) + + expect(onGuardar).toHaveBeenCalledTimes(1) + const dirty = onGuardar.mock.calls[0][0] + expect(dirty['409-2']).toEqual({ certificacion_gas: 'true' }) + }) + + it('click en Descartar elimina los cambios', () => { + const onGuardar = vi.fn() + render( + , + ) + + // Cambiar checkbox + const checkboxes = screen.getAllByRole('checkbox') + fireEvent.click(checkboxes[1]) + + // Descartar + fireEvent.click(screen.getByText('Descartar')) + + // Ya no debería mostrar "Hay cambios sin guardar" + expect(screen.queryByText('Hay cambios sin guardar')).toBeNull() + }) + + it('filtra por estatus', () => { + const onGuardar = vi.fn() + const datosConEstatus: DatosCaptura = { + '409-1': { estatus: 'VENDIDO' }, + '409-2': { estatus: 'DISPONIBLE' }, + } + + render( + , + ) + + expect(screen.getByText('409-1')).toBeTruthy() + expect(screen.queryByText('409-2')).toBeNull() + }) + + it('paginación: muestra paginación cuando hay >50 unidades', () => { + const onGuardar = vi.fn() + const unidadesGrandes = Array.from({ length: 60 }, (_, i) => `U-${String(i + 1).padStart(3, '0')}`) + + render( + , + ) + + expect(screen.getByText(/Mostrando 1–50 de 60/)).toBeTruthy() + expect(screen.getByText('Anterior')).toBeTruthy() + expect(screen.getByText('Siguiente')).toBeTruthy() + + // Navigate to page 2 + fireEvent.click(screen.getByText('Siguiente')) + expect(screen.getByText(/Mostrando 51–60 de 60/)).toBeTruthy() + }) +}) diff --git a/apps/landing/src/components/TablaCaptura.tsx b/apps/landing/src/components/TablaCaptura.tsx new file mode 100644 index 0000000..2cb7fc7 --- /dev/null +++ b/apps/landing/src/components/TablaCaptura.tsx @@ -0,0 +1,296 @@ +import { useMemo, useState, useCallback } from 'react' +import type { CampoDef, DatosCaptura } from '../services/captura' + +// ─── Tipos ────────────────────────────────────────────────────────── + +export type DirtyMap = Record> + +interface Props { + unidades: string[] + campos: CampoDef[] + datos: DatosCaptura + onGuardar: (dirty: DirtyMap) => void + guardando: boolean + /** Filtro de estatus de unidad (columna estestatus en VENTAS) */ + filtroEstatus?: string +} + +const POR_PAGINA = 50 + +// ─── Utilidades ───────────────────────────────────────────────────── + +/** Obtener valor original de una celda */ +function valorOriginal(datos: DatosCaptura, unidad: string, campo: string): string | null { + return datos[unidad]?.[campo] ?? null +} + +// ─── Componente de celda ───────────────────────────────────────────── + +interface CeldaProps { + tipo: string + valor: string | null + options?: string[] + onChange: (nuevo: string | null) => void + dirty: boolean +} + +function CeldaEditable({ tipo, valor, options, onChange, dirty }: CeldaProps) { + const bordeDirty = dirty ? 'ring-2 ring-accent/40' : '' + + if (tipo === 'boolean') { + return ( + + ) + } + + if (tipo === 'select' && options) { + return ( + + ) + } + + if (tipo === 'date') { + return ( + onChange(e.target.value || null)} + className={`w-full border-0 bg-transparent px-1 py-0.5 text-xs text-[#111827] focus:outline-none dark:text-[#E2E8F0] ${bordeDirty}`} + /> + ) + } + + if (tipo === 'number') { + return ( + onChange(e.target.value || null)} + className={`w-full border-0 bg-transparent px-1 py-0.5 text-xs text-[#111827] focus:outline-none dark:text-[#E2E8F0] ${bordeDirty}`} + /> + ) + } + + // text default + return ( + onChange(e.target.value || null)} + className={`w-full border-0 bg-transparent px-1 py-0.5 text-xs text-[#111827] focus:outline-none dark:text-[#E2E8F0] ${bordeDirty}`} + /> + ) +} + +// ─── Tabla principal ───────────────────────────────────────────────── + +export function TablaCaptura({ + unidades, + campos, + datos, + onGuardar, + guardando, + filtroEstatus, +}: Props) { + const [dirty, setDirty] = useState({}) + const [pagina, setPagina] = useState(0) + + /** Obtener estatus de una unidad (campo estatus en dept VENTAS, siempre disponible) */ + const estatusUnidad = useCallback( + (unidad: string): string => { + return datos[unidad]?.['estatus'] ?? '' + }, + [datos], + ) + + /** Unidades filtradas por estatus */ + const unidadesFiltradas = useMemo(() => { + if (!filtroEstatus) return unidades + return unidades.filter((u) => estatusUnidad(u) === filtroEstatus) + }, [unidades, filtroEstatus, estatusUnidad]) + + /** Paginación */ + const totalPaginas = Math.max(1, Math.ceil(unidadesFiltradas.length / POR_PAGINA)) + const paginaActual = Math.min(pagina, totalPaginas - 1) + const unidadesPagina = unidadesFiltradas.slice( + paginaActual * POR_PAGINA, + (paginaActual + 1) * POR_PAGINA, + ) + + /** ¿Hay cambios pendientes? */ + const tieneDirty = useMemo(() => { + for (const u of Object.values(dirty)) { + if (Object.keys(u).length > 0) return true + } + return false + }, [dirty]) + + /** Handler de cambio de celda */ + const handleChange = useCallback( + (unidad: string, campo: string, nuevo: string | null) => { + const original = valorOriginal(datos, unidad, campo) + setDirty((prev) => { + const unidadDirty = { ...(prev[unidad] ?? {}) } + if (nuevo === original) { + delete unidadDirty[campo] + } else { + unidadDirty[campo] = nuevo + } + const next = { ...prev } + if (Object.keys(unidadDirty).length === 0) { + delete next[unidad] + } else { + next[unidad] = unidadDirty + } + return next + }) + }, + [datos], + ) + + /** Enviar cambios al padre */ + const handleSubmit = useCallback(() => { + onGuardar(dirty) + setDirty({}) + }, [dirty, onGuardar]) + + if (unidades.length === 0) { + return ( +

+ No hay unidades registradas para este desarrollo. +

+ ) + } + + return ( +
+ {/* Barra de guardado */} + {tieneDirty && ( +
+ + Hay cambios sin guardar + + + +
+ )} + + {/* Tabla scrollable */} +
+ + + + + {campos.map((c) => ( + + ))} + + + + {unidadesPagina.map((unidad, idx) => ( + + + {campos.map((c) => { + const val = valorOriginal(datos, unidad, c.campo) + const isDirty = dirty[unidad]?.[c.campo] !== undefined + return ( + + ) + })} + + ))} + +
+ Unidad + + {c.label} +
+ {unidad} + + handleChange(unidad, c.campo, nuevo)} + dirty={isDirty} + /> +
+
+ + {/* Paginación */} + {totalPaginas > 1 && ( +
+ + Mostrando {paginaActual * POR_PAGINA + 1}– + {Math.min((paginaActual + 1) * POR_PAGINA, unidadesFiltradas.length)} de{' '} + {unidadesFiltradas.length} unidades + +
+ + + {paginaActual + 1} / {totalPaginas} + + +
+
+ )} +
+ ) +} diff --git a/apps/landing/src/components/TarjetaModulo.tsx b/apps/landing/src/components/TarjetaModulo.tsx index e0bc439..721f01b 100644 --- a/apps/landing/src/components/TarjetaModulo.tsx +++ b/apps/landing/src/components/TarjetaModulo.tsx @@ -15,6 +15,7 @@ const INICIALES: Record = { tableros: 'TB', 'mi-dia': 'MD', contactos: 'CT', + captura: 'CA', } /** Tarjeta de acceso a un módulo del ecosistema */ diff --git a/apps/landing/src/data/modulos.test.ts b/apps/landing/src/data/modulos.test.ts index e38353d..a226a5a 100644 --- a/apps/landing/src/data/modulos.test.ts +++ b/apps/landing/src/data/modulos.test.ts @@ -8,8 +8,8 @@ const porId = (id: string): Modulo => { } describe('MODULOS', () => { - it('expone los 7 módulos del ecosistema', () => { - expect(MODULOS).toHaveLength(7) + it('expone los 8 módulos del ecosistema', () => { + expect(MODULOS).toHaveLength(8) expect(MODULOS.map((m) => m.id)).toEqual([ 'cotizador', 'alta-clientes', @@ -18,6 +18,7 @@ describe('MODULOS', () => { 'tableros', 'mi-dia', 'contactos', + 'captura', ]) }) @@ -39,8 +40,9 @@ describe('MODULOS', () => { expect(porId('mi-dia').url).toBe('/hoy/') }) - it('Contactos es vista interna (sin url externa)', () => { + it('Contactos y Captura son vistas internas (sin url externa)', () => { expect(porId('contactos').url).toBeUndefined() + expect(porId('captura').url).toBeUndefined() }) it('liga cada módulo a la matriz de roles del API', () => { @@ -51,6 +53,7 @@ describe('MODULOS', () => { expect(porId('tableros').claves).toEqual(['mi-tablero', 'tablero-comercial']) expect(porId('mi-dia').claves).toEqual(['mi-dia']) expect(porId('contactos').claves).toEqual(['contactos']) + expect(porId('captura').claves).toEqual(['captura']) }) it('no hay ids ni URLs duplicadas', () => { diff --git a/apps/landing/src/data/modulos.ts b/apps/landing/src/data/modulos.ts index 66bf2b3..ff19d22 100644 --- a/apps/landing/src/data/modulos.ts +++ b/apps/landing/src/data/modulos.ts @@ -59,6 +59,12 @@ export const MODULOS: Modulo[] = [ descripcion: 'Directorio de contactos del CRM: teléfono, email y estado', claves: ['contactos'], }, + { + id: 'captura', + nombre: 'Captura', + descripcion: 'Captura por departamentos: Obra, Ventas, Escrituración, Finanzas y Postventa', + claves: ['captura'], + }, ] /** diff --git a/apps/landing/src/pages/CapturaPage.test.tsx b/apps/landing/src/pages/CapturaPage.test.tsx new file mode 100644 index 0000000..9776d37 --- /dev/null +++ b/apps/landing/src/pages/CapturaPage.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { CapturaPage } from './CapturaPage' + +type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise + +function crearStorageStub(): Storage { + const mapa = new Map() + return { + getItem: (k: string) => mapa.get(k) ?? null, + setItem: (k: string, v: string) => void mapa.set(k, v), + removeItem: (k: string) => void mapa.delete(k), + clear: () => mapa.clear(), + key: () => null, + get length() { return mapa.size }, + } as Storage +} + +const respuestaOk = (data: unknown) => + new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }) + +const sesionFake = { + token: 'jwt-de-prueba', + usuario: { + nombre: 'Test User', + rol: 'admin', + asignacion: null, + mondayUserId: null, + modulos: ['captura'], + }, +} + +const UNIDADES = ['409-1', '409-2', '409-3'] + +const DATOS = { + '409-1': { + certificacion_gas: 'true', + to_c4_fianza: null, + comentarios_obra: 'Listo', + }, + '409-2': {}, +} + +const RESUMEN = [ + { departamento: 'OBRA', camposTotales: 8, camposLlenos: 3, porcentaje: 13 }, + { departamento: 'VENTAS', camposTotales: 10, camposLlenos: 8, porcentaje: 40 }, + { departamento: 'ESCRITURACION', camposTotales: 6, camposLlenos: 6, porcentaje: 50 }, + { departamento: 'FINANZAS', camposTotales: 7, camposLlenos: 5, porcentaje: 36 }, + { departamento: 'POSTVENTA', camposTotales: 2, camposLlenos: 1, porcentaje: 25 }, +] + +let fetchMock: ReturnType> + +beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('localStorage', crearStorageStub()) + localStorage.setItem('frasa_auth', JSON.stringify(sesionFake)) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('CapturaPage', () => { + it('renderiza el título y tabs de departamento', async () => { + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.includes('/captura/unidades')) return Promise.resolve(respuestaOk(UNIDADES)) + if (url.includes('/captura/resumen')) return Promise.resolve(respuestaOk(RESUMEN)) + if (url.includes('/captura')) return Promise.resolve(respuestaOk(DATOS)) + return Promise.reject(new Error(`fetch inesperado: ${url}`)) + }) + + render( {}} />) + + expect(screen.getByText('Captura por Departamentos')).toBeTruthy() + const nav = await screen.findByLabelText('Departamentos') + expect(within(nav).getByText('Obra')).toBeTruthy() + expect(within(nav).getByText('Ventas')).toBeTruthy() + expect(within(nav).getByText('Escrituración')).toBeTruthy() + expect(within(nav).getByText('Finanzas')).toBeTruthy() + expect(within(nav).getByText('Postventa')).toBeTruthy() + }) + + it('muestra el select de desarrollo', async () => { + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.includes('/captura/unidades')) return Promise.resolve(respuestaOk(UNIDADES)) + if (url.includes('/captura/resumen')) return Promise.resolve(respuestaOk(RESUMEN)) + if (url.includes('/captura')) return Promise.resolve(respuestaOk(DATOS)) + return Promise.reject(new Error(`fetch inesperado: ${url}`)) + }) + + render( {}} />) + + const select = await screen.findByLabelText('Desarrollo') + expect(select).toBeTruthy() + expect((select as HTMLSelectElement).value).toBe('SENDA') + }) + + it('cambia de tab de departamento', async () => { + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.includes('/captura/unidades')) return Promise.resolve(respuestaOk(UNIDADES)) + if (url.includes('/captura/resumen')) return Promise.resolve(respuestaOk(RESUMEN)) + if (url.includes('/captura')) return Promise.resolve(respuestaOk(DATOS)) + return Promise.reject(new Error(`fetch inesperado: ${url}`)) + }) + + render( {}} />) + + const nav = await screen.findByLabelText('Departamentos') + fireEvent.click(within(nav).getByText('Ventas')) + + // After clicking Ventas, the fetch should be called again for Ventas tab data + await waitFor(() => { + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/captura/unidades'))).toBe(true) + }) + }) + + it('muestra avance por departamento en la barra lateral', async () => { + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.includes('/captura/unidades')) return Promise.resolve(respuestaOk(UNIDADES)) + if (url.includes('/captura/resumen')) return Promise.resolve(respuestaOk(RESUMEN)) + if (url.includes('/captura')) return Promise.resolve(respuestaOk(DATOS)) + return Promise.reject(new Error(`fetch inesperado: ${url}`)) + }) + + render( {}} />) + + await waitFor(() => { + expect(screen.getByText('Avance por departamento')).toBeTruthy() + }) + expect(screen.getByText('13%')).toBeTruthy() + expect(screen.getByText('3 de 8 campos')).toBeTruthy() + }) + + it('muestra error si falla la carga', async () => { + fetchMock.mockResolvedValue(new Response('{}', { status: 500 })) + + render( {}} />) + + expect(await screen.findByRole('alert')).toBeTruthy() + }) +}) diff --git a/apps/landing/src/pages/CapturaPage.tsx b/apps/landing/src/pages/CapturaPage.tsx new file mode 100644 index 0000000..8a8a2e8 --- /dev/null +++ b/apps/landing/src/pages/CapturaPage.tsx @@ -0,0 +1,232 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import toast from 'react-hot-toast' +import { + DESARROLLOS, + DEPARTAMENTOS, + CAMPOS_POR_DEPARTAMENTO, + DEPARTAMENTOS_POR_ROL, + DEPARTAMENTO_LABELS, + obtenerCaptura, + obtenerUnidadesCaptura, + obtenerResumenCaptura, + guardarBulk, + type DatosCaptura, + type DepartamentoId, + type ResumenDepartamento, +} from '../services/captura' +import { TablaCaptura, type DirtyMap } from '../components/TablaCaptura' +import { ResumenCaptura } from '../components/ResumenCaptura' +import { getSesion } from '../services/auth' + +interface Props { + onVolver: () => void +} + +export function CapturaPage({ onVolver }: Props) { + // ─── Estado ─────────────────────────────────────────────────────── + + const [desarrollo, setDesarrollo] = useState(String(DESARROLLOS[0])) + const [departamento, setDepartamento] = useState(DEPARTAMENTOS[0]) + const [unidades, setUnidades] = useState([]) + const [datos, setDatos] = useState({}) + const [resumen, setResumen] = useState([]) + const [cargando, setCargando] = useState(true) + const [error, setError] = useState('') + const [guardando, setGuardando] = useState(false) + const [filtroEstatus, setFiltroEstatus] = useState('') + + // ─── Permisos ───────────────────────────────────────────────────── + + const rol = getSesion()?.usuario.rol ?? 'admin' + const departamentosPermitidos = useMemo(() => { + return (DEPARTAMENTOS_POR_ROL[rol] ?? DEPARTAMENTOS_POR_ROL['admin']) as DepartamentoId[] + }, [rol]) + + // Si el departamento activo no está permitido, cambiar al primero permitido + useEffect(() => { + if (departamentosPermitidos.length > 0 && !departamentosPermitidos.includes(departamento)) { + setDepartamento(departamentosPermitidos[0]) + } + }, [departamentosPermitidos, departamento]) + + // ─── Carga de datos ─────────────────────────────────────────────── + + const cargar = useCallback(async () => { + setCargando(true) + setError('') + try { + const [u, d, r] = await Promise.all([ + obtenerUnidadesCaptura(desarrollo), + obtenerCaptura(desarrollo), + obtenerResumenCaptura(desarrollo), + ]) + setUnidades(u) + setDatos(d) + setResumen(r) + } catch { + setError('No se pudieron cargar los datos de captura.') + } finally { + setCargando(false) + } + }, [desarrollo]) + + useEffect(() => { void cargar() }, [cargar]) + + // ─── Guardar cambios ────────────────────────────────────────────── + + const handleGuardar = useCallback( + async (dirty: DirtyMap) => { + setGuardando(true) + try { + const registros: { unidad: string; campo: string; valor: string | null }[] = [] + for (const [unidad, campos] of Object.entries(dirty)) { + for (const [campo, valor] of Object.entries(campos)) { + registros.push({ unidad, campo, valor }) + } + } + await guardarBulk({ desarrollo, departamento, registros }) + toast.success('Cambios guardados') + await cargar() + } catch { + toast.error('No se pudieron guardar los cambios') + } finally { + setGuardando(false) + } + }, + [desarrollo, departamento, cargar], + ) + + // ─── Render ─────────────────────────────────────────────────────── + + const campos = CAMPOS_POR_DEPARTAMENTO[departamento] ?? [] + + return ( +
+ {/* Header */} +
+ +

+ Captura por Departamentos +

+
+ + {/* Filtros */} +
+ {/* Desarrollo */} +
+ + +
+ + {/* Filtro de estatus */} +
+ + +
+
+ + {/* Tabs de departamento */} + + + {/* Contenido: tabla + resumen */} +
+ {/* Tabla */} +
+ {cargando ? ( +
+

Cargando captura…

+
+ ) : error ? ( +
+

{error}

+ +
+ ) : ( + + )} +
+ + {/* Resumen lateral */} + +
+
+ ) +} diff --git a/apps/landing/src/services/captura.ts b/apps/landing/src/services/captura.ts new file mode 100644 index 0000000..79544e6 --- /dev/null +++ b/apps/landing/src/services/captura.ts @@ -0,0 +1,179 @@ +import { API_URL, conAuth, manejar401 } from './auth' + +// ─── Tipos ────────────────────────────────────────────────────────── + +export interface CampoDef { + campo: string + tipo: string + label: string + options?: string[] +} + +export interface ResumenDepartamento { + departamento: string + camposTotales: number + camposLlenos: number + porcentaje: number +} + +export type DatosCaptura = Record> + +// ─── Departamentos y campos (espejo del backend) ──────────────────── + +export const DEPARTAMENTOS = ['OBRA', 'VENTAS', 'ESCRITURACION', 'FINANZAS', 'POSTVENTA'] as const +export type DepartamentoId = (typeof DEPARTAMENTOS)[number] + +export const CAMPOS_POR_DEPARTAMENTO: Record = { + OBRA: [ + { campo: 'certificacion_gas', tipo: 'boolean', label: 'Certificación Gas' }, + { campo: 'to_c4_fianza', tipo: 'boolean', label: 'TO y C4-Fianza' }, + { campo: 'regimen_inscrito', tipo: 'boolean', label: 'Régimen Inscrito' }, + { campo: 'no_oficial', tipo: 'boolean', label: 'No. Oficial' }, + { campo: 'clave_catastral', tipo: 'boolean', label: 'Clave Catastral' }, + { campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo' }, + { campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' }, + { campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' }, + ], + VENTAS: [ + { campo: 'cliente_id', tipo: 'text', label: 'Cliente ID' }, + { campo: 'nombre_cliente', tipo: 'text', label: 'Nombre Cliente' }, + { campo: 'ubicacion', tipo: 'text', label: 'Ubicación/Unidad' }, + { campo: 'modelo', tipo: 'text', label: 'Modelo' }, + { campo: 'tipo_credito', tipo: 'select', label: 'Tipo Crédito', options: ['BANCARIO', 'COFINAVIT', 'INFONAVIT', 'SANTANDER', 'HSBC', 'BBVA', 'FOVISSSTE', 'CONTADO'] }, + { campo: 'estatus', tipo: 'select', label: 'Estatus', options: ['DISPONIBLE', 'APARTADO', 'VENDIDO', 'ESCRITURADO'] }, + { campo: 'fecha_apartado', tipo: 'date', label: 'Fecha Apartado' }, + { campo: 'fecha_venta', tipo: 'date', label: 'Fecha Venta' }, + { campo: 'fecha_vencimiento', tipo: 'date', label: 'Fecha Vencimiento Contrato' }, + { campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' }, + ], + ESCRITURACION: [ + { campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' }, + { campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' }, + { campo: 'notaria', tipo: 'text', label: 'Notaría' }, + { campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' }, + { campo: 'fecha_pago_infonavit', tipo: 'date', label: 'Fecha Pago Infonavit' }, + { campo: 'fecha_pago_fovissste', tipo: 'date', label: 'Fecha Pago Fovissste' }, + ], + FINANZAS: [ + { campo: 'otros_ingresos', tipo: 'number', label: 'Otros Ingresos' }, + { campo: 'pagado_transferencia', tipo: 'number', label: 'Pagado Transferencia' }, + { campo: 'infonavit', tipo: 'number', label: 'Infonavit' }, + { campo: 'bancario', tipo: 'number', label: 'Bancario' }, + { campo: 'fovissste', tipo: 'number', label: 'Fovissste' }, + { campo: 'credito_puente', tipo: 'number', label: 'Crédito Puente' }, + { campo: 'saldo_favor', tipo: 'number', label: 'Saldo a Favor' }, + ], + POSTVENTA: [ + { campo: 'entrega_sac', tipo: 'boolean', label: 'Entrega a SAC' }, + { campo: 'comentarios_entrega', tipo: 'text', label: 'Comentarios' }, + ], +} + +export const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO'] as const + +/** Labels legibles para cada departamento */ +export const DEPARTAMENTO_LABELS: Record = { + OBRA: 'Obra', + VENTAS: 'Ventas', + ESCRITURACION: 'Escrituración', + FINANZAS: 'Finanzas', + POSTVENTA: 'Postventa', +} + +// ─── Permisos por rol ─────────────────────────────────────────────── + +/** + * Departamentos visibles/editables por cada rol. + * Director y admin ven todos. + */ +export const DEPARTAMENTOS_POR_ROL: Record = { + admin: [...DEPARTAMENTOS], + director: [...DEPARTAMENTOS], + director_ventas: [...DEPARTAMENTOS], + vendedor: ['VENTAS'], + escrituracion: ['ESCRITURACION'], + cobranza: ['FINANZAS'], + postventa: ['POSTVENTA'], + obra: ['OBRA'], +} + +// ─── API calls ────────────────────────────────────────────────────── + +/** GET /api/captura?desarrollo=X — datos agrupados por unidad */ +export async function obtenerCaptura( + desarrollo: string, + departamento?: string, +): Promise { + const params = new URLSearchParams({ desarrollo }) + if (departamento) params.set('departamento', departamento) + const resp = await fetch(`${API_URL}/captura?${params}`, conAuth()) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return (await resp.json()) as DatosCaptura +} + +/** GET /api/captura/unidades?desarrollo=X — lista de códigos de unidad */ +export async function obtenerUnidadesCaptura(desarrollo: string): Promise { + const params = new URLSearchParams({ desarrollo }) + const resp = await fetch(`${API_URL}/captura/unidades?${params}`, conAuth()) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return (await resp.json()) as string[] +} + +/** GET /api/captura/resumen?desarrollo=X — avance por departamento */ +export async function obtenerResumenCaptura(desarrollo: string): Promise { + const params = new URLSearchParams({ desarrollo }) + const resp = await fetch(`${API_URL}/captura/resumen?${params}`, conAuth()) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return (await resp.json()) as ResumenDepartamento[] +} + +/** POST /api/captura — guardar campo individual */ +export async function guardarCampo(data: { + desarrollo: string + unidad: string + departamento: string + campo: string + valor: string | null +}): Promise { + const resp = await fetch( + `${API_URL}/captura`, + conAuth({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + desarrollo: data.desarrollo, + unidadCodigo: data.unidad, + departamento: data.departamento, + campo: data.campo, + valor: data.valor, + }), + }), + ) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) +} + +/** POST /api/captura/bulk — guardar múltiples campos */ +export async function guardarBulk(data: { + desarrollo: string + departamento: string + registros: { unidad: string; campo: string; valor: string | null }[] +}): Promise { + const resp = await fetch( + `${API_URL}/captura/bulk`, + conAuth({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + desarrollo: data.desarrollo, + departamento: data.departamento, + registros: data.registros, + }), + }), + ) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) +}