feat(mi-dia): botón Afluencia abre el formulario prellenado y actualiza la oportunidad

- Mis oportunidades: 'Afluencia →' navega a /client/?afluencia=<id> con
  nombre, contacto, desarrollo y modelo prellenados
- Alta Afluencia: con oportunidadId el envío ACTUALIZA la oportunidad en
  Monday (perfil completo + etapa Afluencia) en vez de crear duplicado;
  sin oportunidadId el flujo de captura nueva no cambia
- monday.service: actualizarOportunidadAfluencia (mismo builder de la
  creación + name); moverAGrupoSegunEstado tras actualizar
- Tests: 457 API + 16 mi-dia + 116 alta-clientes, verde
This commit is contained in:
2026-09-04 22:53:21 +00:00
parent cbd50e2066
commit 5d7c6f58cd
9 changed files with 266 additions and 22 deletions

View File

@@ -18,6 +18,10 @@ interface Props {
/** Conversión directa: tras registrar la afluencia, abrir el alta completa
* prellenada con los datos recién capturados (sin rebuscar el lead) */
onPasarAAltaCliente: (lead: LeadResumen) => void
/** Prefill desde Mis oportunidades (Mi día): valores iniciales del formulario */
valoresIniciales?: Partial<typeof FORM_INICIAL>
/** Oportunidad de Monday a ACTUALIZAR al enviar (sin duplicar el item) */
oportunidadId?: string
}
const emailSchema = z.string().trim().email()
@@ -58,8 +62,8 @@ const FORM_INICIAL = {
* lead en Monday y su oportunidad en etapa Afluencia. El alta completa de
* cliente sigue siendo el wizard del picker.
*/
export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Props) {
const [form, setForm] = useState(FORM_INICIAL)
export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) {
const [form, setForm] = useState({ ...FORM_INICIAL, ...valoresIniciales })
const [errores, setErrores] = useState<Errores>({})
const [enviando, setEnviando] = useState(false)
const [leadCreado, setLeadCreado] = useState<LeadResumen | null>(null)
@@ -128,6 +132,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
const res = await crearAfluencia({
nombre: form.nombre.trim(),
telefono: form.telefono.trim(),
...(oportunidadId !== undefined ? { oportunidadId } : {}),
...(form.email.trim() ? { email: form.email.trim() } : {}),
...(form.origen ? { origen: form.origen } : {}),
...(form.desarrollo ? { desarrollo: form.desarrollo } : {}),
@@ -178,9 +183,11 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
role="status"
className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700"
>
{asignacion === 'capturista'
? 'Registrado en afluencia — asignado a ti'
: 'Registrado en afluencia — se asignará por round-robin'}
{oportunidadId !== undefined
? 'Afluencia actualizada — el perfil completo quedó guardado en la oportunidad'
: asignacion === 'capturista'
? 'Registrado en afluencia — asignado a ti'
: 'Registrado en afluencia — se asignará por round-robin'}
</p>
<div className="mt-6 flex flex-wrap gap-3">
<button
@@ -190,13 +197,15 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
>
Pasar a Alta de Cliente
</button>
<button
type="button"
onClick={capturarOtro}
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white"
>
Capturar otro
</button>
{oportunidadId === undefined && (
<button
type="button"
onClick={capturarOtro}
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white"
>
Capturar otro
</button>
)}
<button
type="button"
onClick={onVolver}
@@ -211,12 +220,22 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
return (
<form onSubmit={(e) => void enviar(e)} noValidate>
<div className="mb-6 border-b border-[#E5E7EB] pb-6">
<BusquedaLead onSeleccion={prellenarDesdeLead} />
<p className="mt-2 text-xs text-[#64748B]">
Si el interesado ya está en Monday (lead u oportunidad), sus datos se prellenan solos.
{oportunidadId !== undefined ? (
<p
role="note"
className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-xs font-medium text-blue-800"
>
Estás completando la afluencia de {form.nombre || 'esta oportunidad'} — al enviar se actualiza su
oportunidad en Monday (no se duplica).
</p>
</div>
) : (
<div className="mb-6 border-b border-[#E5E7EB] pb-6">
<BusquedaLead onSeleccion={prellenarDesdeLead} />
<p className="mt-2 text-xs text-[#64748B]">
Si el interesado ya está en Monday (lead u oportunidad), sus datos se prellenan solos.
</p>
</div>
)}
<div className="grid gap-6 sm:grid-cols-2">
<label htmlFor="afluencia-nombre" className="block sm:col-span-2">
<span className={labelClase}>
@@ -478,7 +497,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
disabled={enviando}
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{enviando ? 'Registrando…' : 'Registrar en afluencia'}
{enviando ? 'Registrando…' : oportunidadId !== undefined ? 'Actualizar afluencia' : 'Registrar en afluencia'}
</button>
<button
type="button"

View File

@@ -38,6 +38,22 @@ function paramCotizacion(): string | null {
return new URLSearchParams(hash).get('cotizacion')
}
/**
* Lee ?afluencia=<oportunidadId> + prefill (nombre/telefono/email/desarrollo/
* modelo) — botón "Afluencia" de Mis oportunidades en Mi día.
*/
function paramsAfluencia(): { oportunidadId: string; valores: Record<string, string> } | null {
const qs = new URLSearchParams(window.location.search)
const id = qs.get('afluencia')
if (!id) return null
const valores: Record<string, string> = {}
for (const k of ['nombre', 'telefono', 'email', 'desarrollo', 'modelo', 'origen']) {
const v = qs.get(k)
if (v) valores[k] = v
}
return { oportunidadId: id, valores }
}
const formatoFechaHora = (iso: string): string =>
new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
@@ -104,9 +120,23 @@ export default function AltaClientePage() {
const [ligaCopiada, setLigaCopiada] = useState(false)
const [errorLiga, setErrorLiga] = useState('')
// Afluencia prellenada desde Mis oportunidades (Mi día): actualiza la
// oportunidad existente en vez de crear una nueva
const [afluenciaPrevia, setAfluenciaPrevia] = useState<{
oportunidadId: string
valores: Record<string, string>
} | null>(null)
// Al entrar: ?cotizacion=<id> prellena el alta y salta directo al wizard;
// si no, la entrada ofrece Alta Afluencia (captura ligera) y Alta de Cliente
// ?afluencia=<id> abre el formulario de afluencia prellenado; si no, la
// entrada ofrece Alta Afluencia (captura ligera) y Alta de Cliente
useEffect(() => {
const afl = paramsAfluencia()
if (afl) {
setAfluenciaPrevia(afl)
setPantalla('afluencia')
return
}
const cotizacionId = paramCotizacion()
if (cotizacionId) {
obtenerCotizacion(cotizacionId)
@@ -335,7 +365,12 @@ export default function AltaClientePage() {
</p>
<div className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
<AltaAfluenciaForm
onVolver={() => setPantalla('entrada')}
valoresIniciales={afluenciaPrevia?.valores}
oportunidadId={afluenciaPrevia?.oportunidadId}
onVolver={() => {
setAfluenciaPrevia(null)
setPantalla('entrada')
}}
onPasarAAltaCliente={empezarDesdeLead}
/>
</div>

View File

@@ -248,6 +248,8 @@ export interface LeadCreado {
oportunidadId: string | null
/** 'capturista' = asignado a quien lo registró; 'round-robin' = entra al reparto */
asignacion?: 'capturista' | 'round-robin'
/** true cuando se actualizó una oportunidad existente (vino de Mis oportunidades) */
actualizada?: boolean
}
/** Resultado del alta de afluencia: distingue duplicado (409) de fallo de red */
@@ -269,6 +271,8 @@ export async function crearAfluencia(payload: {
origen?: string
desarrollo?: string
notas?: string
/** Oportunidad de Monday a actualizar (botón "Afluencia" de Mi día) */
oportunidadId?: string
modelo?: string
medioContacto?: string
edad?: string

View File

@@ -5,6 +5,7 @@ import {
formatoFechaCorta,
hoyLocal,
urlCotizadorParaOportunidad,
urlAfluenciaParaOportunidad,
DESARROLLOS_LEAD,
MOTIVOS_NO_COMPRA,
ORIGENES_LEAD,
@@ -107,3 +108,40 @@ describe('urlCotizadorParaOportunidad', () => {
expect(params.has('unidad')).toBe(false)
})
})
describe('urlAfluenciaParaOportunidad', () => {
const opBase: OportunidadMiDia = {
id: 'opp-7',
nombre: 'María Pérez',
telefono: '4771234567',
email: 'maria@correo.com',
etapa: 'Agendo Cita',
desarrollo: 'SENDA',
modeloInteres: 'SOHO',
unidadCodigo: '',
updatedAt: '2026-09-04T10:00:00Z',
ultimoCambio: null,
}
it('abre /client con el id de la oportunidad y los datos de prefill', () => {
const url = urlAfluenciaParaOportunidad(opBase)
const params = new URLSearchParams(url.replace('/client/?', ''))
expect(url.startsWith('/client/?')).toBe(true)
expect(params.get('afluencia')).toBe('opp-7')
expect(params.get('nombre')).toBe('María Pérez')
expect(params.get('telefono')).toBe('4771234567')
expect(params.get('email')).toBe('maria@correo.com')
expect(params.get('desarrollo')).toBe('SENDA')
expect(params.get('modelo')).toBe('SOHO')
})
it('omite los campos vacíos', () => {
const url = urlAfluenciaParaOportunidad({ ...opBase, telefono: '', email: '', desarrollo: '', modeloInteres: '' })
const params = new URLSearchParams(url.replace('/client/?', ''))
expect(params.get('afluencia')).toBe('opp-7')
expect(params.has('telefono')).toBe(false)
expect(params.has('email')).toBe(false)
expect(params.has('desarrollo')).toBe(false)
expect(params.has('modelo')).toBe(false)
})
})

View File

@@ -182,6 +182,22 @@ export function urlCotizadorParaOportunidad(op: OportunidadMiDia): string {
return `/quote/?${params.toString()}`
}
/**
* URL del formulario de Alta Afluencia (/client/) prellenado con los datos de
* la oportunidad. Al enviarlo, el API ACTUALIZA esa oportunidad (perfil
* completo + etapa 'Afluencia') en vez de crear un duplicado.
*/
export function urlAfluenciaParaOportunidad(op: OportunidadMiDia): string {
const params = new URLSearchParams()
params.set('afluencia', op.id)
params.set('nombre', op.nombre)
if (op.telefono !== '') params.set('telefono', op.telefono)
if (op.email !== '') params.set('email', op.email)
if (op.desarrollo !== '') params.set('desarrollo', op.desarrollo)
if (op.modeloInteres !== '') params.set('modelo', op.modeloInteres)
return `/client/?${params.toString()}`
}
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
/** ¿El rol ve la sección de equipo? (misma regla que el API) */

View File

@@ -14,6 +14,7 @@ import {
ORIGENES_LEAD,
SLOTS_DOCS_APARTADO,
textoDiasRestantes,
urlAfluenciaParaOportunidad,
urlCotizadorParaOportunidad,
type ApartadoMiDia,
type FaseApartado,
@@ -139,18 +140,22 @@ function FilaOportunidad({
op,
onEtapa,
onCotizar,
onAfluencia,
onDescartar,
enviando,
}: {
op: OportunidadMiDia
onEtapa: (etapa: string, fecha?: string) => void
onCotizar: () => void
onAfluencia: () => void
onDescartar: () => void
enviando: boolean
}) {
const [conCita, setConCita] = useState(false)
const [fechaCita, setFechaCita] = useState('')
const fases = ['Agendo Cita', 'Afluencia', 'Aparto', 'No Asistio', 'Abandono'] as const
// 'Afluencia' no es un cambio de etapa directo: abre el formulario de
// afluencia prellenado para capturar/actualizar el perfil completo
const fases = ['Agendo Cita', 'Aparto', 'No Asistio', 'Abandono'] as const
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -183,6 +188,15 @@ function FilaOportunidad({
>
Cotizar →
</button>
{/* Afluencia: abre el formulario de alta afluencia prellenado */}
<button
type="button"
disabled={enviando}
onClick={onAfluencia}
className="rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:opacity-50"
>
Afluencia →
</button>
{fases.map((f) =>
f === 'Agendo Cita' ? (
<span key={f} className="contents">
@@ -771,6 +785,9 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
void ejecutar(() => cambiarEtapaOportunidad(op.id, etapa, fecha), 'No se pudo actualizar la oportunidad')
}
onCotizar={() => void cotizarOportunidad(op)}
onAfluencia={() => {
window.location.href = urlAfluenciaParaOportunidad(op)
}}
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
/>
))}