feat: rol manager, arreglos de reportes (cancelacion/afluencias), credito unico, cliente enkontrol, vendedor en bitacora y actualizar afluencia existente

This commit is contained in:
2026-09-18 15:25:34 +00:00
parent 9630893974
commit 4965a4488a
35 changed files with 303 additions and 90 deletions

View File

@@ -76,6 +76,8 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
const [asignacion, setAsignacion] = useState<'capturista' | 'round-robin' | null>(null)
const [aviso, setAviso] = useState('')
const [avisoTipo, setAvisoTipo] = useState<'error' | 'advertencia'>('error')
// Registro existente con ese teléfono: permite actualizarlo en vez de fallar
const [duplicado, setDuplicado] = useState<{ id: string; nombre: string } | null>(null)
const cambiar = (campo: keyof typeof FORM_INICIAL) => (e: { target: { value: string } }) => {
setForm((prev) => ({ ...prev, [campo]: e.target.value }))
@@ -152,16 +154,49 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
const enviar = async (e: FormEvent) => {
e.preventDefault()
setAviso('')
setDuplicado(null)
if (!validar()) return
setEnviando(true)
// Teléfono con lada: "<prefijo> <número>" (si ya trae +, se respeta tal cual)
const res = await crearAfluencia(construirPayload(oportunidadId))
setEnviando(false)
procesarResultado(res, oportunidadId)
}
/** Reenvía la captura como ACTUALIZACIÓN del registro existente (duplicado) */
const actualizarExistente = async () => {
if (!duplicado) return
setAviso('')
setEnviando(true)
const res = await crearAfluencia(construirPayload(duplicado.id))
setEnviando(false)
if (res.ok) {
setDuplicado(null)
procesarResultado(res, duplicado.id)
return
}
if (res.motivo === 'duplicado') {
setDuplicado({ id: res.leadId, nombre: res.nombre })
setAvisoTipo('error')
setAviso(`No se pudo actualizar: el teléfono pertenece a ${res.nombre}.`)
return
}
setAvisoTipo('advertencia')
setAviso('La afluencia se actualizó, pero hubo un problema de conexión. Verifica en Mi día.')
}
/** Teléfono con lada: "<prefijo> <número>" (si ya trae +, se respeta tal cual) */
const telefonoConLada = () => {
const numeroTel = form.telefono.trim()
const telefono =
numeroTel === '' ? '' : numeroTel.startsWith('+') ? numeroTel : `${lada === 'US' ? '+1' : '+52'} ${numeroTel}`
const res = await crearAfluencia({
return numeroTel === '' ? '' : numeroTel.startsWith('+') ? numeroTel : `${lada === 'US' ? '+1' : '+52'} ${numeroTel}`
}
/** Payload del alta/actualización de afluencia (teléfono normalizado con lada) */
const construirPayload = (oportunidadIdElegido?: string) => {
const telefono = telefonoConLada()
return {
nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(),
telefono,
...(oportunidadId !== undefined ? { oportunidadId } : {}),
...(oportunidadIdElegido !== undefined ? { oportunidadId: oportunidadIdElegido } : {}),
...(form.email.trim() ? { email: form.email.trim() } : {}),
...(form.origen ? { origen: form.origen } : {}),
...(form.desarrollo ? { desarrollo: form.desarrollo } : {}),
@@ -177,31 +212,41 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
...(form.califica ? { califica: form.califica } : {}),
...(form.loQuierePara.trim() ? { loQuierePara: form.loQuierePara.trim() } : {}),
...(form.notas.trim() ? { notas: form.notas.trim() } : {}),
})
setEnviando(false)
}
}
/** Resultado del API: éxito, duplicado (ofrece actualizar) o problema de red */
const procesarResultado = (
res: Awaited<ReturnType<typeof crearAfluencia>>,
oportunidadIdElegido?: string,
) => {
if (res.ok) {
setLeadCreado({
id: res.lead.id,
nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(),
telefono,
telefono: telefonoConLada(),
email: form.email.trim(),
estado: res.lead.estado,
origen: form.origen,
fuente: 'oportunidad', // la captura nueva vive en el tablero Oportunidades
})
setAsignacion(res.lead.asignacion ?? 'round-robin')
} else if (res.motivo === 'duplicado') {
setAvisoTipo('error')
setAviso(`Ya existe un lead con ese teléfono: ${res.nombre}.`)
} else if (res.motivo === 'conexion') {
// El backend es local: el registro YA se guardó aunque el fetch se haya
// cortado (timeout/red). Aviso amarillo en vez de error rojo.
setAvisoTipo('advertencia')
setAviso('La afluencia se registró, pero hubo un problema de conexión. Verifica en Mi día.')
} else {
setAvisoTipo('error')
setAviso('No se pudo registrar el lead. Revisa tu conexión e intenta de nuevo.')
return
}
if (res.motivo === 'duplicado') {
setDuplicado({ id: res.leadId, nombre: res.nombre })
setAvisoTipo('error')
setAviso(`Ya existe un registro con ese teléfono: ${res.nombre}.`)
return
}
if (oportunidadIdElegido !== undefined) {
// El backend es local: la actualización YA se guardó aunque el fetch se cortara
setAvisoTipo('advertencia')
setAviso('La afluencia se actualizó, pero hubo un problema de conexión. Verifica en Mi día.')
return
}
setAvisoTipo('advertencia')
setAviso('La afluencia se registró, pero hubo un problema de conexión. Verifica en Mi día.')
}
const capturarOtro = () => {
@@ -598,9 +643,19 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
)}
{aviso && avisoTipo === 'error' && (
<p role="alert" className="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
{aviso}
</p>
<div role="alert" className="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
<p>{aviso}</p>
{duplicado && (
<button
type="button"
onClick={() => void actualizarExistente()}
disabled={enviando}
className="mt-3 rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{enviando ? 'Actualizando…' : 'Actualizar este registro con los datos capturados'}
</button>
)}
</div>
)}
<div className="mt-8 flex gap-3">

View File

@@ -84,7 +84,7 @@ export default function DatosGeneralesCampos({ prefix, compacto = false }: Props
/>
)}
{!compacto && (
<CampoTexto nombre={p('usuarioEnkontrol')} etiqueta="Usuario Enkontrol" />
<CampoTexto nombre={p('usuarioEnkontrol')} etiqueta="Cliente Enkontrol" />
)}
</SectionCard>
)

View File

@@ -30,13 +30,14 @@ export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [proyecto])
const alternarCredito = (valor: TipoCredito) => {
/**
* El tipo de crédito es de selección ÚNICA (Infonavit, Apoyo Infonavit,
* Cofinavit, Segundo crédito, etc.). Se guarda como arreglo de un elemento
* para no cambiar el contrato de datos; volver a tocar el elegido lo quita.
*/
const seleccionarCredito = (valor: TipoCredito) => {
const actuales = metodos.getValues('tiposCredito') ?? []
metodos.setValue(
'tiposCredito',
actuales.includes(valor) ? actuales.filter((t) => t !== valor) : [...actuales, valor],
{ shouldDirty: true },
)
metodos.setValue('tiposCredito', actuales.includes(valor) ? [] : [valor], { shouldDirty: true })
}
return (
@@ -88,10 +89,11 @@ export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente }
>
<input
id={`tipoCredito-${tc.valor}`}
type="checkbox"
type="radio"
name="tipoCreditoUnico"
className="sr-only"
checked={tiposSeleccionados.includes(tc.valor)}
onChange={() => alternarCredito(tc.valor)}
onChange={() => seleccionarCredito(tc.valor)}
/>
{tc.etiqueta}
</label>