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

@@ -14,8 +14,8 @@ import {
} from './mi-dia'
describe('catálogos', () => {
it('orígenes = labels reales de la columna Origen del board Leads', () => {
expect([...ORIGENES_LEAD]).toEqual(['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'])
it('orígenes = vocabulario FRASA sembrado en Monday (Leads y Oportunidades)', () => {
expect([...ORIGENES_LEAD]).toEqual(['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'OTRO'])
})
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)
export const ORIGENES_LEAD = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'] as const
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' (Oportunidades),
// 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)
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]
// 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)
export const FASES_OPORTUNIDAD = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'] as const
export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number]

View File

@@ -13,10 +13,14 @@ import {
MOTIVOS_NO_COMPRA,
ORIGENES_LEAD,
SLOTS_DOCS_APARTADO,
SUBCATEGORIAS_NO_COMPRA,
componerTelefono,
LADAS,
textoDiasRestantes,
urlAfluenciaParaOportunidad,
urlCotizadorParaOportunidad,
type ApartadoMiDia,
type ClaveLada,
type FaseApartado,
type LeadMiDia,
type MotivoNoCompra,
@@ -57,7 +61,7 @@ const CLASE_INPUT =
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({
lead,
onCancelar,
@@ -66,11 +70,13 @@ function ModalDescartar({
}: {
lead: { id: string; nombre: string }
onCancelar: () => void
onConfirmar: (motivo: MotivoNoCompra, comentario: string) => void
onConfirmar: (motivo: MotivoNoCompra, subcategoria: string | undefined, comentario: string) => void
enviando: boolean
}) {
const [motivo, setMotivo] = useState<MotivoNoCompra>(MOTIVOS_NO_COMPRA[0])
const [subcategoria, setSubcategoria] = useState('')
const [comentario, setComentario] = useState('')
const subcategorias = SUBCATEGORIAS_NO_COMPRA[motivo]
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="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"
onSubmit={(e) => {
e.preventDefault()
onConfirmar(motivo, comentario.trim())
onConfirmar(motivo, subcategoria === '' ? undefined : subcategoria, comentario.trim())
}}
>
<div>
@@ -90,7 +96,10 @@ function ModalDescartar({
<select
id="motivo-descarte"
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}
>
{MOTIVOS_NO_COMPRA.map((m) => (
@@ -100,6 +109,24 @@ function ModalDescartar({
))}
</select>
</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>
<label htmlFor="comentario-descarte" className={CLASE_LABEL}>
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({
lead,
onAccion,
@@ -464,8 +492,6 @@ function FilaLead({
onDescartar: () => void
enviando: boolean
}) {
const [conCita, setConCita] = useState(false)
const [fechaCita, setFechaCita] = useState('')
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -505,23 +531,9 @@ function FilaLead({
<button type="button" disabled={enviando} onClick={() => onAccion('Intento de contacto')} className={CLASE_BOTON_ACCION}>
Intento
</button>
<button
type="button"
disabled={enviando}
onClick={() => (conCita ? onAccion('AGENDA CITA', fechaCita || undefined) : setConCita(true))}
className={CLASE_BOTON_ACCION}
>
{conCita ? 'Confirmar cita' : 'Agenda cita'}
<button type="button" disabled={enviando} onClick={() => onAccion('INTERESADO')} className={CLASE_BOTON_ACCION}>
Interesado
</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
type="button"
disabled={enviando}
@@ -552,6 +564,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
// Captura rápida
const [capNombre, setCapNombre] = useState('')
const [capLada, setCapLada] = useState<ClaveLada>('MX')
const [capTelefono, setCapTelefono] = useState('')
const [capEmail, setCapEmail] = useState('')
const [capOrigen, setCapOrigen] = useState<string>(ORIGENES_LEAD[0])
@@ -612,9 +625,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
setCapEnviando(true)
setCapMensaje(null)
try {
const telefono = componerTelefono(capLada, capTelefono)
const creado = await crearLead({
nombre: capNombre.trim(),
...(capTelefono.trim() !== '' ? { telefono: capTelefono.trim() } : {}),
...(telefono !== '' ? { telefono } : {}),
...(capEmail.trim() !== '' ? { email: capEmail.trim() } : {}),
...(capOrigen !== '' ? { origen: capOrigen } : {}),
...(capDesarrollo !== '' ? { desarrollo: capDesarrollo } : {}),
@@ -965,13 +979,28 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<label htmlFor="cap-telefono" className={CLASE_LABEL}>
Teléfono
</label>
<input
id="cap-telefono"
type="tel"
value={capTelefono}
onChange={(e) => setCapTelefono(e.target.value)}
className={CLASE_INPUT}
/>
<div className="flex gap-2">
<select
aria-label="Lada"
value={capLada}
onChange={(e) => setCapLada(e.target.value as ClaveLada)}
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>
<label htmlFor="cap-email" className={CLASE_LABEL}>
@@ -1092,11 +1121,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
lead={descartando}
enviando={enviando}
onCancelar={() => setDescartando(null)}
onConfirmar={(motivo, comentario) => {
onConfirmar={(motivo, subcategoria, comentario) => {
const lead = descartando
setDescartando(null)
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',
)
}}

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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
motivo,
...(subcategoria !== undefined && subcategoria !== '' ? { subcategoria } : {}),
...(comentario !== undefined && comentario !== '' ? { comentario } : {}),
...(fuente !== undefined ? { fuente } : {}),
}),