- Campo clienteClave en RecordatorioVendedor (hash nombre+telefono) - Al crear nota: se calcula clienteClave del lead/cliente - Al buscar notas: busca por leadId O clienteClave (no por nombre suelto) - Mantiene compatibilidad: notas viejas sin clienteClave siguen por leadId - Migración SQL aplicada
246 lines
14 KiB
TypeScript
246 lines
14 KiB
TypeScript
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
|
import { X, User, Calendar, CheckSquare, FileText, Users, Search } from 'lucide-react'
|
|
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
|
|
import { formatoFechaCorta } from '../lib/mi-dia'
|
|
import { listarRecordatorios, crearRecordatorio, type Recordatorio } from '../services/recordatorios'
|
|
|
|
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
|
|
|
|
/** Calcula una clave estable del cliente: nombre normalizado + últimos 10 dígitos del teléfono */
|
|
function calcularClienteClave(nombre?: string | null, telefono?: string | null): string {
|
|
const n = (nombre ?? '').trim().toLowerCase()
|
|
const t = (telefono ?? '').replace(/\s+/g, '').slice(-10)
|
|
return `${n}|${t}`
|
|
}
|
|
|
|
function esLead(item: LeadItem): item is LeadMiDia {
|
|
return 'email' in item && 'origen' in item && !('modeloInteres' in item)
|
|
}
|
|
function esOportunidad(item: LeadItem): item is OportunidadMiDia {
|
|
return 'modeloInteres' in item
|
|
}
|
|
|
|
type TabId = 'info' | 'cita' | 'tareas' | 'notas' | 'miembros'
|
|
|
|
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
|
|
{ id: 'info', titulo: 'Información del cliente potencial', icono: User },
|
|
{ id: 'cita', titulo: 'Reserve o actualice una cita', icono: Calendar },
|
|
{ id: 'tareas', titulo: 'Tareas', icono: CheckSquare },
|
|
{ id: 'notas', titulo: 'Notas', icono: FileText },
|
|
{ id: 'miembros', titulo: 'Objetos de los miembros', icono: Users },
|
|
]
|
|
|
|
export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; onCerrar: () => void }) {
|
|
const esOp = esOportunidad(item)
|
|
const esLd = esLead(item)
|
|
const [tab, setTab] = useState<TabId>('info')
|
|
|
|
// --- Notas ---
|
|
const [notas, setNotas] = useState<Recordatorio[]>([])
|
|
const [cargandoNotas, setCargandoNotas] = useState(true)
|
|
const [notaError, setNotaError] = useState('')
|
|
const [nuevaNota, setNuevaNota] = useState('')
|
|
const [enviandoNota, setEnviandoNota] = useState(false)
|
|
const [busquedaNota, setBusquedaNota] = useState('')
|
|
|
|
const cargarNotas = useCallback(async () => {
|
|
setCargandoNotas(true)
|
|
try {
|
|
const todas = await listarRecordatorios()
|
|
const clave = calcularClienteClave(item.nombre, (item as any).telefono)
|
|
setNotas(todas.filter((r) => r.leadId === item.id || (r.clienteClave && r.clienteClave === clave)))
|
|
} catch { setNotaError('Error al cargar notas.') }
|
|
finally { setCargandoNotas(false) }
|
|
}, [item.id])
|
|
|
|
useEffect(() => { void cargarNotas() }, [cargarNotas])
|
|
|
|
async function agregarNota(e: FormEvent) {
|
|
e.preventDefault()
|
|
if (!nuevaNota.trim()) return
|
|
setEnviandoNota(true)
|
|
try {
|
|
await crearRecordatorio({ titulo: nuevaNota.trim(), leadId: item.id, clienteClave: calcularClienteClave(item.nombre, (item as any).telefono), fechaRecordar: new Date().toISOString() })
|
|
setNuevaNota('')
|
|
await cargarNotas()
|
|
} catch { setNotaError('No se pudo guardar.') }
|
|
finally { setEnviandoNota(false) }
|
|
}
|
|
|
|
useEffect(() => {
|
|
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onCerrar() }
|
|
window.addEventListener('keydown', h)
|
|
return () => window.removeEventListener('keydown', h)
|
|
}, [onCerrar])
|
|
|
|
const notasFiltradas = notas.filter((n) =>
|
|
busquedaNota === '' || n.titulo.toLowerCase().includes(busquedaNota.toLowerCase())
|
|
)
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/50 px-4" role="dialog" aria-modal="true">
|
|
<div className="w-full max-w-5xl h-[90vh] rounded-2xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#0F172A] shadow-xl flex overflow-hidden">
|
|
{/* Header */}
|
|
<div className="absolute top-0 left-0 right-0 flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-3 bg-white dark:bg-[#1E293B] z-10">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Editar "{item.nombre}"</h2>
|
|
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Añadir y editar oportunidad Detalles, Actividades, notas y Cita.</p>
|
|
</div>
|
|
<button type="button" onClick={onCerrar} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#334155]"><X className="h-5 w-5" /></button>
|
|
</div>
|
|
|
|
<div className="flex w-full pt-14">
|
|
{/* Sidebar izquierda */}
|
|
<nav className="w-56 shrink-0 border-r border-[#E5E7EB] dark:border-[#334155] bg-[#F9FAFB] dark:bg-[#0F172A] p-3 space-y-1 overflow-y-auto">
|
|
{TABS.map((t) => {
|
|
const activo = tab === t.id
|
|
return (
|
|
<button key={t.id} type="button" onClick={() => setTab(t.id)}
|
|
className={`w-full flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${activo ? 'bg-accent text-white font-medium' : 'text-[#374151] dark:text-[#CBD5E1] hover:bg-[#E5E7EB] dark:hover:bg-[#1E293B]'}`}>
|
|
<t.icono className="h-4 w-4 shrink-0" />
|
|
<span className="truncate">{t.titulo}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
{/* Contenido */}
|
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
|
{tab === 'info' && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<InfoCampo etiqueta="Nombre" valor={item.nombre} />
|
|
{(esLd || esOp) && item.telefono && <InfoCampo etiqueta="Teléfono" valor={item.telefono} />}
|
|
{(esLd || esOp) && item.email && <InfoCampo etiqueta="Email" valor={item.email} />}
|
|
{(esLd || esOp) && item.desarrollo && <InfoCampo etiqueta="Desarrollo" valor={item.desarrollo} />}
|
|
{esOp && item.modeloInteres && <InfoCampo etiqueta="Modelo" valor={item.modeloInteres} />}
|
|
{esOp && item.unidadCodigo && <InfoCampo etiqueta="Unidad" valor={item.unidadCodigo} />}
|
|
{esLd && item.origen && <InfoCampo etiqueta="Origen" valor={item.origen} />}
|
|
<InfoCampo etiqueta="Estado" valor={esOp ? item.etapa : item.estado || '—'} />
|
|
{esOp && item.fechaCita && <InfoCampo etiqueta="Cita" valor={item.fechaCita.replace('T', ' ')} />}
|
|
{!esLd && !esOp && (item as ApartadoMiDia).unidadCodigo && <InfoCampo etiqueta="Unidad" valor={(item as ApartadoMiDia).unidadCodigo} />}
|
|
{!esLd && !esOp && (item as ApartadoMiDia).fechaLimite && <InfoCampo etiqueta="Fecha límite" valor={formatoFechaCorta((item as ApartadoMiDia).fechaLimite)} />}
|
|
{item.ultimoCambio && (
|
|
<div className="sm:col-span-2 mt-2 rounded-lg bg-[#F8FAFC] dark:bg-[#1E293B] p-3">
|
|
<p className="text-[11px] text-[#94A3B8]">Último cambio: {item.ultimoCambio.detalle} · {item.ultimoCambio.usuario ?? '—'} · {formatoFechaCorta(item.ultimoCambio.fechaISO.slice(0, 10))}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'cita' && (
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Reserve o actualice una cita</h3>
|
|
{esOp && item.fechaCita ? (
|
|
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
|
|
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Cita programada</p>
|
|
<p className="text-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')}</p>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-[#94A3B8]">No hay cita programada. Use los botones de fase en la lista para agendar una.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'tareas' && (
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Tareas pendientes</h3>
|
|
{notasFiltradas.filter((n) => !n.completado).length === 0 ? (
|
|
<p className="text-sm text-[#94A3B8]">No hay tareas pendientes.</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{notasFiltradas.filter((n) => !n.completado).map((n) => (
|
|
<li key={n.id} className="flex items-center gap-2 rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] px-3 py-2">
|
|
<input type="checkbox" className="h-4 w-4 rounded" readOnly />
|
|
<span className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</span>
|
|
<span className="ml-auto text-[10px] text-[#94A3B8]">{formatoFechaCorta(n.createdAt.slice(0, 10))}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'notas' && (
|
|
<div className="space-y-4">
|
|
{/* Barra de herramientas */}
|
|
<div className="flex items-center gap-3">
|
|
<h3 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Notas</h3>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[#94A3B8]" />
|
|
<input type="text" value={busquedaNota} onChange={(e) => setBusquedaNota(e.target.value)}
|
|
placeholder="Buscar notas"
|
|
className="w-48 rounded-lg border border-[#D1D5DB] bg-white pl-8 pr-3 py-1.5 text-sm dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Botón añadir */}
|
|
<button type="button" onClick={() => { setTab('notas') }}
|
|
className="w-full rounded-lg border-2 border-dashed border-[#D1D5DB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#0F172A] py-3 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] hover:border-accent hover:text-accent transition-colors">
|
|
+ Añadir nota
|
|
</button>
|
|
|
|
{notaError && <p className="text-xs text-red-600">{notaError}</p>}
|
|
|
|
{/* Formulario inline */}
|
|
<form onSubmit={agregarNota} className="flex gap-2">
|
|
<input type="text" value={nuevaNota} onChange={(e) => setNuevaNota(e.target.value)}
|
|
placeholder="Escribe tu nota…" disabled={enviandoNota}
|
|
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:opacity-50" />
|
|
<button type="submit" disabled={enviandoNota || !nuevaNota.trim()}
|
|
className="shrink-0 rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50">
|
|
{enviandoNota ? '…' : 'Guardar'}
|
|
</button>
|
|
</form>
|
|
|
|
{/* Lista de notas */}
|
|
{cargandoNotas ? (
|
|
<p className="text-sm text-[#94A3B8]">Cargando notas…</p>
|
|
) : notasFiltradas.length === 0 ? (
|
|
<div className="py-12 text-center">
|
|
<FileText className="mx-auto h-10 w-10 text-[#D1D5DB] dark:text-[#334155] mb-3" />
|
|
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Todavía no hay notas</p>
|
|
<p className="text-xs text-[#94A3B8] mt-1">Mantenga un registro de los detalles importantes añadiendo su primera nota</p>
|
|
</div>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{[...notasFiltradas].reverse().map((n) => (
|
|
<li key={n.id} className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] px-4 py-3">
|
|
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</p>
|
|
{n.descripcion && <p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{n.descripcion}</p>}
|
|
<p className="mt-1.5 text-[10px] text-[#94A3B8]">{formatoFechaCorta(n.createdAt.slice(0, 10))}</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'miembros' && (
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Objetos de los miembros</h3>
|
|
<p className="text-sm text-[#94A3B8]">Información de los miembros del equipo asignados a este lead.</p>
|
|
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
|
|
<p className="text-xs text-[#94A3B8]">Vendedor: {(item as any).vendedor ?? '—'}</p>
|
|
<p className="text-xs text-[#94A3B8] mt-1">Origen: {esLd ? item.origen : '—'}</p>
|
|
<p className="text-xs text-[#94A3B8] mt-1">Último cambio: {item.ultimoCambio ? `${item.ultimoCambio.detalle} por ${item.ultimoCambio.usuario ?? '—'}` : 'Sin cambios registrados'}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function InfoCampo({ etiqueta, valor }: { etiqueta: string; valor: string }) {
|
|
return (
|
|
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-[#94A3B8] mb-1">{etiqueta}</p>
|
|
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{valor || '—'}</p>
|
|
</div>
|
|
)
|
|
}
|