feat(leads): captura rápida pide email y desarrollo de interés
- Columna 'Desarrollo' (status, 5 labels) creada en el board Leads de Monday (color_mm6sw4c2) - API: crearLead acepta email y desarrollo; el desarrollo se valida contra los labels reales de la columna (mismo patrón que Origen) - Mi día: formulario de captura con campos Email y Desarrollo de interés (select con los 5 desarrollos, opcional) - Tests: api 296, mi-dia 12
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
|||||||
import type { LeadMonday } from '../services/monday.service.js'
|
import type { LeadMonday } from '../services/monday.service.js'
|
||||||
|
|
||||||
/** Monday mockeado: registra escrituras y devuelve catálogos controlados */
|
/** Monday mockeado: registra escrituras y devuelve catálogos controlados */
|
||||||
function mondayMock(opts: { labelsOrigen?: string[]; duplicado?: LeadMonday | null } = {}) {
|
function mondayMock(opts: { labelsOrigen?: string[]; labelsDesarrollo?: string[]; duplicado?: LeadMonday | null } = {}) {
|
||||||
return {
|
return {
|
||||||
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||||
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
|
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
|
||||||
@@ -25,7 +25,10 @@ function mondayMock(opts: { labelsOrigen?: string[]; duplicado?: LeadMonday | nu
|
|||||||
async buscarLeadPorTelefono(_telefono: string) {
|
async buscarLeadPorTelefono(_telefono: string) {
|
||||||
return opts.duplicado ?? null
|
return opts.duplicado ?? null
|
||||||
},
|
},
|
||||||
async labelsDeColumna(_boardId: string, _titulo: string) {
|
async labelsDeColumna(_boardId: string, titulo: string) {
|
||||||
|
if (titulo === 'Desarrollo') {
|
||||||
|
return opts.labelsDesarrollo ?? ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA']
|
||||||
|
}
|
||||||
return opts.labelsOrigen ?? ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro']
|
return opts.labelsOrigen ?? ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro']
|
||||||
},
|
},
|
||||||
async obtenerNombreItem(_itemId: string) {
|
async obtenerNombreItem(_itemId: string) {
|
||||||
@@ -46,10 +49,36 @@ describe('crearLead (POST /api/leads)', () => {
|
|||||||
const r = await crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' })
|
const r = await crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' })
|
||||||
expect(r).toEqual({ id: 'lead-nuevo-1' })
|
expect(r).toEqual({ id: 'lead-nuevo-1' })
|
||||||
expect(monday.leadsCreados).toEqual([
|
expect(monday.leadsCreados).toEqual([
|
||||||
{ nombre: 'Lead Prueba', telefono: '4771234567', email: undefined, origenLabel: 'Facebook', notas: 'Vio el volante' },
|
{
|
||||||
|
nombre: 'Lead Prueba',
|
||||||
|
telefono: '4771234567',
|
||||||
|
email: undefined,
|
||||||
|
origenLabel: 'Facebook',
|
||||||
|
desarrolloLabel: undefined,
|
||||||
|
notas: 'Vio el volante',
|
||||||
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('pasa email y desarrollo al item de Monday cuando vienen en la captura', async () => {
|
||||||
|
const monday = mondayMock()
|
||||||
|
const r = await crearLead(monday, {
|
||||||
|
...datosBase,
|
||||||
|
email: 'lead@correo.com',
|
||||||
|
desarrollo: 'SENDA',
|
||||||
|
})
|
||||||
|
expect(r).toEqual({ id: 'lead-nuevo-1' })
|
||||||
|
expect(monday.leadsCreados[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechaza un desarrollo que no sea label real de la columna Desarrollo', async () => {
|
||||||
|
const monday = mondayMock()
|
||||||
|
await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(
|
||||||
|
/Desarrollo inválido/,
|
||||||
|
)
|
||||||
|
expect(monday.leadsCreados).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('duplicado por teléfono: no crea nada y devuelve el lead existente (endpoint → 409)', async () => {
|
it('duplicado por teléfono: no crea nada y devuelve el lead existente (endpoint → 409)', async () => {
|
||||||
const existente: LeadMonday = {
|
const existente: LeadMonday = {
|
||||||
id: 'lead-viejo-9',
|
id: 'lead-viejo-9',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const crearLeadSchema = z.object({
|
|||||||
telefono: z.string().trim().min(1).optional(),
|
telefono: z.string().trim().min(1).optional(),
|
||||||
email: z.string().trim().email('Email inválido').optional(),
|
email: z.string().trim().email('Email inválido').optional(),
|
||||||
origen: z.string().trim().min(1).optional(),
|
origen: z.string().trim().min(1).optional(),
|
||||||
|
desarrollo: z.string().trim().min(1).optional(),
|
||||||
notas: z.string().trim().min(1).optional(),
|
notas: z.string().trim().min(1).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,11 +94,19 @@ export async function crearLead(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (datos.desarrollo !== undefined) {
|
||||||
|
const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo')
|
||||||
|
if (!labels.includes(datos.desarrollo)) {
|
||||||
|
throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const id = await monday.crearLead({
|
const id = await monday.crearLead({
|
||||||
nombre: datos.nombre,
|
nombre: datos.nombre,
|
||||||
telefono: datos.telefono,
|
telefono: datos.telefono,
|
||||||
email: datos.email,
|
email: datos.email,
|
||||||
origenLabel: datos.origen,
|
origenLabel: datos.origen,
|
||||||
|
desarrolloLabel: datos.desarrollo,
|
||||||
notas: datos.notas,
|
notas: datos.notas,
|
||||||
})
|
})
|
||||||
return { id }
|
return { id }
|
||||||
|
|||||||
@@ -792,6 +792,7 @@ export class MondayService {
|
|||||||
Teléfono: datos.telefono,
|
Teléfono: datos.telefono,
|
||||||
Email: datos.email,
|
Email: datos.email,
|
||||||
Origen: datos.origenLabel,
|
Origen: datos.origenLabel,
|
||||||
|
Desarrollo: datos.desarrolloLabel,
|
||||||
Notas: datos.notas,
|
Notas: datos.notas,
|
||||||
})
|
})
|
||||||
return this.crearItem(boardId, datos.nombre, columnValues)
|
return this.crearItem(boardId, datos.nombre, columnValues)
|
||||||
@@ -985,6 +986,7 @@ export interface DatosLead {
|
|||||||
telefono?: string | undefined
|
telefono?: string | undefined
|
||||||
email?: string | undefined
|
email?: string | undefined
|
||||||
origenLabel?: string | undefined // label real de la columna status 'Origen' del board Leads
|
origenLabel?: string | undefined // label real de la columna status 'Origen' del board Leads
|
||||||
|
desarrolloLabel?: string | undefined // label real de la columna status 'Desarrollo' del board Leads
|
||||||
notas?: string | undefined
|
notas?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
esRolConEquipo,
|
esRolConEquipo,
|
||||||
formatoFechaCorta,
|
formatoFechaCorta,
|
||||||
hoyLocal,
|
hoyLocal,
|
||||||
|
DESARROLLOS_LEAD,
|
||||||
MOTIVOS_NO_COMPRA,
|
MOTIVOS_NO_COMPRA,
|
||||||
ORIGENES_LEAD,
|
ORIGENES_LEAD,
|
||||||
textoDiasRestantes,
|
textoDiasRestantes,
|
||||||
@@ -14,6 +15,10 @@ describe('catálogos', () => {
|
|||||||
expect([...ORIGENES_LEAD]).toEqual(['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'])
|
expect([...ORIGENES_LEAD]).toEqual(['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('desarrollos = labels reales de la columna Desarrollo del board Leads', () => {
|
||||||
|
expect([...DESARROLLOS_LEAD]).toEqual(['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'])
|
||||||
|
})
|
||||||
|
|
||||||
it('motivos de descarte = vocabulario CRM (igual que el API)', () => {
|
it('motivos de descarte = vocabulario CRM (igual que el API)', () => {
|
||||||
expect(MOTIVOS_NO_COMPRA).toContain('Precio')
|
expect(MOTIVOS_NO_COMPRA).toContain('Precio')
|
||||||
expect(MOTIVOS_NO_COMPRA).toContain('Timing')
|
expect(MOTIVOS_NO_COMPRA).toContain('Timing')
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export interface ResumenMiDia {
|
|||||||
// Labels reales de la columna status 'Origen' del board Leads (verificados en Monday)
|
// Labels reales de la columna status 'Origen' del board Leads (verificados en Monday)
|
||||||
export const ORIGENES_LEAD = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'] as const
|
export const ORIGENES_LEAD = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'] as const
|
||||||
|
|
||||||
|
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
|
||||||
|
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
|
||||||
|
|
||||||
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA, igual que el API)
|
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA, igual que el API)
|
||||||
export const MOTIVOS_NO_COMPRA = [
|
export const MOTIVOS_NO_COMPRA = [
|
||||||
'Precio',
|
'Precio',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
esRolConEquipo,
|
esRolConEquipo,
|
||||||
formatoFechaCorta,
|
formatoFechaCorta,
|
||||||
hoyLocal,
|
hoyLocal,
|
||||||
|
DESARROLLOS_LEAD,
|
||||||
MOTIVOS_NO_COMPRA,
|
MOTIVOS_NO_COMPRA,
|
||||||
ORIGENES_LEAD,
|
ORIGENES_LEAD,
|
||||||
textoDiasRestantes,
|
textoDiasRestantes,
|
||||||
@@ -186,7 +187,9 @@ export default function MiDiaPage() {
|
|||||||
// Captura rápida
|
// Captura rápida
|
||||||
const [capNombre, setCapNombre] = useState('')
|
const [capNombre, setCapNombre] = useState('')
|
||||||
const [capTelefono, setCapTelefono] = useState('')
|
const [capTelefono, setCapTelefono] = useState('')
|
||||||
|
const [capEmail, setCapEmail] = useState('')
|
||||||
const [capOrigen, setCapOrigen] = useState<string>(ORIGENES_LEAD[0])
|
const [capOrigen, setCapOrigen] = useState<string>(ORIGENES_LEAD[0])
|
||||||
|
const [capDesarrollo, setCapDesarrollo] = useState<string>('')
|
||||||
const [capNotas, setCapNotas] = useState('')
|
const [capNotas, setCapNotas] = useState('')
|
||||||
const [capEnviando, setCapEnviando] = useState(false)
|
const [capEnviando, setCapEnviando] = useState(false)
|
||||||
const [capMensaje, setCapMensaje] = useState<{ tipo: 'ok' | 'error'; texto: string } | null>(null)
|
const [capMensaje, setCapMensaje] = useState<{ tipo: 'ok' | 'error'; texto: string } | null>(null)
|
||||||
@@ -229,12 +232,16 @@ export default function MiDiaPage() {
|
|||||||
await crearLead({
|
await crearLead({
|
||||||
nombre: capNombre.trim(),
|
nombre: capNombre.trim(),
|
||||||
...(capTelefono.trim() !== '' ? { telefono: capTelefono.trim() } : {}),
|
...(capTelefono.trim() !== '' ? { telefono: capTelefono.trim() } : {}),
|
||||||
|
...(capEmail.trim() !== '' ? { email: capEmail.trim() } : {}),
|
||||||
...(capOrigen !== '' ? { origen: capOrigen } : {}),
|
...(capOrigen !== '' ? { origen: capOrigen } : {}),
|
||||||
|
...(capDesarrollo !== '' ? { desarrollo: capDesarrollo } : {}),
|
||||||
...(capNotas.trim() !== '' ? { notas: capNotas.trim() } : {}),
|
...(capNotas.trim() !== '' ? { notas: capNotas.trim() } : {}),
|
||||||
})
|
})
|
||||||
setCapMensaje({ tipo: 'ok', texto: 'Lead registrado, se asignará por round-robin' })
|
setCapMensaje({ tipo: 'ok', texto: 'Lead registrado, se asignará por round-robin' })
|
||||||
setCapNombre('')
|
setCapNombre('')
|
||||||
setCapTelefono('')
|
setCapTelefono('')
|
||||||
|
setCapEmail('')
|
||||||
|
setCapDesarrollo('')
|
||||||
setCapNotas('')
|
setCapNotas('')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCapMensaje({ tipo: 'error', texto: err instanceof Error ? err.message : 'No se pudo registrar el lead' })
|
setCapMensaje({ tipo: 'error', texto: err instanceof Error ? err.message : 'No se pudo registrar el lead' })
|
||||||
@@ -388,6 +395,18 @@ export default function MiDiaPage() {
|
|||||||
className="w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none"
|
className="w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="cap-email" className="mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cap-email"
|
||||||
|
type="email"
|
||||||
|
value={capEmail}
|
||||||
|
onChange={(e) => setCapEmail(e.target.value)}
|
||||||
|
className="w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="cap-origen" className="mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500">
|
<label htmlFor="cap-origen" className="mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
Origen
|
Origen
|
||||||
@@ -405,6 +424,24 @@ export default function MiDiaPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="cap-desarrollo" className="mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
|
Desarrollo de interés
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="cap-desarrollo"
|
||||||
|
value={capDesarrollo}
|
||||||
|
onChange={(e) => setCapDesarrollo(e.target.value)}
|
||||||
|
className="w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Sin especificar</option>
|
||||||
|
{DESARROLLOS_LEAD.map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{d}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="cap-notas" className="mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500">
|
<label htmlFor="cap-notas" className="mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
Notas
|
Notas
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export interface LeadCreado {
|
|||||||
export async function crearLead(datos: {
|
export async function crearLead(datos: {
|
||||||
nombre: string
|
nombre: string
|
||||||
telefono?: string
|
telefono?: string
|
||||||
|
email?: string
|
||||||
origen?: string
|
origen?: string
|
||||||
|
desarrollo?: string
|
||||||
notas?: string
|
notas?: string
|
||||||
}): Promise<LeadCreado> {
|
}): Promise<LeadCreado> {
|
||||||
return fetchJson<LeadCreado>('/leads', {
|
return fetchJson<LeadCreado>('/leads', {
|
||||||
|
|||||||
Reference in New Issue
Block a user