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,
SUBCATEGORIAS_NO_COMPRA,
textoDiasRestantes,
urlAfluenciaParaOportunidad,
urlCotizadorParaOportunidad,
urlWhatsApp,
type ApartadoMiDia,
type ClaveLada,
type FaseApartado,
type LeadMiDia,
type MotivoNoCompra,
type OportunidadMiDia,
type ResumenMiDia,
type SlotDocApartado,
} from '../lib/mi-dia'
import {
cambiarEstadoApartado,
cambiarEstadoLead,
cambiarEtapaOportunidad,
convertirLead,
crearLead,
descartarLead,
fetchArchivoDocumentoApartado,
fetchResumenMiDia,
listarDocumentosApartado,
moverLead as moverLeadApi,
reasignarLead as reasignarLeadApi,
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: acciones en orden (Agendo Cita lleva fecha opcional) */
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 ?? '')
return (
{op.nombre}
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
{op.origen ? ` · ${op.origen}` : ''}
{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'}
{/* 1. Confirmar cita (etapa Agendo Cita): al primer toque pide la fecha */}
(conCita ? onEtapa('Agendo Cita', fechaCita || undefined) : setConCita(true))}
className={CLASE_BOTON_ACCION}
>
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD['Agendo Cita']}
{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"
/>
)}
{/* 2. Afluencia: abre el formulario de alta afluencia prellenado */}
Afluencia →
{/* 3. Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */}
Cotizar →
{/* 4. Aparto: marca la etapa (verde) */}
onEtapa('Aparto')}
className="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"
>
Aparto
{/* 5. Descartar */}
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, setSubcategoria] = useState('')
const [comentario, setComentario] = useState('')
// Subcategorías del catálogo para el motivo elegido; se limpia al cambiar de motivo
const subcategorias =
motivo in SUBCATEGORIAS_NO_COMPRA ? SUBCATEGORIAS_NO_COMPRA[motivo as MotivoNoCompra] : []
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.desarrollo ? ` · ${apartado.desarrollo}` : ''}
{apartado.origen ? ` · ${apartado.origen}` : ''}
{apartado.email ? ` · ${apartado.email}` : ''}
{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
}
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 [obteniendo, setObteniendo] = 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)
}
}
/**
* Baja el archivo autenticado y opera sobre su blob URL. Para previsualizar,
* la pestaña se abre DENTRO del gesto del usuario (después del await el
* navegador bloquearía el window.open) y luego se navega al blob.
*/
const conArchivo = async (doc: DocumentoApartado, modo: 'previsualizar' | 'descargar') => {
setObteniendo(doc.id)
setError('')
const pestaña = modo === 'previsualizar' ? window.open('', '_blank') : null
try {
const blob = await fetchArchivoDocumentoApartado(itemId, doc.id)
const url = URL.createObjectURL(blob)
if (pestaña) {
// Previsualizar: navega la pestaña ya abierta al archivo (PDF/imagen)
pestaña.location.href = url
} else {
// Descargar: link con atributo download (nombre original del archivo)
const enlace = document.createElement('a')
enlace.href = url
if (modo === 'descargar') enlace.download = doc.nombreArchivo
else enlace.target = '_blank'
enlace.rel = 'noopener'
document.body.appendChild(enlace)
enlace.click()
enlace.remove()
}
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (err) {
pestaña?.close()
setError(err instanceof Error ? err.message : 'No se pudo obtener el archivo.')
} finally {
setObteniendo(null)
}
}
const previsualizar = (doc: DocumentoApartado) => {
void conArchivo(doc, 'previsualizar')
}
const descargar = (doc: DocumentoApartado) => {
void conArchivo(doc, 'descargar')
}
return (
Expediente del cliente
{documentos !== null &&
SLOTS_DOCS_APARTADO.filter((s) => s.obligatorio && !(documentos ?? []).some((d) => d.tipo === s.tipo)).length > 0 && (
Documentos obligatorios pendientes:{' '}
{SLOTS_DOCS_APARTADO.filter((s) => s.obligatorio && !(documentos ?? []).some((d) => d.tipo === s.tipo))
.map((s) => s.etiqueta)
.join(', ')}
)}
{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))}
previsualizar(d)}
className="font-medium text-accent hover:underline disabled:opacity-50"
>
Previsualizar
descargar(d)}
className="font-medium text-accent hover:underline disabled:opacity-50"
>
{obteniendo === d.id ? 'Descargando…' : 'Descargar'}
))}
)}
)
})}
* 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,
esAdmin,
vendedores,
onReasignar,
onMover,
}: {
lead: LeadMiDia
onAccion: (estado: string, fecha?: string) => void
onConvertir: () => void
onDescartar: () => void
onVerDetalle?: () => void
enviando: boolean
acciones?: string[]
/** Solo admin: reasignar a cualquier vendedor y mover entre tableros */
esAdmin?: boolean
vendedores?: { id: string; nombre: string }[]
onReasignar?: (usuarioId: string) => void
onMover?: () => void
}) {
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
{/* Admin: reasignar a cualquier vendedor y mover entre tableros */}
{esAdmin && (
Admin
{
if (e.target.value !== '' && onReasignar) onReasignar(e.target.value)
e.target.value = ''
}}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs text-[#374151] transition-colors focus:border-accent focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1]"
aria-label="Reasignar lead a vendedor"
>
Reasignar a…
{(vendedores ?? []).map((v) => (
{v.nombre}
))}
{lead.fuente === 'lead' ? '→ Oportunidad' : '→ Leads'}
)}
)
}
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')
await cargar() // refleja la etapa nueva sin recargar la página
} catch {
toast.error('No se pudo actualizar la etapa; el cotizador se abrirá de todos modos.')
} finally {
setEnviando(false) // sin esto la página quedaba bloqueada hasta recargar
}
window.open(urlCotizadorParaOportunidad(op), '_blank')
}
/** Reasignar lead a otro vendedor (solo admin) */
async function reasignar(lead: LeadMiDia, usuarioId: string) {
await ejecutar(async () => {
const nombre = await reasignarLeadApi(lead.id, usuarioId)
toast.success(`Lead reasignado a ${nombre || 'vendedor'}`)
}, 'No se pudo reasignar el lead')
}
/** Mover lead entre tableros Leads ↔ Oportunidades (solo admin) */
async function mover(lead: LeadMiDia) {
const destino = lead.fuente === 'lead' ? 'OPORTUNIDADES' : 'LEADS'
await ejecutar(async () => {
await moverLeadApi(lead.id, destino)
toast.success(destino === 'OPORTUNIDADES' ? 'Lead movido a Oportunidades' : 'Lead movido a Leads')
}, 'No se pudo mover el lead')
}
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,
})
: []
// Escrituración: apartados en fases activas posteriores a Firma Cliente
// (Cancelo y Escriturado son terminales). Usa los filtros PROPIOS del tab
// para que el conteo del tab refleje la lista filtrada de escrituración.
const enEscrituracion = resumen
? (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') ||
// Estados finales: sin ellos el registro desaparecía de Mi Día al
// marcarlo como Escriturado o Titulado
e.includes('ESCRITURADO') ||
e.includes('TITULADO') ||
e.includes('CANCO')
)
})
: []
const escrituracionFiltrados = aplicarFiltros(enEscrituracion, filtrosPorTab.escrituracion, {
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: escrituracionFiltrados.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)
else if (tabCrm === 'escrituracion') exportarApartados(escrituracionFiltrados)
}}
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}
esAdmin={rol === 'admin' || rol === 'manager'}
vendedores={resumen.equipo?.porVendedor.map((v) => ({ id: v.usuarioId, nombre: v.nombre ?? '' }))}
onReasignar={(usuarioId) => void reasignar(lead, usuarioId)}
onMover={() => void mover(lead)}
/>
))}
)}
>
)}
{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 ? (
{
if (estado === 'Cancelacion Apartado') {
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
const a = apartadosFiltrados.find((x) => x.id === id)
if (a) setApartadoCancelando({ id: a.id, nombre: a.nombre })
return
}
void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')
}}
/>
) : (
{apartadosFiltrados.map((a) => (
setLeadSeleccionado(a)}
onFase={(fase) => {
if (fase === 'Cancelacion Apartado') {
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
setApartadoCancelando({ id: a.id, nombre: a.nombre })
return
}
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.
{escrituracionFiltrados.length === 0 ? (
) : vistaKanban ? (
{
if (estado === 'Cancelo') {
const a = escrituracionFiltrados.find((x) => x.id === id)
if (a) setApartadoCancelando({ id: a.id, nombre: a.nombre })
return
}
void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')
}}
/>
) : (
{escrituracionFiltrados.map((apartado) => (
setLeadSeleccionado(apartado)}
className="font-medium text-[#111827] hover:text-accent hover:underline dark:text-[#E2E8F0]"
>
{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
// Si la op ya pasó por Agendo Cita (Cotizador/Aparto) NO se
// retrocede la etapa: solo se actualiza la fecha de la cita.
const etapa = op.etapa === 'Cotizador' || op.etapa === 'Aparto' ? op.etapa : 'Agendo Cita'
return ejecutar(
() => cambiarEtapaOportunidad(op.id, etapa, fechaHora),
'No se pudo guardar la cita',
)
}
: undefined
}
/>
)}
>
)
}