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:
2026-09-01 07:43:42 +00:00
parent 1692dde887
commit ead7a71025
7 changed files with 90 additions and 3 deletions

View File

@@ -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',

View File

@@ -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 }

View File

@@ -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
} }

View File

@@ -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')

View File

@@ -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',

View File

@@ -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

View File

@@ -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', {