feat(8-mejoras): cancelar+motivo, brocker, etapas, calendario, modal cliente

1. Cancelar con motivo: Mis apartados y Escrituración piden motivo (modal reutilizado)
2. Origen BROCKER agregado en alta afluencia y captura rápida
3. Etapas oportunidades reordenadas: Agendo Cita → Afluencia → Cotizador
4. Cotizador: mensualidades diferibles (6, 12, 14, 18, 24 meses)
5. Calendario semanal por vendedor en Mi día
6. Hora en citas (datetime-local en vez de solo fecha)
7. Click en nombre → modal DetalleLeadModal (info + notas + historial)
This commit is contained in:
2026-09-09 10:26:36 +00:00
parent 6db9662838
commit d546460ba4
11 changed files with 808 additions and 26 deletions

View File

@@ -0,0 +1,205 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
import { formatoFechaCorta } from '../lib/mi-dia'
import TimelineLead, { type EventoLead } from './TimelineLead'
import { listarRecordatorios, crearRecordatorio, type Recordatorio } from '../services/recordatorios'
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
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
}
export default function DetalleLeadModal({
item,
onCerrar,
}: {
item: LeadItem
onCerrar: () => void
}) {
const esOp = esOportunidad(item)
const esLd = esLead(item)
// --- 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 ---
const [notas, setNotas] = useState<Recordatorio[]>([])
const [cargandoNotas, setCargandoNotas] = useState(true)
const [notaError, setNotaError] = useState('')
const [nuevaNota, setNuevaNota] = useState('')
const [enviandoNota, setEnviandoNota] = useState(false)
const cargarNotas = useCallback(async () => {
setCargandoNotas(true)
setNotaError('')
try {
const todas = await listarRecordatorios()
setNotas(todas.filter((r) => r.leadId === item.id))
} catch {
setNotaError('No se pudieron cargar las notas.')
} finally {
setCargandoNotas(false)
}
}, [item.id])
useEffect(() => {
void cargarNotas()
}, [cargarNotas])
async function agregarNota(e: FormEvent) {
e.preventDefault()
const texto = nuevaNota.trim()
if (texto === '') return
setEnviandoNota(true)
setNotaError('')
try {
await crearRecordatorio({
titulo: texto,
leadId: item.id,
fechaRecordar: new Date().toISOString(),
})
setNuevaNota('')
await cargarNotas()
} catch {
setNotaError('No se pudo guardar la nota.')
} finally {
setEnviandoNota(false)
}
}
// --- Cerrar con Escape ---
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onCerrar()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onCerrar])
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-lg rounded-2xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-4">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Detalle del lead</p>
<button
type="button"
onClick={onCerrar}
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>
{/* Body scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{/* Sección Información */}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] mb-3">Información</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<InfoCampo etiqueta="Nombre" valor={item.nombre} />
{esLd && item.telefono && <InfoCampo etiqueta="Teléfono" valor={item.telefono} />}
{esOp && item.telefono && <InfoCampo etiqueta="Teléfono" valor={item.telefono} />}
{esLd && item.email && <InfoCampo etiqueta="Email" valor={item.email} />}
{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.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="col-span-2">
<p className="text-[11px] text-[#94A3B8]">
Último cambio: {item.ultimoCambio.detalle} · {item.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(item.ultimoCambio.fechaISO.slice(0, 10))}
</p>
</div>
)}
</div>
</section>
{/* Sección Notas */}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] mb-3">Notas</h3>
<form onSubmit={agregarNota} className="flex gap-2 mb-3">
<input
type="text"
value={nuevaNota}
onChange={(e) => setNuevaNota(e.target.value)}
placeholder="Escribe una nota…"
disabled={enviandoNota}
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 ? (
<p className="text-xs text-[#94A3B8]">Cargando notas…</p>
) : notas.length === 0 ? (
<p className="text-xs text-[#94A3B8]">Sin notas registradas.</p>
) : (
<ul className="space-y-2">
{[...notas].reverse().map((n) => (
<li key={n.id} className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#0F172A] px-3 py-2">
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</p>
{n.descripcion && (
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">{n.descripcion}</p>
)}
<p className="mt-0.5 text-[10px] text-[#94A3B8]">
{formatoFechaCorta(n.createdAt.slice(0, 10))}
</p>
</li>
))}
</ul>
)}
</section>
{/* Sección Historial */}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] mb-3">Historial</h3>
<TimelineLead eventos={eventos} />
</section>
</div>
</div>
</div>
)
}
function InfoCampo({ etiqueta, valor }: { etiqueta: string; valor: string }) {
return (
<div>
<p className="text-[11px] font-medium text-[#94A3B8] uppercase tracking-wider">{etiqueta}</p>
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">{valor || '—'}</p>
</div>
)
}