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

@@ -13,6 +13,7 @@ import {
hoyLocal,
DESARROLLOS_LEAD,
ETIQUETA_FASE_APARTADO,
ETIQUETA_FASE_ESCRITURACION,
ETIQUETA_FASE_OPORTUNIDAD,
FASES_APARTADO,
MOTIVOS_NO_COMPRA,
@@ -55,6 +56,8 @@ import DashboardVendedor from '../components/DashboardVendedor'
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
import KanbanView from '../components/KanbanView'
import TimelineLead, { type EventoLead } from '../components/TimelineLead'
import CalendarioCitas from '../components/CalendarioCitas'
import DetalleLeadModal from '../components/DetalleLeadModal'
function TituloSeccion({ children }: { children: string }) {
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{children}</h2>
@@ -111,6 +114,109 @@ const CLASE_INPUT =
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
/** Modal genérico de descarte/cancelación con motivo + subcategoría + comentario */
function ModalDescarteGenerico({
item,
titulo,
onCancelar,
onConfirmar,
enviando,
}: {
item: { id: string; nombre: string }
titulo: 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]">{titulo}</p>
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{item.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('')
}}
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 ? 'Procesando…' : 'Confirmar'}
</button>
</div>
</form>
</div>
</div>
)
}
/** Modal de descarte: motivo + subcategoría (catálogo Monday) + comentario opcional */
function ModalDescartar({
lead,
@@ -219,6 +325,7 @@ function FilaOportunidad({
onCotizar,
onAfluencia,
onDescartar,
onVerDetalle,
enviando,
acciones,
}: {
@@ -227,19 +334,25 @@ function FilaOportunidad({
onCotizar: () => void
onAfluencia: () => void
onDescartar: () => void
onVerDetalle?: () => 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
const fases = ['Agendo Cita', 'Afluencia', 'Cotizador'] 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}
<button
type="button"
onClick={onVerDetalle}
className="text-left text-accent hover:underline cursor-pointer"
>
{op.nombre}
</button>
{op.pendienteSync === true && <ChipPendienteSync />}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
@@ -248,6 +361,11 @@ function FilaOportunidad({
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
</p>
{op.fechaCita && (
<p className="mt-0.5 text-[11px] text-accent font-medium">
📅 Cita: {op.fechaCita.replace('T', ' ')}
</p>
)}
{op.ultimoCambio && (
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Último cambio: {op.ultimoCambio.detalle} · {op.ultimoCambio.usuario ?? '—'} ·{' '}
@@ -291,8 +409,8 @@ function FilaOportunidad({
</button>
{conCita && (
<input
type="date"
aria-label="Fecha de la cita"
type="datetime-local"
aria-label="Fecha y hora 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"
@@ -305,9 +423,7 @@ function FilaOportunidad({
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}
className={CLASE_BOTON_ACCION}
>
{ETIQUETA_FASE_OPORTUNIDAD[f]}
</button>
@@ -330,10 +446,14 @@ function FilaOportunidad({
function FilaApartado({
apartado,
onFase,
onCancelacion,
onVerDetalle,
enviando,
}: {
apartado: ApartadoMiDia
onFase: (fase: FaseApartado) => void
onCancelacion: () => void
onVerDetalle?: () => void
enviando: boolean
}) {
const [docsAbiertos, setDocsAbiertos] = useState(false)
@@ -342,7 +462,13 @@ function FilaApartado({
<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}
<button
type="button"
onClick={onVerDetalle}
className="text-left text-accent hover:underline cursor-pointer"
>
{apartado.nombre}
</button>
{apartado.pendienteSync === true && <ChipPendienteSync />}
{apartado.unidadCodigo ? (
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
@@ -375,6 +501,10 @@ function FilaApartado({
window.location.href = `/client/?apartado=${apartado.id}`
return
}
if (fase === 'Cancelacion Apartado') {
onCancelacion()
return
}
onFase(fase)
}}
className={
@@ -505,8 +635,8 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
)
}
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero */
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero'
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero / Calendario */
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero' | 'calendario'
function TabsCrm({
activa,
@@ -527,6 +657,7 @@ function TabsCrm({
{ id: 'apartados', titulo: 'Apartados' },
{ id: 'escrituracion', titulo: 'Escrituración' },
{ id: 'tablero', titulo: 'Tablero' },
{ id: 'calendario', titulo: 'Calendario' },
]
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">
@@ -575,6 +706,7 @@ function FilaLead({
onConvertir,
onDescartar,
onTimeline,
onVerDetalle,
enviando,
acciones,
}: {
@@ -583,6 +715,7 @@ function FilaLead({
onConvertir: () => void
onDescartar: () => void
onTimeline?: () => void
onVerDetalle?: () => void
enviando: boolean
acciones?: string[]
}) {
@@ -591,7 +724,13 @@ function FilaLead({
<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}
<button
type="button"
onClick={onVerDetalle}
className="text-left text-accent hover:underline cursor-pointer"
>
{lead.nombre}
</button>
<ScoreBadge score={calcularScore(lead)} />
{lead.pendienteSync === true && <ChipPendienteSync />}
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
@@ -679,6 +818,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
const [nota, setNota] = useState<string | null>(null)
const [apartadoSeleccionado, setApartadoSeleccionado] = useState<ApartadoMiDia | null>(null)
const [leadSeleccionado, setLeadSeleccionado] = useState<LeadMiDia | OportunidadMiDia | ApartadoMiDia | null>(null)
const [apartadoCancelando, setApartadoCancelando] = useState<{
id: string
nombre: string
} | 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)
@@ -1038,7 +1182,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
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,
apartados: resumen.misApartados.length, escrituracion: resumen.misApartados.length, tablero: 0, calendario: 0,
}}
onCambiar={setTabCrm}
vistaKanban={vistaKanban}
@@ -1097,6 +1241,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
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)}
onVerDetalle={() => setLeadSeleccionado(lead)}
onTimeline={() => setLeadTimeline({
nombre: lead.nombre,
eventos: lead.ultimoCambio ? [{
@@ -1152,6 +1297,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
window.location.href = urlAfluenciaParaOportunidad(op)
}}
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
onVerDetalle={() => setLeadSeleccionado(op)}
acciones={acciones}
/>
))}
@@ -1196,6 +1342,8 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<FilaApartado
apartado={a}
enviando={enviando}
onCancelacion={() => setApartadoCancelando({ id: a.id, nombre: a.nombre })}
onVerDetalle={() => setLeadSeleccionado(a)}
onFase={(fase) => {
if (fase === 'Firma Cliente') {
// Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración
@@ -1246,17 +1394,25 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</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) => (
{(['Seguimiento', 'Llamada Escrituras', 'X Escriturar', 'Banco/Infonavit', 'X Autorizar', 'Escriturado', 'Cancelo'] as const).map((fase) => (
<button
key={fase}
type="button"
disabled={enviando}
onClick={() => void ejecutar(() => cambiarEstadoApartado(apartado.id, fase), 'No se pudo actualizar')}
onClick={() => {
if (fase === 'Cancelo') {
setApartadoCancelando({ id: apartado.id, nombre: apartado.nombre })
return
}
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 === 'Cancelo'
? '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'
: CLASE_BOTON_ACCION}
>
{fase}
{ETIQUETA_FASE_ESCRITURACION[fase]}
</button>
))}
</div>
@@ -1270,6 +1426,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{tabCrm === 'tablero' && (
<DashboardVendedor />
)}
{tabCrm === 'calendario' && (
<section aria-label="Calendario">
<CalendarioCitas
actividades={resumen.misActividadesHoy}
oportunidades={resumen.misOportunidades.items}
/>
</section>
)}
</section>
<section aria-label="Mis capturas de afluencia">
@@ -1582,6 +1747,23 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
/>
)}
{apartadoCancelando && (
<ModalDescarteGenerico
item={apartadoCancelando}
titulo="Cancelar apartado"
enviando={enviando}
onCancelar={() => setApartadoCancelando(null)}
onConfirmar={(motivo, subcategoria, comentario) => {
const item = apartadoCancelando
setApartadoCancelando(null)
void ejecutar(
() => cambiarEstadoApartado(item.id, 'Cancelacion Apartado', motivo, subcategoria, comentario),
'No se pudo cancelar el apartado',
)
}}
/>
)}
{apartadoSeleccionado && (
<NotasApartado
apartado={apartadoSeleccionado}
@@ -1610,6 +1792,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</div>
</div>
)}
{leadSeleccionado && (
<DetalleLeadModal
item={leadSeleccionado}
onCerrar={() => setLeadSeleccionado(null)}
/>
)}
</>
)
}