Files
FRASA-CAS/apps/mi-dia/src/pages/MiDiaPage.tsx
Consult AS Technology 075d47c304 fix(mi-dia): 5 bugs críticos + 8 mejoras UX
Bugs críticos: descartar, kanban drag&drop, escrituración, fases API, export filtros
UX: tel clicable, WhatsApp, filtros por tab, tareas, calendario, estado, notas, navegación
2026-09-11 02:05:25 +00:00

1685 lines
74 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,
textoDiasRestantes,
urlAfluenciaParaOportunidad,
urlCotizadorParaOportunidad,
urlWhatsApp,
type ApartadoMiDia,
type ClaveLada,
type FaseApartado,
type LeadMiDia,
type OportunidadMiDia,
type ResumenMiDia,
type SlotDocApartado,
} from '../lib/mi-dia'
import {
cambiarEstadoApartado,
cambiarEstadoLead,
cambiarEtapaOportunidad,
convertirLead,
crearLead,
descartarLead,
fetchResumenMiDia,
listarDocumentosApartado,
subirDocumentoApartado,
type DocumentoApartado,
} from '../services/mi-dia'
import { guardarResumen, cargarResumenLocal } from '../services/offline-db'
import { suscribirPush } from '../services/push'
import { exportarLeads, exportarOportunidades, exportarApartados } from '../services/exportar'
import { FiltrosAvanzados, aplicarFiltros, FILTROS_VACIOS, type FiltrosCRM } from '../components/FiltrosAvanzados'
import { getSesion } from '../services/auth'
import DashboardVendedor from '../components/DashboardVendedor'
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
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 con botones de fase del funnel (Agendo Cita lleva fecha) */
function FilaOportunidad({
op,
onEtapa,
onCotizar,
onAfluencia,
onDescartar,
onVerDetalle,
enviando,
acciones,
}: {
op: OportunidadMiDia
onEtapa: (etapa: string, fecha?: string) => void
onCotizar: () => void
onAfluencia: () => void
onDescartar: () => void
onVerDetalle?: () => void
enviando: boolean
acciones?: string[]
}) {
const [conCita, setConCita] = useState(!!op.fechaCita)
const [fechaCita, setFechaCita] = useState(op.fechaCita ?? '')
const fases = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto'] as const
return (
<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.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">
{/* 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>
{/* 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>
{fases.map((f) =>
f === 'Agendo Cita' ? (
<span key={f} className="contents">
<button
type="button"
disabled={enviando}
onClick={() => (conCita ? onEtapa(f, fechaCita || undefined) : setConCita(true))}
className={CLASE_BOTON_ACCION}
>
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD[f]}
</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"
/>
)}
</span>
) : (
<button
key={f}
type="button"
disabled={enviando}
onClick={() => onEtapa(f)}
className={CLASE_BOTON_ACCION}
>
{ETIQUETA_FASE_OPORTUNIDAD[f]}
</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>
</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] = useState('')
const [comentario, setComentario] = useState('')
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)}
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="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]">
{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
}
if (fase === 'Cancelacion Apartado') {
onFase(fase)
return
}
onFase(fase)
}}
className={
fase === 'Cancelacion Apartado'
? 'rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50 dark:bg-[#0F172A] dark:border-red-800 dark:text-red-400'
: fase === 'Firma Cliente'
? 'rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-green-700 disabled:opacity-50'
: fase === 'Alta Enkontrol'
? 'rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50'
: fase === apartado.estado
? 'rounded-lg border border-accent bg-accent/5 px-3 py-1.5 text-xs font-medium text-accent shadow-sm disabled:opacity-50'
: CLASE_BOTON_ACCION
}
>
{fase === 'Alta Enkontrol' ? 'Alta Enkontrol →' : ETIQUETA_FASE_APARTADO[fase]}
</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 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 (
<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>
{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]">
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
</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,
}: {
lead: LeadMiDia
onAccion: (estado: string, fecha?: string) => void
onConvertir: () => void
onDescartar: () => void
onVerDetalle?: () => void
enviando: boolean
acciones?: string[]
}) {
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>
<ScoreBadge score={calcularScore(lead)} />
{/* 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>
</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 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 {
toast.error('No se pudo actualizar la etapa en Monday; el cotizador se abrirá de todos modos.')
}
window.open(urlCotizadorParaOportunidad(op), '_blank')
}
async function enviarCaptura(e: FormEvent) {
e.preventDefault()
setCapEnviando(true)
setCapMensaje(null)
try {
const telefono = componerTelefono(capLada, capTelefono)
const creado = await crearLead({
nombre: capNombre.trim(),
...(telefono !== '' ? { telefono } : {}),
...(capEmail.trim() !== '' ? { email: capEmail.trim() } : {}),
...(capOrigen !== '' ? { origen: capOrigen } : {}),
...(capDesarrollo !== '' ? { desarrollo: capDesarrollo } : {}),
...(capNotas.trim() !== '' ? { notas: capNotas.trim() } : {}),
})
setCapMensaje({
tipo: 'ok',
texto:
creado.asignacion === 'capturista'
? 'Lead registrado y asignado a ti'
: 'Lead registrado, se asignará por round-robin',
})
toast.success('Lead registrado correctamente')
setCapNombre('')
setCapTelefono('')
setCapEmail('')
setCapDesarrollo('')
setCapNotas('')
// Refresca el resumen para que la captura aparezca al momento en "Mis capturas"
await cargar()
} catch (err) {
setCapMensaje({ tipo: 'error', texto: err instanceof Error ? err.message : 'No se pudo registrar el lead' })
} finally {
setCapEnviando(false)
}
}
// Listas filtradas por los filtros avanzados; las usan el render por pestaña
// Y el botón de exportar (para que la exportación respete los filtros activos)
const leadsFiltrados = resumen
? aplicarFiltros(resumen.misLeads.filter((l) => l.fuente === 'lead'), filtros, {
texto: (l) => `${l.nombre} ${l.telefono} ${l.email}`,
origen: (l) => l.origen,
estado: (l) => l.estado,
fecha: (l) => l.updatedAt.slice(0, 10),
})
: []
const opsFiltradas = resumen
? aplicarFiltros(resumen.misOportunidades.items, filtros, {
texto: (o) => `${o.nombre} ${o.telefono} ${o.email}`,
desarrollo: (o) => o.desarrollo,
estado: (o) => o.etapa,
fecha: (o) => o.updatedAt.slice(0, 10),
})
: []
const apartadosFiltrados = resumen
? aplicarFiltros(resumen.misApartados, filtros, {
texto: (a) => a.nombre,
estado: (a) => a.estado,
fecha: (a) => a.fechaLimite,
})
: []
const hoy = hoyLocal()
const iniciales = nombreVendedor
.split(' ')
.filter((p) => p !== '')
.map((p) => p.charAt(0))
.slice(0, 2)
.join('')
.toUpperCase()
return (
<>
<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?.sinMapeoMonday && (
<p className="mb-6 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] dark:text-[#94A3B8] shadow-sm dark:bg-[#1E293B] dark:border-[#1E293B]">
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
</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: resumen.misApartados.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)
}}
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}
/>
))}
</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) => 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 === '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>
{(() => {
// Filtrar: solo apartados en fases de escrituración o Firma Cliente
// Solo fases activas (Cancelo y Escriturado son terminales)
// Fases activas de escrituración (excluye Cancelo y Escriturado que son terminales)
const enEscrituracion = (resumen.todosApartados || resumen.misApartados || []).filter((a) => {
const e = (a.estado ?? '').toUpperCase()
return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('AUTORIZADO') || e.includes('X ESCRITURAR')
})
const apartadosEscrituracion = aplicarFiltros(
enEscrituracion,
filtros,
{
texto: (a) => a.nombre,
estado: (a) => a.estado,
fecha: (a) => a.fechaLimite,
},
)
return apartadosEscrituracion.length === 0 ? (
<EstadoVacio mensaje="No hay apartados en escrituración" />
) : vistaKanban ? (
<KanbanView
items={apartadosEscrituracion}
columnas={columnasEscrituracion}
obtenerColumna={obtenerColumnaEscrituracion}
onCambiarEstado={(id, estado) => {
if (estado === 'Cancelo') {
const a = apartadosEscrituracion.find((x) => x.id === id)
if (a) setApartadoCancelando({ id: a.id, nombre: a.nombre })
return
}
void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')
}}
/>
) : (
<ul className="space-y-3">
{apartadosEscrituracion.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>
<p className="font-medium text-[#111827] dark:text-[#E2E8F0]">{apartado.nombre}</p>
<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
actividades={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 en Monday (bidireccional); 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.mondayUserId}
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 ?? `Monday #${v.mondayUserId}`}</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)}
/>
)}
</>
)
}