Files
FRASA-CAS/apps/mi-dia/src/pages/MiDiaPage.tsx
Consult AS Technology 1a70199de8 fix(escrituracion): Cancelo/Escriturado remueven de la lista
- Escrituración ahora solo muestra fases ACTIVAS:
  Firma Cliente, Seguimiento, Llamada Escrituras, X Escriturar,
  Banco/Infonavit, X Autorizar
- Cancelo y Escriturado son terminales → se remueven de la lista
2026-09-08 10:49:26 +00:00

1615 lines
71 KiB
TypeScript

import { useCallback, useEffect, useState, type FormEvent } from 'react'
import KpiCard from '../components/KpiCard'
import BannerModoLocal from '../components/BannerModoLocal'
import DarkModeToggle from '../components/DarkModeToggle'
import { SkeletonList } from "../components/Skeleton"
import toast from "react-hot-toast"
import ChipPendienteSync from '../components/ChipPendienteSync'
import type { AccionesSesion } from '../components/AuthGate'
import {
esActividadVencida,
esRolConEquipo,
formatoFechaCorta,
hoyLocal,
DESARROLLOS_LEAD,
ETIQUETA_FASE_APARTADO,
ETIQUETA_FASE_OPORTUNIDAD,
FASES_APARTADO,
MOTIVOS_NO_COMPRA,
ORIGENES_LEAD,
SLOTS_DOCS_APARTADO,
SUBCATEGORIAS_NO_COMPRA,
componerTelefono,
LADAS,
textoDiasRestantes,
urlAfluenciaParaOportunidad,
urlCotizadorParaOportunidad,
type ApartadoMiDia,
type ClaveLada,
type FaseApartado,
type LeadMiDia,
type MotivoNoCompra,
type OportunidadMiDia,
type ResumenMiDia,
type SlotDocApartado,
} from '../lib/mi-dia'
import {
cambiarEstadoApartado,
cambiarEstadoLead,
cambiarEtapaOportunidad,
convertirLead,
crearLead,
descartarLead,
fetchResumenMiDia,
listarDocumentosApartado,
subirDocumentoApartado,
type DocumentoApartado,
} from '../services/mi-dia'
import { guardarResumen, cargarResumenLocal } from '../services/offline-db'
import { suscribirPush } from '../services/push'
import { exportarLeads, exportarOportunidades, exportarApartados } from '../services/exportar'
import { NotasApartado } from '../components/NotasApartado'
import { FiltrosAvanzados, aplicarFiltros, 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 TimelineLead, { type EventoLead } from '../components/TimelineLead'
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]'
/** Modal de descarte: motivo + subcategoría (catálogo Monday) + comentario opcional */
function ModalDescartar({
lead,
onCancelar,
onConfirmar,
enviando,
}: {
lead: { id: string; nombre: string }
onCancelar: () => void
onConfirmar: (motivo: MotivoNoCompra, subcategoria: string | undefined, comentario: string) => void
enviando: boolean
}) {
const [motivo, setMotivo] = useState<MotivoNoCompra>(MOTIVOS_NO_COMPRA[0])
const [subcategoria, setSubcategoria] = useState('')
const [comentario, setComentario] = useState('')
const subcategorias = SUBCATEGORIAS_NO_COMPRA[motivo]
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]">Descartar lead</p>
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{lead.nombre}</p>
<form
className="mt-6 space-y-4"
onSubmit={(e) => {
e.preventDefault()
onConfirmar(motivo, subcategoria === '' ? undefined : subcategoria, comentario.trim())
}}
>
<div>
<label htmlFor="motivo-descarte" className={CLASE_LABEL}>
Motivo
</label>
<select
id="motivo-descarte"
value={motivo}
onChange={(e) => {
setMotivo(e.target.value as MotivoNoCompra)
setSubcategoria('') // las subcategorías dependen de la categoría
}}
className={CLASE_INPUT}
>
{MOTIVOS_NO_COMPRA.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
<div>
<label htmlFor="subcategoria-descarte" className={CLASE_LABEL}>
Subcategoría
</label>
<select
id="subcategoria-descarte"
value={subcategoria}
onChange={(e) => setSubcategoria(e.target.value)}
className={CLASE_INPUT}
>
<option value="">— Selecciona —</option>
{subcategorias.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div>
<label htmlFor="comentario-descarte" className={CLASE_LABEL}>
Comentario (opcional)
</label>
<textarea
id="comentario-descarte"
value={comentario}
onChange={(e) => setComentario(e.target.value)}
rows={3}
className={CLASE_INPUT}
/>
</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 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out 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 transition-all duration-100 ease-out hover:bg-red-700 disabled:opacity-50"
>
{enviando ? 'Descartando…' : 'Descartar'}
</button>
</div>
</form>
</div>
</div>
)
}
/** Fila de oportunidad con botones de fase del funnel (Agendo Cita lleva fecha) */
function FilaOportunidad({
op,
onEtapa,
onCotizar,
onAfluencia,
onDescartar,
enviando,
acciones,
}: {
op: OportunidadMiDia
onEtapa: (etapa: string, fecha?: string) => void
onCotizar: () => void
onAfluencia: () => void
onDescartar: () => void
enviando: boolean
acciones?: string[]
}) {
const [conCita, setConCita] = useState(false)
const [fechaCita, setFechaCita] = useState('')
// Sin 'No Asistio' ni 'Abandono': son redundantes con Descartar
const fases = ['Agendo Cita', 'Aparto'] as const
return (
<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">
{op.nombre}
{op.pendienteSync === true && <ChipPendienteSync />}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{op.telefono || 'Sin teléfono'}
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
</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="date"
aria-label="Fecha 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={f === 'Aparto'
? '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'
: 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 FilaApartado({
apartado,
onFase,
enviando,
}: {
apartado: ApartadoMiDia
onFase: (fase: FaseApartado) => 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">
{apartado.nombre}
{apartado.pendienteSync === true && <ChipPendienteSync />}
{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)
if (fase === 'Alta Enkontrol') {
window.location.href = `/client/?apartado=${apartado.id}`
return
}
onFase(fase)
}}
className={
fase === 'Cancelacion Apartado'
? 'rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50 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 */
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero'
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' },
]
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,
onTimeline,
enviando,
acciones,
}: {
lead: LeadMiDia
onAccion: (estado: string, fecha?: string) => void
onConvertir: () => void
onDescartar: () => void
onTimeline?: () => 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">
{lead.nombre}
<ScoreBadge score={calcularScore(lead)} />
{lead.pendienteSync === true && <ChipPendienteSync />}
{/* 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]">
{lead.telefono || 'Sin teléfono'}
{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>
{onTimeline && (
<button
type="button"
onClick={onTimeline}
className={CLASE_BOTON_ACCION}
>
Historial
</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 [nota, setNota] = useState<string | null>(null)
const [apartadoSeleccionado, setApartadoSeleccionado] = useState<ApartadoMiDia | null>(null)
const [filtros, setFiltros] = useState<FiltrosCRM>({ texto: '', desarrollo: '', origen: '', fechaDesde: '', fechaHasta: '' })
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
const columnasLeads = [
{ id: 'NUEVO', titulo: 'Nuevo', color: 'border-t-gray-400' },
{ id: 'CONTACTADO', titulo: 'Contactado', color: 'border-t-blue-500' },
{ id: 'INTERESADO', titulo: 'Interesado', color: 'border-t-yellow-500' },
{ id: 'AGENDO', titulo: 'Agendo', color: 'border-t-green-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: '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: 'SEGUIMIENTO', titulo: 'Seguimiento', color: 'border-t-gray-400' },
{ id: 'LLAMADA ESCRITURAS', titulo: 'Llamada Escrituras', color: 'border-t-blue-500' },
{ id: 'X ESCRITURAR', titulo: 'X Escriturar', color: 'border-t-yellow-500' },
{ id: 'BANCO/INFONAVIT', titulo: 'Banco/Infonavit', color: 'border-t-orange-500' },
{ id: 'X AUTORIZAR', titulo: 'X Autorizar', color: 'border-t-green-500' },
{ id: 'ESCRITURADO', titulo: 'Escriturado', color: 'border-t-emerald-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 'AGENDO'
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('APARTO')) return 'APARTO'
if (e.includes('PERDIDO') || e.includes('PERDI')) return 'PERDIDO'
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('SEGUIMIENTO')) return 'SEGUIMIENTO'
if (e.includes('LLAMADA')) return 'LLAMADA ESCRITURAS'
if (e.includes('X ESCRIT') || e.includes('ESCRIT')) return 'X ESCRITURAR'
if (e.includes('BANCO') || e.includes('INFONAVIT')) return 'BANCO/INFONAVIT'
if (e.includes('X AUTORIZAR')) return 'X AUTORIZAR'
if (e.includes('ESCRITURADO')) return 'ESCRITURADO'
if (e.includes('CANCEL')) return 'CANCELO'
return 'SEGUIMIENTO'
}
// Timeline modal
const [leadTimeline, setLeadTimeline] = useState<{ nombre: string; eventos: EventoLead[] } | null>(null)
// 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)
setNota(null)
try {
await convertirLead(lead.id)
await cargar()
setNota(`${lead.nombre} pasó a Mis oportunidades (Agendo Cita).`)
} catch (err) {
setAviso(err instanceof Error ? err.message : 'No se pudo convertir el lead')
} finally {
setEnviando(false)
}
}
/**
* Botón "Cotizar": pasa la oportunidad a etapa Cotizador y abre el cotizador
* con los datos del cliente prellenados y la oportunidad vinculada (al
* guardar, la cotización actualiza ESE item en Monday). Si el cambio de etapa
* falla, el cotizador se abre igual: el guardado reintenta la actualización.
*/
async function cotizarOportunidad(op: OportunidadMiDia) {
setEnviando(true)
setAviso(null)
try {
await cambiarEtapaOportunidad(op.id, 'Cotizador')
} catch {
setAviso('No se pudo actualizar la etapa en Monday; el cotizador se abrirá de todos modos.')
}
window.location.href = urlCotizadorParaOportunidad(op)
}
async function enviarCaptura(e: FormEvent) {
e.preventDefault()
setCapEnviando(true)
setCapMensaje(null)
try {
const telefono = componerTelefono(capLada, capTelefono)
const creado = await crearLead({
nombre: capNombre.trim(),
...(telefono !== '' ? { telefono } : {}),
...(capEmail.trim() !== '' ? { email: capEmail.trim() } : {}),
...(capOrigen !== '' ? { origen: capOrigen } : {}),
...(capDesarrollo !== '' ? { desarrollo: capDesarrollo } : {}),
...(capNotas.trim() !== '' ? { notas: capNotas.trim() } : {}),
})
setCapMensaje({
tipo: 'ok',
texto:
creado.asignacion === 'capturista'
? 'Lead registrado y asignado a ti'
: 'Lead registrado, se asignará por round-robin',
})
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)
}
}
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">
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>
<BannerModoLocal />
{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>
)}
{nota && (
<p role="status" className="mb-6 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
{nota}
</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,
}}
onCambiar={setTabCrm}
vistaKanban={vistaKanban}
onToggleKanban={() => setVistaKanban(!vistaKanban)}
/>
{tabCrm !== 'tablero' && (
<div className="flex items-center gap-2">
<div className="flex-1">
<FiltrosAvanzados valor={filtros} onChange={setFiltros} />
</div>
<button
type="button"
onClick={() => {
if (tabCrm === 'leads') exportarLeads(resumen.misLeads.filter((l) => l.fuente === 'lead'))
else if (tabCrm === 'oportunidades') exportarOportunidades(resumen.misOportunidades.items)
else if (tabCrm === 'apartados') exportarApartados(resumen.misApartados)
}}
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' && (() => {
const leadsFiltrados = aplicarFiltros(
resumen.misLeads.filter((l) => l.fuente === 'lead'),
filtros,
{
texto: (l) => `${l.nombre} ${l.telefono} ${l.email}`,
origen: (l) => l.origen,
fecha: (l) => l.updatedAt.slice(0, 10),
},
)
return (
<>
{leadsFiltrados.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" />
) : vistaKanban ? (
<KanbanView
items={leadsFiltrados}
columnas={columnasLeads}
obtenerColumna={obtenerColumnaLead}
onCambiarEstado={(id, estado) => void ejecutar(() => cambiarEstadoLead(id, estado, '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)}
onTimeline={() => setLeadTimeline({
nombre: lead.nombre,
eventos: lead.ultimoCambio ? [{
fechaISO: lead.ultimoCambio.fechaISO,
usuario: lead.ultimoCambio.usuario,
detalle: lead.ultimoCambio.detalle,
fuente: lead.fuente,
}] : [],
})}
acciones={acciones}
/>
))}
</ul>
)}
</>
)
})()}
{tabCrm === 'oportunidades' && (() => {
const opsFiltradas = aplicarFiltros(
resumen.misOportunidades.items,
filtros,
{
texto: (o) => `${o.nombre} ${o.telefono} ${o.email}`,
desarrollo: (o) => o.desarrollo,
fecha: (o) => o.updatedAt.slice(0, 10),
},
)
return (
<>
{opsFiltradas.length === 0 ? (
<EstadoVacio mensaje="Sin oportunidades asignadas" />
) : vistaKanban ? (
<KanbanView
items={opsFiltradas}
columnas={columnasOportunidades}
obtenerColumna={obtenerColumnaOp}
onCambiarEstado={(id, estado) => 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.location.href = urlAfluenciaParaOportunidad(op)
}}
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
acciones={acciones}
/>
))}
</ul>
)}
</>
)
})()}
{tabCrm === 'apartados' && (() => {
const apartadosFiltrados = aplicarFiltros(
resumen.misApartados,
filtros,
{
texto: (a) => a.nombre,
fecha: (a) => a.fechaLimite,
},
)
return (
<>
{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}
className="cursor-pointer"
onClick={() => setApartadoSeleccionado(a)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setApartadoSeleccionado(a) }}
>
<FilaApartado
apartado={a}
enviando={enviando}
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)
const apartadosEscrituracion = (resumen.todosApartados || resumen.misApartados || []).filter((a) => {
const e = (a.estado ?? '').toUpperCase()
return e.includes('FIRMA') || e.includes('SEGUIMIENTO') || e.includes('LLAMADA') || e.includes('X ESCRITURAR') || e.includes('BANCO') || e.includes('INFONAVIT') || e.includes('X AUTORIZAR')
})
return apartadosEscrituracion.length === 0 ? (
<EstadoVacio mensaje="No hay apartados en escrituración" />
) : vistaKanban ? (
<KanbanView
items={resumen.misApartados}
columnas={columnasEscrituracion}
obtenerColumna={obtenerColumnaEscrituracion}
onCambiarEstado={(id, estado) => 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">
{['Seguimiento', 'Llamada Escrituras', 'X Escriturar', 'Banco/Infonavit', 'X Autorizar', 'Escriturado', 'Cancelo'].map((fase) => (
<button
key={fase}
type="button"
disabled={enviando}
onClick={() => 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'
: CLASE_BOTON_ACCION}
>
{fase}
</button>
))}
</div>
</li>
))}
</ul>
)})()}
</section>
)}
{tabCrm === 'tablero' && (
<DashboardVendedor />
)}
</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}
{c.pendienteSync === true && <ChipPendienteSync />}
</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 && (
<ModalDescartar
lead={descartando}
enviando={enviando}
onCancelar={() => setDescartando(null)}
onConfirmar={(motivo, subcategoria, comentario) => {
const lead = descartando
setDescartando(null)
void ejecutar(
() => descartarLead(lead.id, motivo, comentario === '' ? undefined : comentario, lead.fuente, subcategoria),
'No se pudo descartar el lead',
)
}}
/>
)}
{apartadoSeleccionado && (
<NotasApartado
apartado={apartadoSeleccionado}
onCerrar={() => setApartadoSeleccionado(null)}
/>
)}
{/* Modal de historial/timeline del lead */}
{leadTimeline && (
<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 max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Historial</p>
<button
type="button"
onClick={() => setLeadTimeline(null)}
className="rounded-lg px-2 py-1 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
>
Cerrar
</button>
</div>
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{leadTimeline.nombre}</p>
<div className="mt-4">
<TimelineLead eventos={leadTimeline.eventos} />
</div>
</div>
</div>
)}
</>
)
}