Files
FRASA-CAS/apps/mi-dia/src/pages/MiDiaPage.tsx
Consult AS Technology cfc0acb5d3 fix(crm): re-captura tras limpieza, bitacora compartida, origen en oportunidades y privacidad del buscador
1. Re-captura: el dedup por telefono/email ya no considera etapas cerradas
   (Descartado, Perdido, Cancelo, Cancelacion Apartado). Los restos de la
   ultima limpieza (17 filas de APARTADO cancelados sin contraparte activa)
   bloqueaban a los vendedores al re-capturar; los registros activos siguen
   bloqueando con 409 como siempre.
2. Bitacora compartida: GET /recordatorios con leadId/clienteClave devuelve las
   notas de TODOS los usuarios de ese cliente (antes cada quien solo veia las
   suyas). Mi dia pide la bitacora con esos filtros; la lista personal sigue
   siendo propia.
3. Origen en oportunidades: el resumen de mi-dia ya devuelve origen y la card
   de cada oportunidad lo muestra (telefono · desarrollo · canal · modelo).
4. Privacidad del buscador: /leads/buscar y /buscar filtran por dueno cuando
   el rol es vendedor (leads por usuarioId, contactos por capturadoPorId,
   cotizaciones por vendedorId). Los roles de supervision siguen viendo todo.
   Ya no aparecen leads de otros asesores con nombre y telefono al teclear.

Verificado en vivo contra el API: 10/10 comprobaciones (re-captura del
telefono cancelado 201, duplicado activo 409, nota del admin visible para la
vendedora, origen presente en 27/27 oportunidades, buscador del vendedor sin
datos ajenos y del admin completo). 716 pruebas del API y 32 de mi-dia en verde.
Los tests de roles ahora usan prisma mockeado (la BD viva cambia cuando el
cliente edita permisos desde la UI).
2026-09-24 09:49:41 +00:00

1877 lines
82 KiB
TypeScript

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 <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{children}</h2>
}
/** 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 (
<div className="mb-6 rounded-xl bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-950/40 dark:to-blue-900/30 border border-blue-200 dark:border-blue-800/40 p-4 shadow-sm">
<div className="flex items-center gap-3">
<span className="text-2xl">{icono}</span>
<div>
<p className="text-base font-semibold text-[#111827] dark:text-[#E2E8F0]">
{saludo}, {nombre}
</p>
<p className="mt-0.5 text-sm text-[#64748B] dark:text-[#94A3B8]">
Tienes {leadsNuevos} lead{leadsNuevos !== 1 ? 's' : ''} nuevos hoy,{' '}
{oportunidadesActivas} oportunidade{oportunidadesActivas !== 1 ? 's' : ''} activa{oportunidadesActivas !== 1 ? 's' : ''},{' '}
{apartadosPorVencer} apartado{apartadosPorVencer !== 1 ? 's' : ''} por vencer
</p>
</div>
</div>
</div>
)
}
function EstadoVacio({ mensaje }: { mensaje: string }) {
return (
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white dark:border-[#334155] dark:bg-[#1E293B]">
<p className="text-xs font-semibold uppercase tracking-wider text-[#94A3B8]">{mensaje}</p>
</div>
)
}
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 <span>Sin teléfono</span>
return (
<>
<a
href={`tel:${telefono.replace(/\s+/g, '')}`}
className="text-accent hover:underline"
>
{telefono}
</a>
<a
href={urlWhatsApp(nombre, telefono, vendedor)}
target="_blank"
rel="noopener noreferrer"
title={`Enviar WhatsApp a ${nombre}`}
aria-label={`Enviar WhatsApp a ${nombre}`}
className="ml-1.5 hover:underline"
>
💬
</a>
</>
)
}
/** 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 (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="flex items-center gap-2 text-sm font-medium">
<button
type="button"
onClick={onVerDetalle}
className="text-left text-accent hover:underline cursor-pointer"
>
{op.nombre}
</button>
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
<EnlacesContacto nombre={op.nombre} telefono={op.telefono} />
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
{op.origen ? ` · ${op.origen}` : ''}
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
</p>
{op.fechaCita && (
<p className="mt-0.5 text-[11px] text-accent font-medium">
📅 Cita: {op.fechaCita.replace('T', ' ')}
</p>
)}
{op.ultimoCambio && (
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Último cambio: {op.ultimoCambio.detalle} · {op.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(op.ultimoCambio.fechaISO.slice(0, 10))}
</p>
)}
</div>
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
{op.etapa || 'Sin etapa'}
</span>
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{/* 1. Confirmar cita (etapa Agendo Cita): al primer toque pide la fecha */}
<button
type="button"
disabled={enviando}
onClick={() => (conCita ? onEtapa('Agendo Cita', fechaCita || undefined) : setConCita(true))}
className={CLASE_BOTON_ACCION}
>
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD['Agendo Cita']}
</button>
{conCita && (
<input
type="datetime-local"
aria-label="Fecha y hora de la cita"
value={fechaCita}
onChange={(e) => 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 */}
<button
type="button"
disabled={enviando}
onClick={onAfluencia}
className="rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:opacity-50 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
>
Afluencia →
</button>
{/* 3. Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */}
<button
type="button"
disabled={enviando}
onClick={onCotizar}
className="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"
>
Cotizar →
</button>
{/* 4. Aparto: marca la etapa (verde) */}
<button
type="button"
disabled={enviando}
onClick={() => 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
</button>
{/* 5. Descartar */}
<button
type="button"
disabled={enviando || (acciones !== undefined && !acciones.includes('descartar_lead'))}
onClick={onDescartar}
className="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"
>
Descartar
</button>
</div>
</li>
)
}
/** 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<string>('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 (
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</p>
<form
className="mt-6 space-y-4"
onSubmit={(e) => {
e.preventDefault()
onConfirmar(motivo, subcategoria === '' ? undefined : subcategoria, comentario.trim())
}}
>
<div>
<label htmlFor="motivo-cancel" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Motivo</label>
<select
id="motivo-cancel"
value={motivo}
onChange={(e) => {
setMotivo(e.target.value)
setSubcategoria('')
}}
className="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"
>
{MOTIVOS_NO_COMPRA.map((m) => (
<option key={m} value={m}>{m === 'Ubicacion' ? 'Ubicación' : m}</option>
))}
</select>
</div>
<div>
<label htmlFor="subcategoria-cancel" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Subcategoría</label>
<select
id="subcategoria-cancel"
value={subcategoria}
onChange={(e) => setSubcategoria(e.target.value)}
className="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"
>
<option value="">Sin especificar</option>
{subcategorias.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label htmlFor="comentario-cancel" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Comentario (opcional)</label>
<textarea
id="comentario-cancel"
value={comentario}
onChange={(e) => setComentario(e.target.value)}
rows={3}
className="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"
/>
</div>
<div className="flex justify-end gap-3">
<button type="button" onClick={onCancelar} disabled={enviando}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-sm font-medium text-[#374151] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] hover:bg-[#F9FAFB] disabled:opacity-50">
Cancelar
</button>
<button type="submit" disabled={enviando}
className="rounded-lg bg-red-600 px-5 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 disabled:opacity-50">
{enviando ? 'Procesando…' : 'Confirmar'}
</button>
</div>
</form>
</div>
</div>
)
}
function FilaApartado({
apartado,
onFase,
onVerDetalle,
enviando,
}: {
apartado: ApartadoMiDia
onFase: (fase: FaseApartado) => void
onVerDetalle?: () => void
enviando: boolean
}) {
const [docsAbiertos, setDocsAbiertos] = useState(false)
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="flex items-center gap-2 text-sm font-medium">
<button
type="button"
onClick={onVerDetalle}
className="text-left text-accent hover:underline cursor-pointer"
>
{apartado.nombre}
</button>
{apartado.unidadCodigo ? (
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
) : null}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
<EnlacesContacto nombre={apartado.nombre} telefono={apartado.telefono ?? ''} />
{apartado.desarrollo ? ` · ${apartado.desarrollo}` : ''}
{apartado.origen ? ` · ${apartado.origen}` : ''}
{apartado.email ? ` · ${apartado.email}` : ''}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{apartado.fechaLimite !== '' ? `Fecha límite: ${formatoFechaCorta(apartado.fechaLimite)}` : 'Sin fecha límite'}
</p>
{apartado.ultimoCambio && (
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Último cambio: {apartado.ultimoCambio.detalle} · {apartado.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(apartado.ultimoCambio.fechaISO.slice(0, 10))}
</p>
)}
</div>
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
{apartado.estado || 'Sin estado'}
</span>
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{FASES_APARTADO.map((fase) => (
<button
key={fase}
type="button"
disabled={enviando}
onClick={() => {
// 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]}
</button>
))}
<button
type="button"
onClick={() => 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'}
</button>
</div>
{docsAbiertos && <PanelDocumentosApartado itemId={apartado.id} />}
</li>
)
}
/**
* 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<DocumentoApartado[] | null>(null)
const [error, setError] = useState('')
const [subiendo, setSubiendo] = useState<SlotDocApartado | null>(null)
const [obteniendo, setObteniendo] = useState<string | null>(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 (
<div className="mt-3 rounded-lg border border-dashed border-[#D1D5DB] bg-[#F8FAFC] dark:border-[#334155] dark:bg-[#0F172A] p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Expediente del cliente
</p>
{documentos !== null &&
SLOTS_DOCS_APARTADO.filter((s) => s.obligatorio && !(documentos ?? []).some((d) => d.tipo === s.tipo)).length > 0 && (
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
Documentos obligatorios pendientes:{' '}
{SLOTS_DOCS_APARTADO.filter((s) => s.obligatorio && !(documentos ?? []).some((d) => d.tipo === s.tipo))
.map((s) => s.etiqueta)
.join(', ')}
</p>
)}
{error && (
<p role="alert" className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{error}
</p>
)}
<ul className="mt-3 space-y-3">
{SLOTS_DOCS_APARTADO.map((slot) => {
const delSlot = (documentos ?? []).filter((d) => d.tipo === slot.tipo)
return (
<li key={slot.tipo}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-medium text-[#374151] dark:text-[#CBD5E1]">
{slot.etiqueta}
{slot.obligatorio && <span className="ml-1 text-red-600">*</span>}
</p>
<label
className={`cursor-pointer rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white dark:bg-[#0F172A] dark:border-accent dark:text-accent ${
subiendo !== null ? 'pointer-events-none opacity-50' : ''
}`}
>
{subiendo === slot.tipo ? 'Subiendo…' : 'Subir archivo'}
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
className="sr-only"
disabled={subiendo !== null}
onChange={(e) => {
void subir(slot.tipo, e.target.files?.[0])
e.target.value = ''
}}
/>
</label>
</div>
{documentos === null ? (
<p className="mt-1 text-[11px] text-[#94A3B8]">Cargando…</p>
) : delSlot.length === 0 ? (
<p className="mt-1 text-[11px] text-[#94A3B8]">Sin archivos</p>
) : (
<ul className="mt-1 space-y-0.5">
{delSlot.map((d) => (
<li key={d.id} className="text-[11px] text-[#64748B] dark:text-[#94A3B8]">
<div className="flex flex-wrap items-center justify-between gap-1">
<span className="min-w-0 truncate">
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
</span>
<span className="flex shrink-0 gap-2">
<button
type="button"
disabled={obteniendo !== null}
onClick={() => previsualizar(d)}
className="font-medium text-accent hover:underline disabled:opacity-50"
>
Previsualizar
</button>
<button
type="button"
disabled={obteniendo !== null}
onClick={() => descargar(d)}
className="font-medium text-accent hover:underline disabled:opacity-50"
>
{obteniendo === d.id ? 'Descargando…' : 'Descargar'}
</button>
</span>
</div>
</li>
))}
</ul>
)}
</li>
)
})}
</ul>
<p className="mt-3 text-[11px] text-[#94A3B8]">
* Obligatorios para el proceso de venta. PDF, JPG o PNG, máximo 10 MB.
</p>
</div>
)
}
/** 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<TabCrm, number>
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 (
<div className="mb-4 flex items-center gap-1 overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white p-1 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" role="tablist">
{tabs.map((tab) => {
const activo = tab.id === activa
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activo}
onClick={() => 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 && (
<span className={`ml-1.5 font-normal ${activo ? 'text-blue-100' : 'text-[#94A3B8]'}`}>
{conteos[tab.id]}
</span>
)}
</button>
)
})}
{activa !== 'tablero' && (
<div className="ml-auto flex gap-1">
<button
type="button"
onClick={onToggleKanban}
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-[11px] font-medium text-[#374151] shadow-sm transition-all hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#1E293B]"
>
{vistaKanban ? 'Lista' : 'Kanban'}
</button>
</div>
)}
</div>
)
}
/** 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 (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="flex items-center gap-2 text-sm font-medium">
<button
type="button"
onClick={onVerDetalle}
className="text-left text-accent hover:underline cursor-pointer"
>
{lead.nombre}
</button>
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
<span
className={
lead.fuente === 'oportunidad'
? 'rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-800'
: 'rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600'
}
>
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
</span>
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
<EnlacesContacto nombre={lead.nombre} telefono={lead.telefono} />
{lead.desarrollo ? ` · ${lead.desarrollo}` : ''}
{lead.origen ? ` · ${lead.origen}` : ''}
</p>
{lead.ultimoCambio && (
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Último cambio: {lead.ultimoCambio.detalle} · {lead.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(lead.ultimoCambio.fechaISO.slice(0, 10))}
</p>
)}
</div>
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
{lead.estado || 'Sin estado'}
</span>
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<button type="button" disabled={enviando} onClick={() => onAccion('CONTACTADO')} className={CLASE_BOTON_ACCION}>
Contactado
</button>
<button type="button" disabled={enviando} onClick={() => onAccion('Intento de contacto')} className={CLASE_BOTON_ACCION}>
Intento
</button>
<button type="button" disabled={enviando} onClick={() => onAccion('INTERESADO')} className={CLASE_BOTON_ACCION}>
Interesado
</button>
{/* Agendo: convierte el lead en oportunidad (etapa Agendo Cita) */}
<button
type="button"
disabled={enviando}
onClick={onConvertir}
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"
>
Agendo →
</button>
<button
type="button"
disabled={enviando || (acciones !== undefined && !acciones.includes("descartar_lead"))}
onClick={onDescartar}
className="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"
>
Descartar
</button>
</div>
{/* Admin: reasignar a cualquier vendedor y mover entre tableros */}
{esAdmin && (
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-dashed border-[#E5E7EB] pt-2 dark:border-[#334155]">
<span className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">Admin</span>
<select
value=""
disabled={enviando}
onChange={(e) => {
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"
>
<option value="">Reasignar a…</option>
{(vendedores ?? []).map((v) => (
<option key={v.id} value={v.id}>{v.nombre}</option>
))}
</select>
<button type="button" disabled={enviando} onClick={onMover} className={CLASE_BOTON_ACCION}>
{lead.fuente === 'lead' ? '→ Oportunidad' : '→ Leads'}
</button>
</div>
)}
</li>
)
}
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<ResumenMiDia | null>(null)
const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true)
const [enviando, setEnviando] = useState(false)
const [aviso, setAviso] = useState<string | null>(null)
const [descartando, setDescartando] = useState<{
id: string
nombre: string
fuente: 'lead' | 'oportunidad'
} | null>(null)
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
const [leadSeleccionado, setLeadSeleccionado] = useState<LeadMiDia | OportunidadMiDia | ApartadoMiDia | null>(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<Record<TabCrm, FiltrosCRM>>({
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<Record<TabCrm, readonly string[]>> = {
leads: ESTADOS_LEAD,
oportunidades: FASES_OPORTUNIDAD,
apartados: FASES_APARTADO,
escrituracion: FASES_ESCRITURACION,
}
const [offline, setOffline] = useState(!navigator.onLine)
const [offlineFecha, setOfflineFecha] = useState<string | null>(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<ClaveLada>('MX')
const [capTelefono, setCapTelefono] = useState('')
const [capEmail, setCapEmail] = useState('')
const [capOrigen, setCapOrigen] = useState<string>(ORIGENES_LEAD[0])
const [capDesarrollo, setCapDesarrollo] = useState<string>('')
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<unknown>, 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 (
<>
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
<div className="flex h-14 items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
<div className="flex min-w-0 items-center gap-3">
<a
href="/"
className="shrink-0 rounded-lg px-2 py-1.5 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1] transition-colors hover:bg-[#F9FAFB] hover:text-accent"
>
← Menú
</a>
<p className="shrink-0 text-xs font-semibold uppercase tracking-wider text-accent">
CRM360 FRASA <span aria-hidden="true" className="text-[#F97316]">·</span> Mi día
</p>
<span aria-hidden="true" className="hidden h-4 w-px bg-[#E5E7EB] sm:block" />
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-accent/10 text-xs font-semibold text-accent"
>
{iniciales}
</span>
<span className="hidden truncate text-sm font-medium text-[#111827] dark:text-[#E2E8F0] sm:block">{nombreVendedor}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
<DarkModeToggle />
<button
type="button"
onClick={accionesSesion.onCambiarPassword}
className="hidden rounded-md px-2 py-1.5 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0] md:inline-block"
>
Contraseña
</button>
<button
type="button"
onClick={accionesSesion.onLogout}
className="rounded-md px-2 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:text-accent-hover"
>
Salir
</button>
<button
type="button"
onClick={() => void cargar()}
disabled={cargando}
className="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 md:px-4 md:py-2 md:text-sm"
>
{cargando ? 'Actualizando…' : 'Actualizar'}
</button>
</div>
</div>
</header>
{offline && (
<div className="px-4 pt-4 sm:px-6 lg:px-8">
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
Modo offline{offlineFecha ? ` — datos de ${offlineFecha}` : ''}
</div>
</div>
)}
<main className={`px-4 py-6 sm:px-6 lg:px-8 ${vistaKanban ? 'w-full' : 'mx-auto max-w-[1280px]'}`}>
<div className="mb-6">
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{nombreVendedor}</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">{formatoFechaCorta(hoy)}</p>
</div>
{error && (
<p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
Sin datos del API — {error}
</p>
)}
{aviso && (
<p role="alert" className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
{aviso}
</p>
)}
{!resumen && cargando && <SkeletonList count={4} />}
{resumen && (
<>
<ResumenMatutino
nombre={nombreVendedor}
leadsNuevos={resumen.misLeads.filter((l) => l.fuente === 'lead').length}
oportunidadesActivas={resumen.misOportunidades.items.length}
apartadosPorVencer={resumen.apartadosPorVencer.length}
/>
<section aria-label="Indicadores del día" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard titulo="Leads asignados" valor={resumen.misLeads.length} />
<KpiCard
titulo="Actividades de hoy"
valor={resumen.misActividadesHoy.length}
detalle={`${resumen.conteos.actividadesAbiertas} abiertas en total`}
/>
<KpiCard titulo="Apartados por vencer" valor={resumen.apartadosPorVencer.length} detalle="próximos 7 días" />
<KpiCard titulo="Leads nuevos esta semana" valor={resumen.conteos.leadsNuevosSemana} />
</section>
<div className={`mt-6 flex flex-col gap-6 ${vistaKanban ? '' : 'md:grid md:grid-cols-2 md:gap-6 lg:grid-cols-3'}`}>
<div className={`order-2 flex flex-col gap-6 ${vistaKanban ? '' : 'md:order-1 md:col-span-2 lg:col-span-2'}`}>
<section aria-label="CRM personal">
<TabsCrm
activa={tabCrm}
conteos={{
leads: resumen.misLeads.filter((l) => 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' && (
<div className="flex items-center gap-2">
<div className="flex-1">
<FiltrosAvanzados valor={filtros} onChange={actualizarFiltros} estados={estadosPorTab[tabCrm]} />
</div>
<button
type="button"
onClick={() => {
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
</button>
</div>
)}
{tabCrm === 'leads' && (
<>
{leadsFiltrados.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" />
) : vistaKanban ? (
<KanbanView
items={leadsFiltrados}
columnas={columnasLeads}
obtenerColumna={obtenerColumnaLead}
onCambiarEstado={(id, estado) => {
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')
}}
/>
) : (
<ul className="space-y-2">
{leadsFiltrados.map((lead) => (
<FilaLead
key={`${lead.fuente}-${lead.id}`}
lead={lead}
enviando={enviando}
onAccion={(estado, fecha) => 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)}
/>
))}
</ul>
)}
</>
)}
{tabCrm === 'oportunidades' && (
<>
{opsFiltradas.length === 0 ? (
<EstadoVacio mensaje="Sin oportunidades asignadas" />
) : vistaKanban ? (
<KanbanView
items={opsFiltradas}
columnas={columnasOportunidades}
obtenerColumna={obtenerColumnaOp}
onCambiarEstado={(id, estado) => {
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')
}}
/>
) : (
<ul className="space-y-2">
{opsFiltradas.map((op) => (
<FilaOportunidad
key={op.id}
op={op}
enviando={enviando}
onEtapa={(etapa, fecha) =>
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}
/>
))}
</ul>
)}
</>
)}
{tabCrm === 'apartados' && (
<>
{apartadosFiltrados.length === 0 ? (
<EstadoVacio mensaje="Sin apartados asignados" />
) : vistaKanban ? (
<KanbanView
items={apartadosFiltrados}
columnas={columnasApartados}
obtenerColumna={obtenerColumnaApartado}
onCambiarEstado={(id, estado) => {
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')
}}
/>
) : (
<ul className="space-y-2">
{apartadosFiltrados.map((a) => (
<li
key={a.id}
>
<FilaApartado
apartado={a}
enviando={enviando}
onVerDetalle={() => 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')
}}
/>
</li>
))}
</ul>
)}
</>
)}
{tabCrm === 'escrituracion' && (
<section aria-label="Escrituración">
<TituloSeccion>Escrituración</TituloSeccion>
<p className="text-sm text-[#64748B] dark:text-[#94A3B8] mb-4">
Seguimiento de escrituración de apartados.
</p>
{escrituracionFiltrados.length === 0 ? (
<EstadoVacio mensaje="No hay apartados en escrituración" />
) : vistaKanban ? (
<KanbanView
items={escrituracionFiltrados}
columnas={columnasEscrituracion}
obtenerColumna={obtenerColumnaEscrituracion}
onCambiarEstado={(id, estado) => {
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')
}}
/>
) : (
<ul className="space-y-3">
{escrituracionFiltrados.map((apartado) => (
<li key={apartado.id} className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<button
type="button"
onClick={() => setLeadSeleccionado(apartado)}
className="font-medium text-[#111827] hover:text-accent hover:underline dark:text-[#E2E8F0]"
>
{apartado.nombre}
</button>
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">{apartado.unidadCodigo || 'Sin unidad'} · {apartado.estado}</p>
</div>
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{(['Venta', 'Seguimiento', 'Integracion', 'X Autorizar', 'Autorizado', 'X Escriturar', 'Escriturado', 'Titulado', 'Cancelo'] as const).map((fase) => (
<button
key={fase}
type="button"
disabled={enviando}
onClick={() => {
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]}
</button>
))}
</div>
</li>
))}
</ul>
)}
</section>
)}
{tabCrm === 'tablero' && (
<DashboardVendedor />
)}
{tabCrm === 'calendario' && (
<section aria-label="Calendario">
<CalendarioCitas
// Todas las actividades abiertas del usuario (no solo las de hoy);
// los resúmenes cacheados previos pueden no traer el campo nuevo.
actividades={resumen.todasMisActividades ?? resumen.misActividadesHoy}
oportunidades={resumen.misOportunidades.items}
onVerDetalle={(op) => setLeadSeleccionado(op)}
/>
</section>
)}
</section>
<section aria-label="Mis capturas de afluencia">
<TituloSeccion>Mis capturas de afluencia</TituloSeccion>
{resumen.misCapturas.length === 0 ? (
<EstadoVacio mensaje="Aún no registras afluencias" />
) : (
<ul className="space-y-2">
{resumen.misCapturas.map((c) => (
<li
key={c.leadId}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm dark:bg-[#1E293B] dark:border-[#1E293B]"
>
<div>
<p className="flex items-center gap-2 text-sm font-medium">
{c.nombre}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{c.telefono || 'Sin teléfono'}
{c.origen ? ` · ${c.origen}` : ''}
</p>
</div>
<div className="flex items-center gap-3">
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
{c.estado || '—'}
</span>
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
</span>
</div>
</li>
))}
</ul>
)}
</section>
<section aria-label="Mis clientes">
<TituloSeccion>Mis clientes</TituloSeccion>
{resumen.misClientes.length === 0 ? (
<EstadoVacio mensaje="Aún no registras clientes" />
) : (
<ul className="space-y-2">
{resumen.misClientes.map((c) => (
<li
key={c.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm dark:bg-[#1E293B] dark:border-[#1E293B]"
>
<div>
<p className="text-sm font-medium">{c.nombre}</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{[c.proyecto, c.unidadCodigo].filter((p) => p !== '').join(' · ') || 'Alta sin proyecto'}
</p>
</div>
<div className="flex items-center gap-3">
{/* Estado del apartado; solo si hay apartado ligado */}
{c.estadoApartado !== null && (
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
{c.estadoApartado}
</span>
)}
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
c.completado
? 'bg-gray-100 text-gray-600'
: 'bg-amber-50 text-amber-700'
}`}
>
{c.completado ? 'Cliente' : 'En captura'}
</span>
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
</span>
</div>
</li>
))}
</ul>
)}
</section>
</div>
<aside className={`order-1 flex flex-col gap-6 ${vistaKanban && tabCrm === 'leads' ? 'hidden' : 'md:order-2 lg:sticky lg:top-20 lg:self-start'}`}>
<section aria-label="Mis actividades">
<TituloSeccion>Mis actividades</TituloSeccion>
{resumen.misActividadesHoy.length === 0 ? (
<EstadoVacio mensaje="Sin actividades para hoy" />
) : (
<ul className="space-y-2">
{resumen.misActividadesHoy.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm dark:bg-[#1E293B] dark:border-[#1E293B]"
>
<p className="text-sm">
{a.nombre}
{a.tipo ? <span className="ml-2 text-xs uppercase tracking-wider text-[#94A3B8]">{a.tipo}</span> : null}
</p>
<span
className={`text-xs font-medium ${
esActividadVencida(a.fecha, hoy) ? 'text-red-600' : 'text-[#64748B] dark:text-[#94A3B8]'
}`}
>
{formatoFechaCorta(a.fecha)}
</span>
</li>
))}
</ul>
)}
</section>
{resumen.apartadosPorVencer.length > 0 && (
<section aria-label="Apartados por vencer">
<TituloSeccion>Apartados por vencer</TituloSeccion>
<ul className="space-y-2">
{resumen.apartadosPorVencer.map((a) => (
<li
key={a.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm dark:bg-[#1E293B] dark:border-[#1E293B]"
>
<p className="text-sm">
{a.nombre}
{a.unidadCodigo ? <span className="ml-2 text-xs text-[#94A3B8]">{a.unidadCodigo}</span> : null}
</p>
<span
className={`text-xs font-medium ${
a.diasRestantes <= 1 ? 'text-red-600' : 'text-amber-600'
}`}
>
{textoDiasRestantes(a.diasRestantes)} · {formatoFechaCorta(a.fechaLimite)}
</span>
</li>
))}
</ul>
</section>
)}
<section aria-label="Captura rápida de lead">
<TituloSeccion>Captura rápida de lead</TituloSeccion>
<form
onSubmit={enviarCaptura}
className="grid gap-4 rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
>
<div>
<label htmlFor="cap-nombre" className={CLASE_LABEL}>
Nombre
</label>
<input
id="cap-nombre"
type="text"
value={capNombre}
onChange={(e) => setCapNombre(e.target.value)}
required
className={CLASE_INPUT}
/>
</div>
<div>
<label htmlFor="cap-telefono" className={CLASE_LABEL}>
Teléfono
</label>
<div className="flex gap-2">
<select
aria-label="Lada"
value={capLada}
onChange={(e) => setCapLada(e.target.value as ClaveLada)}
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 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"
>
{LADAS.map((l) => (
<option key={l.clave} value={l.clave}>
{l.etiqueta}
</option>
))}
</select>
<input
id="cap-telefono"
type="tel"
placeholder="664 123 4567"
value={capTelefono}
onChange={(e) => setCapTelefono(e.target.value)}
className={CLASE_INPUT}
/>
</div>
</div>
<div>
<label htmlFor="cap-email" className={CLASE_LABEL}>
Email
</label>
<input
id="cap-email"
type="email"
value={capEmail}
onChange={(e) => setCapEmail(e.target.value)}
className={CLASE_INPUT}
/>
</div>
<div>
<label htmlFor="cap-origen" className={CLASE_LABEL}>
Origen
</label>
<select
id="cap-origen"
value={capOrigen}
onChange={(e) => setCapOrigen(e.target.value)}
className={CLASE_INPUT}
>
{ORIGENES_LEAD.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</div>
<div>
<label htmlFor="cap-desarrollo" className={CLASE_LABEL}>
Desarrollo de interés
</label>
<select
id="cap-desarrollo"
value={capDesarrollo}
onChange={(e) => setCapDesarrollo(e.target.value)}
className={CLASE_INPUT}
>
<option value="">Sin especificar</option>
{DESARROLLOS_LEAD.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
<div>
<label htmlFor="cap-notas" className={CLASE_LABEL}>
Notas
</label>
<input
id="cap-notas"
type="text"
value={capNotas}
onChange={(e) => setCapNotas(e.target.value)}
className={CLASE_INPUT}
/>
</div>
{capMensaje && (
<p
role="status"
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
capMensaje.tipo === 'ok'
? 'border-green-200 bg-green-50 text-green-700'
: 'border-red-200 bg-red-50 text-red-700'
}`}
>
{capMensaje.texto}
</p>
)}
<div>
<button
type="submit"
disabled={capEnviando}
className="w-full rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{capEnviando ? 'Registrando…' : 'Registrar lead'}
</button>
</div>
</form>
</section>
</aside>
</div>
{esRolConEquipo(rol) && resumen.equipo && (
<section aria-label="Equipo" className="mt-6">
<TituloSeccion>Equipo</TituloSeccion>
<p className="mb-4 text-sm text-[#64748B] dark:text-[#94A3B8]">
Leads nuevos sin asignar: <strong>{resumen.equipo.leadsNuevosSinAsignar}</strong>
</p>
{resumen.equipo.porVendedor.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados en el equipo" />
) : (
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{resumen.equipo.porVendedor.map((v) => (
<li
key={v.usuarioId}
className="flex items-center justify-between rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm dark:bg-[#1E293B] dark:border-[#1E293B]"
>
<p className="text-sm">{v.nombre}</p>
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
{v.leadsAsignados} lead(s)
</span>
</li>
))}
</ul>
)}
</section>
)}
</>
)}
</main>
{descartando && (
<ModalDescarteGenerico
item={descartando}
titulo={descartando.fuente === 'oportunidad' ? 'Descartar oportunidad' : 'Descartar lead'}
enviando={enviando}
onCancelar={() => 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 && (
<ModalDescarteGenerico
item={apartadoCancelando}
titulo="Cancelar apartado"
enviando={enviando}
onCancelar={() => 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 && (
<DetalleLeadModal
item={leadSeleccionado}
onCerrar={() => 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
}
/>
)}
</>
)
}