import { useCallback, useEffect, useState, type FormEvent } from 'react'
import KpiCard from '../components/KpiCard'
import DarkModeToggle from '../components/DarkModeToggle'
import { SkeletonList } from "../components/Skeleton"
import toast from "react-hot-toast"
import type { AccionesSesion } from '../components/AuthGate'
import {
esActividadVencida,
esRolConEquipo,
formatoFechaCorta,
hoyLocal,
DESARROLLOS_LEAD,
ESTADOS_LEAD,
ETIQUETA_FASE_APARTADO,
ETIQUETA_FASE_ESCRITURACION,
ETIQUETA_FASE_OPORTUNIDAD,
FASES_APARTADO,
FASES_ESCRITURACION,
FASES_OPORTUNIDAD,
ORIGENES_LEAD,
SLOTS_DOCS_APARTADO,
componerTelefono,
LADAS,
textoDiasRestantes,
urlAfluenciaParaOportunidad,
urlCotizadorParaOportunidad,
urlWhatsApp,
type ApartadoMiDia,
type ClaveLada,
type FaseApartado,
type LeadMiDia,
type OportunidadMiDia,
type ResumenMiDia,
type SlotDocApartado,
} from '../lib/mi-dia'
import {
cambiarEstadoApartado,
cambiarEstadoLead,
cambiarEtapaOportunidad,
convertirLead,
crearLead,
descartarLead,
fetchResumenMiDia,
listarDocumentosApartado,
subirDocumentoApartado,
type DocumentoApartado,
} from '../services/mi-dia'
import { guardarResumen, cargarResumenLocal } from '../services/offline-db'
import { suscribirPush } from '../services/push'
import { exportarLeads, exportarOportunidades, exportarApartados } from '../services/exportar'
import { FiltrosAvanzados, aplicarFiltros, FILTROS_VACIOS, type FiltrosCRM } from '../components/FiltrosAvanzados'
import { getSesion } from '../services/auth'
import DashboardVendedor from '../components/DashboardVendedor'
import KanbanView from '../components/KanbanView'
import CalendarioCitas from '../components/CalendarioCitas'
import DetalleLeadModal from '../components/DetalleLeadModal'
function TituloSeccion({ children }: { children: string }) {
return
{children}
}
/** Banner de resumen matutino: saludo + KPIs del día */
function ResumenMatutino({
nombre,
leadsNuevos,
oportunidadesActivas,
apartadosPorVencer,
}: {
nombre: string
leadsNuevos: number
oportunidadesActivas: number
apartadosPorVencer: number
}) {
const hora = new Date().getHours()
const esManana = hora < 12
const saludo = esManana ? 'Buenos días' : 'Buenas tardes'
const icono = esManana ? '☀️' : '🌙'
return (
{icono}
{saludo}, {nombre}
Tienes {leadsNuevos} lead{leadsNuevos !== 1 ? 's' : ''} nuevos hoy,{' '}
{oportunidadesActivas} oportunidade{oportunidadesActivas !== 1 ? 's' : ''} activa{oportunidadesActivas !== 1 ? 's' : ''},{' '}
{apartadosPorVencer} apartado{apartadosPorVencer !== 1 ? 's' : ''} por vencer
)
}
function EstadoVacio({ mensaje }: { mensaje: string }) {
return (
)
}
const CLASE_BOTON_ACCION =
'rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50'
const CLASE_INPUT =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
/** Motivos de no compra: valores EXACTOS que valida el API (z.enum MOTIVOS_NO_COMPRA) */
const MOTIVOS_NO_COMPRA = ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'] as const
/** Teléfono clicable (tel:) + botón de WhatsApp con saludo FRASA prellenado */
function EnlacesContacto({ nombre, telefono }: { nombre: string; telefono: string }) {
const vendedor = getSesion()?.usuario.nombre ?? ''
if (telefono.trim() === '') return Sin teléfono
return (
<>
{telefono}
💬
>
)
}
/** Fila de oportunidad con botones de fase del funnel (Agendo Cita lleva fecha) */
function FilaOportunidad({
op,
onEtapa,
onCotizar,
onAfluencia,
onDescartar,
onVerDetalle,
enviando,
acciones,
}: {
op: OportunidadMiDia
onEtapa: (etapa: string, fecha?: string) => void
onCotizar: () => void
onAfluencia: () => void
onDescartar: () => void
onVerDetalle?: () => void
enviando: boolean
acciones?: string[]
}) {
const [conCita, setConCita] = useState(!!op.fechaCita)
const [fechaCita, setFechaCita] = useState(op.fechaCita ?? '')
const fases = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto'] as const
return (
{op.nombre}
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
{op.fechaCita && (
📅 Cita: {op.fechaCita.replace('T', ' ')}
)}
{op.ultimoCambio && (
Último cambio: {op.ultimoCambio.detalle} · {op.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(op.ultimoCambio.fechaISO.slice(0, 10))}
)}
{op.etapa || 'Sin etapa'}
{/* Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */}
Cotizar →
{/* Afluencia: abre el formulario de alta afluencia prellenado */}
Afluencia →
{fases.map((f) =>
f === 'Agendo Cita' ? (
(conCita ? onEtapa(f, fechaCita || undefined) : setConCita(true))}
className={CLASE_BOTON_ACCION}
>
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD[f]}
{conCita && (
setFechaCita(e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
)}
) : (
onEtapa(f)}
className={CLASE_BOTON_ACCION}
>
{ETIQUETA_FASE_OPORTUNIDAD[f]}
),
)}
Descartar
)
}
/** Fila de apartado con botones de fase del pipeline Enkontrol */
function ModalDescarteGenerico({
item,
titulo,
onCancelar,
onConfirmar,
enviando,
}: {
item: { id: string; nombre: string }
titulo: string
onCancelar: () => void
onConfirmar: (motivo: string, subcategoria: string | undefined, comentario: string) => void
enviando: boolean
}) {
const [motivo, setMotivo] = useState('Timing')
const [subcategoria] = useState('')
const [comentario, setComentario] = useState('')
return (
)
}
function FilaApartado({
apartado,
onFase,
onVerDetalle,
enviando,
}: {
apartado: ApartadoMiDia
onFase: (fase: FaseApartado) => void
onVerDetalle?: () => void
enviando: boolean
}) {
const [docsAbiertos, setDocsAbiertos] = useState(false)
return (
{apartado.nombre}
{apartado.unidadCodigo ? (
{apartado.unidadCodigo}
) : null}
{apartado.fechaLimite !== '' ? `Fecha límite: ${formatoFechaCorta(apartado.fechaLimite)}` : 'Sin fecha límite'}
{apartado.ultimoCambio && (
Último cambio: {apartado.ultimoCambio.detalle} · {apartado.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(apartado.ultimoCambio.fechaISO.slice(0, 10))}
)}
{apartado.estado || 'Sin estado'}
{FASES_APARTADO.map((fase) => (
{
// Alta Enkontrol: abre el alta de cliente prellenada con
// todo lo recolectado del apartado (ligada, sin duplicar).
// En pestaña nueva para no perder el estado de Mi día.
if (fase === 'Alta Enkontrol') {
window.open(`/client/?apartado=${apartado.id}`, '_blank')
return
}
if (fase === 'Cancelacion Apartado') {
onFase(fase)
return
}
onFase(fase)
}}
className={
fase === 'Cancelacion Apartado'
? 'rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50 dark:bg-[#0F172A] dark:border-red-800 dark:text-red-400'
: fase === 'Firma Cliente'
? 'rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-green-700 disabled:opacity-50'
: fase === 'Alta Enkontrol'
? 'rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50'
: fase === apartado.estado
? 'rounded-lg border border-accent bg-accent/5 px-3 py-1.5 text-xs font-medium text-accent shadow-sm disabled:opacity-50'
: CLASE_BOTON_ACCION
}
>
{fase === 'Alta Enkontrol' ? 'Alta Enkontrol →' : ETIQUETA_FASE_APARTADO[fase]}
))}
setDocsAbiertos((v) => !v)}
aria-expanded={docsAbiertos}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
{docsAbiertos ? 'Cerrar documentos' : 'Documentos'}
{docsAbiertos && }
)
}
/**
* Precarga de documentos del expediente desde "Mis apartados": captura del
* cotizador, captura de pago apartado (ambos obligatorios) y documentos
* generales. Los archivos se guardan en el cliente ligado al apartado.
*/
function PanelDocumentosApartado({ itemId }: { itemId: string }) {
const [documentos, setDocumentos] = useState(null)
const [error, setError] = useState('')
const [subiendo, setSubiendo] = useState(null)
const cargar = useCallback(async () => {
try {
setDocumentos(await listarDocumentosApartado(itemId))
setError('')
} catch {
setError('No se pudieron cargar los documentos.')
}
}, [itemId])
useEffect(() => {
void cargar()
}, [cargar])
const subir = async (tipo: SlotDocApartado, archivo: File | undefined) => {
if (!archivo) return
setSubiendo(tipo)
setError('')
try {
await subirDocumentoApartado(itemId, tipo, archivo)
await cargar()
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo subir el archivo.')
} finally {
setSubiendo(null)
}
}
return (
Expediente del cliente
{error && (
{error}
)}
{SLOTS_DOCS_APARTADO.map((slot) => {
const delSlot = (documentos ?? []).filter((d) => d.tipo === slot.tipo)
return (
{documentos === null ? (
Cargando…
) : delSlot.length === 0 ? (
Sin archivos
) : (
{delSlot.map((d) => (
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
))}
)}
)
})}
* Obligatorios para el proceso de venta. PDF, JPG o PNG, máximo 10 MB.
)
}
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero / Calendario */
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero' | 'calendario'
function TabsCrm({
activa,
conteos,
onCambiar,
vistaKanban,
onToggleKanban,
}: {
activa: TabCrm
conteos: Record
onCambiar: (tab: TabCrm) => void
vistaKanban: boolean
onToggleKanban: () => void
}) {
const tabs: { id: TabCrm; titulo: string }[] = [
{ id: 'leads', titulo: 'Leads' },
{ id: 'oportunidades', titulo: 'Oportunidades' },
{ id: 'apartados', titulo: 'Apartados' },
{ id: 'escrituracion', titulo: 'Escrituración' },
{ id: 'tablero', titulo: 'Tablero' },
{ id: 'calendario', titulo: 'Calendario' },
]
return (
{tabs.map((tab) => {
const activo = tab.id === activa
return (
onCambiar(tab.id)}
className={`rounded-lg px-3 py-1.5 text-xs font-semibold uppercase tracking-wider transition-colors duration-100 md:px-4 md:py-2 ${
activo ? 'bg-accent text-white' : 'text-[#64748B] dark:text-[#94A3B8] hover:bg-[#F9FAFB] hover:text-accent'
}`}
>
{tab.titulo}
{conteos[tab.id] != null && (
{conteos[tab.id]}
)}
)
})}
{activa !== 'tablero' && (
{vistaKanban ? 'Lista' : 'Kanban'}
)}
)
}
/** 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,
onConvertir,
onDescartar,
onVerDetalle,
enviando,
acciones,
}: {
lead: LeadMiDia
onAccion: (estado: string, fecha?: string) => void
onConvertir: () => void
onDescartar: () => void
onVerDetalle?: () => void
enviando: boolean
acciones?: string[]
}) {
return (
{lead.nombre}
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
{lead.desarrollo ? ` · ${lead.desarrollo}` : ''}
{lead.origen ? ` · ${lead.origen}` : ''}
{lead.ultimoCambio && (
Último cambio: {lead.ultimoCambio.detalle} · {lead.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(lead.ultimoCambio.fechaISO.slice(0, 10))}
)}
{lead.estado || 'Sin estado'}
onAccion('CONTACTADO')} className={CLASE_BOTON_ACCION}>
Contactado
onAccion('Intento de contacto')} className={CLASE_BOTON_ACCION}>
Intento
onAccion('INTERESADO')} className={CLASE_BOTON_ACCION}>
Interesado
{/* Agendo: convierte el lead en oportunidad (etapa Agendo Cita) */}
Agendo →
Descartar
)
}
export default function MiDiaPage({ accionesSesion }: { accionesSesion: AccionesSesion }) {
const nombreVendedor = getSesion()?.usuario.nombre ?? ''
const rol = getSesion()?.usuario.rol ?? 'vendedor'
const acciones = getSesion()?.usuario.acciones ?? []
const [resumen, setResumen] = useState(null)
const [error, setError] = useState(null)
const [cargando, setCargando] = useState(true)
const [enviando, setEnviando] = useState(false)
const [aviso, setAviso] = useState(null)
const [descartando, setDescartando] = useState<{
id: string
nombre: string
fuente: 'lead' | 'oportunidad'
} | null>(null)
const [tabCrm, setTabCrm] = useState('leads')
const [leadSeleccionado, setLeadSeleccionado] = useState(null)
const [apartadoCancelando, setApartadoCancelando] = useState<{ id: string; nombre: string } | null>(null)
// Filtros independientes por tab: cada pestaña conserva su propia búsqueda
const [filtrosPorTab, setFiltrosPorTab] = useState>({
leads: FILTROS_VACIOS,
oportunidades: FILTROS_VACIOS,
apartados: FILTROS_VACIOS,
escrituracion: FILTROS_VACIOS,
tablero: FILTROS_VACIOS,
calendario: FILTROS_VACIOS,
})
const filtros = filtrosPorTab[tabCrm]
const actualizarFiltros = (f: FiltrosCRM) => setFiltrosPorTab((prev) => ({ ...prev, [tabCrm]: f }))
// Estados posibles por tab: alimenta el select "Estado" de FiltrosAvanzados
const estadosPorTab: Partial> = {
leads: ESTADOS_LEAD,
oportunidades: FASES_OPORTUNIDAD,
apartados: FASES_APARTADO,
escrituracion: FASES_ESCRITURACION,
}
const [offline, setOffline] = useState(!navigator.onLine)
const [offlineFecha, setOfflineFecha] = useState(null)
// Kanban toggle
const [vistaKanban, setVistaKanban] = useState(false)
// Configuración de columnas por pestaña. Los IDs son los valores EXACTOS que
// valida el API (z.enum en los controllers). NUEVO/DESCARTADO/PERDIDO son
// columnas de solo-display: su drop abre el modal de descarte o no hace nada.
const columnasLeads = [
{ id: 'NUEVO', titulo: 'Nuevo', color: 'border-t-gray-400' },
{ id: 'CONTACTADO', titulo: 'Contactado', color: 'border-t-blue-500' },
{ id: 'Intento de contacto', titulo: 'Intento de contacto', color: 'border-t-sky-500' },
{ id: 'INTERESADO', titulo: 'Interesado', color: 'border-t-yellow-500' },
{ id: 'AGENDA CITA', titulo: 'Agenda Cita', color: 'border-t-green-500' },
{ id: 'CALIFICADO', titulo: 'Calificado', color: 'border-t-teal-500' },
{ id: 'DESCARTADO', titulo: 'Descartado', color: 'border-t-red-500' },
]
const columnasOportunidades = [
{ id: 'Afluencia', titulo: 'Afluencia', color: 'border-t-purple-500' },
{ id: 'Cotizador', titulo: 'Cotizador', color: 'border-t-blue-500' },
{ id: 'Agendo Cita', titulo: 'Agendó Cita', color: 'border-t-green-500' },
{ id: 'Aparto', titulo: 'Apartó', color: 'border-t-emerald-600' },
{ id: 'No Asistio', titulo: 'No Asistió', color: 'border-t-orange-500' },
{ id: 'Abandono', titulo: 'Abandono', color: 'border-t-yellow-600' },
{ id: 'PERDIDO', titulo: 'Perdido', color: 'border-t-red-500' },
]
const columnasApartados = [
{ id: 'Pago Apartado', titulo: 'Pago Apartado', color: 'border-t-blue-500' },
{ id: 'Alta Enkontrol', titulo: 'Alta Enkontrol', color: 'border-t-yellow-500' },
{ id: 'Entrego Documentacion', titulo: 'Entregó Docs', color: 'border-t-orange-500' },
{ id: 'Cita Clientes', titulo: 'Cita Clientes', color: 'border-t-green-500' },
{ id: 'Contrato Enkontrol', titulo: 'Contrato Enkontrol', color: 'border-t-emerald-600' },
{ id: 'Firma Cliente', titulo: 'Firma Cliente', color: 'border-t-accent' },
{ id: 'Cancelacion Apartado', titulo: 'Cancelación', color: 'border-t-red-500' },
]
const columnasEscrituracion = [
{ id: 'Venta', titulo: 'Venta', color: 'border-t-gray-400' },
{ id: 'Seguimiento', titulo: 'Seguimiento', color: 'border-t-blue-500' },
{ id: 'Integracion', titulo: 'Integración', color: 'border-t-yellow-500' },
{ id: 'X Autorizar', titulo: 'X Autorizar', color: 'border-t-orange-500' },
{ id: 'Autorizado', titulo: 'Autorizado', color: 'border-t-green-500' },
{ id: 'X Escriturar', titulo: 'X Escriturar', color: 'border-t-emerald-500' },
{ id: 'Escriturado', titulo: 'Escriturado', color: 'border-t-emerald-600' },
{ id: 'Titulado', titulo: 'Titulado', color: 'border-t-purple-600' },
{ id: 'Cancelo', titulo: 'Canceló', color: 'border-t-red-500' },
]
const obtenerColumnaLead = (lead: LeadMiDia) => {
const e = (lead.estado ?? '').toUpperCase()
if (e.includes('DESCART')) return 'DESCARTADO'
if (e.includes('AGENDO') || e.includes('CITA')) return 'AGENDA CITA'
if (e.includes('CALIFIC')) return 'CALIFICADO'
if (e.includes('INTENTO')) return 'Intento de contacto'
if (e.includes('INTERES')) return 'INTERESADO'
if (e.includes('CONTACT')) return 'CONTACTADO'
return 'NUEVO'
}
const obtenerColumnaOp = (op: OportunidadMiDia) => {
const e = (op.etapa ?? '').toUpperCase()
if (e.includes('PERDIDO') || e.includes('PERDI')) return 'PERDIDO'
if (e.includes('ASISTIO')) return 'No Asistio'
if (e.includes('ABANDONO')) return 'Abandono'
if (e.includes('APARTO')) return 'Aparto'
if (e.includes('COTIZ')) return 'Cotizador'
if (e.includes('AGENDO') || e.includes('CITA')) return 'Agendo Cita'
return 'Afluencia'
}
const obtenerColumnaApartado = (a: ApartadoMiDia) => {
const e = (a.estado ?? '').toUpperCase()
if (e.includes('PAGO')) return 'Pago Apartado'
if (e.includes('ALTA') && e.includes('ENKONTROL')) return 'Alta Enkontrol'
if (e.includes('ENTREG') || e.includes('DOC')) return 'Entrego Documentacion'
if (e.includes('CITA')) return 'Cita Clientes'
if (e.includes('CONTRATO')) return 'Contrato Enkontrol'
if (e.includes('FIRMA')) return 'Firma Cliente'
if (e.includes('CANCEL')) return 'Cancelacion Apartado'
return 'Pago Apartado'
}
const obtenerColumnaEscrituracion = (a: ApartadoMiDia) => {
const e = (a.estado ?? '').toUpperCase()
if (e.includes('VENTA')) return 'Venta'
if (e.includes('SEGUIMIENTO')) return 'Seguimiento'
if (e.includes('INTEGRACION')) return 'Integracion'
if (e.includes('X AUTORIZAR')) return 'X Autorizar'
if (e.includes('AUTORIZADO')) return 'Autorizado'
if (e.includes('X ESCRITURAR')) return 'X Escriturar'
if (e.includes('ESCRITURADO')) return 'Escriturado'
if (e.includes('TITULADO')) return 'Titulado'
if (e.includes('CANCEL')) return 'Cancelo'
return 'Venta'
}
// Captura rápida
const [capNombre, setCapNombre] = useState('')
const [capLada, setCapLada] = useState('MX')
const [capTelefono, setCapTelefono] = useState('')
const [capEmail, setCapEmail] = useState('')
const [capOrigen, setCapOrigen] = useState(ORIGENES_LEAD[0])
const [capDesarrollo, setCapDesarrollo] = useState('')
const [capNotas, setCapNotas] = useState('')
const [capEnviando, setCapEnviando] = useState(false)
const [capMensaje, setCapMensaje] = useState<{ tipo: 'ok' | 'error'; texto: string } | null>(null)
const cargar = useCallback(async () => {
setCargando(true)
try {
const datos = await fetchResumenMiDia()
setResumen(datos)
setError(null)
setOfflineFecha(null)
// Fire-and-forget: guardar en IDB para uso offline
void guardarResumen(datos)
} catch (err) {
// Si falla el fetch, intentar datos cacheados
const cached = await cargarResumenLocal()
if (cached) {
setResumen(cached)
setOfflineFecha(new Date().toLocaleDateString('es-MX'))
setError(null)
} else {
setError(err instanceof Error ? err.message : 'No se pudo cargar tu día')
}
} finally {
setCargando(false)
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
// Detectar online/offline
useEffect(() => {
const onOnline = () => { setOffline(false); void cargar() }
const onOffline = () => setOffline(true)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [cargar])
// Suscribir a push notifications (fire-and-forget)
useEffect(() => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().then((perm) => {
if (perm === 'granted') void suscribirPush()
})
} else if ('Notification' in window && Notification.permission === 'granted') {
void suscribirPush()
}
}, [])
/** Ejecuta una acción sobre un lead y refresca el resumen */
async function ejecutar(accion: () => Promise, mensajeError: string) {
setEnviando(true)
setAviso(null)
try {
await accion()
await cargar()
} catch (err) {
toast.error(mensajeError); setAviso(err instanceof Error ? err.message : mensajeError)
} finally {
setEnviando(false)
}
}
/**
* Botón "Agendo" de Mis leads: convierte el lead en oportunidad (etapa
* Agendo Cita); el lead sale de la lista y aparece en Mis oportunidades.
*/
async function convertir(lead: LeadMiDia) {
setEnviando(true)
setAviso(null)
try {
await convertirLead(lead.id)
await cargar()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo convertir el lead')
} finally {
setEnviando(false)
}
}
/**
* Botón "Cotizar": pasa la oportunidad a etapa Cotizador y abre el cotizador
* con los datos del cliente prellenados y la oportunidad vinculada (al
* guardar, la cotización queda ligada a ese cliente). Si el cambio de etapa
* falla, el cotizador se abre igual: el guardado reintenta la actualización.
*/
async function cotizarOportunidad(op: OportunidadMiDia) {
setEnviando(true)
setAviso(null)
try {
await cambiarEtapaOportunidad(op.id, 'Cotizador')
} catch {
toast.error('No se pudo actualizar la etapa; el cotizador se abrirá de todos modos.')
}
window.open(urlCotizadorParaOportunidad(op), '_blank')
}
async function enviarCaptura(e: FormEvent) {
e.preventDefault()
setCapEnviando(true)
setCapMensaje(null)
try {
const telefono = componerTelefono(capLada, capTelefono)
const creado = await crearLead({
nombre: capNombre.trim(),
...(telefono !== '' ? { telefono } : {}),
...(capEmail.trim() !== '' ? { email: capEmail.trim() } : {}),
...(capOrigen !== '' ? { origen: capOrigen } : {}),
...(capDesarrollo !== '' ? { desarrollo: capDesarrollo } : {}),
...(capNotas.trim() !== '' ? { notas: capNotas.trim() } : {}),
})
setCapMensaje({
tipo: 'ok',
texto:
creado.asignacion === 'capturista'
? 'Lead registrado y asignado a ti'
: 'Lead registrado, se asignará por round-robin',
})
toast.success('Lead registrado correctamente')
setCapNombre('')
setCapTelefono('')
setCapEmail('')
setCapDesarrollo('')
setCapNotas('')
// Refresca el resumen para que la captura aparezca al momento en "Mis capturas"
await cargar()
} catch (err) {
setCapMensaje({ tipo: 'error', texto: err instanceof Error ? err.message : 'No se pudo registrar el lead' })
} finally {
setCapEnviando(false)
}
}
// Listas filtradas por los filtros avanzados; las usan el render por pestaña
// Y el botón de exportar (para que la exportación respete los filtros activos)
const leadsFiltrados = resumen
? aplicarFiltros(resumen.misLeads.filter((l) => l.fuente === 'lead'), filtros, {
texto: (l) => `${l.nombre} ${l.telefono} ${l.email}`,
origen: (l) => l.origen,
estado: (l) => l.estado,
fecha: (l) => l.updatedAt.slice(0, 10),
})
: []
const opsFiltradas = resumen
? aplicarFiltros(resumen.misOportunidades.items, filtros, {
texto: (o) => `${o.nombre} ${o.telefono} ${o.email}`,
desarrollo: (o) => o.desarrollo,
estado: (o) => o.etapa,
fecha: (o) => o.updatedAt.slice(0, 10),
})
: []
const apartadosFiltrados = resumen
? aplicarFiltros(resumen.misApartados, filtros, {
texto: (a) => a.nombre,
estado: (a) => a.estado,
fecha: (a) => a.fechaLimite,
})
: []
const hoy = hoyLocal()
const iniciales = nombreVendedor
.split(' ')
.filter((p) => p !== '')
.map((p) => p.charAt(0))
.slice(0, 2)
.join('')
.toUpperCase()
return (
<>
{offline && (
Modo offline{offlineFecha ? ` — datos de ${offlineFecha}` : ''}
)}
{nombreVendedor}
{formatoFechaCorta(hoy)}
{error && (
Sin datos del API — {error}
)}
{aviso && (
{aviso}
)}
{!resumen && cargando && }
{resumen && (
<>
l.fuente === 'lead').length}
oportunidadesActivas={resumen.misOportunidades.items.length}
apartadosPorVencer={resumen.apartadosPorVencer.length}
/>
l.fuente === 'lead').length,
oportunidades: resumen.misOportunidades.items.length,
apartados: resumen.misApartados.length, escrituracion: resumen.misApartados.length, tablero: 0, calendario: 0,
}}
onCambiar={setTabCrm}
vistaKanban={vistaKanban}
onToggleKanban={() => setVistaKanban(!vistaKanban)}
/>
{tabCrm !== 'tablero' && (
{
if (tabCrm === 'leads') exportarLeads(leadsFiltrados)
else if (tabCrm === 'oportunidades') exportarOportunidades(opsFiltradas)
else if (tabCrm === 'apartados') exportarApartados(apartadosFiltrados)
}}
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-xs font-medium text-[#374151] shadow-sm transition-all hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#1E293B]"
>
📥 Exportar
)}
{tabCrm === 'leads' && (
<>
{leadsFiltrados.length === 0 ? (
) : vistaKanban ? (
{
if (estado === 'DESCARTADO') {
const lead = resumen.misLeads.find((l) => l.id === id)
if (lead) setDescartando(lead)
return
}
if (estado === 'NUEVO') return
void ejecutar(() => cambiarEstadoLead(id, estado, undefined, 'lead'), 'No se pudo actualizar')
}}
/>
) : (
{leadsFiltrados.map((lead) => (
void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
onConvertir={() => void convertir(lead)}
onDescartar={() => setDescartando(lead)}
onVerDetalle={() => setLeadSeleccionado(lead)}
acciones={acciones}
/>
))}
)}
>
)}
{tabCrm === 'oportunidades' && (
<>
{opsFiltradas.length === 0 ? (
) : vistaKanban ? (
{
if (estado === 'PERDIDO') {
const op = resumen.misOportunidades.items.find((o) => o.id === id)
if (op) setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })
return
}
void ejecutar(() => cambiarEtapaOportunidad(id, estado), 'No se pudo actualizar')
}}
/>
) : (
{opsFiltradas.map((op) => (
void ejecutar(() => cambiarEtapaOportunidad(op.id, etapa, fecha), 'No se pudo actualizar la oportunidad')
}
onCotizar={() => void cotizarOportunidad(op)}
onAfluencia={() => {
window.open(urlAfluenciaParaOportunidad(op), '_blank')
}}
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
onVerDetalle={() => setLeadSeleccionado(op)}
acciones={acciones}
/>
))}
)}
>
)}
{tabCrm === 'apartados' && (
<>
{apartadosFiltrados.length === 0 ? (
) : vistaKanban ? (
void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')}
/>
) : (
{apartadosFiltrados.map((a) => (
setLeadSeleccionado(a)}
onFase={(fase) => {
if (fase === 'Firma Cliente') {
// Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración
void ejecutar(() => cambiarEstadoApartado(a.id, fase).then(() => setTabCrm('escrituracion')), 'No se pudo actualizar el apartado')
return
}
void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')
}}
/>
))}
)}
>
)}
{tabCrm === 'escrituracion' && (
Escrituración
Seguimiento de escrituración de apartados.
{(() => {
// Filtrar: solo apartados en fases de escrituración o Firma Cliente
// Solo fases activas (Cancelo y Escriturado son terminales)
// Fases activas de escrituración (excluye Cancelo y Escriturado que son terminales)
const enEscrituracion = (resumen.todosApartados || resumen.misApartados || []).filter((a) => {
const e = (a.estado ?? '').toUpperCase()
return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('AUTORIZADO') || e.includes('X ESCRITURAR')
})
const apartadosEscrituracion = aplicarFiltros(
enEscrituracion,
filtros,
{
texto: (a) => a.nombre,
estado: (a) => a.estado,
fecha: (a) => a.fechaLimite,
},
)
return apartadosEscrituracion.length === 0 ? (
) : vistaKanban ? (
{
if (estado === 'Cancelo') {
const a = apartadosEscrituracion.find((x) => x.id === id)
if (a) setApartadoCancelando({ id: a.id, nombre: a.nombre })
return
}
void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')
}}
/>
) : (
{apartadosEscrituracion.map((apartado) => (
{apartado.nombre}
{apartado.unidadCodigo || 'Sin unidad'} · {apartado.estado}
{(['Venta', 'Seguimiento', 'Integracion', 'X Autorizar', 'Autorizado', 'X Escriturar', 'Escriturado', 'Titulado', 'Cancelo'] as const).map((fase) => (
{
if (fase === 'Cancelo') {
setApartadoCancelando({ id: apartado.id, nombre: apartado.nombre })
return
}
void ejecutar(() => cambiarEstadoApartado(apartado.id, fase), 'No se pudo actualizar')
}}
className={fase === apartado.estado
? 'rounded-lg border border-accent bg-accent/5 px-3 py-1.5 text-xs font-medium text-accent shadow-sm disabled:opacity-50'
: fase === 'Cancelo'
? 'rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50 dark:bg-[#0F172A] dark:border-red-800 dark:text-red-400'
: CLASE_BOTON_ACCION}
>
{ETIQUETA_FASE_ESCRITURACION[fase]}
))}
))}
)})()}
)}
{tabCrm === 'tablero' && (
)}
{tabCrm === 'calendario' && (
setLeadSeleccionado(op)}
/>
)}
Mis capturas de afluencia
{resumen.misCapturas.length === 0 ? (
) : (
)}
Mis clientes
{resumen.misClientes.length === 0 ? (
) : (
)}
Mis actividades
{resumen.misActividadesHoy.length === 0 ? (
) : (
)}
{resumen.apartadosPorVencer.length > 0 && (
)}
{esRolConEquipo(rol) && resumen.equipo && (
Equipo
Leads nuevos sin asignar: {resumen.equipo.leadsNuevosSinAsignar}
{resumen.equipo.porVendedor.length === 0 ? (
) : (
{resumen.equipo.porVendedor.map((v) => (
{v.nombre}
{v.leadsAsignados} lead(s)
))}
)}
)}
>
)}
{descartando && (
setDescartando(null)}
onConfirmar={(motivo, subcategoria, comentario) => {
const item = descartando
setDescartando(null)
void ejecutar(
() => descartarLead(item.id, motivo, comentario, item.fuente, subcategoria),
'No se pudo descartar el lead',
)
}}
/>
)}
{apartadoCancelando && (
setApartadoCancelando(null)}
onConfirmar={(motivo, subcategoria, comentario) => {
const item = apartadoCancelando
setApartadoCancelando(null)
void ejecutar(
() => cambiarEstadoApartado(item.id, 'Cancelacion Apartado', motivo, subcategoria, comentario),
'No se pudo cancelar el apartado',
)
}}
/>
)}
{leadSeleccionado && (
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
}
/>
)}
>
)
}