- 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
1615 lines
71 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|