feat(bitacora): modal de cliente rediseñado con sidebar lateral

- 5 secciones: Info, Cita, Tareas, Notas, Miembros
- Sidebar de navegación con íconos (lucide-react)
- Notas con búsqueda, botón +Añadir, empty state
- Cita programada visible
- Tareas pendientes con checkbox
- Dark mode completo
- Max-w-5xl, layout flex horizontal
This commit is contained in:
2026-09-10 21:17:17 +00:00
parent 44360591b0
commit 913bdff0d1

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react' 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 type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
import { formatoFechaCorta } from '../lib/mi-dia' import { formatoFechaCorta } from '../lib/mi-dia'
import TimelineLead, { type EventoLead } from './TimelineLead'
import { listarRecordatorios, crearRecordatorio, type Recordatorio } from '../services/recordatorios' import { listarRecordatorios, crearRecordatorio, type Recordatorio } from '../services/recordatorios'
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
@@ -9,30 +9,24 @@ type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
function esLead(item: LeadItem): item is LeadMiDia { function esLead(item: LeadItem): item is LeadMiDia {
return 'email' in item && 'origen' in item && !('modeloInteres' in item) return 'email' in item && 'origen' in item && !('modeloInteres' in item)
} }
function esOportunidad(item: LeadItem): item is OportunidadMiDia { function esOportunidad(item: LeadItem): item is OportunidadMiDia {
return 'modeloInteres' in item return 'modeloInteres' in item
} }
export default function DetalleLeadModal({ type TabId = 'info' | 'cita' | 'tareas' | 'notas' | 'miembros'
item,
onCerrar, const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
}: { { id: 'info', titulo: 'Información del cliente potencial', icono: User },
item: LeadItem { id: 'cita', titulo: 'Reserve o actualice una cita', icono: Calendar },
onCerrar: () => void { 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 esOp = esOportunidad(item)
const esLd = esLead(item) const esLd = esLead(item)
const [tab, setTab] = useState<TabId>('info')
// --- Historial ---
const eventos: EventoLead[] = item.ultimoCambio
? [{
fechaISO: item.ultimoCambio.fechaISO,
usuario: item.ultimoCambio.usuario,
detalle: item.ultimoCambio.detalle,
fuente: esLd ? item.fuente : esOp ? undefined : undefined,
}]
: []
// --- Notas --- // --- Notas ---
const [notas, setNotas] = useState<Recordatorio[]>([]) const [notas, setNotas] = useState<Recordatorio[]>([])
@@ -40,155 +34,193 @@ export default function DetalleLeadModal({
const [notaError, setNotaError] = useState('') const [notaError, setNotaError] = useState('')
const [nuevaNota, setNuevaNota] = useState('') const [nuevaNota, setNuevaNota] = useState('')
const [enviandoNota, setEnviandoNota] = useState(false) const [enviandoNota, setEnviandoNota] = useState(false)
const [busquedaNota, setBusquedaNota] = useState('')
const cargarNotas = useCallback(async () => { const cargarNotas = useCallback(async () => {
setCargandoNotas(true) setCargandoNotas(true)
setNotaError('')
try { try {
const todas = await listarRecordatorios() const todas = await listarRecordatorios()
setNotas(todas.filter((r) => r.leadId === item.id)) setNotas(todas.filter((r) => r.leadId === item.id))
} catch { } catch { setNotaError('Error al cargar notas.') }
setNotaError('No se pudieron cargar las notas.') finally { setCargandoNotas(false) }
} finally {
setCargandoNotas(false)
}
}, [item.id]) }, [item.id])
useEffect(() => { useEffect(() => { void cargarNotas() }, [cargarNotas])
void cargarNotas()
}, [cargarNotas])
async function agregarNota(e: FormEvent) { async function agregarNota(e: FormEvent) {
e.preventDefault() e.preventDefault()
const texto = nuevaNota.trim() if (!nuevaNota.trim()) return
if (texto === '') return
setEnviandoNota(true) setEnviandoNota(true)
setNotaError('')
try { try {
await crearRecordatorio({ await crearRecordatorio({ titulo: nuevaNota.trim(), leadId: item.id, fechaRecordar: new Date().toISOString() })
titulo: texto,
leadId: item.id,
fechaRecordar: new Date().toISOString(),
})
setNuevaNota('') setNuevaNota('')
await cargarNotas() await cargarNotas()
} catch { } catch { setNotaError('No se pudo guardar.') }
setNotaError('No se pudo guardar la nota.') finally { setEnviandoNota(false) }
} finally {
setEnviandoNota(false)
}
} }
// --- Cerrar con Escape ---
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent) { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onCerrar() }
if (e.key === 'Escape') onCerrar() window.addEventListener('keydown', h)
} return () => window.removeEventListener('keydown', h)
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onCerrar]) }, [onCerrar])
const notasFiltradas = notas.filter((n) =>
busquedaNota === '' || n.titulo.toLowerCase().includes(busquedaNota.toLowerCase())
)
return ( 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="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-lg rounded-2xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl max-h-[85vh] flex flex-col"> <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 */} {/* Header */}
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-4"> <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">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Detalle del lead</p> <div>
<button <h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Editar "{item.nombre}"</h2>
type="button" <p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Añadir y editar oportunidad Detalles, Actividades, notas y Cita.</p>
onClick={onCerrar} </div>
className="rounded-lg px-2 py-1 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] hover:bg-[#F9FAFB] dark:hover:bg-[#334155]" <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>
>
Cerrar ✕
</button>
</div> </div>
{/* Body scrollable */} <div className="flex w-full pt-14">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6"> {/* Sidebar izquierda */}
{/* Sección Información */} <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">
<section> {TABS.map((t) => {
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] mb-3">Información</h3> const activo = tab === t.id
<div className="grid grid-cols-2 gap-3 text-sm"> 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} /> <InfoCampo etiqueta="Nombre" valor={item.nombre} />
{esLd && item.telefono && <InfoCampo etiqueta="Teléfono" valor={item.telefono} />} {(esLd || esOp) && item.telefono && <InfoCampo etiqueta="Teléfono" valor={item.telefono} />}
{esOp && item.telefono && <InfoCampo etiqueta="Teléfono" valor={item.telefono} />} {(esLd || esOp) && item.email && <InfoCampo etiqueta="Email" valor={item.email} />}
{esLd && item.email && <InfoCampo etiqueta="Email" valor={item.email} />} {(esLd || esOp) && item.desarrollo && <InfoCampo etiqueta="Desarrollo" valor={item.desarrollo} />}
{esOp && item.email && <InfoCampo etiqueta="Email" valor={item.email} />}
{esLd && item.desarrollo && <InfoCampo etiqueta="Desarrollo" valor={item.desarrollo} />}
{esOp && item.desarrollo && <InfoCampo etiqueta="Desarrollo" valor={item.desarrollo} />}
{esOp && item.modeloInteres && <InfoCampo etiqueta="Modelo" valor={item.modeloInteres} />} {esOp && item.modeloInteres && <InfoCampo etiqueta="Modelo" valor={item.modeloInteres} />}
{esOp && item.unidadCodigo && <InfoCampo etiqueta="Unidad" valor={item.unidadCodigo} />} {esOp && item.unidadCodigo && <InfoCampo etiqueta="Unidad" valor={item.unidadCodigo} />}
{esLd && item.origen && <InfoCampo etiqueta="Origen" valor={item.origen} />} {esLd && item.origen && <InfoCampo etiqueta="Origen" valor={item.origen} />}
<InfoCampo etiqueta="Estado" valor={esOp ? (item.etapa || '—') : item.estado || '—'} /> <InfoCampo etiqueta="Estado" valor={esOp ? item.etapa : item.estado || '—'} />
{esOp && item.fechaCita && ( {esOp && item.fechaCita && <InfoCampo etiqueta="Cita" valor={item.fechaCita.replace('T', ' ')} />}
<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)} />}
{!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 && ( {item.ultimoCambio && (
<div className="col-span-2"> <div className="sm:col-span-2 mt-2 rounded-lg bg-[#F8FAFC] dark:bg-[#1E293B] p-3">
<p className="text-[11px] text-[#94A3B8]"> <p className="text-[11px] text-[#94A3B8]">Último cambio: {item.ultimoCambio.detalle} · {item.ultimoCambio.usuario ?? '—'} · {formatoFechaCorta(item.ultimoCambio.fechaISO.slice(0, 10))}</p>
Último cambio: {item.ultimoCambio.detalle} · {item.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(item.ultimoCambio.fechaISO.slice(0, 10))}
</p>
</div> </div>
)} )}
</div> </div>
</section> )}
{/* Sección Notas */} {tab === 'cita' && (
<section> <div className="space-y-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] mb-3">Notas</h3> <h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Reserve o actualice una cita</h3>
<form onSubmit={agregarNota} className="flex gap-2 mb-3"> {esOp && item.fechaCita ? (
<input <div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
type="text" <p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Cita programada</p>
value={nuevaNota} <p className="text-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')}</p>
onChange={(e) => setNuevaNota(e.target.value)} </div>
placeholder="Escribe una nota…" ) : (
disabled={enviandoNota} <p className="text-sm text-[#94A3B8]">No hay cita programada. Use los botones de fase en la lista para agendar una.</p>
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm transition-colors focus:border-accent focus:outline-none 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-3 py-2 text-xs font-medium text-white shadow-sm transition-all hover:bg-accent-hover disabled:opacity-50"
>
{enviandoNota ? '…' : 'Agregar'}
</button>
</form>
{notaError && (
<p className="text-xs text-red-600 mb-2">{notaError}</p>
)} )}
{cargandoNotas ? ( </div>
<p className="text-xs text-[#94A3B8]">Cargando notas…</p> )}
) : notas.length === 0 ? (
<p className="text-xs text-[#94A3B8]">Sin notas registradas.</p> {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"> <ul className="space-y-2">
{[...notas].reverse().map((n) => ( {notasFiltradas.filter((n) => !n.completado).map((n) => (
<li key={n.id} className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#0F172A] px-3 py-2"> <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">
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</p> <input type="checkbox" className="h-4 w-4 rounded" readOnly />
{n.descripcion && ( <span className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</span>
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">{n.descripcion}</p> <span className="ml-auto text-[10px] text-[#94A3B8]">{formatoFechaCorta(n.createdAt.slice(0, 10))}</span>
)}
<p className="mt-0.5 text-[10px] text-[#94A3B8]">
{formatoFechaCorta(n.createdAt.slice(0, 10))}
</p>
</li> </li>
))} ))}
</ul> </ul>
)} )}
</section> </div>
)}
{/* Sección Historial */} {tab === 'notas' && (
<section> <div className="space-y-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] mb-3">Historial</h3> {/* Barra de herramientas */}
<TimelineLead eventos={eventos} /> <div className="flex items-center gap-3">
</section> <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> </div>
</div> </div>
@@ -197,9 +229,9 @@ export default function DetalleLeadModal({
function InfoCampo({ etiqueta, valor }: { etiqueta: string; valor: string }) { function InfoCampo({ etiqueta, valor }: { etiqueta: string; valor: string }) {
return ( return (
<div> <div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-3">
<p className="text-[11px] font-medium text-[#94A3B8] uppercase tracking-wider">{etiqueta}</p> <p className="text-[10px] font-semibold uppercase tracking-wider text-[#94A3B8] mb-1">{etiqueta}</p>
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">{valor || '—'}</p> <p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{valor || '—'}</p>
</div> </div>
) )
} }