feat(captura): módulo de captura por departamentos
Backend: tabla CapturaDepartamento, 5 departamentos, 7 desarrollos, service CRUD + importador Excel + 6 endpoints. Frontend: tabla editable inline, tabs por dept, resumen avance, paginación, filtro estatus, permisos por rol, dark mode. 746 API tests + 42 landing tests.
This commit is contained in:
115
api-gateway/package-lock.json
generated
115
api-gateway/package-lock.json
generated
@@ -31,7 +31,8 @@
|
|||||||
"prisma": "^6.14.0",
|
"prisma": "^6.14.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
@@ -1258,6 +1259,16 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@@ -1447,6 +1458,20 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/chai": {
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||||
@@ -1500,6 +1525,16 @@
|
|||||||
"consola": "^3.2.3"
|
"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": {
|
"node_modules/concat-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
@@ -1589,6 +1624,19 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1955,6 +2003,16 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
@@ -3040,6 +3098,19 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stackback": {
|
"node_modules/stackback": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
@@ -3481,12 +3552,54 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"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": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"prisma": "^6.14.0",
|
"prisma": "^6.14.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -412,6 +412,25 @@ model AuditoriaLog {
|
|||||||
@@index([createdAt])
|
@@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)
|
// Suscripciones push de los navegadores (Web Push API)
|
||||||
model PushSubscription {
|
model PushSubscription {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|||||||
277
api-gateway/scripts/importar-maestro.js
Normal file
277
api-gateway/scripts/importar-maestro.js
Normal file
@@ -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)
|
||||||
|
})
|
||||||
49
api-gateway/src/config/campos-departamento.ts
Normal file
49
api-gateway/src/config/campos-departamento.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
export const DEPARTAMENTOS = ['OBRA', 'VENTAS', 'ESCRITURACION', 'FINANZAS', 'POSTVENTA'] as const
|
||||||
|
|
||||||
|
export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: string; label: string; options?: string[] }[]> = {
|
||||||
|
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
|
||||||
@@ -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<string, string>; body?: unknown; params?: Record<string, string>; 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
133
api-gateway/src/controllers/captura-departamento.controller.ts
Normal file
133
api-gateway/src/controllers/captura-departamento.controller.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 }
|
||||||
@@ -106,6 +106,15 @@ import {
|
|||||||
desuscribirEndpoint,
|
desuscribirEndpoint,
|
||||||
vapidKeyEndpoint,
|
vapidKeyEndpoint,
|
||||||
} from '../controllers/push.controller.js'
|
} 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()
|
export const apiRouter = Router()
|
||||||
|
|
||||||
@@ -236,6 +245,14 @@ apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint)
|
|||||||
apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint)
|
apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint)
|
||||||
apiRouter.get('/config/acciones', obtenerAccionesEndpoint)
|
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 ──────────────────────────────────────────────
|
// ── Push notifications ──────────────────────────────────────────────
|
||||||
apiRouter.get('/push/vapid-key', vapidKeyEndpoint)
|
apiRouter.get('/push/vapid-key', vapidKeyEndpoint)
|
||||||
apiRouter.post('/push/suscribir', suscribirEndpoint)
|
apiRouter.post('/push/suscribir', suscribirEndpoint)
|
||||||
|
|||||||
180
api-gateway/src/services/captura-departamento.service.test.ts
Normal file
180
api-gateway/src/services/captura-departamento.service.test.ts
Normal file
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
269
api-gateway/src/services/captura-departamento.service.ts
Normal file
269
api-gateway/src/services/captura-departamento.service.ts
Normal file
@@ -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<string[]> {
|
||||||
|
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<Record<string, Record<string, string | null>>> {
|
||||||
|
const where: Record<string, string> = { desarrollo }
|
||||||
|
if (departamento) where.departamento = departamento
|
||||||
|
|
||||||
|
const rows = await prisma.capturaDepartamento.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ unidadCodigo: 'asc' }, { campo: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const agrupado: Record<string, Record<string, string | null>> = {}
|
||||||
|
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<Record<string, string | null>> {
|
||||||
|
const where: Record<string, string> = { desarrollo, unidadCodigo: unidad }
|
||||||
|
if (departamento) where.departamento = departamento
|
||||||
|
|
||||||
|
const rows = await prisma.capturaDepartamento.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { campo: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const campos: Record<string, string | null> = {}
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<ResumenDepartamento[]> {
|
||||||
|
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<ResultadoImportacion> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -6,10 +6,11 @@ import { ConfiguracionCuenta } from './components/ConfiguracionCuenta'
|
|||||||
import BannerModoLocal from './components/BannerModoLocal'
|
import BannerModoLocal from './components/BannerModoLocal'
|
||||||
import { VistaContactos } from './components/VistaContactos'
|
import { VistaContactos } from './components/VistaContactos'
|
||||||
import { ConfigPage } from './components/config/ConfigPage'
|
import { ConfigPage } from './components/config/ConfigPage'
|
||||||
|
import { CapturaPage } from './pages/CapturaPage'
|
||||||
import { getToken, type UsuarioSesion } from './services/auth'
|
import { getToken, type UsuarioSesion } from './services/auth'
|
||||||
import { getMe } from './services/api'
|
import { getMe } from './services/api'
|
||||||
|
|
||||||
type Vista = 'home' | 'contactos' | 'configuraciones'
|
type Vista = 'home' | 'contactos' | 'captura' | 'configuraciones'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [vista, setVista] = useState<Vista>('home')
|
const [vista, setVista] = useState<Vista>('home')
|
||||||
@@ -53,6 +54,16 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (vista === 'captura') {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
||||||
|
<BannerModoLocal />
|
||||||
|
<CapturaPage onVolver={() => setVista('home')} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (vista === 'configuraciones' && usuario?.rol === 'admin') {
|
if (vista === 'configuraciones' && usuario?.rol === 'admin') {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
@@ -88,7 +99,13 @@ export default function App() {
|
|||||||
<TarjetaModulo
|
<TarjetaModulo
|
||||||
key={modulo.id}
|
key={modulo.id}
|
||||||
modulo={modulo}
|
modulo={modulo}
|
||||||
onAbrir={modulo.id === 'contactos' ? () => setVista('contactos') : undefined}
|
onAbrir={
|
||||||
|
modulo.id === 'contactos'
|
||||||
|
? () => setVista('contactos')
|
||||||
|
: modulo.id === 'captura'
|
||||||
|
? () => setVista('captura')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
97
apps/landing/src/components/ResumenCaptura.tsx
Normal file
97
apps/landing/src/components/ResumenCaptura.tsx
Normal file
@@ -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 (
|
||||||
|
<aside className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Avance por departamento
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Sin datos disponibles.</p>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Avance por departamento
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-3 space-y-3">
|
||||||
|
{filtrado.map((r) => {
|
||||||
|
const dept = r.departamento as DepartamentoId
|
||||||
|
const activo = dept === departamentoActivo
|
||||||
|
const label = DEPARTAMENTO_LABELS[dept]
|
||||||
|
return (
|
||||||
|
<li key={dept}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSeleccionar(dept)}
|
||||||
|
className={`w-full rounded-lg p-2 text-left transition-colors ${
|
||||||
|
activo
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/30'
|
||||||
|
: 'hover:bg-[#F8FAFC] dark:hover:bg-[#334155]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
activo
|
||||||
|
? 'text-accent'
|
||||||
|
: 'text-[#111827] dark:text-[#E2E8F0]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs font-semibold ${colorTexto(r.porcentaje)}`}>
|
||||||
|
{r.porcentaje}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-[#E5E7EB] dark:bg-[#334155]">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${colorBarra(r.porcentaje)}`}
|
||||||
|
style={{ width: `${r.porcentaje}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
{r.camposLlenos} de {r.camposTotales} campos
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
209
apps/landing/src/components/TablaCaptura.test.tsx
Normal file
209
apps/landing/src/components/TablaCaptura.test.tsx
Normal file
@@ -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(
|
||||||
|
<TablaCaptura
|
||||||
|
unidades={UNIDADES}
|
||||||
|
campos={CAMPOS_OBRA}
|
||||||
|
datos={DATOS}
|
||||||
|
onGuardar={onGuardar}
|
||||||
|
guardando={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TablaCaptura
|
||||||
|
unidades={UNIDADES}
|
||||||
|
campos={CAMPOS_OBRA}
|
||||||
|
datos={DATOS}
|
||||||
|
onGuardar={onGuardar}
|
||||||
|
guardando={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TablaCaptura
|
||||||
|
unidades={UNIDADES}
|
||||||
|
campos={CAMPOS_VENTAS}
|
||||||
|
datos={{}}
|
||||||
|
onGuardar={onGuardar}
|
||||||
|
guardando={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TablaCaptura
|
||||||
|
unidades={[]}
|
||||||
|
campos={CAMPOS_OBRA}
|
||||||
|
datos={{}}
|
||||||
|
onGuardar={onGuardar}
|
||||||
|
guardando={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText(/No hay unidades/)).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edita una celda y muestra el botón de guardar', () => {
|
||||||
|
const onGuardar = vi.fn()
|
||||||
|
render(
|
||||||
|
<TablaCaptura
|
||||||
|
unidades={UNIDADES}
|
||||||
|
campos={CAMPOS_OBRA}
|
||||||
|
datos={DATOS}
|
||||||
|
onGuardar={onGuardar}
|
||||||
|
guardando={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<TablaCaptura
|
||||||
|
unidades={UNIDADES}
|
||||||
|
campos={CAMPOS_OBRA}
|
||||||
|
datos={DATOS}
|
||||||
|
onGuardar={onGuardar}
|
||||||
|
guardando={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<TablaCaptura
|
||||||
|
unidades={UNIDADES}
|
||||||
|
campos={CAMPOS_OBRA}
|
||||||
|
datos={DATOS}
|
||||||
|
onGuardar={onGuardar}
|
||||||
|
guardando={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<TablaCaptura
|
||||||
|
unidades={UNIDADES}
|
||||||
|
campos={CAMPOS_VENTAS}
|
||||||
|
datos={datosConEstatus}
|
||||||
|
onGuardar={onGuardar}
|
||||||
|
guardando={false}
|
||||||
|
filtroEstatus="VENDIDO"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
<TablaCaptura
|
||||||
|
unidades={unidadesGrandes}
|
||||||
|
campos={CAMPOS_OBRA}
|
||||||
|
datos={{}}
|
||||||
|
onGuardar={onGuardar}
|
||||||
|
guardando={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
296
apps/landing/src/components/TablaCaptura.tsx
Normal file
296
apps/landing/src/components/TablaCaptura.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { useMemo, useState, useCallback } from 'react'
|
||||||
|
import type { CampoDef, DatosCaptura } from '../services/captura'
|
||||||
|
|
||||||
|
// ─── Tipos ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type DirtyMap = Record<string, Record<string, string | null>>
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<label className="flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={valor === 'true'}
|
||||||
|
onChange={(e) => onChange(e.target.checked ? 'true' : null)}
|
||||||
|
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-accent/20 dark:border-[#334155]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tipo === 'select' && options) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={valor ?? ''}
|
||||||
|
onChange={(e) => 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}`}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o} value={o}>{o}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tipo === 'date') {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={valor ?? ''}
|
||||||
|
onChange={(e) => 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 (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={valor ?? ''}
|
||||||
|
onChange={(e) => 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 (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={valor ?? ''}
|
||||||
|
onChange={(e) => 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<DirtyMap>({})
|
||||||
|
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 (
|
||||||
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
No hay unidades registradas para este desarrollo.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Barra de guardado */}
|
||||||
|
{tieneDirty && (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg bg-amber-50 px-4 py-2 dark:bg-amber-900/20">
|
||||||
|
<span className="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
Hay cambios sin guardar
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={guardando}
|
||||||
|
className="rounded-lg bg-accent px-4 py-1.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{guardando ? 'Guardando…' : 'Guardar cambios'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDirty({})}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-sm font-medium text-[#374151] transition-colors hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||||
|
>
|
||||||
|
Descartar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabla scrollable */}
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#E5E7EB] bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#1E293B]">
|
||||||
|
<th className="sticky left-0 z-10 border-r border-[#E5E7EB] bg-[#F9FAFB] px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#94A3B8]">
|
||||||
|
Unidad
|
||||||
|
</th>
|
||||||
|
{campos.map((c) => (
|
||||||
|
<th
|
||||||
|
key={c.campo}
|
||||||
|
className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
|
||||||
|
title={c.label}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{unidadesPagina.map((unidad, idx) => (
|
||||||
|
<tr
|
||||||
|
key={unidad}
|
||||||
|
className={`border-b border-[#E5E7EB] dark:border-[#334155] last:border-0 ${
|
||||||
|
idx % 2 === 0
|
||||||
|
? 'bg-white dark:bg-[#1E293B]'
|
||||||
|
: 'bg-[#F9FAFB] dark:bg-[#0F172A]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="sticky left-0 z-10 border-r border-[#E5E7EB] bg-inherit px-3 py-2 text-xs font-semibold text-[#111827] dark:border-[#334155] dark:text-[#E2E8F0]">
|
||||||
|
{unidad}
|
||||||
|
</td>
|
||||||
|
{campos.map((c) => {
|
||||||
|
const val = valorOriginal(datos, unidad, c.campo)
|
||||||
|
const isDirty = dirty[unidad]?.[c.campo] !== undefined
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={`${unidad}-${c.campo}`}
|
||||||
|
className="px-1 py-0.5"
|
||||||
|
>
|
||||||
|
<CeldaEditable
|
||||||
|
tipo={c.tipo}
|
||||||
|
valor={val}
|
||||||
|
options={c.options}
|
||||||
|
onChange={(nuevo) => handleChange(unidad, c.campo, nuevo)}
|
||||||
|
dirty={isDirty}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paginación */}
|
||||||
|
{totalPaginas > 1 && (
|
||||||
|
<div className="flex items-center justify-between text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
<span>
|
||||||
|
Mostrando {paginaActual * POR_PAGINA + 1}–
|
||||||
|
{Math.min((paginaActual + 1) * POR_PAGINA, unidadesFiltradas.length)} de{' '}
|
||||||
|
{unidadesFiltradas.length} unidades
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPagina((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={paginaActual === 0}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1 text-xs font-medium text-[#374151] transition-colors hover:bg-[#F9FAFB] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<span className="flex items-center px-2 text-xs">
|
||||||
|
{paginaActual + 1} / {totalPaginas}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPagina((p) => Math.min(totalPaginas - 1, p + 1))}
|
||||||
|
disabled={paginaActual >= totalPaginas - 1}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1 text-xs font-medium text-[#374151] transition-colors hover:bg-[#F9FAFB] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ const INICIALES: Record<string, string> = {
|
|||||||
tableros: 'TB',
|
tableros: 'TB',
|
||||||
'mi-dia': 'MD',
|
'mi-dia': 'MD',
|
||||||
contactos: 'CT',
|
contactos: 'CT',
|
||||||
|
captura: 'CA',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tarjeta de acceso a un módulo del ecosistema */
|
/** Tarjeta de acceso a un módulo del ecosistema */
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ const porId = (id: string): Modulo => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('MODULOS', () => {
|
describe('MODULOS', () => {
|
||||||
it('expone los 7 módulos del ecosistema', () => {
|
it('expone los 8 módulos del ecosistema', () => {
|
||||||
expect(MODULOS).toHaveLength(7)
|
expect(MODULOS).toHaveLength(8)
|
||||||
expect(MODULOS.map((m) => m.id)).toEqual([
|
expect(MODULOS.map((m) => m.id)).toEqual([
|
||||||
'cotizador',
|
'cotizador',
|
||||||
'alta-clientes',
|
'alta-clientes',
|
||||||
@@ -18,6 +18,7 @@ describe('MODULOS', () => {
|
|||||||
'tableros',
|
'tableros',
|
||||||
'mi-dia',
|
'mi-dia',
|
||||||
'contactos',
|
'contactos',
|
||||||
|
'captura',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,8 +40,9 @@ describe('MODULOS', () => {
|
|||||||
expect(porId('mi-dia').url).toBe('/hoy/')
|
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('contactos').url).toBeUndefined()
|
||||||
|
expect(porId('captura').url).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('liga cada módulo a la matriz de roles del API', () => {
|
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('tableros').claves).toEqual(['mi-tablero', 'tablero-comercial'])
|
||||||
expect(porId('mi-dia').claves).toEqual(['mi-dia'])
|
expect(porId('mi-dia').claves).toEqual(['mi-dia'])
|
||||||
expect(porId('contactos').claves).toEqual(['contactos'])
|
expect(porId('contactos').claves).toEqual(['contactos'])
|
||||||
|
expect(porId('captura').claves).toEqual(['captura'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('no hay ids ni URLs duplicadas', () => {
|
it('no hay ids ni URLs duplicadas', () => {
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ export const MODULOS: Modulo[] = [
|
|||||||
descripcion: 'Directorio de contactos del CRM: teléfono, email y estado',
|
descripcion: 'Directorio de contactos del CRM: teléfono, email y estado',
|
||||||
claves: ['contactos'],
|
claves: ['contactos'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'captura',
|
||||||
|
nombre: 'Captura',
|
||||||
|
descripcion: 'Captura por departamentos: Obra, Ventas, Escrituración, Finanzas y Postventa',
|
||||||
|
claves: ['captura'],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
149
apps/landing/src/pages/CapturaPage.test.tsx
Normal file
149
apps/landing/src/pages/CapturaPage.test.tsx
Normal file
@@ -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<Response>
|
||||||
|
|
||||||
|
function crearStorageStub(): Storage {
|
||||||
|
const mapa = new Map<string, string>()
|
||||||
|
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<typeof vi.fn<FetchFn>>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn<FetchFn>()
|
||||||
|
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(<CapturaPage onVolver={() => {}} />)
|
||||||
|
|
||||||
|
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(<CapturaPage onVolver={() => {}} />)
|
||||||
|
|
||||||
|
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(<CapturaPage onVolver={() => {}} />)
|
||||||
|
|
||||||
|
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(<CapturaPage onVolver={() => {}} />)
|
||||||
|
|
||||||
|
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(<CapturaPage onVolver={() => {}} />)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
232
apps/landing/src/pages/CapturaPage.tsx
Normal file
232
apps/landing/src/pages/CapturaPage.tsx
Normal file
@@ -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>(String(DESARROLLOS[0]))
|
||||||
|
const [departamento, setDepartamento] = useState<DepartamentoId>(DEPARTAMENTOS[0])
|
||||||
|
const [unidades, setUnidades] = useState<string[]>([])
|
||||||
|
const [datos, setDatos] = useState<DatosCaptura>({})
|
||||||
|
const [resumen, setResumen] = useState<ResumenDepartamento[]>([])
|
||||||
|
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 (
|
||||||
|
<main className="mx-auto w-full max-w-[1280px] flex-1 px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onVolver}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||||
|
>
|
||||||
|
← Volver
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0] sm:text-3xl">
|
||||||
|
Captura por Departamentos
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtros */}
|
||||||
|
<div className="mt-6 flex flex-wrap items-end gap-4">
|
||||||
|
{/* Desarrollo */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="captura-desarrollo"
|
||||||
|
className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||||
|
>
|
||||||
|
Desarrollo
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="captura-desarrollo"
|
||||||
|
value={desarrollo}
|
||||||
|
onChange={(e) => setDesarrollo(e.target.value)}
|
||||||
|
className="mt-1.5 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
|
||||||
|
>
|
||||||
|
{DESARROLLOS.map((d) => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtro de estatus */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="captura-estatus"
|
||||||
|
className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||||
|
>
|
||||||
|
Filtrar por estatus
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="captura-estatus"
|
||||||
|
value={filtroEstatus}
|
||||||
|
onChange={(e) => setFiltroEstatus(e.target.value)}
|
||||||
|
className="mt-1.5 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="DISPONIBLE">Disponible</option>
|
||||||
|
<option value="APARTADO">Apartado</option>
|
||||||
|
<option value="VENDIDO">Vendido</option>
|
||||||
|
<option value="ESCRITURADO">Escriturado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs de departamento */}
|
||||||
|
<nav aria-label="Departamentos" className="mt-6">
|
||||||
|
<ul className="flex gap-1 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-1 dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
{DEPARTAMENTOS.filter((d) =>
|
||||||
|
departamentosPermitidos.includes(d as DepartamentoId),
|
||||||
|
).map((d) => {
|
||||||
|
const dept = d as DepartamentoId
|
||||||
|
const activo = dept === departamento
|
||||||
|
return (
|
||||||
|
<li key={d}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDepartamento(dept)}
|
||||||
|
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
activo
|
||||||
|
? 'bg-white text-[#111827] shadow-sm dark:bg-[#1E293B] dark:text-[#E2E8F0]'
|
||||||
|
: 'text-[#64748B] hover:text-[#374151] dark:text-[#94A3B8] dark:hover:text-[#CBD5E1]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{DEPARTAMENTO_LABELS[dept]}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Contenido: tabla + resumen */}
|
||||||
|
<div className="mt-6 grid gap-6 lg:grid-cols-[1fr_240px]">
|
||||||
|
{/* Tabla */}
|
||||||
|
<section>
|
||||||
|
{cargando ? (
|
||||||
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando captura…</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
<p role="alert" className="text-sm text-red-600">{error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void cargar()}
|
||||||
|
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TablaCaptura
|
||||||
|
unidades={unidades}
|
||||||
|
campos={campos}
|
||||||
|
datos={datos}
|
||||||
|
onGuardar={handleGuardar}
|
||||||
|
guardando={guardando}
|
||||||
|
filtroEstatus={filtroEstatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Resumen lateral */}
|
||||||
|
<ResumenCaptura
|
||||||
|
resumen={resumen}
|
||||||
|
departamentoActivo={departamento}
|
||||||
|
departamentosPermitidos={departamentosPermitidos}
|
||||||
|
onSeleccionar={setDepartamento}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
179
apps/landing/src/services/captura.ts
Normal file
179
apps/landing/src/services/captura.ts
Normal file
@@ -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<string, Record<string, string | null>>
|
||||||
|
|
||||||
|
// ─── 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<DepartamentoId, CampoDef[]> = {
|
||||||
|
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<DepartamentoId, string> = {
|
||||||
|
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<string, DepartamentoId[]> = {
|
||||||
|
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<DatosCaptura> {
|
||||||
|
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<string[]> {
|
||||||
|
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<ResumenDepartamento[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user