import { useCallback, useEffect, useState, type FormEvent } from 'react'
import KpiCard from '../components/KpiCard'
import BannerModoLocal from '../components/BannerModoLocal'
import ChipPendienteSync from '../components/ChipPendienteSync'
import type { AccionesSesion } from '../components/AuthGate'
import {
esActividadVencida,
esRolConEquipo,
formatoFechaCorta,
hoyLocal,
DESARROLLOS_LEAD,
ETIQUETA_FASE_APARTADO,
ETIQUETA_FASE_OPORTUNIDAD,
FASES_APARTADO,
MOTIVOS_NO_COMPRA,
ORIGENES_LEAD,
SLOTS_DOCS_APARTADO,
SUBCATEGORIAS_NO_COMPRA,
componerTelefono,
LADAS,
textoDiasRestantes,
urlAfluenciaParaOportunidad,
urlCotizadorParaOportunidad,
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,
fetchResumenMiDia,
listarDocumentosApartado,
subirDocumentoApartado,
type DocumentoApartado,
} from '../services/mi-dia'
import { getSesion } from '../services/auth'
function TituloSeccion({ children }: { children: string }) {
return
{children}
}
function EstadoVacio({ mensaje }: { mensaje: string }) {
return (
)
}
const CLASE_BOTON_ACCION =
'rounded-lg border border-[#D1D5DB] bg-white px-3 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 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]'
/** Modal de descarte: motivo + subcategoría (catálogo Monday) + comentario opcional */
function ModalDescartar({
lead,
onCancelar,
onConfirmar,
enviando,
}: {
lead: { id: string; nombre: string }
onCancelar: () => void
onConfirmar: (motivo: MotivoNoCompra, subcategoria: string | undefined, comentario: string) => void
enviando: boolean
}) {
const [motivo, setMotivo] = useState(MOTIVOS_NO_COMPRA[0])
const [subcategoria, setSubcategoria] = useState('')
const [comentario, setComentario] = useState('')
const subcategorias = SUBCATEGORIAS_NO_COMPRA[motivo]
return (
Descartar lead
{lead.nombre}
)
}
/** Fila de oportunidad con botones de fase del funnel (Agendo Cita lleva fecha) */
function FilaOportunidad({
op,
onEtapa,
onCotizar,
onAfluencia,
onDescartar,
enviando,
}: {
op: OportunidadMiDia
onEtapa: (etapa: string, fecha?: string) => void
onCotizar: () => void
onAfluencia: () => void
onDescartar: () => void
enviando: boolean
}) {
const [conCita, setConCita] = useState(false)
const [fechaCita, setFechaCita] = useState('')
// Sin 'No Asistio' ni 'Abandono': son redundantes con Descartar
const fases = ['Agendo Cita', 'Aparto'] as const
return (
{op.nombre}
{op.pendienteSync === true && }
{op.telefono || 'Sin teléfono'}
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
{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 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 FilaApartado({
apartado,
onFase,
enviando,
}: {
apartado: ApartadoMiDia
onFase: (fase: FaseApartado) => void
enviando: boolean
}) {
const [docsAbiertos, setDocsAbiertos] = useState(false)
return (
{apartado.nombre}
{apartado.pendienteSync === true && }
{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) => (
{
// Firma Cliente (Venta): abre el alta de cliente prellenada con
// todo lo recolectado del apartado (ligada, sin duplicar)
if (fase === 'Firma Cliente (Venta)') {
window.location.href = `/client/?apartado=${apartado.id}`
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'
: fase === 'Firma Cliente (Venta)'
? '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 === 'Firma Cliente (Venta)' ? 'Firma cliente (venta) →' : ETIQUETA_FASE_APARTADO[fase]}
))}
setDocsAbiertos((v) => !v)}
aria-expanded={docsAbiertos}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] 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 */
type TabCrm = 'leads' | 'oportunidades' | 'apartados'
function TabsCrm({
activa,
conteos,
onCambiar,
}: {
activa: TabCrm
conteos: Record
onCambiar: (tab: TabCrm) => void
}) {
const tabs: { id: TabCrm; titulo: string }[] = [
{ id: 'leads', titulo: 'Mis leads' },
{ id: 'oportunidades', titulo: 'Mis oportunidades' },
{ id: 'apartados', titulo: 'Mis apartados' },
]
return (
{tabs.map((tab) => {
const activo = tab.id === activa
return (
onCambiar(tab.id)}
className={`rounded-lg px-4 py-2 text-xs font-semibold uppercase tracking-wider transition-colors duration-100 ${
activo ? 'bg-accent text-white' : 'text-[#64748B] hover:bg-[#F9FAFB] hover:text-accent'
}`}
>
{tab.titulo}
{conteos[tab.id]}
)
})}
)
}
/** 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,
enviando,
}: {
lead: LeadMiDia
onAccion: (estado: string, fecha?: string) => void
onConvertir: () => void
onDescartar: () => void
enviando: boolean
}) {
return (
{lead.nombre}
{lead.pendienteSync === true && }
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
{lead.telefono || 'Sin teléfono'}
{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 [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 [nota, setNota] = useState(null)
// 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 {
setResumen(await fetchResumenMiDia())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar tu día')
} finally {
setCargando(false)
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
/** 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) {
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)
setNota(null)
try {
await convertirLead(lead.id)
await cargar()
setNota(`${lead.nombre} pasó a Mis oportunidades (Agendo Cita).`)
} catch (err) {
setAviso(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 actualiza ESE item en Monday). 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 {
setAviso('No se pudo actualizar la etapa en Monday; el cotizador se abrirá de todos modos.')
}
window.location.href = urlCotizadorParaOportunidad(op)
}
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',
})
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)
}
}
const hoy = hoyLocal()
const iniciales = nombreVendedor
.split(' ')
.filter((p) => p !== '')
.map((p) => p.charAt(0))
.slice(0, 2)
.join('')
.toUpperCase()
return (
<>
{nombreVendedor}
{formatoFechaCorta(hoy)}
{error && (
Sin datos del API — {error}
)}
{aviso && (
{aviso}
)}
{nota && (
{nota}
)}
{resumen?.sinMapeoMonday && (
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
)}
{!resumen && cargando && }
{resumen && (
<>
l.fuente === 'lead').length,
oportunidades: resumen.misOportunidades.items.length,
apartados: resumen.misApartados.length,
}}
onCambiar={setTabCrm}
/>
{tabCrm === 'leads' &&
(resumen.misLeads.filter((l) => l.fuente === 'lead').length === 0 ? (
) : (
{resumen.misLeads
.filter((l) => l.fuente === 'lead')
.map((lead) => (
void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
onConvertir={() => void convertir(lead)}
onDescartar={() => setDescartando(lead)}
/>
))}
))}
{tabCrm === 'oportunidades' &&
(resumen.misOportunidades.items.length === 0 ? (
) : (
{resumen.misOportunidades.items.map((op) => (
void ejecutar(() => cambiarEtapaOportunidad(op.id, etapa, fecha), 'No se pudo actualizar la oportunidad')
}
onCotizar={() => void cotizarOportunidad(op)}
onAfluencia={() => {
window.location.href = urlAfluenciaParaOportunidad(op)
}}
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
/>
))}
))}
{tabCrm === 'apartados' &&
(resumen.misApartados.length === 0 ? (
) : (
{resumen.misApartados.map((a) => (
void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')}
/>
))}
))}
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 ? (
) : (
)}
)}
>
)}
{descartando && (
setDescartando(null)}
onConfirmar={(motivo, subcategoria, comentario) => {
const lead = descartando
setDescartando(null)
void ejecutar(
() => descartarLead(lead.id, motivo, comentario === '' ? undefined : comentario, lead.fuente, subcategoria),
'No se pudo descartar el lead',
)
}}
/>
)}
>
)
}