// 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 /** Vendedor asignado (nombre); '' si no tiene */ vendedor: string updatedAt: string /** Fecha de alta (ISO) */ creadoEn: 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 /** Captura (lead/oportunidad/apartado) ligada a la actividad; null si no aplica */ leadCapturaId: string | null } 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 /** Vendedor asignado (nombre); '' si no tiene */ vendedor: string unidadCodigo: string /** Canal/origen de donde llegó el lead (PASE, FACEBOOK, REFERIDO…) */ origen: string updatedAt: string /** Fecha de alta (ISO) */ creadoEn: 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 /** ¿Afluencia con perfil completo? Sin esto NO puede avanzar a 'Aparto' */ afluenciaCompleta: boolean } /** Apartado asignado al vendedor (tab "Mis apartados") */ export interface ApartadoMiDia { id: string nombre: string telefono: string email: string origen: string desarrollo: string /** Vendedor asignado (nombre); '' si no tiene */ vendedor: string unidadCodigo: string estado: string fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene) /** Fecha de alta (ISO) */ creadoEn: string ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null } /** * Registro descartado/abandonado (tab "Descartados" de Mi día, solo admin): * leads descartados, oportunidades perdidas/abandonadas y apartados * cancelados. Reasignable a cualquier vendedor. */ export interface DescartadoMiDia { id: string nombre: string telefono: string /** De dónde viene: lead | oportunidad | apartado */ fuente: 'lead' | 'oportunidad' | 'apartado' etapa: string motivo: string /** Vendedor asignado (nombre); '' si no tiene */ vendedor: string /** Fecha de alta (ISO) */ creadoEn: string updatedAt: string } 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; items: OportunidadMiDia[] } // conteo + lista de trabajo misApartados: ApartadoMiDia[] todosApartados: ApartadoMiDia[] /** Descartados/abandonados (leads, oportunidades y apartados). Tab solo admin */ descartados: DescartadoMiDia[] 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', 'Intento de contacto', 'CONTACTADO', '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 = 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: " " (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 = { '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 = { '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 = { '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 (INE, 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: 'INE', etiqueta: 'INE (identificación oficial)', obligatorio: true }, { tipo: 'OTRO', etiqueta: 'Documentos generales (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()}` } /** * Perfil de afluencia capturado de un lead/oportunidad/apartado * (GET /api/afluencias/:leadId). Solo valores de texto no vacíos. */ export interface AfluenciaPerfil { leadId: string nombre: string perfil: Record afluenciaCompleta: boolean } /** * 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}` } /** ISO → 'dd/mm/aaaa hh:mm' (hora local): bitácoras y notas con hora exacta */ export function formatoFechaHora(iso: string): string { const d = new Date(iso) if (Number.isNaN(d.getTime())) return iso const dd = String(d.getDate()).padStart(2, '0') const m = String(d.getMonth() + 1).padStart(2, '0') const y = d.getFullYear() const hh = String(d.getHours()).padStart(2, '0') const mm = String(d.getMinutes()).padStart(2, '0') return `${dd}/${m}/${y} ${hh}:${mm}` } /** 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}` }