312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
// Tipos y transformaciones de la app "Mi día" (espejo del contrato del API)
|
|
|
|
export interface LeadMiDia {
|
|
id: string
|
|
nombre: string
|
|
telefono: string
|
|
email: string
|
|
estado: string
|
|
origen: string
|
|
desarrollo?: string
|
|
updatedAt: string
|
|
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
|
fuente: 'lead' | 'oportunidad'
|
|
/** Último cambio conocido; null si no hay */
|
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
|
}
|
|
|
|
export interface ActividadMiDia {
|
|
id: string
|
|
nombre: string
|
|
tipo: string
|
|
fecha: string // 'YYYY-MM-DD'
|
|
estado: string
|
|
}
|
|
|
|
export interface ApartadoPorVencer {
|
|
id: string
|
|
nombre: string
|
|
unidadCodigo: string
|
|
fechaLimite: string // 'YYYY-MM-DD'
|
|
estado: string
|
|
diasRestantes: number
|
|
}
|
|
|
|
export interface CapturaMiDia {
|
|
leadId: string
|
|
nombre: string
|
|
telefono: string
|
|
estado: string
|
|
origen: string
|
|
capturadoEn: string // ISO
|
|
}
|
|
|
|
export interface ClienteMiDia {
|
|
id: string
|
|
nombre: string
|
|
proyecto: string // '' = alta aún en captura
|
|
unidadCodigo: string
|
|
completado: boolean
|
|
capturadoEn: string // ISO
|
|
/** Estado actual del apartado ligado; null si no hay apartado ligado */
|
|
estadoApartado: string | null
|
|
}
|
|
|
|
/** Oportunidad asignada al asesor (tab "Mis oportunidades") */
|
|
export interface OportunidadMiDia {
|
|
id: string
|
|
nombre: string
|
|
telefono: string
|
|
email: string
|
|
etapa: string
|
|
desarrollo: string
|
|
modeloInteres: string
|
|
unidadCodigo: string
|
|
updatedAt: string
|
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
|
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
|
|
fechaCita?: string
|
|
}
|
|
|
|
/** Apartado asignado al vendedor (tab "Mis apartados") */
|
|
export interface ApartadoMiDia {
|
|
id: string
|
|
nombre: string
|
|
unidadCodigo: string
|
|
estado: string
|
|
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
|
}
|
|
|
|
export interface ResumenMiDia {
|
|
generadoEn: string
|
|
misLeads: LeadMiDia[]
|
|
misCapturas: CapturaMiDia[]
|
|
misClientes: ClienteMiDia[]
|
|
misActividadesHoy: ActividadMiDia[]
|
|
/** Todas las actividades ABIERTAS del usuario (sin filtro de fecha): alimenta el calendario */
|
|
todasMisActividades: ActividadMiDia[]
|
|
apartadosPorVencer: ApartadoPorVencer[]
|
|
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo
|
|
misApartados: ApartadoMiDia[]
|
|
todosApartados: ApartadoMiDia[]
|
|
conteos: {
|
|
leadsNuevosSemana: number
|
|
actividadesAbiertas: number
|
|
}
|
|
equipo?: {
|
|
leadsNuevosSinAsignar: number
|
|
porVendedor: {
|
|
usuarioId: string
|
|
nombre: string | null
|
|
leadsAsignados: number
|
|
leadsNuevosSemana: number
|
|
}[]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 origen (captura ligera y afluencia), verificados el 2026-09-04
|
|
ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'],
|
|
// Labels oficiales de desarrollo
|
|
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA', 'SOLE', 'MUTUO'],
|
|
} as const
|
|
|
|
// Constantes derivadas (nombres estables que consume el resto de la app)
|
|
export const ORIGENES_LEAD = VOCABULARIO.ORIGENES
|
|
|
|
export const DESARROLLOS_LEAD = VOCABULARIO.DESARROLLOS
|
|
|
|
// 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]
|
|
|
|
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 = [
|
|
{ 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}`
|
|
}
|
|
|
|
/**
|
|
* Normaliza un teléfono a dígitos para enlaces tel:/wa.me. Si el número no trae
|
|
* lada internacional (10 dígitos), asume México (+52); si ya la trae, la respeta.
|
|
*/
|
|
export function telefonoParaEnlace(telefono: string): string {
|
|
const digitos = telefono.replace(/\D/g, '')
|
|
if (digitos.length === 10) return `52${digitos}`
|
|
return digitos
|
|
}
|
|
|
|
/** URL de WhatsApp con saludo FRASA prellenado: "Hola {nombre}, soy {vendedor} de FRASA" */
|
|
export function urlWhatsApp(nombre: string, telefono: string, vendedor: string): string {
|
|
const texto = encodeURIComponent(`Hola ${nombre}, soy ${vendedor} de FRASA`)
|
|
return `https://wa.me/${telefonoParaEnlace(telefono)}?text=${texto}`
|
|
}
|
|
|
|
// 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]
|
|
|
|
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)
|
|
export const ETIQUETA_FASE_APARTADO: Record<FaseApartado, string> = {
|
|
'Pago Apartado': 'Pago apartado',
|
|
'Alta Enkontrol': 'Alta Enkontrol',
|
|
'Entrego Documentacion': 'Entregó documentación',
|
|
'Cita Clientes': 'Cita cliente',
|
|
'Contrato Enkontrol': 'Contrato Enkontrol',
|
|
'Firma Cliente': 'Firma cliente →',
|
|
'Cancelacion Apartado': 'Cancelación',
|
|
}
|
|
|
|
export const FASES_ESCRITURACION = VOCABULARIO.FASES_ESCRITURACION
|
|
export type FaseEscrituracion = (typeof FASES_ESCRITURACION)[number]
|
|
|
|
export const ETIQUETA_FASE_ESCRITURACION: Record<FaseEscrituracion, string> = {
|
|
'Venta': 'Venta',
|
|
'Seguimiento': 'Seguimiento',
|
|
'Integracion': 'Integración',
|
|
'X Autorizar': 'X Autorizar',
|
|
'Autorizado': 'Autorizado',
|
|
'X Escriturar': 'X Escriturar',
|
|
'Escriturado': 'Escriturado',
|
|
'Titulado': 'Titulado',
|
|
'Cancelo': 'Canceló',
|
|
}
|
|
|
|
// Texto bonito de los botones de oportunidad
|
|
export const ETIQUETA_FASE_OPORTUNIDAD: Record<FaseOportunidad, string> = {
|
|
'Agendo Cita': 'Agendó cita',
|
|
Afluencia: 'Afluencia',
|
|
Cotizador: 'Cotizador',
|
|
Aparto: 'Apartó',
|
|
'No Asistio': 'No asistió',
|
|
Abandono: 'Abandono',
|
|
}
|
|
|
|
// Slots de la precarga de documentos en "Mis apartados"; los obligatorios son
|
|
// los que pide el proceso de venta (captura del cotizador y de pago apartado)
|
|
export const SLOTS_DOCS_APARTADO = [
|
|
{ tipo: 'CAPTURA_COTIZADOR', etiqueta: 'Captura del cotizador', obligatorio: true },
|
|
{ tipo: 'CAPTURA_PAGO_APARTADO', etiqueta: 'Captura de pago apartado', obligatorio: true },
|
|
{ tipo: 'OTRO', etiqueta: 'Documentos generales (INE, CURP, comprobantes…)', obligatorio: false },
|
|
] as const
|
|
|
|
export type SlotDocApartado = (typeof SLOTS_DOCS_APARTADO)[number]['tipo']
|
|
|
|
/**
|
|
* URL del cotizador (/quote/) con los datos del cliente prellenados y el id de
|
|
* la oportunidad: al guardar, la cotización queda ligada a ese cliente.
|
|
*/
|
|
export function urlCotizadorParaOportunidad(op: OportunidadMiDia): string {
|
|
const params = new URLSearchParams()
|
|
params.set('cliente', op.nombre)
|
|
if (op.telefono !== '') params.set('telefono', op.telefono)
|
|
if (op.email !== '') params.set('email', op.email)
|
|
if (op.desarrollo !== '') params.set('desarrollo', op.desarrollo)
|
|
if (op.unidadCodigo !== '') params.set('unidad', op.unidadCodigo)
|
|
params.set('oportunidadId', op.id)
|
|
return `/quote/?${params.toString()}`
|
|
}
|
|
|
|
/**
|
|
* URL del formulario de Alta Afluencia (/client/) prellenado con los datos de
|
|
* la oportunidad. Al enviarlo, el API ACTUALIZA esa oportunidad (perfil
|
|
* completo + etapa 'Afluencia') en vez de crear un duplicado.
|
|
*/
|
|
export function urlAfluenciaParaOportunidad(op: OportunidadMiDia): string {
|
|
const params = new URLSearchParams()
|
|
params.set('afluencia', op.id)
|
|
params.set('nombre', op.nombre)
|
|
if (op.telefono !== '') params.set('telefono', op.telefono)
|
|
if (op.email !== '') params.set('email', op.email)
|
|
if (op.desarrollo !== '') params.set('desarrollo', op.desarrollo)
|
|
if (op.modeloInteres !== '') params.set('modelo', op.modeloInteres)
|
|
return `/client/?${params.toString()}`
|
|
}
|
|
|
|
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
|
|
|
/** ¿El rol ve la sección de equipo? (misma regla que el API) */
|
|
export function esRolConEquipo(rol: string): boolean {
|
|
return ROLES_CON_EQUIPO.includes(rol)
|
|
}
|
|
|
|
/** 'YYYY-MM-DD' → 'dd/mm' (sin año: la agenda es de esta semana) */
|
|
export function formatoFechaCorta(fecha: string): string {
|
|
const [, m, d] = fecha.split('-')
|
|
if (!m || !d) return fecha
|
|
return `${d}/${m}`
|
|
}
|
|
|
|
/** Texto de urgencia de un apartado según los días que faltan */
|
|
export function textoDiasRestantes(dias: number): string {
|
|
if (dias <= 0) return 'Vence hoy'
|
|
if (dias === 1) return 'Vence mañana'
|
|
return `Vence en ${dias} días`
|
|
}
|
|
|
|
/** ¿La fecha de una actividad ya pasó? (vencida, para marcarla en la lista) */
|
|
export function esActividadVencida(fecha: string, hoy: string): boolean {
|
|
return fecha !== '' && fecha < hoy
|
|
}
|
|
|
|
/** Fecha local 'YYYY-MM-DD' de hoy (para comparar contra las del API) */
|
|
export function hoyLocal(): string {
|
|
const d = new Date()
|
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
const dd = String(d.getDate()).padStart(2, '0')
|
|
return `${d.getFullYear()}-${m}-${dd}`
|
|
}
|