feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday
Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario Mi día: modal español real, cita desde modal, score falso eliminado Cotizador: barra sticky, persistencia local con folio Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones 69 archivos, 7 apps construidas
This commit is contained in:
@@ -53,9 +53,9 @@ describe('DashboardVendedor', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Ventas del mes')).toBeTruthy()
|
||||
})
|
||||
expect(screen.getByText('Actividades de hoy')).toBeTruthy()
|
||||
expect(screen.getByText('Leads nuevos hoy')).toBeTruthy()
|
||||
expect(screen.getByText('Leads totales')).toBeTruthy()
|
||||
expect(screen.getByText('Funnel conversión')).toBeTruthy()
|
||||
expect(screen.getByText('Funnel de conversión')).toBeTruthy()
|
||||
expect(screen.getByText('Leads por día (últimos 30 días)')).toBeTruthy()
|
||||
expect(screen.getByText('Funnel de conversión')).toBeTruthy()
|
||||
expect(screen.getByText('Rendimiento por origen')).toBeTruthy()
|
||||
|
||||
@@ -76,16 +76,14 @@ export default function DashboardVendedor() {
|
||||
const mesAnterior = mesActual === 0 ? 11 : mesActual - 1
|
||||
const anioAnterior = mesActual === 0 ? anioActual - 1 : anioActual
|
||||
|
||||
// leadsPorDia es el único histórico mensual del tablero (últimos 30 días);
|
||||
// se agrupa por mes parseando 'YYYY-MM-DD' sin Date (evita corrimientos UTC)
|
||||
let leadsMesActual = 0
|
||||
let leadsMesAnterior = 0
|
||||
for (const d of tablero.leadsPorDia) {
|
||||
const fecha = new Date(d.fecha)
|
||||
if (fecha.getMonth() === mesActual && fecha.getFullYear() === anioActual) {
|
||||
leadsMesActual += d.cantidad
|
||||
}
|
||||
if (fecha.getMonth() === mesAnterior && fecha.getFullYear() === anioAnterior) {
|
||||
leadsMesAnterior += d.cantidad
|
||||
}
|
||||
const [anio, mes] = d.fecha.split('-').map(Number)
|
||||
if (anio === anioActual && mes === mesActual + 1) leadsMesActual += d.cantidad
|
||||
if (anio === anioAnterior && mes === mesAnterior + 1) leadsMesAnterior += d.cantidad
|
||||
}
|
||||
|
||||
const nombresMes = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
|
||||
@@ -100,14 +98,16 @@ export default function DashboardVendedor() {
|
||||
{
|
||||
metrica: 'Oportunidades',
|
||||
mesActual: tablero.conversion.oportunidades,
|
||||
mesAnterior: Math.round(tablero.conversion.oportunidades * 0.8), // estimado
|
||||
// Sin histórico mensual: no se estima el mes anterior
|
||||
mesAnterior: null,
|
||||
nombreMesActual: nombresMes[mesActual],
|
||||
nombreMesAnterior: nombresMes[mesAnterior],
|
||||
},
|
||||
{
|
||||
metrica: 'Ventas',
|
||||
mesActual: tablero.ventasMes.unidades,
|
||||
mesAnterior: Math.round(tablero.ventasMes.unidades * 0.85), // estimado
|
||||
// Sin histórico mensual: no se estima el mes anterior
|
||||
mesAnterior: null,
|
||||
nombreMesActual: nombresMes[mesActual],
|
||||
nombreMesAnterior: nombresMes[mesAnterior],
|
||||
},
|
||||
@@ -211,6 +211,9 @@ export default function DashboardVendedor() {
|
||||
{/* F1: Comparativa mensual */}
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm lg:col-span-2">
|
||||
<TituloSeccion>Comparativa mensual (mes actual vs anterior)</TituloSeccion>
|
||||
<p className="-mt-2 mb-3 text-xs text-[#94A3B8]">
|
||||
El mes anterior solo se compara en leads (único histórico disponible); oportunidades y ventas muestran el mes en curso.
|
||||
</p>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={comparativaMensual} margin={{ top: 8, right: 4, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke()} vertical={false} />
|
||||
|
||||
@@ -27,14 +27,23 @@ const CLASE_ACCION_RAPIDA =
|
||||
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10'
|
||||
|
||||
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
|
||||
{ id: 'info', titulo: 'Información del cliente potencial', icono: User },
|
||||
{ id: 'cita', titulo: 'Reserve o actualice una cita', icono: Calendar },
|
||||
{ id: 'info', titulo: 'Información', icono: User },
|
||||
{ id: 'cita', titulo: 'Cita', icono: Calendar },
|
||||
{ id: 'tareas', titulo: 'Tareas', icono: CheckSquare },
|
||||
{ id: 'notas', titulo: 'Notas', icono: FileText },
|
||||
{ id: 'miembros', titulo: 'Objetos de los miembros', icono: Users },
|
||||
{ id: 'miembros', titulo: 'Miembros', icono: Users },
|
||||
]
|
||||
|
||||
export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; onCerrar: () => void }) {
|
||||
export default function DetalleLeadModal({
|
||||
item,
|
||||
onCerrar,
|
||||
onGuardarCita,
|
||||
}: {
|
||||
item: LeadItem
|
||||
onCerrar: () => void
|
||||
/** Guarda la cita de una oportunidad (fechaHora 'YYYY-MM-DDTHH:mm'); la página maneja el error */
|
||||
onGuardarCita?: (fechaHora: string) => Promise<void> | void
|
||||
}) {
|
||||
const esOp = esOportunidad(item)
|
||||
const esLd = esLead(item)
|
||||
const vendedor = getSesion()?.usuario.nombre ?? ''
|
||||
@@ -49,6 +58,23 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
|
||||
const [busquedaNota, setBusquedaNota] = useState('')
|
||||
// --- Tareas ---
|
||||
const [completandoTarea, setCompletandoTarea] = useState<string | null>(null)
|
||||
// --- Cita ---
|
||||
const [fechaCita, setFechaCita] = useState('')
|
||||
const [guardandoCita, setGuardandoCita] = useState(false)
|
||||
|
||||
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
|
||||
async function guardarCita(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!onGuardarCita || fechaCita === '') return
|
||||
setGuardandoCita(true)
|
||||
try {
|
||||
await onGuardarCita(fechaCita)
|
||||
} catch {
|
||||
// el error ya se muestra en la página (toast/aviso del caller)
|
||||
} finally {
|
||||
setGuardandoCita(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cargarNotas = useCallback(async () => {
|
||||
setCargandoNotas(true)
|
||||
@@ -102,8 +128,8 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-3 bg-white dark:bg-[#1E293B] z-10">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Editar "{item.nombre}"</h2>
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Añadir y editar oportunidad Detalles, Actividades, notas y Cita.</p>
|
||||
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Bitácora de {item.nombre}</h2>
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Seguimiento del cliente: información, citas, tareas y notas</p>
|
||||
</div>
|
||||
<button type="button" onClick={onCerrar} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#334155]"><X className="h-5 w-5" /></button>
|
||||
</div>
|
||||
@@ -168,14 +194,37 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
|
||||
|
||||
{tab === 'cita' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Reserve o actualice una cita</h3>
|
||||
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Cita</h3>
|
||||
{esOp && item.fechaCita ? (
|
||||
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
|
||||
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Cita programada</p>
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')}</p>
|
||||
</div>
|
||||
) : esOp ? (
|
||||
<form onSubmit={guardarCita} className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="cita-fecha" className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
|
||||
Fecha y hora de la cita
|
||||
</label>
|
||||
<input
|
||||
id="cita-fecha"
|
||||
type="datetime-local"
|
||||
value={fechaCita}
|
||||
onChange={(e) => setFechaCita(e.target.value)}
|
||||
disabled={guardandoCita}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={guardandoCita || fechaCita === ''}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{guardandoCita ? 'Guardando…' : 'Guardar cita'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-sm text-[#94A3B8]">No hay cita programada. Use los botones de fase en la lista para agendar una.</p>
|
||||
<p className="text-sm text-[#94A3B8]">No hay cita programada. Las citas se agendan desde Mis oportunidades.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -267,7 +316,7 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
|
||||
|
||||
{tab === 'miembros' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Objetos de los miembros</h3>
|
||||
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Miembros</h3>
|
||||
<p className="text-sm text-[#94A3B8]">Información de los miembros del equipo asignados a este lead.</p>
|
||||
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
|
||||
<p className="text-xs text-[#94A3B8]">Vendedor: {(item as any).vendedor ?? '—'}</p>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import ScoreBadge from './ScoreBadge'
|
||||
|
||||
/** Columna genérica del kanban */
|
||||
interface ColumnaDef {
|
||||
@@ -77,7 +76,6 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</p>
|
||||
{'score' in item && <ScoreBadge score={Number((item as Record<string, unknown>).score) || 0} />}
|
||||
</div>
|
||||
{item.telefono && <p className="mt-1 text-xs text-[#94A3B8]">{item.telefono}</p>}
|
||||
{item.unidadCodigo && <p className="mt-0.5 text-xs text-[#94A3B8]">{item.unidadCodigo}</p>}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/** Badge circular con score 0-100 para lead scoring. */
|
||||
|
||||
interface ScoreBadgeProps {
|
||||
score: number
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
function colorForScore(score: number): string {
|
||||
if (score > 70) return 'bg-green-500 text-white'
|
||||
if (score > 40) return 'bg-yellow-400 text-yellow-900'
|
||||
return 'bg-red-500 text-white'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el score de un lead de forma local usando:
|
||||
* etapa (20-90) + recencia + teléfono + email + origen.
|
||||
*
|
||||
* Para oportunidades usa la `etapa`; para leads usa `estado`.
|
||||
*/
|
||||
export function calcularScore(lead: {
|
||||
estado?: string
|
||||
etapa?: string
|
||||
telefono?: string
|
||||
email?: string
|
||||
origen?: string
|
||||
updatedAt?: string
|
||||
}): number {
|
||||
const etapa = lead.etapa ?? lead.estado ?? ''
|
||||
const normalizada = etapa.toUpperCase()
|
||||
|
||||
// Base por etapa: 20-90
|
||||
let base = 20
|
||||
if (normalizada.includes('AGENDO') || normalizada.includes('CITA')) base = 60
|
||||
else if (normalizada.includes('INTERES')) base = 50
|
||||
else if (normalizada.includes('CONTACTAD')) base = 40
|
||||
else if (normalizada.includes('COTIZ')) base = 70
|
||||
else if (normalizada.includes('APARTO') || normalizada.includes('APARTAD')) base = 90
|
||||
else if (normalizada.includes('DESCART') || normalizada.includes('NO ASIST')) base = 5
|
||||
|
||||
// Recencia
|
||||
let recencia = 0
|
||||
if (lead.updatedAt) {
|
||||
const diffMs = Date.now() - new Date(lead.updatedAt).getTime()
|
||||
const diffHoras = diffMs / (1000 * 60 * 60)
|
||||
if (diffHoras < 24) recencia = 10
|
||||
else if (diffHoras < 168) recencia = 5
|
||||
else if (diffHoras > 720) recencia = -20 // >30 días
|
||||
}
|
||||
|
||||
// Señales extra
|
||||
const tel = lead.telefono && lead.telefono.trim() !== '' ? 5 : 0
|
||||
const em = lead.email && lead.email.trim() !== '' ? 5 : 0
|
||||
const ori = lead.origen && lead.origen.trim() !== '' ? 5 : 0
|
||||
|
||||
return Math.max(0, Math.min(100, base + recencia + tel + em + ori))
|
||||
}
|
||||
|
||||
export default function ScoreBadge({ score, size = 'sm' }: ScoreBadgeProps) {
|
||||
const dim = size === 'sm' ? 'h-6 w-6 text-[10px]' : 'h-8 w-8 text-xs'
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full font-bold ${dim} ${colorForScore(score)}`}
|
||||
title={`Score: ${score}`}
|
||||
aria-label={`Score: ${score}`}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -112,50 +112,57 @@ export interface ResumenMiDia {
|
||||
}
|
||||
}
|
||||
|
||||
// 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', 'BROCKER', 'OTRO'] as const
|
||||
/**
|
||||
* VOCABULARIO canónico FRASA: única fuente de verdad de fases, estados,
|
||||
* etapas, motivos, subcategorías, orígenes y desarrollos. Las constantes
|
||||
* individuales (FASES_OPORTUNIDAD, MOTIVOS_NO_COMPRA…) se derivan de aquí.
|
||||
* Espejo del backend en api-gateway/src/config/vocabulario.ts (mantener
|
||||
* sincronizados).
|
||||
*/
|
||||
export const VOCABULARIO = {
|
||||
// Estados de la columna status del board Leads (ESTADOS_LEAD_PERMITIDOS del
|
||||
// API + NUEVO/DESCARTADO)
|
||||
FASES_LEAD: ['NUEVO', 'CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'],
|
||||
// Etapas del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
|
||||
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
|
||||
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
|
||||
FASES_APARTADO: ['Pago Apartado', 'Alta Enkontrol', 'Entrego Documentacion', 'Cita Clientes', 'Contrato Enkontrol', 'Firma Cliente', 'Cancelacion Apartado'],
|
||||
// Fases de escrituración (pipeline posterior a 'Firma Cliente')
|
||||
FASES_ESCRITURACION: ['Venta', 'Seguimiento', 'Integracion', 'X Autorizar', 'Autorizado', 'X Escriturar', 'Escriturado', 'Titulado', 'Cancelo'],
|
||||
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA)
|
||||
MOTIVOS_NO_COMPRA: ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'],
|
||||
// Subcategorías por categoría (catálogo del tablero Motivos No Compra,
|
||||
// sembrado en Frasa-CAS el 2026-09-04; 'Mesualidad' y 'Cambo de Planes'
|
||||
// conservan las faltas de ortografía originales del tablero del cliente)
|
||||
SUBCATEGORIAS: {
|
||||
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'],
|
||||
},
|
||||
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead'
|
||||
// (Oportunidades), verificados y sembrados en Monday el 2026-09-04
|
||||
ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'],
|
||||
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
|
||||
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'],
|
||||
} 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
|
||||
// Constantes derivadas (nombres estables que consume el resto de la app)
|
||||
export const ORIGENES_LEAD = VOCABULARIO.ORIGENES
|
||||
|
||||
// Estados de la columna status del board Leads (vocabulario real del API:
|
||||
// ESTADOS_LEAD_PERMITIDOS + NUEVO/DESCARTADO); alimenta el filtro por estado
|
||||
export const ESTADOS_LEAD = [
|
||||
'NUEVO',
|
||||
'CONTACTADO',
|
||||
'Intento de contacto',
|
||||
'INTERESADO',
|
||||
'AGENDA CITA',
|
||||
'CALIFICADO',
|
||||
'DESCARTADO',
|
||||
] as const
|
||||
export const DESARROLLOS_LEAD = VOCABULARIO.DESARROLLOS
|
||||
|
||||
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA, igual que el API)
|
||||
export const MOTIVOS_NO_COMPRA = [
|
||||
'Precio',
|
||||
'Producto',
|
||||
'Ubicacion',
|
||||
'Financiamiento',
|
||||
'Competencia',
|
||||
'Cliente',
|
||||
'Timing',
|
||||
] as const
|
||||
// Alimenta el filtro por estado de Mis leads
|
||||
export const ESTADOS_LEAD = VOCABULARIO.FASES_LEAD
|
||||
|
||||
export const MOTIVOS_NO_COMPRA = VOCABULARIO.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'],
|
||||
}
|
||||
export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, readonly string[]> = VOCABULARIO.SUBCATEGORIAS
|
||||
|
||||
// LADAs soportadas en la captura de teléfonos (México default, USA opcional)
|
||||
export const LADAS = [
|
||||
@@ -193,20 +200,11 @@ export function urlWhatsApp(nombre: string, telefono: string, vendedor: string):
|
||||
return `https://wa.me/${telefonoParaEnlace(telefono)}?text=${texto}`
|
||||
}
|
||||
|
||||
// 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
|
||||
// Fases del funnel de Oportunidades y del pipeline Enkontrol (del VOCABULARIO)
|
||||
export const FASES_OPORTUNIDAD = VOCABULARIO.FASES_OPORTUNIDAD
|
||||
export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number]
|
||||
|
||||
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
|
||||
export const FASES_APARTADO = [
|
||||
'Pago Apartado',
|
||||
'Alta Enkontrol',
|
||||
'Entrego Documentacion',
|
||||
'Cita Clientes',
|
||||
'Contrato Enkontrol',
|
||||
'Firma Cliente',
|
||||
'Cancelacion Apartado',
|
||||
] as const
|
||||
export const FASES_APARTADO = VOCABULARIO.FASES_APARTADO
|
||||
export type FaseApartado = (typeof FASES_APARTADO)[number]
|
||||
|
||||
// Texto bonito de los botones (el valor que se manda al API es el label real)
|
||||
@@ -220,17 +218,7 @@ export const ETIQUETA_FASE_APARTADO: Record<FaseApartado, string> = {
|
||||
'Cancelacion Apartado': 'Cancelación',
|
||||
}
|
||||
|
||||
export const FASES_ESCRITURACION = [
|
||||
'Venta',
|
||||
'Seguimiento',
|
||||
'Integracion',
|
||||
'X Autorizar',
|
||||
'Autorizado',
|
||||
'X Escriturar',
|
||||
'Escriturado',
|
||||
'Titulado',
|
||||
'Cancelo',
|
||||
] as const
|
||||
export const FASES_ESCRITURACION = VOCABULARIO.FASES_ESCRITURACION
|
||||
export type FaseEscrituracion = (typeof FASES_ESCRITURACION)[number]
|
||||
|
||||
export const ETIQUETA_FASE_ESCRITURACION: Record<FaseEscrituracion, string> = {
|
||||
|
||||
@@ -51,7 +51,6 @@ import { exportarLeads, exportarOportunidades, exportarApartados } from '../serv
|
||||
import { FiltrosAvanzados, aplicarFiltros, FILTROS_VACIOS, type FiltrosCRM } from '../components/FiltrosAvanzados'
|
||||
import { getSesion } from '../services/auth'
|
||||
import DashboardVendedor from '../components/DashboardVendedor'
|
||||
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
|
||||
import KanbanView from '../components/KanbanView'
|
||||
import CalendarioCitas from '../components/CalendarioCitas'
|
||||
import DetalleLeadModal from '../components/DetalleLeadModal'
|
||||
@@ -618,7 +617,6 @@ function FilaLead({
|
||||
>
|
||||
{lead.nombre}
|
||||
</button>
|
||||
<ScoreBadge score={calcularScore(lead)} />
|
||||
|
||||
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
||||
<span
|
||||
@@ -1677,6 +1675,17 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
<DetalleLeadModal
|
||||
item={leadSeleccionado}
|
||||
onCerrar={() => setLeadSeleccionado(null)}
|
||||
onGuardarCita={
|
||||
'modeloInteres' in leadSeleccionado
|
||||
? (fechaHora) => {
|
||||
const op = leadSeleccionado
|
||||
return ejecutar(
|
||||
() => cambiarEtapaOportunidad(op.id, 'Agendo Cita', fechaHora),
|
||||
'No se pudo guardar la cita',
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user