feat(mi-dia): gate de afluencia para apartar, pestana Afluencia en bitacora, cita cliente con fecha en calendario, filtros por desarrollo/origen corregidos, rangos salariales nuevos y soporte como acceso directo

This commit is contained in:
2026-09-25 10:23:24 +00:00
parent 8a5db114ec
commit 7f663208a0
19 changed files with 381 additions and 501 deletions

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, 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 { X, User, Calendar, CheckSquare, FileText, Users, Search, ClipboardList } from 'lucide-react'
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia, AfluenciaPerfil } from '../lib/mi-dia'
import { formatoFechaCorta, formatoFechaHora, urlWhatsApp } from '../lib/mi-dia'
import { listarRecordatorios, crearRecordatorio, completarRecordatorio, type Recordatorio } from '../services/recordatorios'
import { fetchAfluencia } from '../services/mi-dia'
import { getSesion } from '../services/auth'
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
@@ -21,7 +22,33 @@ function esOportunidad(item: LeadItem): item is OportunidadMiDia {
return 'modeloInteres' in item
}
type TabId = 'info' | 'cita' | 'tareas' | 'notas' | 'miembros'
type TabId = 'info' | 'cita' | 'afluencia' | 'tareas' | 'notas' | 'miembros'
/** Etiquetas legibles de los campos del perfil de afluencia (bitácora) */
const ETIQUETAS_AFLUENCIA: Record<string, string> = {
nombre: 'Nombre',
telefono: 'Teléfono',
email: 'Email',
origen: 'Origen',
desarrollo: 'Desarrollo',
modelo: 'Modelo',
medioContacto: 'Medio de contacto',
numDependientes: 'No. de dependientes',
edad: 'Edad',
estadoCivil: 'Estado civil',
tipoEmpleo: 'Tipo de empleo',
ocupacion: 'Ocupación',
rangoSalarial: 'Rango salarial',
credito: 'Crédito',
tipoEconomia: 'Tipo de economía',
califica: 'Califica',
loQuierePara: 'Lo quiere para',
notas: 'Notas',
unidadCodigo: 'Unidad',
}
/** Claves internas que no se muestran como campo del perfil */
const CLAVES_AFLUENCIA_OCULTAS = ['fechaCita', 'oportunidadId']
const CLASE_ACCION_RAPIDA =
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10'
@@ -29,6 +56,7 @@ const CLASE_ACCION_RAPIDA =
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
{ id: 'info', titulo: 'Información', icono: User },
{ id: 'cita', titulo: 'Cita', icono: Calendar },
{ id: 'afluencia', titulo: 'Afluencia', icono: ClipboardList },
{ id: 'tareas', titulo: 'Tareas', icono: CheckSquare },
{ id: 'notas', titulo: 'Notas', icono: FileText },
{ id: 'miembros', titulo: 'Miembros', icono: Users },
@@ -63,6 +91,20 @@ export default function DetalleLeadModal({
// --- Cita ---
const [fechaCita, setFechaCita] = useState(() => ('fechaCita' in item ? (item.fechaCita ?? '') : ''))
const [guardandoCita, setGuardandoCita] = useState(false)
// --- Afluencia (pestaña de la bitácora) ---
const [afluencia, setAfluencia] = useState<AfluenciaPerfil | null>(null)
const [cargandoAfluencia, setCargandoAfluencia] = useState(false)
useEffect(() => {
if (tab !== 'afluencia') return
let vivo = true
setCargandoAfluencia(true)
fetchAfluencia(item.id)
.then((a) => { if (vivo) setAfluencia(a) })
.catch(() => { if (vivo) setAfluencia(null) })
.finally(() => { if (vivo) setCargandoAfluencia(false) })
return () => { vivo = false }
}, [tab, item.id])
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
async function guardarCita(e: FormEvent) {
@@ -231,6 +273,35 @@ export default function DetalleLeadModal({
</div>
)}
{tab === 'afluencia' && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Afluencia</h3>
{afluencia?.afluenciaCompleta && (
<span className="rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-300">
Afluencia completa
</span>
)}
</div>
{cargandoAfluencia ? (
<p className="text-sm text-[#94A3B8]">Cargando afluencia…</p>
) : !afluencia || Object.keys(afluencia.perfil).length === 0 ? (
<p className="text-sm text-[#94A3B8]">
Aún no se ha capturado la afluencia de este cliente. Se llena desde el botón
"Afluencia →" de Mis oportunidades.
</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{Object.entries(afluencia.perfil)
.filter(([clave]) => !CLAVES_AFLUENCIA_OCULTAS.includes(clave))
.map(([clave, valor]) => (
<InfoCampo key={clave} etiqueta={ETIQUETAS_AFLUENCIA[clave] ?? clave} valor={valor} />
))}
</div>
)}
</div>
)}
{tab === 'tareas' && (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Tareas pendientes</h3>

View File

@@ -141,14 +141,15 @@ export function aplicarFiltros(
if (!valor.includes(busqueda)) return false
}
// Desarrollo
// Desarrollo (comparación normalizada: el catálogo y los datos pueden
// variar en mayúsculas/espacios — era la causa principal de filtros rotos)
if (filtros.desarrollo !== '' && accessor.desarrollo) {
if (accessor.desarrollo(item) !== filtros.desarrollo) return false
if (accessor.desarrollo(item).trim().toUpperCase() !== filtros.desarrollo.trim().toUpperCase()) return false
}
// Origen
// Origen (misma normalización que desarrollo)
if (filtros.origen !== '' && accessor.origen) {
if (accessor.origen(item) !== filtros.origen) return false
if (accessor.origen(item).trim().toUpperCase() !== filtros.origen.trim().toUpperCase()) return false
}
// Estado (comparación case-insensitive: los labels pueden variar en mayúsculas)

View File

@@ -80,6 +80,7 @@ describe('urlCotizadorParaOportunidad', () => {
etapa: 'Afluencia',
desarrollo: 'SENDA',
modeloInteres: 'SOHO',
afluenciaCompleta: true,
vendedor: 'Martin Zavala',
unidadCodigo: 'U-9',
origen: 'FACEBOOK',
@@ -120,6 +121,7 @@ describe('urlAfluenciaParaOportunidad', () => {
etapa: 'Agendo Cita',
desarrollo: 'SENDA',
modeloInteres: 'SOHO',
afluenciaCompleta: false,
vendedor: 'Martin Zavala',
unidadCodigo: '',
origen: '',

View File

@@ -74,6 +74,8 @@ export interface OportunidadMiDia {
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
fechaCita?: string
/** ¿Afluencia con perfil completo? Sin esto NO puede avanzar a 'Aparto' */
afluenciaCompleta: boolean
}
/** Apartado asignado al vendedor (tab "Mis apartados") */
@@ -275,6 +277,17 @@ export function urlCotizadorParaOportunidad(op: OportunidadMiDia): string {
return `/quote/?${params.toString()}`
}
/**
* Perfil de afluencia capturado de un lead/oportunidad/apartado
* (GET /api/afluencias/:leadId). Solo valores de texto no vacíos.
*/
export interface AfluenciaPerfil {
leadId: string
nombre: string
perfil: Record<string, string>
afluenciaCompleta: boolean
}
/**
* URL del formulario de Alta Afluencia (/client/) prellenado con los datos de
* la oportunidad. Al enviarlo, el API ACTUALIZA esa oportunidad (perfil

View File

@@ -250,11 +250,18 @@ function FilaOportunidad({
>
Cotizar →
</button>
{/* 4. Aparto: marca la etapa (verde) */}
{/* 4. Aparto: marca la etapa (verde). REQUIERE afluencia completa:
sin perfil capturado no se puede apartar (regla de negocio) */}
<button
type="button"
disabled={enviando}
onClick={() => onEtapa('Aparto')}
onClick={() => {
if (!op.afluenciaCompleta) {
toast.error('Primero debes capturar la afluencia del cliente (botón Afluencia →)')
return
}
onEtapa('Aparto')
}}
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"
>
Aparto
@@ -368,11 +375,15 @@ function FilaApartado({
enviando,
}: {
apartado: ApartadoMiDia
onFase: (fase: FaseApartado) => void
onFase: (fase: FaseApartado, fecha?: string) => void
onVerDetalle?: () => void
enviando: boolean
}) {
const [docsAbiertos, setDocsAbiertos] = useState(false)
// 'Cita Clientes': al primer toque pide la fecha y hora; al confirmar agenda
// la cita en el calendario del vendedor (ActividadLocal en el API)
const [conCita, setConCita] = useState(false)
const [fechaCita, setFechaCita] = useState('')
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">
@@ -424,6 +435,21 @@ function FilaApartado({
window.open(`/client/?apartado=${apartado.id}`, '_blank')
return
}
// Cita Clientes: al primer toque pide fecha y hora; al confirmar
// agenda la cita en el calendario (ActividadLocal del API)
if (fase === 'Cita Clientes') {
if (!conCita) {
setConCita(true)
return
}
if (fechaCita === '') {
toast.error('Elige la fecha y hora de la cita')
return
}
onFase(fase, fechaCita)
setConCita(false)
return
}
onFase(fase)
}}
className={
@@ -438,9 +464,22 @@ function FilaApartado({
: CLASE_BOTON_ACCION
}
>
{fase === 'Alta Enkontrol' ? 'Alta Enkontrol →' : ETIQUETA_FASE_APARTADO[fase]}
{fase === 'Alta Enkontrol'
? 'Alta Enkontrol →'
: fase === 'Cita Clientes' && conCita
? 'Confirmar cita'
: ETIQUETA_FASE_APARTADO[fase]}
</button>
))}
{conCita && (
<input
type="datetime-local"
aria-label="Fecha y hora de la cita del cliente"
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"
/>
)}
<button
type="button"
onClick={() => setDocsAbiertos((v) => !v)}
@@ -1140,6 +1179,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
const leadsFiltrados = resumen
? aplicarFiltros(resumen.misLeads.filter((l) => l.fuente === 'lead' && coincideVendedor(l.vendedor)), filtros, {
texto: (l) => `${l.nombre} ${l.telefono} ${l.email}`,
desarrollo: (l) => l.desarrollo ?? '',
origen: (l) => l.origen,
estado: (l) => l.estado,
fecha: (l) => l.updatedAt.slice(0, 10),
@@ -1149,13 +1189,16 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
? aplicarFiltros(resumen.misOportunidades.items.filter((o) => coincideVendedor(o.vendedor)), filtros, {
texto: (o) => `${o.nombre} ${o.telefono} ${o.email}`,
desarrollo: (o) => o.desarrollo,
origen: (o) => o.origen,
estado: (o) => o.etapa,
fecha: (o) => o.updatedAt.slice(0, 10),
})
: []
const apartadosFiltrados = resumen
? aplicarFiltros(resumen.misApartados.filter((a) => coincideVendedor(a.vendedor)), filtros, {
texto: (a) => a.nombre,
texto: (a) => `${a.nombre} ${a.telefono} ${a.email}`,
desarrollo: (a) => a.desarrollo,
origen: (a) => a.origen,
estado: (a) => a.estado,
fecha: (a) => a.fechaLimite,
})
@@ -1184,7 +1227,9 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
})
: []
const escrituracionFiltrados = aplicarFiltros(enEscrituracion, filtrosPorTab.escrituracion, {
texto: (a) => a.nombre,
texto: (a) => `${a.nombre} ${a.telefono} ${a.email}`,
desarrollo: (a) => a.desarrollo,
origen: (a) => a.origen,
estado: (a) => a.estado,
fecha: (a) => a.fechaLimite,
})
@@ -1422,6 +1467,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
// No retroceder etapa al soltar en "Agendo Cita" una
// op que ya está en Cotizador/Aparto
const op = resumen.misOportunidades.items.find((o) => o.id === id)
// Gate de negocio: sin afluencia completa no se aparta
if (estado === 'Aparto' && op && !op.afluenciaCompleta) {
toast.error('Primero debes capturar la afluencia del cliente (botón Afluencia →)')
return
}
const destino =
estado === 'Agendo Cita' && op && (op.etapa === 'Cotizador' || op.etapa === 'Aparto')
? op.etapa
@@ -1482,7 +1532,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
apartado={a}
enviando={enviando}
onVerDetalle={() => setLeadSeleccionado(a)}
onFase={(fase) => {
onFase={(fase, fecha) => {
if (fase === 'Cancelacion Apartado') {
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
setApartadoCancelando({ id: a.id, nombre: a.nombre })
@@ -1493,7 +1543,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
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')
void ejecutar(
() => cambiarEstadoApartado(a.id, fase, undefined, undefined, undefined, fecha),
'No se pudo actualizar el apartado',
)
}}
/>
</li>

View File

@@ -47,6 +47,7 @@ const opFake: OportunidadMiDia = {
etapa: 'Agendo Cita',
desarrollo: 'DIAGONAL',
modeloInteres: 'Modelo A',
afluenciaCompleta: false,
vendedor: 'Martin Zavala',
unidadCodigo: 'D-101',
origen: '',

View File

@@ -94,6 +94,7 @@ export function cambiarEstadoApartado(
motivo?: string,
subcategoria?: string,
comentario?: string,
fecha?: string,
): Promise<unknown> {
return fetchJson(`/apartados/${itemId}/estado`, {
method: 'POST',
@@ -103,10 +104,16 @@ export function cambiarEstadoApartado(
...(motivo !== undefined && motivo !== '' ? { motivo } : {}),
...(subcategoria !== undefined && subcategoria !== '' ? { subcategoria } : {}),
...(comentario !== undefined && comentario !== '' ? { comentario } : {}),
...(fecha !== undefined && fecha !== '' ? { fecha } : {}),
}),
})
}
/** GET /afluencias/:leadId — perfil de afluencia capturado (pestaña de la bitácora) */
export async function fetchAfluencia(leadId: string): Promise<import('../lib/mi-dia').AfluenciaPerfil> {
return fetchJson(`/afluencias/${leadId}`)
}
/** POST /leads/:itemId/mover — mover lead a otro tablero (solo admin) */
export async function moverLead(itemId: string, board: 'LEADS' | 'OPORTUNIDADES'): Promise<void> {
const res = await fetch(`${API_URL}/leads/${itemId}/mover`, conAuth({