fix(auditoria): afluencia (validacion, update sin perder datos ni retroceder etapa, dedup cruzado, notas), alta cliente (registro tras firma, documentos por cliente, aviso de fallos) y reporte de clientes aplanado
This commit is contained in:
@@ -202,14 +202,8 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Actualización de oportunidad existente (botón "Afluencia" de Mi día): no
|
// La nota del alta/actualización de afluencia vive en la bitácora (con clave
|
||||||
// hay captura nueva que registrar
|
// de cliente para que siga al contacto en las fases siguientes).
|
||||||
if (resultado.actualizada === true) {
|
|
||||||
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// La nota del alta de afluencia también vive en la bitácora (con clave de
|
|
||||||
// cliente para que siga al contacto en las fases siguientes).
|
|
||||||
if (body.notas !== undefined && body.notas.trim() !== '') {
|
if (body.notas !== undefined && body.notas.trim() !== '') {
|
||||||
await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', {
|
await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', {
|
||||||
titulo: body.notas,
|
titulo: body.notas,
|
||||||
@@ -218,6 +212,11 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
|
|||||||
fechaRecordar: new Date().toISOString(),
|
fechaRecordar: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Actualización de oportunidad existente (botón "Afluencia" de Mi día)
|
||||||
|
if (resultado.actualizada === true) {
|
||||||
|
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
id: resultado.id,
|
id: resultado.id,
|
||||||
estado: 'Afluencia',
|
estado: 'Afluencia',
|
||||||
|
|||||||
@@ -51,6 +51,42 @@ const campoDesarrollo = (query: Record<string, unknown>): string | undefined =>
|
|||||||
const ETAPAS_CANCELACION = ['Descartado', 'Perdido', 'Cancelacion Apartado', 'Cancelo']
|
const ETAPAS_CANCELACION = ['Descartado', 'Perdido', 'Cancelacion Apartado', 'Cancelo']
|
||||||
const FASES_ESCRITURACION: readonly string[] = VOCABULARIO.FASES_ESCRITURACION
|
const FASES_ESCRITURACION: readonly string[] = VOCABULARIO.FASES_ESCRITURACION
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplana el payload del alta de cliente, que viene anidado por secciones
|
||||||
|
* ({ datosGenerales, domicilio, empleo, economicos, coacreditado, … }) a un
|
||||||
|
* mapa plano de campos (usuarioEnkontrol, actividadOcupacion, compania, …).
|
||||||
|
*/
|
||||||
|
function perfilDeCliente(payloadJson: string | null): Record<string, string> {
|
||||||
|
if (!payloadJson) return {}
|
||||||
|
try {
|
||||||
|
const datos = JSON.parse(payloadJson) as Record<string, unknown>
|
||||||
|
const plano: Record<string, string> = {}
|
||||||
|
// Secciones que NO se aplanan: el co-acreditado repite los mismos campos
|
||||||
|
// (compania, ingresosMensuales, estadoCivil…) y, al venir después, vacíos,
|
||||||
|
// sobrescribía los datos reales del cliente. Documentos/firma/foto tampoco
|
||||||
|
// aportan datos de perfil.
|
||||||
|
const SECCIONES_IGNORADAS = new Set(['coacreditado', 'documentos', 'firma', 'foto', 'referencias'])
|
||||||
|
const recorrer = (obj: Record<string, unknown>, raiz: boolean): void => {
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
if (v == null) continue
|
||||||
|
if (typeof v === 'object' && !Array.isArray(v)) {
|
||||||
|
if (raiz && SECCIONES_IGNORADAS.has(k)) continue
|
||||||
|
recorrer(v as Record<string, unknown>, false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (typeof v === 'object') continue
|
||||||
|
const texto = String(v)
|
||||||
|
if (texto.trim() === '') continue
|
||||||
|
if (plano[k] === undefined) plano[k] = texto // gana el primer valor no vacío
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recorrer(datos, true)
|
||||||
|
return plano
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function perfilDeDatos(datosJson: string | null): Record<string, string> {
|
function perfilDeDatos(datosJson: string | null): Record<string, string> {
|
||||||
if (!datosJson) return {}
|
if (!datosJson) return {}
|
||||||
try {
|
try {
|
||||||
@@ -286,7 +322,9 @@ export async function reporteClientes(_req: Request, res: Response): Promise<voi
|
|||||||
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
|
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
|
||||||
res.json({
|
res.json({
|
||||||
filas: clientes.map((c) => {
|
filas: clientes.map((c) => {
|
||||||
const perfil = perfilDeDatos(c.payloadJson)
|
// El alta guarda el payload ANIDADO por secciones (datosGenerales,
|
||||||
|
// empleo, economicos…) — hay que aplanarlo para leer sus campos.
|
||||||
|
const perfil = perfilDeCliente(c.payloadJson)
|
||||||
return {
|
return {
|
||||||
nombre: c.nombreCompleto,
|
nombre: c.nombreCompleto,
|
||||||
curp: c.curp,
|
curp: c.curp,
|
||||||
@@ -295,13 +333,14 @@ export async function reporteClientes(_req: Request, res: Response): Promise<voi
|
|||||||
celular: c.celular ?? '',
|
celular: c.celular ?? '',
|
||||||
proyecto: c.proyecto,
|
proyecto: c.proyecto,
|
||||||
unidad: c.unidadCodigo ?? '',
|
unidad: c.unidadCodigo ?? '',
|
||||||
ekontrol: perfil['ekontrol'] ?? perfil['usuarioEnkontrol'] ?? '',
|
ekontrol: perfil['usuarioEnkontrol'] ?? perfil['ekontrol'] ?? '',
|
||||||
estadoCivil: perfil['estadoCivil'] ?? '',
|
estadoCivil: perfil['estadoCivil'] ?? '',
|
||||||
ocupacion: perfil['ocupacion'] ?? '',
|
ocupacion: perfil['actividadOcupacion'] ?? perfil['ocupacion'] ?? '',
|
||||||
rangoSalarial: perfil['rangoSalarial'] ?? '',
|
rangoSalarial: perfil['rangoSalarial'] ?? '',
|
||||||
empresa: perfil['compania'] ?? perfil['empresa'] ?? '',
|
empresa: perfil['compania'] ?? perfil['empresa'] ?? '',
|
||||||
antiguedad: perfil['antiguedad'] ?? '',
|
antiguedad: [perfil['antiguedadAnios'], perfil['antiguedadMeses']].filter(Boolean).join(' años / ') ||
|
||||||
ingresoMensual: perfil['ingresoMensual'] ?? perfil['ingresos'] ?? '',
|
perfil['antiguedad'] || '',
|
||||||
|
ingresoMensual: perfil['ingresosMensuales'] ?? perfil['ingresoMensual'] ?? '',
|
||||||
capturadoPor: c.capturadoPorId ? (nombrePorId.get(c.capturadoPorId) ?? '') : '',
|
capturadoPor: c.capturadoPorId ? (nombrePorId.get(c.capturadoPorId) ?? '') : '',
|
||||||
fecha: c.createdAt.toISOString(),
|
fecha: c.createdAt.toISOString(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,11 +115,13 @@ export class AutomatizacionesService {
|
|||||||
if (configService.obtenerConfigSync('round_robin_habilitado') !== 'true') {
|
if (configService.obtenerConfigSync('round_robin_habilitado') !== 'true') {
|
||||||
return { asignados: 0, detalle: [] }
|
return { asignados: 0, detalle: [] }
|
||||||
}
|
}
|
||||||
// Leads locales sin asignar
|
// Leads locales sin asignar (los que ya tienen capturista NO se reasignan:
|
||||||
|
// el round-robin sólo reparte los que entraron sin dueño)
|
||||||
const leadsSinAsignar = await prisma.leadCaptura.findMany({
|
const leadsSinAsignar = await prisma.leadCaptura.findMany({
|
||||||
where: {
|
where: {
|
||||||
board: 'LEADS',
|
board: 'LEADS',
|
||||||
etapa: 'NUEVO',
|
etapa: 'NUEVO',
|
||||||
|
usuarioId: 'desconocido',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const leadIdsAsignados = (
|
const leadIdsAsignados = (
|
||||||
|
|||||||
@@ -76,13 +76,12 @@ export class CrmLocalService {
|
|||||||
* teléfono y email entre los leads locales (mismo contrato 409 de siempre).
|
* teléfono y email entre los leads locales (mismo contrato 409 de siempre).
|
||||||
*/
|
*/
|
||||||
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
|
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
|
||||||
// Dedup por teléfono (normalizando: últimos 10 dígitos)
|
// Dedup por teléfono (normalizado a los últimos 10 dígitos) en CUALQUIER
|
||||||
|
// tablero: evita que la misma persona quede como lead y como afluencia.
|
||||||
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
|
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
|
||||||
const telLimpio = datos.telefono.replace(/\s+/g, '').slice(-10)
|
const telLimpio = soloDigitos(datos.telefono).slice(-10)
|
||||||
if (telLimpio.length >= 7) {
|
if (telLimpio.length >= 7) {
|
||||||
const existente = await prisma.leadCaptura.findFirst({
|
const existente = await buscarPorTelefono(telLimpio)
|
||||||
where: { board: 'LEADS', telefono: { contains: telLimpio } },
|
|
||||||
})
|
|
||||||
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } }
|
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,31 +123,66 @@ export class CrmLocalService {
|
|||||||
|
|
||||||
if (oportunidadId !== undefined) {
|
if (oportunidadId !== undefined) {
|
||||||
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
|
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
|
||||||
if (existente) {
|
// Sin fila local (id heredado del CRM anterior): se crea la sombra con
|
||||||
await prisma.leadCaptura.update({
|
// ese id para que la captura no se pierda en silencio.
|
||||||
where: { leadId: oportunidadId },
|
if (!existente) {
|
||||||
|
await prisma.leadCaptura.create({
|
||||||
data: {
|
data: {
|
||||||
|
leadId: oportunidadId,
|
||||||
|
oportunidadId,
|
||||||
|
usuarioId: usuarioId ?? 'desconocido',
|
||||||
nombreLead: datos.nombre,
|
nombreLead: datos.nombre,
|
||||||
telefono: datos.telefono ?? null,
|
telefono: datos.telefono ?? null,
|
||||||
email: datos.email ?? null,
|
email: datos.email ?? null,
|
||||||
origen: datos.origen ?? null,
|
origen: datos.origen ?? null,
|
||||||
desarrollo: datos.desarrollo ?? null,
|
desarrollo: datos.desarrollo ?? null,
|
||||||
modelo: modelo ?? null,
|
modelo: modelo ?? null,
|
||||||
|
board: 'OPORTUNIDADES',
|
||||||
etapa: 'Afluencia',
|
etapa: 'Afluencia',
|
||||||
datosJson: JSON.stringify(datos),
|
datosJson: JSON.stringify(datos),
|
||||||
pendienteSync: true,
|
pendienteSync: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
|
||||||
return { id: oportunidadId, oportunidadId, actualizada: true }
|
return { id: oportunidadId, oportunidadId, actualizada: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (datos.telefono !== undefined) {
|
// Sólo se pisan los campos capturados: lo que no viene en el payload se
|
||||||
const duplicado = await prisma.leadCaptura.findFirst({
|
// conserva (antes se borraba con null y se perdía la cita agendada).
|
||||||
where: { telefono: datos.telefono, board: 'OPORTUNIDADES' },
|
const cambio: Record<string, unknown> = { pendienteSync: true }
|
||||||
})
|
if (datos.nombre.trim() !== '') cambio['nombreLead'] = datos.nombre
|
||||||
|
if (datos.telefono !== undefined) cambio['telefono'] = datos.telefono
|
||||||
|
if (datos.email !== undefined) cambio['email'] = datos.email
|
||||||
|
if (datos.origen !== undefined) cambio['origen'] = datos.origen
|
||||||
|
if (datos.desarrollo !== undefined) cambio['desarrollo'] = datos.desarrollo
|
||||||
|
if (modelo !== undefined) cambio['modelo'] = modelo
|
||||||
|
|
||||||
|
// La etapa NO retrocede: sólo se marca Afluencia si aún no había avanzado
|
||||||
|
const etapaActual = existente.etapa.trim()
|
||||||
|
const etapasIniciales = ['', 'NUEVO', 'Afluencia']
|
||||||
|
const etapaFinal = etapasIniciales.includes(etapaActual) ? 'Afluencia' : existente.etapa
|
||||||
|
if (etapaFinal !== existente.etapa) cambio['etapa'] = etapaFinal
|
||||||
|
|
||||||
|
// datosJson se MEZCLA: preserva fechaCita y los campos no reenviados
|
||||||
|
let previos: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
previos = existente.datosJson ? (JSON.parse(existente.datosJson) as Record<string, unknown>) : {}
|
||||||
|
} catch {
|
||||||
|
previos = {}
|
||||||
|
}
|
||||||
|
const nuevos = Object.fromEntries(Object.entries(datos).filter(([, v]) => v !== undefined && v !== null))
|
||||||
|
cambio['datosJson'] = JSON.stringify({ ...previos, ...nuevos })
|
||||||
|
|
||||||
|
await prisma.leadCaptura.update({ where: { leadId: oportunidadId }, data: cambio })
|
||||||
|
return { id: oportunidadId, oportunidadId, actualizada: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
|
||||||
|
const telLimpio = soloDigitos(datos.telefono).slice(-10)
|
||||||
|
if (telLimpio.length >= 7) {
|
||||||
|
const duplicado = await buscarPorTelefono(telLimpio)
|
||||||
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
|
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const id = idLocal()
|
const id = idLocal()
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
@@ -411,4 +445,20 @@ export class CrmLocalService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
|
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
|
||||||
export const crmLocal = new CrmLocalService()
|
export const crmLocal = new CrmLocalService()/** Sólo dígitos de un teléfono (quita +52, espacios, guiones, paréntesis) */
|
||||||
|
function soloDigitos(telefono: string): string {
|
||||||
|
return telefono.replace(/\D/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero.
|
||||||
|
* Así el mismo número no se duplica entre Leads, Oportunidades y Apartados.
|
||||||
|
*/
|
||||||
|
async function buscarPorTelefono(diezDigitos: string) {
|
||||||
|
return prisma.leadCaptura.findFirst({
|
||||||
|
where: { telefono: { contains: diezDigitos } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -120,32 +120,32 @@ describe('AltaAfluenciaForm', () => {
|
|||||||
llenarRequeridos()
|
llenarRequeridos()
|
||||||
fireEvent.change(screen.getByLabelText(/Desarrollo de interés/), { target: { value: 'SENDA' } })
|
fireEvent.change(screen.getByLabelText(/Desarrollo de interés/), { target: { value: 'SENDA' } })
|
||||||
fireEvent.change(screen.getByLabelText(/Departamento o casa de interés/), { target: { value: 'HIMALAYA' } })
|
fireEvent.change(screen.getByLabelText(/Departamento o casa de interés/), { target: { value: 'HIMALAYA' } })
|
||||||
fireEvent.change(screen.getByLabelText(/Medio de contacto/), { target: { value: 'Llamada' } })
|
fireEvent.change(screen.getByLabelText(/Número de dependientes/), { target: { value: '3' } })
|
||||||
fireEvent.change(screen.getByLabelText(/^Edad/), { target: { value: '35' } })
|
fireEvent.change(screen.getByLabelText(/^Edad/), { target: { value: '35' } })
|
||||||
fireEvent.change(screen.getByLabelText(/^Estado civil/), { target: { value: 'Casado(a)' } })
|
fireEvent.change(screen.getByLabelText(/^Estado civil/), { target: { value: 'Casado(a)' } })
|
||||||
fireEvent.change(screen.getByLabelText(/Tipo de empleo/), { target: { value: 'Asalariado' } })
|
fireEvent.change(screen.getByLabelText(/Tipo de empleo/), { target: { value: 'Asalariado' } })
|
||||||
fireEvent.change(screen.getByLabelText(/^Ocupación/), { target: { value: 'Ingeniero' } })
|
fireEvent.change(screen.getByLabelText(/^Ocupación/), { target: { value: 'Ingeniero' } })
|
||||||
fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: '$20,000 - $30,000' } })
|
fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: 'F-25001.00-30000.00' } })
|
||||||
fireEvent.change(screen.getByLabelText(/^Crédito/), { target: { value: 'Infonavit' } })
|
fireEvent.change(screen.getByLabelText(/^Crédito/), { target: { value: 'Infonavit' } })
|
||||||
fireEvent.change(screen.getByLabelText(/Tipo de economía/), { target: { value: 'Economía mexicana' } })
|
fireEvent.change(screen.getByLabelText(/Tipo de economía/), { target: { value: 'Economía mexicana' } })
|
||||||
fireEvent.change(screen.getByLabelText(/^Califica/), { target: { value: 'Sí' } })
|
fireEvent.change(screen.getByLabelText(/^Califica/), { target: { value: 'Sí' } })
|
||||||
fireEvent.change(screen.getByLabelText(/Lo quiere para/), { target: { value: 'Vivir ahí' } })
|
fireEvent.change(screen.getByLabelText(/Lo quiere para/), { target: { value: 'Para vivir' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
|
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
|
||||||
|
|
||||||
await screen.findByRole('status')
|
await screen.findByRole('status')
|
||||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||||
expect(JSON.parse(String(init.body))).toMatchObject({
|
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||||
modelo: 'HIMALAYA',
|
modelo: 'HIMALAYA',
|
||||||
medioContacto: 'Llamada',
|
numDependientes: '3',
|
||||||
edad: '35',
|
edad: '35',
|
||||||
estadoCivil: 'Casado(a)',
|
estadoCivil: 'Casado(a)',
|
||||||
tipoEmpleo: 'Asalariado',
|
tipoEmpleo: 'Asalariado',
|
||||||
ocupacion: 'Ingeniero',
|
ocupacion: 'Ingeniero',
|
||||||
rangoSalarial: '$20,000 - $30,000',
|
rangoSalarial: 'F-25001.00-30000.00',
|
||||||
credito: 'Infonavit',
|
credito: 'Infonavit',
|
||||||
tipoEconomia: 'Economía mexicana',
|
tipoEconomia: 'Economía mexicana',
|
||||||
califica: 'Sí',
|
califica: 'Sí',
|
||||||
loQuierePara: 'Vivir ahí',
|
loQuierePara: 'Para vivir',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ describe('AltaAfluenciaForm', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
|
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
|
||||||
|
|
||||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||||
'Ya existe un lead con ese teléfono: ANA DEMO.',
|
'Ya existe un registro con ese teléfono: ANA DEMO.',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CB
|
|||||||
|
|
||||||
interface Errores {
|
interface Errores {
|
||||||
nombre?: string
|
nombre?: string
|
||||||
|
nombrePila?: string
|
||||||
telefono?: string
|
telefono?: string
|
||||||
email?: string
|
email?: string
|
||||||
}
|
}
|
||||||
@@ -65,8 +66,33 @@ const FORM_INICIAL = {
|
|||||||
* lead y su oportunidad en etapa Afluencia. El alta completa de
|
* lead y su oportunidad en etapa Afluencia. El alta completa de
|
||||||
* cliente sigue siendo el wizard del picker.
|
* cliente sigue siendo el wizard del picker.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Reparte un nombre completo en nombre(s) + apellido paterno + materno para
|
||||||
|
* prellenar los tres campos del formulario (el nombre viene de un solo campo
|
||||||
|
* en el lead/oportunidad de origen).
|
||||||
|
*/
|
||||||
|
function repartirNombre(nombre: string): { nombrePila: string; apellidoPaterno: string; apellidoMaterno: string } {
|
||||||
|
const partes = nombre.trim().split(/\s+/).filter(Boolean)
|
||||||
|
if (partes.length === 0) return { nombrePila: '', apellidoPaterno: '', apellidoMaterno: '' }
|
||||||
|
if (partes.length === 1) return { nombrePila: partes[0]!, apellidoPaterno: '', apellidoMaterno: '' }
|
||||||
|
if (partes.length === 2) return { nombrePila: partes[0]!, apellidoPaterno: partes[1]!, apellidoMaterno: '' }
|
||||||
|
return {
|
||||||
|
nombrePila: partes[0]!,
|
||||||
|
apellidoPaterno: partes[1]!,
|
||||||
|
apellidoMaterno: partes.slice(2).join(' '),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) {
|
export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) {
|
||||||
const [form, setForm] = useState({ ...FORM_INICIAL, ...valoresIniciales })
|
const [form, setForm] = useState(() => {
|
||||||
|
const base = { ...FORM_INICIAL, ...valoresIniciales }
|
||||||
|
// Si el origen trae el nombre completo en un solo campo, se reparte en
|
||||||
|
// nombre(s) + apellidos para que los tres inputs se vean llenos.
|
||||||
|
if (base.nombrePila.trim() === '' && base.nombre.trim() !== '') {
|
||||||
|
return { ...base, ...repartirNombre(base.nombre) }
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
})
|
||||||
const [lada, setLada] = useState<'MX' | 'US'>(
|
const [lada, setLada] = useState<'MX' | 'US'>(
|
||||||
(valoresIniciales?.telefono ?? '').trim().startsWith('+1') ? 'US' : 'MX',
|
(valoresIniciales?.telefono ?? '').trim().startsWith('+1') ? 'US' : 'MX',
|
||||||
)
|
)
|
||||||
@@ -142,7 +168,10 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
|
|
||||||
const validar = (): boolean => {
|
const validar = (): boolean => {
|
||||||
const nuevos: Errores = {}
|
const nuevos: Errores = {}
|
||||||
if (!form.nombre.trim()) nuevos.nombre = 'El nombre es obligatorio'
|
// El nombre capturado se compone de nombre(s) + apellidos (el campo `nombre`
|
||||||
|
// sólo se usa cuando viene prellenado de un lead existente)
|
||||||
|
const nombreCompuesto = nombreCompleto()
|
||||||
|
if (nombreCompuesto === '') nuevos.nombrePila = 'El nombre es obligatorio'
|
||||||
if (!form.telefono.trim()) nuevos.telefono = 'El teléfono es obligatorio'
|
if (!form.telefono.trim()) nuevos.telefono = 'El teléfono es obligatorio'
|
||||||
if (form.email.trim() && !emailSchema.safeParse(form.email).success) {
|
if (form.email.trim() && !emailSchema.safeParse(form.email).success) {
|
||||||
nuevos.email = 'Correo electrónico inválido'
|
nuevos.email = 'Correo electrónico inválido'
|
||||||
@@ -184,6 +213,11 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
setAviso('La afluencia se actualizó, pero hubo un problema de conexión. Verifica en Mi día.')
|
setAviso('La afluencia se actualizó, pero hubo un problema de conexión. Verifica en Mi día.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nombre completo capturado (nombre(s) + apellidos), con respaldo en `nombre` */
|
||||||
|
const nombreCompleto = () =>
|
||||||
|
[form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() ||
|
||||||
|
form.nombre.trim()
|
||||||
|
|
||||||
/** Teléfono con lada: "<prefijo> <número>" (si ya trae +, se respeta tal cual) */
|
/** Teléfono con lada: "<prefijo> <número>" (si ya trae +, se respeta tal cual) */
|
||||||
const telefonoConLada = () => {
|
const telefonoConLada = () => {
|
||||||
const numeroTel = form.telefono.trim()
|
const numeroTel = form.telefono.trim()
|
||||||
@@ -223,7 +257,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setLeadCreado({
|
setLeadCreado({
|
||||||
id: res.lead.id,
|
id: res.lead.id,
|
||||||
nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(),
|
nombre: nombreCompleto(),
|
||||||
telefono: telefonoConLada(),
|
telefono: telefonoConLada(),
|
||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
estado: res.lead.estado,
|
estado: res.lead.estado,
|
||||||
@@ -239,6 +273,11 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
setAviso(`Ya existe un registro con ese teléfono: ${res.nombre}.`)
|
setAviso(`Ya existe un registro con ese teléfono: ${res.nombre}.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (res.motivo === 'validacion') {
|
||||||
|
setAvisoTipo('error')
|
||||||
|
setAviso(`El servidor rechazó la captura: ${res.detalle ?? 'revisa los datos.'}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (oportunidadIdElegido !== undefined) {
|
if (oportunidadIdElegido !== undefined) {
|
||||||
// El backend es local: la actualización YA se guardó aunque el fetch se cortara
|
// El backend es local: la actualización YA se guardó aunque el fetch se cortara
|
||||||
setAvisoTipo('advertencia')
|
setAvisoTipo('advertencia')
|
||||||
@@ -340,9 +379,9 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
type="text"
|
type="text"
|
||||||
value={form.nombrePila}
|
value={form.nombrePila}
|
||||||
onChange={cambiar('nombrePila')}
|
onChange={cambiar('nombrePila')}
|
||||||
className={`${inputClase} ${errores.nombre ? 'border-red-400' : ''}`}
|
className={`${inputClase} ${errores.nombrePila ? 'border-red-400' : ''}`}
|
||||||
/>
|
/>
|
||||||
{errores.nombre && <p className="mt-1 text-xs text-red-700">{errores.nombre}</p>}
|
{errores.nombrePila && <p className="mt-1 text-xs text-red-700">{errores.nombrePila}</p>}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label htmlFor="afluencia-apellido-paterno" className="block">
|
<label htmlFor="afluencia-apellido-paterno" className="block">
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ interface Props {
|
|||||||
alta: AltaCliente
|
alta: AltaCliente
|
||||||
onCambiarFirma: (firma: DatosFirma) => void
|
onCambiarFirma: (firma: DatosFirma) => void
|
||||||
onAnterior: () => void
|
onAnterior: () => void
|
||||||
|
/** Registra el alta en el servidor (después de firmar y revisar el expediente) */
|
||||||
|
onRegistrar: () => void
|
||||||
|
/** true mientras el alta se está registrando */
|
||||||
|
registrando?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const GENERADORES: { tipo: string; descargar: (alta: AltaCliente) => void }[] = [
|
const GENERADORES: { tipo: string; descargar: (alta: AltaCliente) => void }[] = [
|
||||||
@@ -28,7 +32,7 @@ const GENERADORES: { tipo: string; descargar: (alta: AltaCliente) => void }[] =
|
|||||||
]
|
]
|
||||||
|
|
||||||
/** Paso 8: firma del cliente, nombre del asesor, resumen y generación de PDFs */
|
/** Paso 8: firma del cliente, nombre del asesor, resumen y generación de PDFs */
|
||||||
export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props) {
|
export default function FirmasForm({ alta, onCambiarFirma, onAnterior, onRegistrar, registrando = false }: Props) {
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [avisoExpediente, setAvisoExpediente] = useState('')
|
const [avisoExpediente, setAvisoExpediente] = useState('')
|
||||||
const { datosGenerales: g, viviendaCredito: v, economicos, firma } = alta
|
const { datosGenerales: g, viviendaCredito: v, economicos, firma } = alta
|
||||||
@@ -158,14 +162,31 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<div className="sticky bottom-0 flex items-center rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
|
<div className="sticky bottom-0 flex flex-wrap items-center gap-3 rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAnterior}
|
onClick={onAnterior}
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
disabled={registrando}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Anterior
|
Anterior
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// La firma es obligatoria para registrar el alta
|
||||||
|
if (!validar()) return
|
||||||
|
avisarExpediente()
|
||||||
|
onRegistrar()
|
||||||
|
}}
|
||||||
|
disabled={registrando}
|
||||||
|
className="rounded-lg bg-accent px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{registrando ? 'Registrando…' : 'Registrar alta'}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-[#64748B]">
|
||||||
|
El expediente se registra y los PDFs se generan después de firmar.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const PASOS = [
|
|||||||
export function useAltaCliente() {
|
export function useAltaCliente() {
|
||||||
const [paso, setPaso] = useState(0)
|
const [paso, setPaso] = useState(0)
|
||||||
const [datos, setDatos] = useState(estadoInicial)
|
const [datos, setDatos] = useState(estadoInicial)
|
||||||
const { documentos, agregarDocumento, quitarDocumento } = useDocumentos()
|
const { documentos, agregarDocumento, quitarDocumento, limpiarDocumentos } = useDocumentos()
|
||||||
|
|
||||||
const alta: AltaCliente = { ...datos, documentos }
|
const alta: AltaCliente = { ...datos, documentos }
|
||||||
|
|
||||||
@@ -146,9 +146,10 @@ export function useAltaCliente() {
|
|||||||
setPaso(pasoDestino)
|
setPaso(pasoDestino)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vuelve al estado inicial (alta nueva) */
|
/** Vuelve al estado inicial (alta nueva), incluido el expediente de documentos */
|
||||||
const reiniciar = () => {
|
const reiniciar = () => {
|
||||||
setDatos(estadoInicial())
|
setDatos(estadoInicial())
|
||||||
|
limpiarDocumentos()
|
||||||
setPaso(0)
|
setPaso(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,5 +13,8 @@ export function useDocumentos() {
|
|||||||
setDocumentos((prev) => prev.filter((d) => d.slot !== slot))
|
setDocumentos((prev) => prev.filter((d) => d.slot !== slot))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { documentos, agregarDocumento, quitarDocumento }
|
/** Limpia el expediente (alta nueva): evita heredar documentos del cliente anterior */
|
||||||
|
const limpiarDocumentos = useCallback(() => setDocumentos([]), [])
|
||||||
|
|
||||||
|
return { documentos, agregarDocumento, quitarDocumento, limpiarDocumentos }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,18 @@ import AltaClientePage from './AltaClientePage'
|
|||||||
|
|
||||||
// El lienzo de firma necesita canvas 2d (no existe en jsdom); el paso 7 se
|
// El lienzo de firma necesita canvas 2d (no existe en jsdom); el paso 7 se
|
||||||
// prueba solo hasta la orquestación de la página, no el trazo de la firma.
|
// prueba solo hasta la orquestación de la página, no el trazo de la firma.
|
||||||
vi.mock('../forms/FirmasForm', () => ({ default: () => <div>Paso de firmas</div> }))
|
// Stub del paso de firmas: expone el botón de registro (el registro del alta
|
||||||
|
// ahora se dispara desde este paso, no al salir de Documentos)
|
||||||
|
vi.mock('../forms/FirmasForm', () => ({
|
||||||
|
default: ({ onRegistrar }: { onRegistrar?: () => void }) => (
|
||||||
|
<div>
|
||||||
|
<p>Paso de firmas</p>
|
||||||
|
<button type="button" onClick={onRegistrar}>
|
||||||
|
Registrar alta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||||
|
|
||||||
@@ -136,8 +147,11 @@ describe('AltaClientePage — guardado progresivo (etapa por etapa)', () => {
|
|||||||
fireEvent.change(await screen.findByLabelText(/^Proyecto/), { target: { value: 'DIAGONAL' } })
|
fireEvent.change(await screen.findByLabelText(/^Proyecto/), { target: { value: 'DIAGONAL' } })
|
||||||
await avanzar() // Vivienda → Documentos
|
await avanzar() // Vivienda → Documentos
|
||||||
|
|
||||||
// Documentos → Firmas: dispara el submit final del alta
|
// Documentos → Firmas: ya NO registra el alta (se registra tras firmar)
|
||||||
await avanzar()
|
await avanzar()
|
||||||
|
expect(llamadasA('/clientes/progreso/prog-1/completar', 'POST')).toHaveLength(0)
|
||||||
|
// El registro se dispara con el botón del paso de firmas
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Registrar alta/ }))
|
||||||
await waitFor(() => expect(llamadasA('/clientes/progreso/prog-1/completar', 'POST')).toHaveLength(1))
|
await waitFor(() => expect(llamadasA('/clientes/progreso/prog-1/completar', 'POST')).toHaveLength(1))
|
||||||
// NO se usó el endpoint legado
|
// NO se usó el endpoint legado
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -115,6 +115,12 @@ export default function AltaClientePage() {
|
|||||||
const [registro, setRegistro] = useState<string>('')
|
const [registro, setRegistro] = useState<string>('')
|
||||||
const [sinConexion, setSinConexion] = useState(false)
|
const [sinConexion, setSinConexion] = useState(false)
|
||||||
const [errorValidacion, setErrorValidacion] = useState<string>('')
|
const [errorValidacion, setErrorValidacion] = useState<string>('')
|
||||||
|
/** Falta un documento obligatorio (p. ej. INE) al avanzar del paso Documentos */
|
||||||
|
const [errorDocumentos, setErrorDocumentos] = useState('')
|
||||||
|
/** Documentos que no se pudieron subir tras registrar el alta */
|
||||||
|
const [avisoDocumentos, setAvisoDocumentos] = useState('')
|
||||||
|
/** El alta se está registrando en el servidor */
|
||||||
|
const [registrandoAlta, setRegistrandoAlta] = useState(false)
|
||||||
|
|
||||||
// ---------- Borradores ----------
|
// ---------- Borradores ----------
|
||||||
type Pantalla = 'cargando' | 'entrada' | 'picker' | 'afluencia' | 'wizard'
|
type Pantalla = 'cargando' | 'entrada' | 'picker' | 'afluencia' | 'wizard'
|
||||||
@@ -327,15 +333,26 @@ export default function AltaClientePage() {
|
|||||||
// responde, el flujo continúa solo local (los PDFs se generan igual). Con
|
// responde, el flujo continúa solo local (los PDFs se generan igual). Con
|
||||||
// guardado progresivo activo se cierra por el endpoint de progreso.
|
// guardado progresivo activo se cierra por el endpoint de progreso.
|
||||||
const registrarAlta = async () => {
|
const registrarAlta = async () => {
|
||||||
|
setRegistrandoAlta(true)
|
||||||
const res = datos.progresoId
|
const res = datos.progresoId
|
||||||
? await completarProgreso(datos.progresoId, alta)
|
? await completarProgreso(datos.progresoId, alta)
|
||||||
: await crearAltaCliente(alta)
|
: await crearAltaCliente(alta)
|
||||||
toast.success("Alta de cliente completada")
|
toast.success("Alta de cliente completada")
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
await subirDocumentos(res.id, alta)
|
const { fallidos } = await subirDocumentos(res.id, alta)
|
||||||
setRegistro(res.id)
|
setRegistro(res.id)
|
||||||
setSinConexion(false)
|
setSinConexion(false)
|
||||||
setErrorValidacion('')
|
setErrorValidacion('')
|
||||||
|
// El alta quedó registrada, pero algunos archivos no subieron: se avisa
|
||||||
|
// con el detalle para que el asesor los vuelva a adjuntar.
|
||||||
|
if (fallidos.length > 0) {
|
||||||
|
setAvisoDocumentos(
|
||||||
|
`El alta se registró, pero no se pudieron subir ${fallidos.length} documento(s): ` +
|
||||||
|
`${fallidos.map((f) => f.nombre).join(', ')}. Vuelve a adjuntarlos desde el expediente del cliente.`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setAvisoDocumentos('')
|
||||||
|
}
|
||||||
// El alta ya quedó registrada: el borrador usado ya no hace falta
|
// El alta ya quedó registrada: el borrador usado ya no hace falta
|
||||||
if (borradorId) {
|
if (borradorId) {
|
||||||
eliminarBorrador(borradorId).catch(() => undefined)
|
eliminarBorrador(borradorId).catch(() => undefined)
|
||||||
@@ -350,6 +367,7 @@ export default function AltaClientePage() {
|
|||||||
setSinConexion(true)
|
setSinConexion(true)
|
||||||
setErrorValidacion('')
|
setErrorValidacion('')
|
||||||
}
|
}
|
||||||
|
setRegistrandoAlta(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pantalla de éxito tras registrar el alta (en lugar del redirect automático a Mi día)
|
// Pantalla de éxito tras registrar el alta (en lugar del redirect automático a Mi día)
|
||||||
@@ -559,6 +577,15 @@ export default function AltaClientePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{avisoDocumentos !== '' && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800"
|
||||||
|
>
|
||||||
|
{avisoDocumentos}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -747,6 +774,14 @@ export default function AltaClientePage() {
|
|||||||
)}
|
)}
|
||||||
{paso === 6 && (
|
{paso === 6 && (
|
||||||
<>
|
<>
|
||||||
|
{errorDocumentos !== '' && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
|
||||||
|
>
|
||||||
|
{errorDocumentos}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{cargoDeBorrador && (
|
{cargoDeBorrador && (
|
||||||
<p
|
<p
|
||||||
role="note"
|
role="note"
|
||||||
@@ -767,7 +802,14 @@ export default function AltaClientePage() {
|
|||||||
}
|
}
|
||||||
onAnterior={anterior}
|
onAnterior={anterior}
|
||||||
onSiguiente={() => {
|
onSiguiente={() => {
|
||||||
void registrarAlta()
|
// La INE es obligatoria en el expediente: se avisa al pasar
|
||||||
|
// de paso (el asesor puede adjuntarla después o por la liga
|
||||||
|
// de firma), y el paso de firmas muestra lo que falte.
|
||||||
|
setErrorDocumentos(
|
||||||
|
alta.documentos.some((d) => d.slot === 'INE')
|
||||||
|
? ''
|
||||||
|
: 'Recuerda adjuntar la INE del cliente: es un documento obligatorio del expediente.',
|
||||||
|
)
|
||||||
siguiente()
|
siguiente()
|
||||||
guardarAvance(datos, paso + 1)
|
guardarAvance(datos, paso + 1)
|
||||||
}}
|
}}
|
||||||
@@ -804,6 +846,8 @@ export default function AltaClientePage() {
|
|||||||
alta={alta}
|
alta={alta}
|
||||||
onCambiarFirma={(f) => actualizar('firma', f)}
|
onCambiarFirma={(f) => actualizar('firma', f)}
|
||||||
onAnterior={anterior}
|
onAnterior={anterior}
|
||||||
|
onRegistrar={() => void registrarAlta()}
|
||||||
|
registrando={registrandoAlta}
|
||||||
/>
|
/>
|
||||||
</TarjetaPaso>
|
</TarjetaPaso>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ describe('services/api', () => {
|
|||||||
expect(resultado).toEqual({ ok: true, id: 'srv-123' })
|
expect(resultado).toEqual({ ok: true, id: 'srv-123' })
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||||
expect(url).toBe('http://192.168.10.123:3000/api/clientes')
|
expect(url).toBe('/api/clientes')
|
||||||
expect(init.method).toBe('POST')
|
expect(init.method).toBe('POST')
|
||||||
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
|
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ describe('services/api', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
const [url1, init1] = fetchMock.mock.calls[0] as [string, RequestInit]
|
const [url1, init1] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||||
expect(url1).toBe('http://192.168.10.123:3000/api/clientes/srv-123/documentos')
|
expect(url1).toBe('/api/clientes/srv-123/documentos')
|
||||||
expect(init1.method).toBe('POST')
|
expect(init1.method).toBe('POST')
|
||||||
// Sin Content-Type manual: el navegador pone el boundary del multipart
|
// Sin Content-Type manual: el navegador pone el boundary del multipart
|
||||||
expect(init1.headers).toBeUndefined()
|
expect(init1.headers).toBeUndefined()
|
||||||
@@ -143,11 +143,13 @@ describe('services/api', () => {
|
|||||||
expect(fetchMock).not.toHaveBeenCalled()
|
expect(fetchMock).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ante un fallo de red avisa y continúa sin lanzar', async () => {
|
it('ante un fallo de red avisa y devuelve los documentos que no subieron', async () => {
|
||||||
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
|
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
|
||||||
|
|
||||||
await expect(subirDocumentos('srv-123', altaConDocumentos())).resolves.toBeUndefined()
|
// No lanza: el alta ya quedó registrada; se reportan los fallos al usuario
|
||||||
expect(warnSpy).toHaveBeenCalledTimes(2)
|
const { fallidos } = await subirDocumentos('srv-123', altaConDocumentos())
|
||||||
|
expect(fallidos.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(warnSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -126,10 +126,17 @@ export async function completarProgreso(id: string, payload: AltaCliente): Promi
|
|||||||
* Sube los documentos reales del expediente (POST multipart
|
* Sube los documentos reales del expediente (POST multipart
|
||||||
* /clientes/:id/documentos, campo 'archivos'). El backend acepta un solo
|
* /clientes/:id/documentos, campo 'archivos'). El backend acepta un solo
|
||||||
* `tipo` por petición, así que se envía una petición por documento con su
|
* `tipo` por petición, así que se envía una petición por documento con su
|
||||||
* slot como tipo. Los fallos se reportan con console.warn sin interrumpir
|
* slot como tipo.
|
||||||
* el flujo local.
|
*
|
||||||
|
* Devuelve los documentos que NO se pudieron subir: el alta ya quedó
|
||||||
|
* registrada, así que el usuario debe enterarse para reintentar (antes se
|
||||||
|
* perdían en silencio).
|
||||||
*/
|
*/
|
||||||
export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise<void> {
|
export async function subirDocumentos(
|
||||||
|
clienteId: string,
|
||||||
|
payload: AltaCliente,
|
||||||
|
): Promise<{ fallidos: { slot: string; nombre: string }[] }> {
|
||||||
|
const fallidos: { slot: string; nombre: string }[] = []
|
||||||
for (const doc of payload.documentos) {
|
for (const doc of payload.documentos) {
|
||||||
try {
|
try {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
@@ -146,8 +153,10 @@ export async function subirDocumentos(clienteId: string, payload: AltaCliente):
|
|||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[api] subirDocumentos: no se pudo subir ${doc.nombre} (${doc.slot})`, err)
|
console.warn(`[api] subirDocumentos: no se pudo subir ${doc.nombre} (${doc.slot})`, err)
|
||||||
|
fallidos.push({ slot: doc.slot, nombre: doc.nombre })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { fallidos }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Borradores del alta (auth) ----------
|
// ---------- Borradores del alta (auth) ----------
|
||||||
@@ -264,6 +273,7 @@ export interface LeadCreado {
|
|||||||
export type ResultadoCrearLead =
|
export type ResultadoCrearLead =
|
||||||
| { ok: true; lead: LeadCreado }
|
| { ok: true; lead: LeadCreado }
|
||||||
| { ok: false; motivo: 'duplicado'; leadId: string; nombre: string }
|
| { ok: false; motivo: 'duplicado'; leadId: string; nombre: string }
|
||||||
|
| { ok: false; motivo: 'validacion'; detalle: string }
|
||||||
| { ok: false; motivo: 'conexion' }
|
| { ok: false; motivo: 'conexion' }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -308,13 +318,25 @@ export async function crearAfluencia(payload: {
|
|||||||
return { ok: false, motivo: 'conexion' }
|
return { ok: false, motivo: 'conexion' }
|
||||||
}
|
}
|
||||||
manejar401(res)
|
manejar401(res)
|
||||||
if (res.status === 201) {
|
// 201 = afluencia nueva; 200 = actualización de una afluencia existente
|
||||||
|
if (res.status === 201 || res.status === 200) {
|
||||||
return { ok: true, lead: (await res.json()) as LeadCreado }
|
return { ok: true, lead: (await res.json()) as LeadCreado }
|
||||||
}
|
}
|
||||||
if (res.status === 409) {
|
if (res.status === 409) {
|
||||||
const cuerpo = (await res.json()) as { leadId: string; nombre: string }
|
const cuerpo = (await res.json()) as { leadId: string; nombre: string }
|
||||||
return { ok: false, motivo: 'duplicado', leadId: cuerpo.leadId, nombre: cuerpo.nombre }
|
return { ok: false, motivo: 'duplicado', leadId: cuerpo.leadId, nombre: cuerpo.nombre }
|
||||||
}
|
}
|
||||||
|
if (res.status === 400) {
|
||||||
|
// Validación del servidor: se muestra el motivo real, no "sin conexión"
|
||||||
|
const cuerpo = (await res.json().catch(() => null)) as
|
||||||
|
| { error?: string; issues?: { message?: string; path?: (string | number)[] }[] }
|
||||||
|
| null
|
||||||
|
const detalle =
|
||||||
|
cuerpo?.issues?.map((i) => `${i.path?.join('.') ?? ''}: ${i.message ?? ''}`).filter(Boolean).join(' · ') ||
|
||||||
|
cuerpo?.error ||
|
||||||
|
'Datos incompletos o inválidos'
|
||||||
|
return { ok: false, motivo: 'validacion', detalle }
|
||||||
|
}
|
||||||
console.warn(`[api] crearAfluencia: HTTP ${res.status}`)
|
console.warn(`[api] crearAfluencia: HTTP ${res.status}`)
|
||||||
return { ok: false, motivo: 'conexion' }
|
return { ok: false, motivo: 'conexion' }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user