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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -47,6 +47,7 @@ const opFake: OportunidadMiDia = {
|
||||
etapa: 'Agendo Cita',
|
||||
desarrollo: 'DIAGONAL',
|
||||
modeloInteres: 'Modelo A',
|
||||
afluenciaCompleta: false,
|
||||
vendedor: 'Martin Zavala',
|
||||
unidadCodigo: 'D-101',
|
||||
origen: '',
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user