feat: Interesado en leads, lada MX/USA, orígenes FRASA y subcategoría de descarte

- Mis leads: botón Interesado (label sembrado en Monday, mueve a Leads
  Progreso); el botón Agenda cita ahora solo vive en Mis oportunidades
- Descarte: además de la categoría, subcategoría del catálogo de Monday
  (workspace de referencia CRM FRASA); se guarda en la columna
  Subcategoria del tablero Motivos No Compra y el item queda en el grupo
  de su categoría
- Teléfonos con selector de lada 🇲🇽 +52 / 🇺🇸 +1 en captura rápida (Mi
  día), Alta Afluencia y Alta de Cliente (tel. casa y celular); Monday
  escribe countryShortName MX/US y el número compacto (rechaza espacios);
  el dedup sigue comparando por los últimos 10 dígitos
- Origen nuevo en captura rápida y afluencia: PASE, PROSPECCION ASESOR,
  ACTIVACION, FACEBOOK FRASA, REFERIDO, OTRO (sembrados en Leads.Origen y
  Oportunidades.Origen del Lead)
- Tests: 461 API + 16 mi-dia + 116 alta-clientes, verde
This commit is contained in:
2026-09-04 23:28:56 +00:00
parent 5d7c6f58cd
commit 27ba9ea8fa
14 changed files with 279 additions and 72 deletions

View File

@@ -308,7 +308,7 @@ describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunid
describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => { describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => {
it('la whitelist solo permite las transiciones del vendedor', () => { it('la whitelist solo permite las transiciones del vendedor', () => {
expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO']) expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO'])
// El descarte tiene su propio endpoint (lleva motivo) // El descarte tiene su propio endpoint (lleva motivo)
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('DESCARTADO') expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('DESCARTADO')
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO') expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO')
@@ -329,6 +329,20 @@ describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => {
]) ])
}) })
it('INTERESADO escribe el label en el tablero Leads (444)', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'INTERESADO', undefined, 'lead')
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Interesado' }])
})
it('INTERESADO no aplica al tablero Oportunidades (no es etapa del funnel)', async () => {
const monday = mondayMock()
await expect(cambiarEstadoLead(monday, 'op-1', 'INTERESADO', undefined, 'oportunidad')).rejects.toThrow(
/Interesado solo aplica a leads/,
)
expect(monday.cambios).toEqual([])
})
it('AGENDA CITA sin fecha no toca la columna Fecha', async () => { it('AGENDA CITA sin fecha no toca la columna Fecha', async () => {
const monday = mondayMock() const monday = mondayMock()
await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA') await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA')
@@ -401,6 +415,20 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' }) expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' })
}) })
it('con subcategoría: va en el nombre y en la columna Subcategoria del catálogo', async () => {
const monday = mondayMock()
await descartarLead(monday, 'lead-1', 'Precio', undefined, 'lead', 'Precio Total')
expect(monday.motivos).toEqual([
{ nombre: 'Precio / Precio Total — Lead Prueba', categoria: 'Precio', subcategoria: 'Precio Total' },
])
})
it('sin subcategoría el nombre del catálogo queda como antes', async () => {
const monday = mondayMock()
await descartarLead(monday, 'lead-1', 'Timing', undefined, 'lead')
expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' })
})
it('propaga el error si Monday falla (el error handler responde 5xx, no truena el server)', async () => { it('propaga el error si Monday falla (el error handler responde 5xx, no truena el server)', async () => {
const monday = mondayMock() const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído')) vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))

View File

@@ -5,7 +5,7 @@ import { mondayService, type LeadMonday, type MondayService, type OportunidadMon
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js' import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js' import { prisma } from '../services/prisma.js'
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js' import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js' import { AppError } from '../utils/errors.js'
/** /**
@@ -23,19 +23,21 @@ import { AppError } from '../utils/errors.js'
*/ */
// Transiciones que el vendedor puede hacer desde la app Mi día // Transiciones que el vendedor puede hacer desde la app Mi día
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'] as const export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO'] as const
// Traducción de la whitelist (valores que mandan los frontends) a los labels // Traducción de la whitelist (valores que mandan los frontends) a los labels
// reales de la columna status 'Estado' de cada board // reales de la columna status 'Estado' de cada board
const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = { const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado', CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto', 'Intento de contacto': 'Intento de contacto',
INTERESADO: 'Interesado',
'AGENDA CITA': 'Agenda Cita', 'AGENDA CITA': 'Agenda Cita',
CALIFICADO: 'Calificado', CALIFICADO: 'Calificado',
} }
const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = { const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado', CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto', 'Intento de contacto': 'Intento de contacto',
INTERESADO: 'Interesado', // no existe en el board Oportunidades: se rechaza abajo
'AGENDA CITA': 'Agendo Cita', 'AGENDA CITA': 'Agendo Cita',
CALIFICADO: 'Calificado', CALIFICADO: 'Calificado',
} }
@@ -93,9 +95,14 @@ const cambiarEstadoSchema = z.object({
const descartarSchema = z.object({ const descartarSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA), motivo: z.enum(MOTIVOS_NO_COMPRA),
// Subcategoría del catálogo de Monday (debe pertenecer a la categoría elegida)
subcategoria: z.string().trim().min(1).optional(),
comentario: z.string().trim().min(1).optional(), comentario: z.string().trim().min(1).optional(),
fuente: fuenteSchema, fuente: fuenteSchema,
}) }).refine(
(b) => b.subcategoria === undefined || SUBCATEGORIAS_NO_COMPRA[b.motivo].includes(b.subcategoria),
{ message: 'La subcategoría no pertenece a la categoría elegida', path: ['subcategoria'] },
)
// ---------- Búsqueda dual (prellenado del alta de clientes) ---------- // ---------- Búsqueda dual (prellenado del alta de clientes) ----------
@@ -333,6 +340,10 @@ export async function cambiarEstadoLead(
fuente: FuenteLead = 'oportunidad', fuente: FuenteLead = 'oportunidad',
): Promise<void> { ): Promise<void> {
const esLead = fuente === 'lead' const esLead = fuente === 'lead'
// 'Interesado' solo existe en el tablero Leads (no es etapa del funnel)
if (estado === 'INTERESADO' && !esLead) {
throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads')
}
const boardId = esLead ? config.monday.boards.leads : config.monday.boards.oportunidades const boardId = esLead ? config.monday.boards.leads : config.monday.boards.oportunidades
const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado] const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado]
await monday.cambiarValorColumna(boardId, itemId, 'Estado', label) await monday.cambiarValorColumna(boardId, itemId, 'Estado', label)
@@ -346,7 +357,8 @@ export async function cambiarEstadoLead(
* - 'oportunidad': Estado 'Perdido' + dropdown Motivo No Compra (label ya * - 'oportunidad': Estado 'Perdido' + dropdown Motivo No Compra (label ya
* sembrado; si no existiera se omite) + comentario en COMENTARIOS. * sembrado; si no existiera se omite) + comentario en COMENTARIOS.
* - 'lead': Estado 'Descartado' + comentario en Notas. * - 'lead': Estado 'Descartado' + comentario en Notas.
* Ambos registran el motivo en el tablero catálogo Motivos No Compra. * Ambos registran el motivo (y la subcategoría si viene) en el tablero
* catálogo Motivos No Compra.
*/ */
export async function descartarLead( export async function descartarLead(
monday: MondayLeads, monday: MondayLeads,
@@ -354,6 +366,7 @@ export async function descartarLead(
motivo: string, motivo: string,
comentario?: string, comentario?: string,
fuente: FuenteLead = 'oportunidad', fuente: FuenteLead = 'oportunidad',
subcategoria?: string,
): Promise<string> { ): Promise<string> {
const nombreItem = await monday.obtenerNombreItem(itemId) const nombreItem = await monday.obtenerNombreItem(itemId)
if (fuente === 'lead') { if (fuente === 'lead') {
@@ -377,8 +390,9 @@ export async function descartarLead(
} }
} }
return monday.crearMotivoNoCompra({ return monday.crearMotivoNoCompra({
nombre: `${motivo} — ${nombreItem}`, nombre: `${motivo}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`,
categoria: motivo, categoria: motivo,
...(subcategoria !== undefined ? { subcategoria } : {}),
...(comentario !== undefined ? { descripcion: comentario } : {}), ...(comentario !== undefined ? { descripcion: comentario } : {}),
}) })
} }
@@ -513,11 +527,11 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
} }
/** POST /api/leads/:itemId/descartar { motivo, comentario?, fuente? } */ /** POST /api/leads/:itemId/descartar { motivo, subcategoria?, comentario?, fuente? } */
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> { export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = descartarSchema.parse(req.body) const body = descartarSchema.parse(req.body)
const itemId = String(req.params['itemId']) const itemId = String(req.params['itemId'])
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente) const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria)
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId }) res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
} }

View File

@@ -15,6 +15,23 @@ export const MOTIVOS_NO_COMPRA = [
'Timing', 'Timing',
] as const ] as const
/**
* Subcategorías por categoría, tomadas del tablero de referencia "Motivos No
* Compra" del workspace CRM FRASA (los items de cada grupo son el catálogo).
* Sembradas como labels de la columna status 'Subcategoria' del tablero
* Motivos No Compra de Frasa-CAS. NOTA: 'Mesualidad' y 'Cambo de Planes'
* llevan las faltas de ortografía originales del tablero del cliente.
*/
export const SUBCATEGORIAS_NO_COMPRA: Record<(typeof MOTIVOS_NO_COMPRA)[number], string[]> = {
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
}
const perderSchema = z.object({ const perderSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA), motivo: z.enum(MOTIVOS_NO_COMPRA),
comentario: z.string().optional(), comentario: z.string().optional(),

View File

@@ -24,6 +24,7 @@ export const GRUPOS_POR_ESTADO: Record<string, { columna: string; destinos: Reco
NUEVO: 'topics', NUEVO: 'topics',
'Intento de contacto': 'group_mm6s1jct', 'Intento de contacto': 'group_mm6s1jct',
CONTACTADO: 'group_mm6s1jct', CONTACTADO: 'group_mm6s1jct',
Interesado: 'group_mm6s1jct',
'AGENDA CITA': 'group_mm6s1jct', 'AGENDA CITA': 'group_mm6s1jct',
CALIFICADO: 'group_mm6s1jct', CALIFICADO: 'group_mm6s1jct',
DESCARTADO: 'group_mm6s96mc', DESCARTADO: 'group_mm6s96mc',

View File

@@ -415,8 +415,14 @@ export class MondayService {
return typeof crudo === 'number' ? crudo : Number(crudo) return typeof crudo === 'number' ? crudo : Number(crudo)
case 'email': case 'email':
return { email: String(crudo), text: String(crudo) } return { email: String(crudo), text: String(crudo) }
case 'phone': case 'phone': {
return { phone: String(crudo), countryShortName: 'MX' } // LADA explícita: '+1...' (o 11 dígitos empezando en 1) = USA; el resto = México.
// Monday rechaza el número con espacios ("+52 664..."): se envía compacto.
const tel = String(crudo).replace(/\s+/g, '')
const digitos = tel.replace(/\D/g, '')
const esUsa = tel.startsWith('+1') || (digitos.length === 11 && digitos.startsWith('1'))
return { phone: tel, countryShortName: esUsa ? 'US' : 'MX' }
}
case 'checkbox': case 'checkbox':
return { checked: crudo === true || crudo === 'true' ? 'true' : 'false' } return { checked: crudo === true || crudo === 'true' ? 'true' : 'false' }
case 'people': case 'people':
@@ -751,6 +757,7 @@ export class MondayService {
if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_MOTIVOS no está configurado') if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_MOTIVOS no está configurado')
const columnValues = await this.construirColumnValues(boardId, { const columnValues = await this.construirColumnValues(boardId, {
Categoria: datos.categoria, Categoria: datos.categoria,
Subcategoria: datos.subcategoria,
Descripcion: datos.descripcion, Descripcion: datos.descripcion,
}) })
const buscada = datos.categoria.trim().toUpperCase() const buscada = datos.categoria.trim().toUpperCase()
@@ -1501,6 +1508,8 @@ export interface DatosVenta {
export interface DatosMotivoNoCompra { export interface DatosMotivoNoCompra {
nombre: string // '<motivo> — <nombre del item de origen>' nombre: string // '<motivo> — <nombre del item de origen>'
categoria: string // una de MOTIVOS_NO_COMPRA categoria: string // una de MOTIVOS_NO_COMPRA
/** Label de la columna status 'Subcategoria' (catálogo del board de referencia) */
subcategoria?: string
descripcion?: string descripcion?: string
} }

View File

@@ -48,7 +48,7 @@ describe('AltaAfluenciaForm', () => {
fetchMock.mockResolvedValueOnce(respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1' })) fetchMock.mockResolvedValueOnce(respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1' }))
render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={() => undefined} />) render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={() => undefined} />)
llenarRequeridos() llenarRequeridos()
fireEvent.change(screen.getByLabelText(/¿Cómo se enteró de nosotros\?/), { target: { value: 'Web' } }) fireEvent.change(screen.getByLabelText(/¿Cómo se enteró de nosotros\?/), { target: { value: 'PASE' } })
fireEvent.change(screen.getByLabelText(/Desarrollo de interés/), { target: { value: 'SENDA' } }) fireEvent.change(screen.getByLabelText(/Desarrollo de interés/), { target: { value: 'SENDA' } })
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ })) fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
@@ -59,8 +59,8 @@ describe('AltaAfluenciaForm', () => {
expect(url).toContain('/afluencias') expect(url).toContain('/afluencias')
expect(JSON.parse(String(init.body))).toMatchObject({ expect(JSON.parse(String(init.body))).toMatchObject({
nombre: 'Ana Demo', nombre: 'Ana Demo',
telefono: '6641234567', telefono: '+52 6641234567',
origen: 'Web', origen: 'PASE',
desarrollo: 'SENDA', desarrollo: 'SENDA',
}) })
}) })
@@ -158,7 +158,7 @@ describe('AltaAfluenciaForm', () => {
await screen.findByRole('status') await screen.findByRole('status')
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const cuerpo = JSON.parse(String(init.body)) as Record<string, unknown> const cuerpo = JSON.parse(String(init.body)) as Record<string, unknown>
expect(cuerpo).toEqual({ nombre: 'Ana Demo', telefono: '6641234567' }) expect(cuerpo).toEqual({ nombre: 'Ana Demo', telefono: '+52 6641234567' })
}) })
it('con 409 muestra el duplicado con el nombre del lead existente', async () => { it('con 409 muestra el duplicado con el nombre del lead existente', async () => {
@@ -218,7 +218,7 @@ describe('AltaAfluenciaForm', () => {
expect(onPasar).toHaveBeenCalledWith({ expect(onPasar).toHaveBeenCalledWith({
id: 'op-1', id: 'op-1',
nombre: 'Ana Demo', nombre: 'Ana Demo',
telefono: '6641234567', telefono: '+52 6641234567',
email: 'ana@correo.com', email: 'ana@correo.com',
estado: 'Afluencia', estado: 'Afluencia',
origen: '', origen: '',

View File

@@ -64,6 +64,9 @@ const FORM_INICIAL = {
*/ */
export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) { export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) {
const [form, setForm] = useState({ ...FORM_INICIAL, ...valoresIniciales }) const [form, setForm] = useState({ ...FORM_INICIAL, ...valoresIniciales })
const [lada, setLada] = useState<'MX' | 'US'>(
(valoresIniciales?.telefono ?? '').trim().startsWith('+1') ? 'US' : 'MX',
)
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)
@@ -89,6 +92,11 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
* lead existe entre las opciones del formulario. * lead existe entre las opciones del formulario.
*/ */
const prellenarDesdeLead = (lead: LeadResumen) => { const prellenarDesdeLead = (lead: LeadResumen) => {
// Teléfono con prefijo internacional: separa lada y número para el selector
const tel = lead.telefono.trim()
const esUsa = tel.startsWith('+1')
const numero = tel.startsWith('+1') ? tel.slice(2).trim() : tel.startsWith('+52') ? tel.slice(3).trim() : tel
setLada(esUsa ? 'US' : 'MX')
setForm((prev) => { setForm((prev) => {
const desarrollo = const desarrollo =
lead.desarrollo !== undefined && (DESARROLLOS_LEAD as readonly string[]).includes(lead.desarrollo) lead.desarrollo !== undefined && (DESARROLLOS_LEAD as readonly string[]).includes(lead.desarrollo)
@@ -102,7 +110,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
return { return {
...prev, ...prev,
nombre: lead.nombre, nombre: lead.nombre,
telefono: lead.telefono, telefono: numero,
email: lead.email, email: lead.email,
origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen, origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen,
desarrollo, desarrollo,
@@ -129,9 +137,13 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
setAviso('') setAviso('')
if (!validar()) return if (!validar()) return
setEnviando(true) setEnviando(true)
// Teléfono con lada: "<prefijo> <número>" (si ya trae +, se respeta tal cual)
const numeroTel = form.telefono.trim()
const telefono =
numeroTel === '' ? '' : numeroTel.startsWith('+') ? numeroTel : `${lada === 'US' ? '+1' : '+52'} ${numeroTel}`
const res = await crearAfluencia({ const res = await crearAfluencia({
nombre: form.nombre.trim(), nombre: form.nombre.trim(),
telefono: form.telefono.trim(), telefono,
...(oportunidadId !== undefined ? { oportunidadId } : {}), ...(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 } : {}),
@@ -154,7 +166,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
setLeadCreado({ setLeadCreado({
id: res.lead.id, id: res.lead.id,
nombre: form.nombre.trim(), nombre: form.nombre.trim(),
telefono: form.telefono.trim(), telefono,
email: form.email.trim(), email: form.email.trim(),
estado: res.lead.estado, estado: res.lead.estado,
origen: form.origen, origen: form.origen,
@@ -170,6 +182,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
const capturarOtro = () => { const capturarOtro = () => {
setForm(FORM_INICIAL) setForm(FORM_INICIAL)
setLada('MX')
setErrores({}) setErrores({})
setAviso('') setAviso('')
setLeadCreado(null) setLeadCreado(null)
@@ -251,19 +264,31 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
{errores.nombre && <p className="mt-1 text-xs text-red-700">{errores.nombre}</p>} {errores.nombre && <p className="mt-1 text-xs text-red-700">{errores.nombre}</p>}
</label> </label>
<label htmlFor="afluencia-telefono" className="block"> <div>
<span className={labelClase}> <label htmlFor="afluencia-telefono" className={labelClase}>
Teléfono<span className="text-red-700"> *</span> Teléfono<span className="text-red-700"> *</span>
</span> </label>
<input <div className="flex gap-2">
id="afluencia-telefono" <select
type="tel" aria-label="Lada del teléfono"
value={form.telefono} value={lada}
onChange={cambiar('telefono')} onChange={(e) => setLada(e.target.value as 'MX' | 'US')}
className={`${inputClase} ${errores.telefono ? 'border-red-400' : ''}`} className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/> >
<option value="MX">🇲🇽 +52</option>
<option value="US">🇺🇸 +1</option>
</select>
<input
id="afluencia-telefono"
type="tel"
placeholder="664 123 4567"
value={form.telefono}
onChange={cambiar('telefono')}
className={`${inputClase} ${errores.telefono ? 'border-red-400' : ''}`}
/>
</div>
{errores.telefono && <p className="mt-1 text-xs text-red-700">{errores.telefono}</p>} {errores.telefono && <p className="mt-1 text-xs text-red-700">{errores.telefono}</p>}
</label> </div>
<label htmlFor="afluencia-email" className="block"> <label htmlFor="afluencia-email" className="block">
<span className={labelClase}>Correo</span> <span className={labelClase}>Correo</span>

View File

@@ -89,6 +89,7 @@ interface CampoSelectProps extends CampoBaseProps {
opciones: { valor: string; etiqueta: string }[] opciones: { valor: string; etiqueta: string }[]
} }
/** CampoSelect (dropdown) conectado a react-hook-form */
export function CampoSelect({ nombre, etiqueta, opciones, requerido, className }: CampoSelectProps) { export function CampoSelect({ nombre, etiqueta, opciones, requerido, className }: CampoSelectProps) {
const { register, formState: { errors } } = useFormContext() const { register, formState: { errors } } = useFormContext()
return ( return (
@@ -106,3 +107,50 @@ export function CampoSelect({ nombre, etiqueta, opciones, requerido, className }
</label> </label>
) )
} }
/**
* Teléfono con selector de lada (México +52 / USA +1). El campo del formulario
* guarda el valor compuesto "<prefijo> <número>" (p. ej. "+52 6641234567");
* el dedup del API compara por los últimos 10 dígitos, así que no afecta.
*/
export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoBaseProps) {
const { watch, setValue, formState: { errors } } = useFormContext()
const valor = ((watch(nombre) as string) ?? '').trim()
const lada = valor.startsWith('+1') ? '+1' : '+52'
const numero = valor.startsWith('+1') ? valor.slice(2).trim() : valor.startsWith('+52') ? valor.slice(3).trim() : valor
const actualizar = (nuevaLada: string, nuevoNumero: string) => {
const limpio = nuevoNumero.trim()
// Vacío se guarda vacío (sin prefijo huérfano); con número, compuesto
setValue(nombre, limpio === '' ? '' : `${nuevaLada} ${limpio}`, { shouldValidate: true })
}
return (
<div className={className ?? ''}>
<label htmlFor={nombre}>
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
</label>
<div className="flex gap-2">
<select
id={`${nombre}-lada`}
aria-label="Lada"
value={lada}
onChange={(e) => actualizar(e.target.value, numero)}
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
>
<option value="+52">🇲🇽 +52</option>
<option value="+1">🇺🇸 +1</option>
</select>
<input
id={nombre}
type="tel"
placeholder="664 123 4567"
value={numero}
onChange={(e) => actualizar(lada, e.target.value)}
className={claseInput(nombre, errors)}
/>
</div>
<MensajeError nombre={nombre} />
</div>
)
}

View File

@@ -93,8 +93,9 @@ export const SLOTS_DOCUMENTOS: { slot: SlotDocumento; etiqueta: string }[] = [
export const TAMANO_MAX_DOCUMENTO = 10 * 1024 * 1024 // 10 MB export const TAMANO_MAX_DOCUMENTO = 10 * 1024 * 1024 // 10 MB
// Catálogos del tablero Leads de Monday (labels exactos, verificados ago-2026) // Catálogos de Monday (labels exactos). Origen: vocabulario FRASA sembrado en
export const ORIGENES_LEAD = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'] as const // los tableros Leads y Oportunidades el 2026-09-04 (reemplaza al set anterior)
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'OTRO'] as const
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
// Label de la columna status 'Califica' del tablero Leads (captura de afluencia). // Label de la columna status 'Califica' del tablero Leads (captura de afluencia).

View File

@@ -1,5 +1,5 @@
import { useFormContext } from 'react-hook-form' import { useFormContext } from 'react-hook-form'
import { CampoTexto, CampoSelect } from '../components/Campos' import { CampoTexto, CampoSelect, CampoTelefono } from '../components/Campos'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { compararConDatos } from '../lib/curp' import { compararConDatos } from '../lib/curp'
import { ESTADOS_CIVILES, REGIMENES_BIENES, TIPOS_VIVIENDA, TIPOS_COMPRA } from '../data/constantes' import { ESTADOS_CIVILES, REGIMENES_BIENES, TIPOS_VIVIENDA, TIPOS_COMPRA } from '../data/constantes'
@@ -52,8 +52,8 @@ export default function DatosGeneralesCampos({ prefix, compacto = false }: Props
<CampoTexto nombre={p('peso')} etiqueta="Peso (kg)" /> <CampoTexto nombre={p('peso')} etiqueta="Peso (kg)" />
<CampoTexto nombre={p('estatura')} etiqueta="Estatura (m)" /> <CampoTexto nombre={p('estatura')} etiqueta="Estatura (m)" />
<CampoTexto nombre={p('email')} etiqueta="Email" tipo="email" requerido={!compacto} /> <CampoTexto nombre={p('email')} etiqueta="Email" tipo="email" requerido={!compacto} />
<CampoTexto nombre={p('telCasa')} etiqueta="Tel. casa" /> <CampoTelefono nombre={p('telCasa')} etiqueta="Tel. casa" />
<CampoTexto nombre={p('celular')} etiqueta="Celular" requerido={!compacto} /> <CampoTelefono nombre={p('celular')} etiqueta="Celular" requerido={!compacto} />
<CampoTexto nombre={p('viviendaAnios')} etiqueta="Años en vivienda" /> <CampoTexto nombre={p('viviendaAnios')} etiqueta="Años en vivienda" />
<CampoSelect nombre={p('tipoVivienda')} etiqueta="Tipo de vivienda" opciones={TIPOS_VIVIENDA} /> <CampoSelect nombre={p('tipoVivienda')} etiqueta="Tipo de vivienda" opciones={TIPOS_VIVIENDA} />
<CampoSelect nombre={p('tipoCompra')} etiqueta="Tipo de compra" opciones={TIPOS_COMPRA} /> <CampoSelect nombre={p('tipoCompra')} etiqueta="Tipo de compra" opciones={TIPOS_COMPRA} />

View File

@@ -14,8 +14,8 @@ import {
} from './mi-dia' } from './mi-dia'
describe('catálogos', () => { describe('catálogos', () => {
it('orígenes = labels reales de la columna Origen del board Leads', () => { it('orígenes = vocabulario FRASA sembrado en Monday (Leads y Oportunidades)', () => {
expect([...ORIGENES_LEAD]).toEqual(['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro']) expect([...ORIGENES_LEAD]).toEqual(['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'OTRO'])
}) })
it('desarrollos = labels reales de la columna Desarrollo del board Leads', () => { it('desarrollos = labels reales de la columna Desarrollo del board Leads', () => {

View File

@@ -100,8 +100,9 @@ export interface ResumenMiDia {
} }
} }
// Labels reales de la columna status 'Origen' del board Leads (verificados en Monday) // Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' (Oportunidades),
export const ORIGENES_LEAD = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'] as const // vocabulario FRASA verificado y sembrado en Monday el 2026-09-04
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'OTRO'] as const
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday) // Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
@@ -119,6 +120,39 @@ export const MOTIVOS_NO_COMPRA = [
export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number] export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number]
// Subcategorías por categoría del tablero Motivos No Compra (catálogo del
// workspace de referencia CRM FRASA, sembrado en Frasa-CAS el 2026-09-04).
// Espejo de SUBCATEGORIAS_NO_COMPRA del API (mantener sincronizados).
export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, string[]> = {
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
}
// LADAs soportadas en la captura de teléfonos (México default, USA opcional)
export const LADAS = [
{ clave: 'MX', etiqueta: '🇲🇽 +52', prefijo: '+52' },
{ clave: 'US', etiqueta: '🇺🇸 +1', prefijo: '+1' },
] as const
export type ClaveLada = (typeof LADAS)[number]['clave']
/**
* Compone el teléfono a guardar: "<prefijo> <numero>" (p. ej. "+52 4771234567").
* Si el número ya trae prefijo internacional, se respeta tal cual.
*/
export function componerTelefono(lada: ClaveLada, numero: string): string {
const limpio = numero.trim()
if (limpio === '') return ''
if (limpio.startsWith('+')) return limpio
const prefijo = LADAS.find((l) => l.clave === lada)?.prefijo ?? '+52'
return `${prefijo} ${limpio}`
}
// Fases del funnel de Oportunidades (labels REALES del board; el API las valida tal cual) // Fases del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
export const FASES_OPORTUNIDAD = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'] as const export const FASES_OPORTUNIDAD = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'] as const
export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number] export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number]

View File

@@ -13,10 +13,14 @@ import {
MOTIVOS_NO_COMPRA, MOTIVOS_NO_COMPRA,
ORIGENES_LEAD, ORIGENES_LEAD,
SLOTS_DOCS_APARTADO, SLOTS_DOCS_APARTADO,
SUBCATEGORIAS_NO_COMPRA,
componerTelefono,
LADAS,
textoDiasRestantes, textoDiasRestantes,
urlAfluenciaParaOportunidad, urlAfluenciaParaOportunidad,
urlCotizadorParaOportunidad, urlCotizadorParaOportunidad,
type ApartadoMiDia, type ApartadoMiDia,
type ClaveLada,
type FaseApartado, type FaseApartado,
type LeadMiDia, type LeadMiDia,
type MotivoNoCompra, type MotivoNoCompra,
@@ -57,7 +61,7 @@ const CLASE_INPUT =
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563]' const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563]'
/** Modal de descarte: motivo (dropdown CRM) + comentario opcional */ /** Modal de descarte: motivo + subcategoría (catálogo Monday) + comentario opcional */
function ModalDescartar({ function ModalDescartar({
lead, lead,
onCancelar, onCancelar,
@@ -66,11 +70,13 @@ function ModalDescartar({
}: { }: {
lead: { id: string; nombre: string } lead: { id: string; nombre: string }
onCancelar: () => void onCancelar: () => void
onConfirmar: (motivo: MotivoNoCompra, comentario: string) => void onConfirmar: (motivo: MotivoNoCompra, subcategoria: string | undefined, comentario: string) => void
enviando: boolean enviando: boolean
}) { }) {
const [motivo, setMotivo] = useState<MotivoNoCompra>(MOTIVOS_NO_COMPRA[0]) const [motivo, setMotivo] = useState<MotivoNoCompra>(MOTIVOS_NO_COMPRA[0])
const [subcategoria, setSubcategoria] = useState('')
const [comentario, setComentario] = useState('') const [comentario, setComentario] = useState('')
const subcategorias = SUBCATEGORIAS_NO_COMPRA[motivo]
return ( return (
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true"> <div className="fixed inset-0 z-10 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl"> <div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl">
@@ -80,7 +86,7 @@ function ModalDescartar({
className="mt-6 space-y-4" className="mt-6 space-y-4"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault() e.preventDefault()
onConfirmar(motivo, comentario.trim()) onConfirmar(motivo, subcategoria === '' ? undefined : subcategoria, comentario.trim())
}} }}
> >
<div> <div>
@@ -90,7 +96,10 @@ function ModalDescartar({
<select <select
id="motivo-descarte" id="motivo-descarte"
value={motivo} value={motivo}
onChange={(e) => setMotivo(e.target.value as MotivoNoCompra)} onChange={(e) => {
setMotivo(e.target.value as MotivoNoCompra)
setSubcategoria('') // las subcategorías dependen de la categoría
}}
className={CLASE_INPUT} className={CLASE_INPUT}
> >
{MOTIVOS_NO_COMPRA.map((m) => ( {MOTIVOS_NO_COMPRA.map((m) => (
@@ -100,6 +109,24 @@ function ModalDescartar({
))} ))}
</select> </select>
</div> </div>
<div>
<label htmlFor="subcategoria-descarte" className={CLASE_LABEL}>
Subcategoría
</label>
<select
id="subcategoria-descarte"
value={subcategoria}
onChange={(e) => setSubcategoria(e.target.value)}
className={CLASE_INPUT}
>
<option value="">— Selecciona —</option>
{subcategorias.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div> <div>
<label htmlFor="comentario-descarte" className={CLASE_LABEL}> <label htmlFor="comentario-descarte" className={CLASE_LABEL}>
Comentario (opcional) Comentario (opcional)
@@ -452,7 +479,8 @@ function TabsCrm({
) )
} }
/** Fila de lead con acciones rápidas (contactado / intento / cita / descartar) */ /** Fila de lead con acciones rápidas (contactado / intento / interesado / descartar).
* Agendar cita ya no está aquí: la cita se agenda desde Mis oportunidades. */
function FilaLead({ function FilaLead({
lead, lead,
onAccion, onAccion,
@@ -464,8 +492,6 @@ function FilaLead({
onDescartar: () => void onDescartar: () => void
enviando: boolean enviando: boolean
}) { }) {
const [conCita, setConCita] = useState(false)
const [fechaCita, setFechaCita] = useState('')
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">
@@ -505,23 +531,9 @@ function FilaLead({
<button type="button" disabled={enviando} onClick={() => onAccion('Intento de contacto')} className={CLASE_BOTON_ACCION}> <button type="button" disabled={enviando} onClick={() => onAccion('Intento de contacto')} className={CLASE_BOTON_ACCION}>
Intento Intento
</button> </button>
<button <button type="button" disabled={enviando} onClick={() => onAccion('INTERESADO')} className={CLASE_BOTON_ACCION}>
type="button" Interesado
disabled={enviando}
onClick={() => (conCita ? onAccion('AGENDA CITA', fechaCita || undefined) : setConCita(true))}
className={CLASE_BOTON_ACCION}
>
{conCita ? 'Confirmar cita' : 'Agenda cita'}
</button> </button>
{conCita && (
<input
type="date"
aria-label="Fecha de la cita"
value={fechaCita}
onChange={(e) => setFechaCita(e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
)}
<button <button
type="button" type="button"
disabled={enviando} disabled={enviando}
@@ -552,6 +564,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
// Captura rápida // Captura rápida
const [capNombre, setCapNombre] = useState('') const [capNombre, setCapNombre] = useState('')
const [capLada, setCapLada] = useState<ClaveLada>('MX')
const [capTelefono, setCapTelefono] = useState('') const [capTelefono, setCapTelefono] = useState('')
const [capEmail, setCapEmail] = useState('') const [capEmail, setCapEmail] = useState('')
const [capOrigen, setCapOrigen] = useState<string>(ORIGENES_LEAD[0]) const [capOrigen, setCapOrigen] = useState<string>(ORIGENES_LEAD[0])
@@ -612,9 +625,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
setCapEnviando(true) setCapEnviando(true)
setCapMensaje(null) setCapMensaje(null)
try { try {
const telefono = componerTelefono(capLada, capTelefono)
const creado = await crearLead({ const creado = await crearLead({
nombre: capNombre.trim(), nombre: capNombre.trim(),
...(capTelefono.trim() !== '' ? { telefono: capTelefono.trim() } : {}), ...(telefono !== '' ? { telefono } : {}),
...(capEmail.trim() !== '' ? { email: capEmail.trim() } : {}), ...(capEmail.trim() !== '' ? { email: capEmail.trim() } : {}),
...(capOrigen !== '' ? { origen: capOrigen } : {}), ...(capOrigen !== '' ? { origen: capOrigen } : {}),
...(capDesarrollo !== '' ? { desarrollo: capDesarrollo } : {}), ...(capDesarrollo !== '' ? { desarrollo: capDesarrollo } : {}),
@@ -965,13 +979,28 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<label htmlFor="cap-telefono" className={CLASE_LABEL}> <label htmlFor="cap-telefono" className={CLASE_LABEL}>
Teléfono Teléfono
</label> </label>
<input <div className="flex gap-2">
id="cap-telefono" <select
type="tel" aria-label="Lada"
value={capTelefono} value={capLada}
onChange={(e) => setCapTelefono(e.target.value)} onChange={(e) => setCapLada(e.target.value as ClaveLada)}
className={CLASE_INPUT} className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/> >
{LADAS.map((l) => (
<option key={l.clave} value={l.clave}>
{l.etiqueta}
</option>
))}
</select>
<input
id="cap-telefono"
type="tel"
placeholder="664 123 4567"
value={capTelefono}
onChange={(e) => setCapTelefono(e.target.value)}
className={CLASE_INPUT}
/>
</div>
</div> </div>
<div> <div>
<label htmlFor="cap-email" className={CLASE_LABEL}> <label htmlFor="cap-email" className={CLASE_LABEL}>
@@ -1092,11 +1121,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
lead={descartando} lead={descartando}
enviando={enviando} enviando={enviando}
onCancelar={() => setDescartando(null)} onCancelar={() => setDescartando(null)}
onConfirmar={(motivo, comentario) => { onConfirmar={(motivo, subcategoria, comentario) => {
const lead = descartando const lead = descartando
setDescartando(null) setDescartando(null)
void ejecutar( void ejecutar(
() => descartarLead(lead.id, motivo, comentario === '' ? undefined : comentario, lead.fuente), () => descartarLead(lead.id, motivo, comentario === '' ? undefined : comentario, lead.fuente, subcategoria),
'No se pudo descartar el lead', 'No se pudo descartar el lead',
) )
}} }}

View File

@@ -53,12 +53,13 @@ export function cambiarEstadoLead(itemId: string, estado: string, fecha?: string
}) })
} }
export function descartarLead(itemId: string, motivo: string, comentario?: string, fuente?: 'lead' | 'oportunidad'): Promise<unknown> { export function descartarLead(itemId: string, motivo: string, comentario?: string, fuente?: 'lead' | 'oportunidad', subcategoria?: string): Promise<unknown> {
return fetchJson(`/leads/${itemId}/descartar`, { return fetchJson(`/leads/${itemId}/descartar`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
motivo, motivo,
...(subcategoria !== undefined && subcategoria !== '' ? { subcategoria } : {}),
...(comentario !== undefined && comentario !== '' ? { comentario } : {}), ...(comentario !== undefined && comentario !== '' ? { comentario } : {}),
...(fuente !== undefined ? { fuente } : {}), ...(fuente !== undefined ? { fuente } : {}),
}), }),