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:
2026-09-07 07:59:14 +00:00
parent 7d19e0da38
commit c2a8f4e929
21 changed files with 2496 additions and 7 deletions

View File

@@ -31,7 +31,8 @@
"prisma": "^6.14.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
"vitest": "^3.2.4",
"xlsx": "^0.18.5"
},
"engines": {
"node": ">=22"
@@ -1258,6 +1259,16 @@
"node": ">= 0.6"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1447,6 +1458,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@@ -1500,6 +1525,16 @@
"consola": "^3.2.3"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@@ -1589,6 +1624,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1955,6 +2003,16 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -3040,6 +3098,19 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -3481,12 +3552,54 @@
"node": ">=8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@@ -45,6 +45,7 @@
"prisma": "^6.14.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
"vitest": "^3.2.4",
"xlsx": "^0.18.5"
}
}

View File

@@ -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");

View File

@@ -412,6 +412,25 @@ model AuditoriaLog {
@@index([createdAt])
}
// Captura por departamentos: cada campo de cada unidad se registra como fila.
// Unique constraint: desarrollo + unidadCodigo + departamento + campo.
model CapturaDepartamento {
id String @id @default(uuid())
desarrollo String
unidadCodigo String
departamento String
campo String
valor String?
tipo String @default("text")
usuarioId String
capturadoEn DateTime @default(now())
actualizadoEn DateTime @updatedAt
@@unique([desarrollo, unidadCodigo, departamento, campo])
@@index([desarrollo])
@@index([departamento])
}
// Suscripciones push de los navegadores (Web Push API)
model PushSubscription {
id String @id @default(uuid())

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

View 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

View File

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

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

View File

@@ -106,6 +106,15 @@ import {
desuscribirEndpoint,
vapidKeyEndpoint,
} from '../controllers/push.controller.js'
import {
listarEndpoint as listarCapturaEndpoint,
unidadesEndpoint as unidadesCapturaEndpoint,
resumenEndpoint as resumenCapturaEndpoint,
guardarEndpoint as guardarCapturaEndpoint,
bulkEndpoint as bulkCapturaEndpoint,
importarEndpoint as importarCapturaEndpoint,
requireAdminCaptura,
} from '../controllers/captura-departamento.controller.js'
export const apiRouter = Router()
@@ -236,6 +245,14 @@ apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint)
apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint)
apiRouter.get('/config/acciones', obtenerAccionesEndpoint)
// ── Captura por departamentos ────────────────────────────────────────
apiRouter.get('/captura', listarCapturaEndpoint)
apiRouter.get('/captura/unidades', unidadesCapturaEndpoint)
apiRouter.get('/captura/resumen', resumenCapturaEndpoint)
apiRouter.post('/captura', guardarCapturaEndpoint)
apiRouter.post('/captura/bulk', bulkCapturaEndpoint)
apiRouter.post('/captura/importar', requireAdminCaptura, importarCapturaEndpoint)
// ── Push notifications ──────────────────────────────────────────────
apiRouter.get('/push/vapid-key', vapidKeyEndpoint)
apiRouter.post('/push/suscribir', suscribirEndpoint)

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

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

View File

@@ -6,10 +6,11 @@ import { ConfiguracionCuenta } from './components/ConfiguracionCuenta'
import BannerModoLocal from './components/BannerModoLocal'
import { VistaContactos } from './components/VistaContactos'
import { ConfigPage } from './components/config/ConfigPage'
import { CapturaPage } from './pages/CapturaPage'
import { getToken, type UsuarioSesion } from './services/auth'
import { getMe } from './services/api'
type Vista = 'home' | 'contactos' | 'configuraciones'
type Vista = 'home' | 'contactos' | 'captura' | 'configuraciones'
export default function App() {
const [vista, setVista] = useState<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') {
return (
<div className="flex min-h-screen flex-col">
@@ -88,7 +99,13 @@ export default function App() {
<TarjetaModulo
key={modulo.id}
modulo={modulo}
onAbrir={modulo.id === 'contactos' ? () => setVista('contactos') : undefined}
onAbrir={
modulo.id === 'contactos'
? () => setVista('contactos')
: modulo.id === 'captura'
? () => setVista('captura')
: undefined
}
/>
))}
</ul>

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

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

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

View File

@@ -15,6 +15,7 @@ const INICIALES: Record<string, string> = {
tableros: 'TB',
'mi-dia': 'MD',
contactos: 'CT',
captura: 'CA',
}
/** Tarjeta de acceso a un módulo del ecosistema */

View File

@@ -8,8 +8,8 @@ const porId = (id: string): Modulo => {
}
describe('MODULOS', () => {
it('expone los 7 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(7)
it('expone los 8 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(8)
expect(MODULOS.map((m) => m.id)).toEqual([
'cotizador',
'alta-clientes',
@@ -18,6 +18,7 @@ describe('MODULOS', () => {
'tableros',
'mi-dia',
'contactos',
'captura',
])
})
@@ -39,8 +40,9 @@ describe('MODULOS', () => {
expect(porId('mi-dia').url).toBe('/hoy/')
})
it('Contactos es vista interna (sin url externa)', () => {
it('Contactos y Captura son vistas internas (sin url externa)', () => {
expect(porId('contactos').url).toBeUndefined()
expect(porId('captura').url).toBeUndefined()
})
it('liga cada módulo a la matriz de roles del API', () => {
@@ -51,6 +53,7 @@ describe('MODULOS', () => {
expect(porId('tableros').claves).toEqual(['mi-tablero', 'tablero-comercial'])
expect(porId('mi-dia').claves).toEqual(['mi-dia'])
expect(porId('contactos').claves).toEqual(['contactos'])
expect(porId('captura').claves).toEqual(['captura'])
})
it('no hay ids ni URLs duplicadas', () => {

View File

@@ -59,6 +59,12 @@ export const MODULOS: Modulo[] = [
descripcion: 'Directorio de contactos del CRM: teléfono, email y estado',
claves: ['contactos'],
},
{
id: 'captura',
nombre: 'Captura',
descripcion: 'Captura por departamentos: Obra, Ventas, Escrituración, Finanzas y Postventa',
claves: ['captura'],
},
]
/**

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

View 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]"
>
&larr; 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>
)
}

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