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:
@@ -65,6 +65,10 @@ function mondayMock(
|
|||||||
this.oportunidadesCreadas.push(datos)
|
this.oportunidadesCreadas.push(datos)
|
||||||
return 'op-nueva-1'
|
return 'op-nueva-1'
|
||||||
},
|
},
|
||||||
|
oportunidadesActualizadas: [] as { id: string; datos: Record<string, unknown> }[],
|
||||||
|
async actualizarOportunidadAfluencia(id: string, datos: Record<string, unknown>) {
|
||||||
|
this.oportunidadesActualizadas.push({ id, datos })
|
||||||
|
},
|
||||||
async buscarLeadPorTelefono(_telefono: string) {
|
async buscarLeadPorTelefono(_telefono: string) {
|
||||||
return opts.duplicadoLead ?? null
|
return opts.duplicadoLead ?? null
|
||||||
},
|
},
|
||||||
@@ -260,6 +264,33 @@ describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunid
|
|||||||
espia.mockRestore()
|
espia.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('con oportunidadId (botón "Afluencia" de Mi día): ACTUALIZA la oportunidad, sin duplicado ni validación de teléfono', async () => {
|
||||||
|
const monday = mondayMock()
|
||||||
|
const r = await crearAfluencia(monday, {
|
||||||
|
...datosBase,
|
||||||
|
oportunidadId: 'op-existente-3',
|
||||||
|
desarrollo: 'SENDA',
|
||||||
|
modelo: 'SOHO',
|
||||||
|
edad: 34,
|
||||||
|
notas: 'Perfil completado en punto de venta',
|
||||||
|
})
|
||||||
|
expect(r).toEqual({ id: 'op-existente-3', oportunidadId: 'op-existente-3', actualizada: true })
|
||||||
|
expect(monday.oportunidadesCreadas).toEqual([]) // no se duplica
|
||||||
|
expect(monday.oportunidadesActualizadas).toEqual([
|
||||||
|
{
|
||||||
|
id: 'op-existente-3',
|
||||||
|
datos: expect.objectContaining({
|
||||||
|
nombre: 'Lead Prueba',
|
||||||
|
telefono: '4771234567',
|
||||||
|
desarrolloLabel: 'SENDA',
|
||||||
|
modeloLabel: 'SOHO',
|
||||||
|
edad: 34,
|
||||||
|
notas: 'Perfil completado en punto de venta',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('rechaza un desarrollo que no sea label real de la columna Desarrollo (board Oportunidades)', async () => {
|
it('rechaza un desarrollo que no sea label real de la columna Desarrollo (board Oportunidades)', async () => {
|
||||||
const monday = mondayMock()
|
const monday = mondayMock()
|
||||||
await expect(crearAfluencia(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(
|
await expect(crearAfluencia(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ const crearAfluenciaSchema = z.object({
|
|||||||
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(),
|
||||||
notas: z.string().trim().min(1).optional(),
|
notas: z.string().trim().min(1).optional(),
|
||||||
|
// Con oportunidadId (botón "Afluencia" de Mis oportunidades en Mi día): se
|
||||||
|
// ACTUALIZA esa oportunidad con el perfil capturado en vez de crear una nueva
|
||||||
|
oportunidadId: z.string().trim().min(1).optional(),
|
||||||
// Campos de calificación de la afluencia (opcionales; los labels fijos del
|
// Campos de calificación de la afluencia (opcionales; los labels fijos del
|
||||||
// front coinciden con los sembrados en Monday, no se validan contra el board)
|
// front coinciden con los sembrados en Monday, no se validan contra el board)
|
||||||
modelo: z.string().trim().min(1).optional(),
|
modelo: z.string().trim().min(1).optional(),
|
||||||
@@ -165,6 +168,7 @@ type MondayLeads = Pick<
|
|||||||
| 'cambiarValorColumna'
|
| 'cambiarValorColumna'
|
||||||
| 'crearLead'
|
| 'crearLead'
|
||||||
| 'crearOportunidadAfluencia'
|
| 'crearOportunidadAfluencia'
|
||||||
|
| 'actualizarOportunidadAfluencia'
|
||||||
| 'crearMotivoNoCompra'
|
| 'crearMotivoNoCompra'
|
||||||
| 'buscarLeadPorTelefono'
|
| 'buscarLeadPorTelefono'
|
||||||
| 'buscarOportunidadPorTelefono'
|
| 'buscarOportunidadPorTelefono'
|
||||||
@@ -176,7 +180,7 @@ type MondayLeads = Pick<
|
|||||||
>
|
>
|
||||||
|
|
||||||
export type ResultadoCrearLead =
|
export type ResultadoCrearLead =
|
||||||
| { id: string; oportunidadId: string | null }
|
| { id: string; oportunidadId: string | null; actualizada?: boolean }
|
||||||
| { duplicado: { id: string; nombre: string } }
|
| { duplicado: { id: string; nombre: string } }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,6 +241,38 @@ export async function crearAfluencia(
|
|||||||
): Promise<ResultadoCrearLead> {
|
): Promise<ResultadoCrearLead> {
|
||||||
const boardOportunidades = config.monday.boards.oportunidades
|
const boardOportunidades = config.monday.boards.oportunidades
|
||||||
|
|
||||||
|
// Con oportunidadId: el formulario venía prellenado desde Mis oportunidades
|
||||||
|
// (Mi día) → se ACTUALIZA esa oportunidad con el perfil completo y queda en
|
||||||
|
// etapa 'Afluencia'; no se crea duplicado ni se valida teléfono.
|
||||||
|
if (datos.oportunidadId !== undefined) {
|
||||||
|
await monday.actualizarOportunidadAfluencia(datos.oportunidadId, {
|
||||||
|
nombre: datos.nombre,
|
||||||
|
telefono: datos.telefono,
|
||||||
|
email: datos.email,
|
||||||
|
desarrolloLabel: datos.desarrollo,
|
||||||
|
origenLabel: datos.origen,
|
||||||
|
modeloLabel: datos.modelo,
|
||||||
|
medioContacto: datos.medioContacto,
|
||||||
|
edad: datos.edad,
|
||||||
|
estadoCivilLabel: datos.estadoCivil,
|
||||||
|
tipoEmpleoLabel: datos.tipoEmpleo,
|
||||||
|
ocupacion: datos.ocupacion,
|
||||||
|
rangoSalarial: datos.rangoSalarial,
|
||||||
|
creditoLabel: datos.credito,
|
||||||
|
tipoEconomiaLabel: datos.tipoEconomia,
|
||||||
|
calificaLabel: datos.califica,
|
||||||
|
loQuierePara: datos.loQuierePara,
|
||||||
|
notas: datos.notas,
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await moverAGrupoSegunEstado(monday, boardOportunidades, datos.oportunidadId)
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`No se pudo mover al grupo correcto la oportunidad ${datos.oportunidadId}:`, err)
|
||||||
|
}
|
||||||
|
return { id: datos.oportunidadId, oportunidadId: datos.oportunidadId, actualizada: true }
|
||||||
|
}
|
||||||
|
|
||||||
if (datos.telefono !== undefined) {
|
if (datos.telefono !== undefined) {
|
||||||
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
|
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
|
||||||
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
|
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
|
||||||
@@ -429,6 +465,12 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Actualización de oportunidad existente (botón "Afluencia" de Mi día): no
|
||||||
|
// hay captura nueva que registrar ni round-robin que correr
|
||||||
|
if (resultado.actualizada === true) {
|
||||||
|
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
// Registro local del capturista para "Mis capturas" del panel Mi día.
|
// Registro local del capturista para "Mis capturas" del panel Mi día.
|
||||||
// leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un
|
// leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un
|
||||||
// fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday.
|
// fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday.
|
||||||
|
|||||||
@@ -520,6 +520,48 @@ export class MondayService {
|
|||||||
return this.crearItem(boardId, datos.nombre, columnValues)
|
return this.crearItem(boardId, datos.nombre, columnValues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza una oportunidad existente con el perfil completo de la afluencia
|
||||||
|
* (mismo builder que crearOportunidadAfluencia) y la pone en etapa
|
||||||
|
* 'Afluencia'. La usa el botón "Afluencia" de Mis oportunidades en Mi día:
|
||||||
|
* el formulario se manda prellenado y el envío enriquece el item en vez de
|
||||||
|
* duplicarlo. El nombre del item se actualiza por el column id 'name'.
|
||||||
|
*/
|
||||||
|
async actualizarOportunidadAfluencia(oportunidadId: string, datos: DatosOportunidadAfluencia): Promise<void> {
|
||||||
|
const boardId = config.monday.boards.oportunidades
|
||||||
|
const hoy = new Date()
|
||||||
|
const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}`
|
||||||
|
const valores = JSON.parse(
|
||||||
|
await this.construirColumnValues(boardId, {
|
||||||
|
Estado: 'Afluencia',
|
||||||
|
Fecha: fechaHoy,
|
||||||
|
'Cliente teléfono': datos.telefono,
|
||||||
|
'Cliente email': datos.email,
|
||||||
|
Desarrollo: datos.desarrolloLabel,
|
||||||
|
'Modelo de Interés': datos.modeloLabel,
|
||||||
|
'Origen del Lead': datos.origenLabel,
|
||||||
|
'Medio de Contacto': datos.medioContacto,
|
||||||
|
Edad: datos.edad,
|
||||||
|
'Estado Civil': datos.estadoCivilLabel,
|
||||||
|
'Tipo de Empleo': datos.tipoEmpleoLabel,
|
||||||
|
Ocupación: datos.ocupacion,
|
||||||
|
'Rango Salarial': datos.rangoSalarial,
|
||||||
|
Crédito: datos.creditoLabel,
|
||||||
|
'Tipo de Econimia': datos.tipoEconomiaLabel, // SIC: typo en el título real
|
||||||
|
'Lead Califica': datos.calificaLabel,
|
||||||
|
'Proposito de Compra': datos.loQuierePara,
|
||||||
|
COMENTARIOS: datos.notas,
|
||||||
|
}),
|
||||||
|
) as Record<string, unknown>
|
||||||
|
if (datos.nombre !== '') valores['name'] = datos.nombre
|
||||||
|
await this.graphql(
|
||||||
|
`mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) {
|
||||||
|
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id }
|
||||||
|
}`,
|
||||||
|
{ boardId, itemId: oportunidadId, columnValues: JSON.stringify(valores) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Actualiza una oportunidad existente con los datos de una cotización
|
* Actualiza una oportunidad existente con los datos de una cotización
|
||||||
* (mismo builder de valores que crearOportunidad) y la mueve a 'Cotizador'.
|
* (mismo builder de valores que crearOportunidad) y la mueve a 'Cotizador'.
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ interface Props {
|
|||||||
/** Conversión directa: tras registrar la afluencia, abrir el alta completa
|
/** Conversión directa: tras registrar la afluencia, abrir el alta completa
|
||||||
* prellenada con los datos recién capturados (sin rebuscar el lead) */
|
* prellenada con los datos recién capturados (sin rebuscar el lead) */
|
||||||
onPasarAAltaCliente: (lead: LeadResumen) => void
|
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()
|
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
|
* lead en Monday y su oportunidad en etapa Afluencia. El alta completa de
|
||||||
* cliente sigue siendo el wizard del picker.
|
* cliente sigue siendo el wizard del picker.
|
||||||
*/
|
*/
|
||||||
export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Props) {
|
export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) {
|
||||||
const [form, setForm] = useState(FORM_INICIAL)
|
const [form, setForm] = useState({ ...FORM_INICIAL, ...valoresIniciales })
|
||||||
const [errores, setErrores] = useState<Errores>({})
|
const [errores, setErrores] = useState<Errores>({})
|
||||||
const [enviando, setEnviando] = useState(false)
|
const [enviando, setEnviando] = useState(false)
|
||||||
const [leadCreado, setLeadCreado] = useState<LeadResumen | null>(null)
|
const [leadCreado, setLeadCreado] = useState<LeadResumen | null>(null)
|
||||||
@@ -128,6 +132,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
|||||||
const res = await crearAfluencia({
|
const res = await crearAfluencia({
|
||||||
nombre: form.nombre.trim(),
|
nombre: form.nombre.trim(),
|
||||||
telefono: form.telefono.trim(),
|
telefono: form.telefono.trim(),
|
||||||
|
...(oportunidadId !== undefined ? { oportunidadId } : {}),
|
||||||
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
||||||
...(form.origen ? { origen: form.origen } : {}),
|
...(form.origen ? { origen: form.origen } : {}),
|
||||||
...(form.desarrollo ? { desarrollo: form.desarrollo } : {}),
|
...(form.desarrollo ? { desarrollo: form.desarrollo } : {}),
|
||||||
@@ -178,9 +183,11 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
|||||||
role="status"
|
role="status"
|
||||||
className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700"
|
className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700"
|
||||||
>
|
>
|
||||||
{asignacion === 'capturista'
|
{oportunidadId !== undefined
|
||||||
? 'Registrado en afluencia — asignado a ti'
|
? 'Afluencia actualizada — el perfil completo quedó guardado en la oportunidad'
|
||||||
: 'Registrado en afluencia — se asignará por round-robin'}
|
: asignacion === 'capturista'
|
||||||
|
? 'Registrado en afluencia — asignado a ti'
|
||||||
|
: 'Registrado en afluencia — se asignará por round-robin'}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -190,13 +197,15 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
|||||||
>
|
>
|
||||||
Pasar a Alta de Cliente
|
Pasar a Alta de Cliente
|
||||||
</button>
|
</button>
|
||||||
<button
|
{oportunidadId === undefined && (
|
||||||
type="button"
|
<button
|
||||||
onClick={capturarOtro}
|
type="button"
|
||||||
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"
|
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>
|
Capturar otro
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onVolver}
|
onClick={onVolver}
|
||||||
@@ -211,12 +220,22 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => void enviar(e)} noValidate>
|
<form onSubmit={(e) => void enviar(e)} noValidate>
|
||||||
<div className="mb-6 border-b border-[#E5E7EB] pb-6">
|
{oportunidadId !== undefined ? (
|
||||||
<BusquedaLead onSeleccion={prellenarDesdeLead} />
|
<p
|
||||||
<p className="mt-2 text-xs text-[#64748B]">
|
role="note"
|
||||||
Si el interesado ya está en Monday (lead u oportunidad), sus datos se prellenan solos.
|
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>
|
</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">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
<label htmlFor="afluencia-nombre" className="block sm:col-span-2">
|
<label htmlFor="afluencia-nombre" className="block sm:col-span-2">
|
||||||
<span className={labelClase}>
|
<span className={labelClase}>
|
||||||
@@ -478,7 +497,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
|||||||
disabled={enviando}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -38,6 +38,22 @@ function paramCotizacion(): string | null {
|
|||||||
return new URLSearchParams(hash).get('cotizacion')
|
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 =>
|
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' })
|
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 [ligaCopiada, setLigaCopiada] = useState(false)
|
||||||
const [errorLiga, setErrorLiga] = useState('')
|
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;
|
// 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(() => {
|
useEffect(() => {
|
||||||
|
const afl = paramsAfluencia()
|
||||||
|
if (afl) {
|
||||||
|
setAfluenciaPrevia(afl)
|
||||||
|
setPantalla('afluencia')
|
||||||
|
return
|
||||||
|
}
|
||||||
const cotizacionId = paramCotizacion()
|
const cotizacionId = paramCotizacion()
|
||||||
if (cotizacionId) {
|
if (cotizacionId) {
|
||||||
obtenerCotizacion(cotizacionId)
|
obtenerCotizacion(cotizacionId)
|
||||||
@@ -335,7 +365,12 @@ export default function AltaClientePage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
<div className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||||
<AltaAfluenciaForm
|
<AltaAfluenciaForm
|
||||||
onVolver={() => setPantalla('entrada')}
|
valoresIniciales={afluenciaPrevia?.valores}
|
||||||
|
oportunidadId={afluenciaPrevia?.oportunidadId}
|
||||||
|
onVolver={() => {
|
||||||
|
setAfluenciaPrevia(null)
|
||||||
|
setPantalla('entrada')
|
||||||
|
}}
|
||||||
onPasarAAltaCliente={empezarDesdeLead}
|
onPasarAAltaCliente={empezarDesdeLead}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -248,6 +248,8 @@ export interface LeadCreado {
|
|||||||
oportunidadId: string | null
|
oportunidadId: string | null
|
||||||
/** 'capturista' = asignado a quien lo registró; 'round-robin' = entra al reparto */
|
/** 'capturista' = asignado a quien lo registró; 'round-robin' = entra al reparto */
|
||||||
asignacion?: 'capturista' | 'round-robin'
|
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 */
|
/** Resultado del alta de afluencia: distingue duplicado (409) de fallo de red */
|
||||||
@@ -269,6 +271,8 @@ export async function crearAfluencia(payload: {
|
|||||||
origen?: string
|
origen?: string
|
||||||
desarrollo?: string
|
desarrollo?: string
|
||||||
notas?: string
|
notas?: string
|
||||||
|
/** Oportunidad de Monday a actualizar (botón "Afluencia" de Mi día) */
|
||||||
|
oportunidadId?: string
|
||||||
modelo?: string
|
modelo?: string
|
||||||
medioContacto?: string
|
medioContacto?: string
|
||||||
edad?: string
|
edad?: string
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
formatoFechaCorta,
|
formatoFechaCorta,
|
||||||
hoyLocal,
|
hoyLocal,
|
||||||
urlCotizadorParaOportunidad,
|
urlCotizadorParaOportunidad,
|
||||||
|
urlAfluenciaParaOportunidad,
|
||||||
DESARROLLOS_LEAD,
|
DESARROLLOS_LEAD,
|
||||||
MOTIVOS_NO_COMPRA,
|
MOTIVOS_NO_COMPRA,
|
||||||
ORIGENES_LEAD,
|
ORIGENES_LEAD,
|
||||||
@@ -107,3 +108,40 @@ describe('urlCotizadorParaOportunidad', () => {
|
|||||||
expect(params.has('unidad')).toBe(false)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -182,6 +182,22 @@ export function urlCotizadorParaOportunidad(op: OportunidadMiDia): string {
|
|||||||
return `/quote/?${params.toString()}`
|
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']
|
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
||||||
|
|
||||||
/** ¿El rol ve la sección de equipo? (misma regla que el API) */
|
/** ¿El rol ve la sección de equipo? (misma regla que el API) */
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ORIGENES_LEAD,
|
ORIGENES_LEAD,
|
||||||
SLOTS_DOCS_APARTADO,
|
SLOTS_DOCS_APARTADO,
|
||||||
textoDiasRestantes,
|
textoDiasRestantes,
|
||||||
|
urlAfluenciaParaOportunidad,
|
||||||
urlCotizadorParaOportunidad,
|
urlCotizadorParaOportunidad,
|
||||||
type ApartadoMiDia,
|
type ApartadoMiDia,
|
||||||
type FaseApartado,
|
type FaseApartado,
|
||||||
@@ -139,18 +140,22 @@ function FilaOportunidad({
|
|||||||
op,
|
op,
|
||||||
onEtapa,
|
onEtapa,
|
||||||
onCotizar,
|
onCotizar,
|
||||||
|
onAfluencia,
|
||||||
onDescartar,
|
onDescartar,
|
||||||
enviando,
|
enviando,
|
||||||
}: {
|
}: {
|
||||||
op: OportunidadMiDia
|
op: OportunidadMiDia
|
||||||
onEtapa: (etapa: string, fecha?: string) => void
|
onEtapa: (etapa: string, fecha?: string) => void
|
||||||
onCotizar: () => void
|
onCotizar: () => void
|
||||||
|
onAfluencia: () => void
|
||||||
onDescartar: () => void
|
onDescartar: () => void
|
||||||
enviando: boolean
|
enviando: boolean
|
||||||
}) {
|
}) {
|
||||||
const [conCita, setConCita] = useState(false)
|
const [conCita, setConCita] = useState(false)
|
||||||
const [fechaCita, setFechaCita] = useState('')
|
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 (
|
return (
|
||||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -183,6 +188,15 @@ function FilaOportunidad({
|
|||||||
>
|
>
|
||||||
Cotizar →
|
Cotizar →
|
||||||
</button>
|
</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) =>
|
{fases.map((f) =>
|
||||||
f === 'Agendo Cita' ? (
|
f === 'Agendo Cita' ? (
|
||||||
<span key={f} className="contents">
|
<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')
|
void ejecutar(() => cambiarEtapaOportunidad(op.id, etapa, fecha), 'No se pudo actualizar la oportunidad')
|
||||||
}
|
}
|
||||||
onCotizar={() => void cotizarOportunidad(op)}
|
onCotizar={() => void cotizarOportunidad(op)}
|
||||||
|
onAfluencia={() => {
|
||||||
|
window.location.href = urlAfluenciaParaOportunidad(op)
|
||||||
|
}}
|
||||||
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
|
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user