Files
FRASA-CAS/apps/mi-dia/src/lib/mi-dia.ts

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