feat(alta-clientes): telefono obligatorio en alta y afluencia, importacion de informacion Enkontrol por ID en pantalla final y campos de obra movidos a la seccion Obra en captura
This commit is contained in:
@@ -16,6 +16,15 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
|
|||||||
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' },
|
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' },
|
||||||
{ campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
|
{ campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
|
||||||
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' },
|
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' },
|
||||||
|
// Fechas de obra (las llena el área de obra; rigen el arranque y la entrega)
|
||||||
|
{ campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' },
|
||||||
|
{ campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' },
|
||||||
|
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
|
||||||
|
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
|
||||||
|
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
|
||||||
|
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
|
||||||
|
{ campo: 'desviacion', tipo: 'number', label: 'Desviación (días)' },
|
||||||
|
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
|
||||||
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
|
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
|
||||||
],
|
],
|
||||||
VENTAS: [
|
VENTAS: [
|
||||||
@@ -31,13 +40,6 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
|
|||||||
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
||||||
],
|
],
|
||||||
ESCRITURACION: [
|
ESCRITURACION: [
|
||||||
{ campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' },
|
|
||||||
{ campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' },
|
|
||||||
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
|
|
||||||
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
|
|
||||||
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
|
|
||||||
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
|
|
||||||
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
|
|
||||||
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
|
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
|
||||||
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
||||||
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },
|
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ describe('formaPagoLabel (tipos de crédito del alta)', () => {
|
|||||||
|
|
||||||
describe('crearClienteSchema', () => {
|
describe('crearClienteSchema', () => {
|
||||||
const base = {
|
const base = {
|
||||||
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
|
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' },
|
||||||
viviendaCredito: { proyecto: 'SENDA' },
|
viviendaCredito: { proyecto: 'SENDA' },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ describe('crearClienteSchema', () => {
|
|||||||
// ---------- Alta local (POST /api/clientes) ----------
|
// ---------- Alta local (POST /api/clientes) ----------
|
||||||
|
|
||||||
const altaBase = {
|
const altaBase = {
|
||||||
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
|
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' },
|
||||||
viviendaCredito: { proyecto: 'SENDA' },
|
viviendaCredito: { proyecto: 'SENDA' },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ describe('alta progresiva — local', () => {
|
|||||||
mondayApartadoId: null,
|
mondayApartadoId: null,
|
||||||
})
|
})
|
||||||
const altaCompleta = {
|
const altaCompleta = {
|
||||||
datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0' },
|
datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0', celular: '6649990003' },
|
||||||
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 },
|
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 },
|
||||||
leadMondayId: 'loc_op_9',
|
leadMondayId: 'loc_op_9',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from 'node:path'
|
|||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
|
import * as XLSX from 'xlsx'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { prisma } from '../services/prisma.js'
|
import { prisma } from '../services/prisma.js'
|
||||||
@@ -20,7 +21,8 @@ export const crearClienteSchema = z
|
|||||||
curp: z.string().min(1),
|
curp: z.string().min(1),
|
||||||
rfc: z.string().min(1),
|
rfc: z.string().min(1),
|
||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
celular: z.string().optional(),
|
// Teléfono obligatorio (regla de negocio: sin celular no hay alta)
|
||||||
|
celular: z.string().trim().min(8, 'El celular es obligatorio'),
|
||||||
direccion: z.string().optional(),
|
direccion: z.string().optional(),
|
||||||
// Perfil demográfico (opcionales; viajan en el payload del alta)
|
// Perfil demográfico (opcionales; viajan en el payload del alta)
|
||||||
fechaNacimiento: z.string().optional(),
|
fechaNacimiento: z.string().optional(),
|
||||||
@@ -575,3 +577,132 @@ async function prefillApartadoLocal(itemId: string) {
|
|||||||
cotizacion: cotizacion ? cotizacionPrefill(cotizacion) : null,
|
cotizacion: cotizacion ? cotizacionPrefill(cotizacion) : null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------- Importación de información desde Enkontrol ----------
|
||||||
|
|
||||||
|
/** Upload en memoria del documento de Enkontrol (Excel o CSV, máx 10 MB) */
|
||||||
|
export const uploadImportacionEnkontrol = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Normaliza un encabezado para compararlo: minúsculas, sin acentos, espacios→' ' */
|
||||||
|
function normalizarEncabezado(h: string): string {
|
||||||
|
return h
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encabezados aceptados como ID de Enkontrol (el campo determinante del documento) */
|
||||||
|
const COLUMNAS_ID_ENKONTROL = new Set([
|
||||||
|
'id enkontrol',
|
||||||
|
'cliente enkontrol',
|
||||||
|
'usuario enkontrol',
|
||||||
|
'enkontrol id',
|
||||||
|
'id de enkontrol',
|
||||||
|
'cliente enkontrol id',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** Encabezado normalizado → columna del Cliente que actualiza */
|
||||||
|
const COLUMNAS_CLIENTE: Record<string, 'nombreCompleto' | 'curp' | 'rfc' | 'email' | 'celular' | 'proyecto' | 'unidadCodigo'> = {
|
||||||
|
'nombre': 'nombreCompleto',
|
||||||
|
'nombre completo': 'nombreCompleto',
|
||||||
|
'cliente': 'nombreCompleto',
|
||||||
|
'curp': 'curp',
|
||||||
|
'rfc': 'rfc',
|
||||||
|
'email': 'email',
|
||||||
|
'correo': 'email',
|
||||||
|
'correo electronico': 'email',
|
||||||
|
'celular': 'celular',
|
||||||
|
'telefono': 'celular',
|
||||||
|
'telefono celular': 'celular',
|
||||||
|
'proyecto': 'proyecto',
|
||||||
|
'desarrollo': 'proyecto',
|
||||||
|
'unidad': 'unidadCodigo',
|
||||||
|
'ubicacion': 'unidadCodigo',
|
||||||
|
'unidad codigo': 'unidadCodigo',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/clientes/importar-enkontrol — importa un documento (Excel/CSV) de
|
||||||
|
* Enkontrol usando el ID de Enkontrol como campo determinante de cada cliente:
|
||||||
|
* cada fila se empareja con la ficha local cuyo alta tenga ese usuarioEnkontrol
|
||||||
|
* y su información completa se guarda en el payload de la ficha. Las columnas
|
||||||
|
* conocidas (nombre, CURP, RFC, email, celular, proyecto, unidad) actualizan
|
||||||
|
* también los campos directos del Cliente.
|
||||||
|
*/
|
||||||
|
export async function importarEnkontrolEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const archivo = (req as Request & { file?: Express.Multer.File }).file
|
||||||
|
if (!archivo) throw new BadRequestError('Adjunta el archivo Excel o CSV de Enkontrol')
|
||||||
|
|
||||||
|
let filas: Record<string, unknown>[]
|
||||||
|
try {
|
||||||
|
const libro = XLSX.read(archivo.buffer, { type: 'buffer' })
|
||||||
|
const hoja = libro.Sheets[libro.SheetNames[0] ?? '']
|
||||||
|
if (!hoja) throw new Error('sin hojas')
|
||||||
|
filas = XLSX.utils.sheet_to_json<Record<string, unknown>>(hoja, { defval: '' })
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestError('No se pudo leer el documento: usa un Excel (.xlsx) o CSV válido')
|
||||||
|
}
|
||||||
|
if (filas.length === 0) throw new BadRequestError('El documento no contiene filas de datos')
|
||||||
|
|
||||||
|
const encabezados = Object.keys(filas[0]!)
|
||||||
|
const columnaId = encabezados.find((h) => COLUMNAS_ID_ENKONTROL.has(normalizarEncabezado(h)))
|
||||||
|
if (!columnaId) {
|
||||||
|
throw new BadRequestError(
|
||||||
|
`No encontré la columna del ID de Enkontrol (encabezados: ${encabezados.slice(0, 8).join(', ')})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Índice de clientes locales por ID de Enkontrol (payloadJson.datosGenerales.usuarioEnkontrol)
|
||||||
|
const clientes = await prisma.cliente.findMany({ select: { id: true, nombreCompleto: true, payloadJson: true } })
|
||||||
|
const porEnkontrol = new Map<string, { id: string; nombreCompleto: string; payloadJson: string }>()
|
||||||
|
for (const c of clientes) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(c.payloadJson) as { datosGenerales?: { usuarioEnkontrol?: unknown } }
|
||||||
|
const idE = payload.datosGenerales?.usuarioEnkontrol
|
||||||
|
if (typeof idE === 'string' && idE.trim() !== '') {
|
||||||
|
porEnkontrol.set(idE.trim().toUpperCase(), c)
|
||||||
|
}
|
||||||
|
} catch { /* payload ilegible: se omite del índice */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let actualizados = 0
|
||||||
|
const noEncontrados: string[] = []
|
||||||
|
const ahora = new Date().toISOString()
|
||||||
|
for (const fila of filas) {
|
||||||
|
const idE = String(fila[columnaId] ?? '').trim()
|
||||||
|
if (idE === '') continue
|
||||||
|
const cliente = porEnkontrol.get(idE.toUpperCase())
|
||||||
|
if (!cliente) {
|
||||||
|
noEncontrados.push(idE)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let payload: Record<string, unknown> = {}
|
||||||
|
try { payload = JSON.parse(cliente.payloadJson) as Record<string, unknown> } catch { payload = {} }
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
payloadJson: JSON.stringify({ ...payload, importacionEnkontrol: { fecha: ahora, datos: fila } }),
|
||||||
|
}
|
||||||
|
for (const [encabezado, valor] of Object.entries(fila)) {
|
||||||
|
const columna = COLUMNAS_CLIENTE[normalizarEncabezado(encabezado)]
|
||||||
|
const texto = String(valor ?? '').trim()
|
||||||
|
if (columna !== undefined && texto !== '') data[columna] = texto
|
||||||
|
}
|
||||||
|
await prisma.cliente.update({ where: { id: cliente.id }, data })
|
||||||
|
actualizados++
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ totalFilas: filas.length, actualizados, noEncontrados })
|
||||||
|
auditoria.registrar(
|
||||||
|
req.usuarioToken?.sub ?? 'sistema',
|
||||||
|
'IMPORTAR_ENKONTROL',
|
||||||
|
'cliente',
|
||||||
|
archivo.originalname,
|
||||||
|
`${actualizados} actualizados, ${noEncontrados.length} sin ficha local`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ describe('crearAfluenciaEndpoint (POST /api/afluencias) — la afluencia ES la o
|
|||||||
reqCon({
|
reqCon({
|
||||||
body: {
|
body: {
|
||||||
nombre: 'Lead Prueba',
|
nombre: 'Lead Prueba',
|
||||||
|
telefono: '4771234567',
|
||||||
oportunidadId: 'loc_op_3',
|
oportunidadId: 'loc_op_3',
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
modelo: 'SOHO',
|
modelo: 'SOHO',
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ const crearLeadSchema = z.object({
|
|||||||
/** Alta Afluencia (tablero Oportunidades): captura con perfil completo */
|
/** Alta Afluencia (tablero Oportunidades): captura con perfil completo */
|
||||||
const crearAfluenciaSchema = z.object({
|
const crearAfluenciaSchema = z.object({
|
||||||
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
|
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||||
telefono: z.string().trim().min(1).optional(),
|
// Teléfono obligatorio (regla de negocio: sin teléfono no hay afluencia)
|
||||||
|
telefono: z.string().trim().min(7, 'El teléfono es obligatorio'),
|
||||||
email: z.string().trim().email('Email inválido').optional(),
|
email: z.string().trim().email('Email inválido').optional(),
|
||||||
origen: z.string().trim().min(1).optional(),
|
origen: z.string().trim().min(1).optional(),
|
||||||
desarrollo: z.string().trim().min(1).optional(),
|
desarrollo: z.string().trim().min(1).optional(),
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import {
|
|||||||
actualizarProgreso,
|
actualizarProgreso,
|
||||||
completarProgreso,
|
completarProgreso,
|
||||||
crearCliente,
|
crearCliente,
|
||||||
|
importarEnkontrolEndpoint,
|
||||||
iniciarProgreso,
|
iniciarProgreso,
|
||||||
listarDocumentos,
|
listarDocumentos,
|
||||||
obtenerCliente,
|
obtenerCliente,
|
||||||
prefillApartadoEndpoint,
|
prefillApartadoEndpoint,
|
||||||
subirDocumentos,
|
subirDocumentos,
|
||||||
uploadDocumentos,
|
uploadDocumentos,
|
||||||
|
uploadImportacionEnkontrol,
|
||||||
} from '../controllers/clientes.controller.js'
|
} from '../controllers/clientes.controller.js'
|
||||||
import {
|
import {
|
||||||
eliminarBorrador,
|
eliminarBorrador,
|
||||||
@@ -208,6 +210,14 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
|
|||||||
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
||||||
|
|
||||||
apiRouter.post('/clientes', crearCliente)
|
apiRouter.post('/clientes', crearCliente)
|
||||||
|
// Importación de información desde un documento de Enkontrol (ID Enkontrol =
|
||||||
|
// campo determinante). Solo administración.
|
||||||
|
apiRouter.post(
|
||||||
|
'/clientes/importar-enkontrol',
|
||||||
|
requireAdminOManager,
|
||||||
|
uploadImportacionEnkontrol.single('archivo'),
|
||||||
|
importarEnkontrolEndpoint,
|
||||||
|
)
|
||||||
// Prefill del alta desde un apartado existente (botón "Alta Enkontrol"
|
// Prefill del alta desde un apartado existente (botón "Alta Enkontrol"
|
||||||
// de Mis apartados). Va ANTES de /clientes/:id para no colisionar.
|
// de Mis apartados). Va ANTES de /clientes/:id para no colisionar.
|
||||||
apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint)
|
apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint)
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ describe('fechas de Obra bloqueadas', () => {
|
|||||||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ESCRITURACION: no-admin no puede modificar una fecha ya establecida', async () => {
|
it('OBRA: no-admin no puede modificar la fecha de entrega programada ya establecida', async () => {
|
||||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||||
{ unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: '2026-10-01' },
|
{ unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: '2026-10-01' },
|
||||||
])
|
])
|
||||||
@@ -201,11 +201,11 @@ describe('fechas de Obra bloqueadas', () => {
|
|||||||
guardarCampo(
|
guardarCampo(
|
||||||
'SENDA',
|
'SENDA',
|
||||||
'U-101',
|
'U-101',
|
||||||
'ESCRITURACION',
|
'OBRA',
|
||||||
'entrega_programada_fecha',
|
'entrega_programada_fecha',
|
||||||
'2026-10-15',
|
'2026-10-15',
|
||||||
'user-1',
|
'user-1',
|
||||||
'escrituracion',
|
'obra',
|
||||||
),
|
),
|
||||||
).rejects.toThrow('solo puede modificarlas un administrador')
|
).rejects.toThrow('solo puede modificarlas un administrador')
|
||||||
expect(prismaMock.capturaDepartamento.upsert).not.toHaveBeenCalled()
|
expect(prismaMock.capturaDepartamento.upsert).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -426,6 +426,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
placeholder="664 123 4567"
|
placeholder="664 123 4567"
|
||||||
value={form.telefono}
|
value={form.telefono}
|
||||||
onChange={cambiar('telefono')}
|
onChange={cambiar('telefono')}
|
||||||
|
required
|
||||||
className={`${inputClase} ${errores.telefono ? 'border-red-400' : ''}`}
|
className={`${inputClase} ${errores.telefono ? 'border-red-400' : ''}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
eliminarBorrador,
|
eliminarBorrador,
|
||||||
generarLigaRemota,
|
generarLigaRemota,
|
||||||
guardarBorrador,
|
guardarBorrador,
|
||||||
|
importarInformacionEnkontrol,
|
||||||
iniciarProgreso,
|
iniciarProgreso,
|
||||||
listarBorradores,
|
listarBorradores,
|
||||||
obtenerBorrador,
|
obtenerBorrador,
|
||||||
@@ -27,7 +28,9 @@ import {
|
|||||||
subirDocumentos,
|
subirDocumentos,
|
||||||
type BorradorResumen,
|
type BorradorResumen,
|
||||||
type LeadResumen,
|
type LeadResumen,
|
||||||
|
type ResultadoImportacionEnkontrol,
|
||||||
} from '../services/api'
|
} from '../services/api'
|
||||||
|
import { getSesion } from '../services/auth'
|
||||||
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
|
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
|
||||||
import { apartadoAPrellenado } from '../lib/apartadoAlta'
|
import { apartadoAPrellenado } from '../lib/apartadoAlta'
|
||||||
import { leadAPrellenado } from '../lib/leadAlta'
|
import { leadAPrellenado } from '../lib/leadAlta'
|
||||||
@@ -137,6 +140,29 @@ export default function AltaClientePage() {
|
|||||||
const [ligaCopiada, setLigaCopiada] = useState(false)
|
const [ligaCopiada, setLigaCopiada] = useState(false)
|
||||||
const [errorLiga, setErrorLiga] = useState('')
|
const [errorLiga, setErrorLiga] = useState('')
|
||||||
|
|
||||||
|
// ---------- Importación del documento de Enkontrol (solo administración) ----------
|
||||||
|
const rolSesion = getSesion()?.usuario.rol ?? ''
|
||||||
|
const puedeImportarEnkontrol = rolSesion === 'admin' || rolSesion === 'manager'
|
||||||
|
const [importandoEnkontrol, setImportandoEnkontrol] = useState(false)
|
||||||
|
const [resultadoImportacion, setResultadoImportacion] = useState<ResultadoImportacionEnkontrol | null>(null)
|
||||||
|
const [errorImportacion, setErrorImportacion] = useState('')
|
||||||
|
const inputImportacionRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const importarEnkontrol = async (archivo: File | undefined) => {
|
||||||
|
if (!archivo) return
|
||||||
|
setImportandoEnkontrol(true)
|
||||||
|
setErrorImportacion('')
|
||||||
|
setResultadoImportacion(null)
|
||||||
|
try {
|
||||||
|
setResultadoImportacion(await importarInformacionEnkontrol(archivo))
|
||||||
|
} catch (err) {
|
||||||
|
setErrorImportacion(err instanceof Error ? err.message : 'No se pudo importar el documento.')
|
||||||
|
} finally {
|
||||||
|
setImportandoEnkontrol(false)
|
||||||
|
if (inputImportacionRef.current) inputImportacionRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Afluencia prellenada desde Mis oportunidades (Mi día): actualiza la
|
// Afluencia prellenada desde Mis oportunidades (Mi día): actualiza la
|
||||||
// oportunidad existente en vez de crear una nueva
|
// oportunidad existente en vez de crear una nueva
|
||||||
const [afluenciaPrevia, setAfluenciaPrevia] = useState<{
|
const [afluenciaPrevia, setAfluenciaPrevia] = useState<{
|
||||||
@@ -610,6 +636,51 @@ export default function AltaClientePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{puedeImportarEnkontrol && (
|
||||||
|
<div className="mt-6 rounded-lg border border-[#E5E7EB] bg-white p-4 dark:border-[#334155] dark:bg-[#0F172A]">
|
||||||
|
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
Importar información de Enkontrol
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Sube el documento de Enkontrol (Excel o CSV). Cada fila se empareja con la ficha
|
||||||
|
del cliente usando el ID de Enkontrol como campo determinante y su información se
|
||||||
|
guarda en la ficha.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputImportacionRef.current?.click()}
|
||||||
|
disabled={importandoEnkontrol}
|
||||||
|
className="rounded-lg border border-accent bg-white px-4 py-2 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:opacity-50 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
|
||||||
|
>
|
||||||
|
{importandoEnkontrol ? 'Importando…' : 'Seleccionar documento…'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputImportacionRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.csv"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(e) => void importarEnkontrol(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{resultadoImportacion && (
|
||||||
|
<p role="status" className="mt-3 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-xs text-green-700 dark:border-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
Importación lista: {resultadoImportacion.actualizados} de {resultadoImportacion.totalFilas}{' '}
|
||||||
|
clientes actualizados.
|
||||||
|
{resultadoImportacion.noEncontrados.length > 0 && (
|
||||||
|
<> Sin ficha local: {resultadoImportacion.noEncontrados.slice(0, 10).join(', ')}
|
||||||
|
{resultadoImportacion.noEncontrados.length > 10 ? '…' : ''}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errorImportacion && (
|
||||||
|
<p role="alert" className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||||
|
{errorImportacion}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="mt-6 text-xs text-green-800/80 dark:text-green-400/80">
|
<p className="mt-6 text-xs text-green-800/80 dark:text-green-400/80">
|
||||||
Con la liga de firma, el cliente firma su expediente y sube documentos desde su
|
Con la liga de firma, el cliente firma su expediente y sube documentos desde su
|
||||||
celular (vigencia 72 h, sin usuario ni contraseña).
|
celular (vigencia 72 h, sin usuario ni contraseña).
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ async function enviarAlta(ruta: string, payload: AltaCliente): Promise<Resultado
|
|||||||
/**
|
/**
|
||||||
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
|
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
|
||||||
* SQLite.
|
* SQLite.
|
||||||
*/
|
*/export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
|
||||||
export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
|
|
||||||
return enviarAlta('/clientes', payload)
|
return enviarAlta('/clientes', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,3 +413,30 @@ export async function subirDocumentoRemoto(token: string, tipo: string, archivo:
|
|||||||
})
|
})
|
||||||
manejarErrorLiga(res)
|
manejarErrorLiga(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Resultado de la importación del documento de Enkontrol */
|
||||||
|
export interface ResultadoImportacionEnkontrol {
|
||||||
|
totalFilas: number
|
||||||
|
actualizados: number
|
||||||
|
noEncontrados: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /clientes/importar-enkontrol — sube el documento de Enkontrol (Excel o
|
||||||
|
* CSV). El ID de Enkontrol es el campo determinante: cada fila actualiza la
|
||||||
|
* ficha del cliente con ese usuarioEnkontrol.
|
||||||
|
*/
|
||||||
|
export async function importarInformacionEnkontrol(
|
||||||
|
archivo: File,
|
||||||
|
): Promise<ResultadoImportacionEnkontrol> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('archivo', archivo, archivo.name)
|
||||||
|
const res = await fetch(`${API_URL}/clientes/importar-enkontrol`, conAuth({ method: 'POST', body: form }))
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) {
|
||||||
|
const cuerpo = (await res.json().catch(() => null)) as { error?: string } | null
|
||||||
|
throw new Error(cuerpo?.error ?? `No se pudo importar (HTTP ${res.status})`)
|
||||||
|
}
|
||||||
|
return (await res.json()) as ResultadoImportacionEnkontrol
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<DepartamentoId, CampoDef[]> = {
|
|||||||
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' },
|
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' },
|
||||||
{ campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
|
{ campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
|
||||||
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' },
|
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' },
|
||||||
|
// Fechas de obra (las llena el área de obra; rigen el arranque y la entrega)
|
||||||
|
{ campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' },
|
||||||
|
{ campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' },
|
||||||
|
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
|
||||||
|
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
|
||||||
|
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
|
||||||
|
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
|
||||||
|
{ campo: 'desviacion', tipo: 'number', label: 'Desviación (días)' },
|
||||||
|
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
|
||||||
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
|
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
|
||||||
],
|
],
|
||||||
VENTAS: [
|
VENTAS: [
|
||||||
@@ -55,13 +64,6 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<DepartamentoId, CampoDef[]> = {
|
|||||||
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
||||||
],
|
],
|
||||||
ESCRITURACION: [
|
ESCRITURACION: [
|
||||||
{ campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' },
|
|
||||||
{ campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' },
|
|
||||||
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
|
|
||||||
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
|
|
||||||
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
|
|
||||||
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
|
|
||||||
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
|
|
||||||
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
|
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
|
||||||
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
||||||
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },
|
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },
|
||||||
|
|||||||
Reference in New Issue
Block a user