From 075d47c304734fb684d7684d6e73c4ad6d370298 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 11 Sep 2026 02:05:25 +0000 Subject: [PATCH] =?UTF-8?q?fix(mi-dia):=205=20bugs=20cr=C3=ADticos=20+=208?= =?UTF-8?q?=20mejoras=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs críticos: descartar, kanban drag&drop, escrituración, fases API, export filtros UX: tel clicable, WhatsApp, filtros por tab, tareas, calendario, estado, notas, navegación --- .../src/controllers/apartados.controller.ts | 4 + api-gateway/src/services/mi-dia.service.ts | 11 +- .../mi-dia/src/components/CalendarioCitas.tsx | 49 +- .../src/components/DetalleLeadModal.tsx | 56 ++- .../src/components/FiltrosAvanzados.tsx | 30 +- apps/mi-dia/src/components/KanbanView.tsx | 12 +- apps/mi-dia/src/lib/mi-dia.ts | 28 ++ apps/mi-dia/src/pages/MiDiaPage.tsx | 470 +++++++++++------- apps/mi-dia/src/services/recordatorios.ts | 13 + 9 files changed, 463 insertions(+), 210 deletions(-) diff --git a/api-gateway/src/controllers/apartados.controller.ts b/api-gateway/src/controllers/apartados.controller.ts index 381132d..66d682e 100644 --- a/api-gateway/src/controllers/apartados.controller.ts +++ b/api-gateway/src/controllers/apartados.controller.ts @@ -28,12 +28,16 @@ export const FASES_APARTADO_PERMITIDAS = [ 'Contrato Enkontrol', 'Firma Cliente', 'Cancelacion Apartado', + 'Venta', 'Seguimiento', + 'Integracion', 'Llamada Escrituras', 'X Escriturar', 'Banco/Infonavit', 'X Autorizar', + 'Autorizado', 'Escriturado', + 'Titulado', 'Cancelo', ] as const diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 6eaf60a..8c69ce3 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -334,10 +334,10 @@ export async function resumenMiDia( const miosOportunidadesIds = new Set( capturas.filter((c) => c.oportunidadId).map((c) => c.oportunidadId), ) - const miosApartados = apartados.filter( - (a) => - (esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id)) && !esEstadoFinalApartado(a.estado), + const miosApartadosTodos = apartados.filter((a) => + esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id), ) + const miosApartados = miosApartadosTodos.filter((a) => !esEstadoFinalApartado(a.estado)) const [cambiosLeads, cambiosOportunidades, cambiosApartados] = await Promise.all([ monday.getUltimosCambios( @@ -377,8 +377,9 @@ export async function resumenMiDia( // "Mis apartados": Vendedor = yo, estado no final del pipeline Enkontrol, // por fecha límite ascendente (sin fecha al final) - // Todos los apartados incluyendo terminales (para pestaña Escrituración) - const todosApartados: ApartadoMiDia[] = miosApartados + // Todos los apartados míos incluyendo terminales (para pestaña Escrituración); + // se calcula desde la lista ANTES de excluir estados finales + const todosApartados: ApartadoMiDia[] = miosApartadosTodos .map((a) => ({ id: a.id, nombre: a.nombre, diff --git a/apps/mi-dia/src/components/CalendarioCitas.tsx b/apps/mi-dia/src/components/CalendarioCitas.tsx index aadf183..4621d32 100644 --- a/apps/mi-dia/src/components/CalendarioCitas.tsx +++ b/apps/mi-dia/src/components/CalendarioCitas.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react' import type { ActividadMiDia, OportunidadMiDia } from '../lib/mi-dia' -import { formatoFechaCorta } from '../lib/mi-dia' +import { formatoFechaCorta, urlWhatsApp } from '../lib/mi-dia' +import { getSesion } from '../services/auth' /** Evento unificado para el calendario */ interface EventoCalendario { @@ -12,16 +13,56 @@ interface EventoCalendario { detalle?: string estado?: string idOriginal: string + /** Oportunidad fuente (solo tipo 'oportunidad'): habilita llamar/WhatsApp/ver detalle */ + item?: OportunidadMiDia } interface Props { actividades: ActividadMiDia[] oportunidades: OportunidadMiDia[] + /** Abre el DetalleLeadModal de la oportunidad del evento (desde MiDiaPage) */ + onVerDetalle?: (item: OportunidadMiDia) => void } const NOMBRE_DIAS = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'] const NOMBRE_MESES = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'] +const CLASE_BOTON_EVENTO = + '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' + +/** Botones de contacto del evento: llamar, WhatsApp y ver detalle de la oportunidad */ +function AccionesEvento({ item, onVerDetalle }: { item: OportunidadMiDia; onVerDetalle?: (item: OportunidadMiDia) => void }) { + const vendedor = getSesion()?.usuario.nombre ?? '' + const tieneTelefono = item.telefono.trim() !== '' + return ( +
+ {tieneTelefono && ( + + 📞 Llamar + + )} + {tieneTelefono && ( + + 💬 WhatsApp + + )} + {onVerDetalle && ( + + )} +
+ ) +} + function obtenerSemanaBase(fecha: Date): Date { const d = new Date(fecha) const diaSemana = d.getDay() @@ -48,7 +89,7 @@ function parsearFechaCita(fechaCita: string): { fecha: string; hora?: string } { return { fecha: fechaCita } } -export default function CalendarioCitas({ actividades, oportunidades }: Props) { +export default function CalendarioCitas({ actividades, oportunidades, onVerDetalle }: Props) { const hoy = useMemo(() => { const d = new Date() d.setHours(0, 0, 0, 0) @@ -87,6 +128,7 @@ export default function CalendarioCitas({ actividades, oportunidades }: Props) { detalle: op.etapa, estado: op.etapa, idOriginal: op.id, + item: op, }) } @@ -275,6 +317,9 @@ export default function CalendarioCitas({ actividades, oportunidades }: Props) {

)} + {eventoSeleccionado.item && ( + + )} diff --git a/apps/mi-dia/src/components/DetalleLeadModal.tsx b/apps/mi-dia/src/components/DetalleLeadModal.tsx index f4380a7..108104e 100644 --- a/apps/mi-dia/src/components/DetalleLeadModal.tsx +++ b/apps/mi-dia/src/components/DetalleLeadModal.tsx @@ -1,8 +1,9 @@ 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 { formatoFechaCorta } from '../lib/mi-dia' -import { listarRecordatorios, crearRecordatorio, type Recordatorio } from '../services/recordatorios' +import { formatoFechaCorta, urlWhatsApp } from '../lib/mi-dia' +import { listarRecordatorios, crearRecordatorio, completarRecordatorio, type Recordatorio } from '../services/recordatorios' +import { getSesion } from '../services/auth' type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia @@ -22,6 +23,9 @@ function esOportunidad(item: LeadItem): item is OportunidadMiDia { type TabId = 'info' | 'cita' | 'tareas' | 'notas' | 'miembros' +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' + const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [ { id: 'info', titulo: 'Información del cliente potencial', icono: User }, { id: 'cita', titulo: 'Reserve o actualice una cita', icono: Calendar }, @@ -33,6 +37,7 @@ const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; onCerrar: () => void }) { const esOp = esOportunidad(item) const esLd = esLead(item) + const vendedor = getSesion()?.usuario.nombre ?? '' const [tab, setTab] = useState('info') // --- Notas --- @@ -42,6 +47,8 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o const [nuevaNota, setNuevaNota] = useState('') const [enviandoNota, setEnviandoNota] = useState(false) const [busquedaNota, setBusquedaNota] = useState('') + // --- Tareas --- + const [completandoTarea, setCompletandoTarea] = useState(null) const cargarNotas = useCallback(async () => { setCargandoNotas(true) @@ -60,13 +67,25 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o if (!nuevaNota.trim()) return setEnviandoNota(true) try { - await crearRecordatorio({ titulo: nuevaNota.trim(), leadId: item.id, clienteClave: calcularClienteClave(item.nombre, (item as any).telefono), fechaRecordar: new Date().toISOString() }) + // fechaRecordar a 7 días en el futuro: la nota nace como tarea pendiente de la semana + await crearRecordatorio({ titulo: nuevaNota.trim(), leadId: item.id, clienteClave: calcularClienteClave(item.nombre, (item as any).telefono), fechaRecordar: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() }) setNuevaNota('') await cargarNotas() } catch { setNotaError('No se pudo guardar.') } finally { setEnviandoNota(false) } } + /** Marca una tarea (recordatorio) como completada y refresca la lista */ + async function completarTarea(id: string) { + setCompletandoTarea(id) + setNotaError('') + try { + await completarRecordatorio(id) + await cargarNotas() + } catch { setNotaError('No se pudo completar la tarea.') } + finally { setCompletandoTarea(null) } + } + useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onCerrar() } window.addEventListener('keydown', h) @@ -107,6 +126,7 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o {/* Contenido */}
{tab === 'info' && ( + <>
{(esLd || esOp) && item.telefono && } @@ -125,6 +145,25 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
)}
+ + {/* Acciones rápidas de contacto (bajo el teléfono) */} + {(esLd || esOp) && item.telefono && ( +
+ 📞 Llamar + + 💬 WhatsApp + + {item.email && ( + 📧 Email + )} +
+ )} + )} {tab === 'cita' && ( @@ -144,13 +183,22 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o {tab === 'tareas' && (

Tareas pendientes

+ {notaError &&

{notaError}

} {notasFiltradas.filter((n) => !n.completado).length === 0 ? (

No hay tareas pendientes.

) : (
    {notasFiltradas.filter((n) => !n.completado).map((n) => (
  • - + void completarTarea(n.id)} + /> {n.titulo} {formatoFechaCorta(n.createdAt.slice(0, 10))}
  • diff --git a/apps/mi-dia/src/components/FiltrosAvanzados.tsx b/apps/mi-dia/src/components/FiltrosAvanzados.tsx index de69954..4438f0d 100644 --- a/apps/mi-dia/src/components/FiltrosAvanzados.tsx +++ b/apps/mi-dia/src/components/FiltrosAvanzados.tsx @@ -5,6 +5,7 @@ export interface FiltrosCRM { texto: string desarrollo: string origen: string + estado: string fechaDesde: string fechaHasta: string } @@ -16,14 +17,16 @@ const CLASE_SELECT = interface Props { valor: FiltrosCRM onChange: (f: FiltrosCRM) => void + /** Estados posibles del tab activo; el select de Estado se muestra solo si se pasan */ + estados?: readonly string[] } -export function FiltrosAvanzados({ valor, onChange }: Props) { +export function FiltrosAvanzados({ valor, onChange, estados }: Props) { const actualizar = (campo: keyof FiltrosCRM, v: string) => onChange({ ...valor, [campo]: v }) const hayActivos = - valor.texto !== '' || valor.desarrollo !== '' || valor.origen !== '' || valor.fechaDesde !== '' || valor.fechaHasta !== '' + valor.texto !== '' || valor.desarrollo !== '' || valor.origen !== '' || valor.estado !== '' || valor.fechaDesde !== '' || valor.fechaHasta !== '' return (
    @@ -61,6 +64,19 @@ export function FiltrosAvanzados({ valor, onChange }: Props) { + {/* Estado: opciones según el tab activo (leads / oportunidades / apartados / escrituración) */} + {estados !== undefined && estados.length > 0 && ( + + )} + {/* Fecha desde */}
    )} - {tabCrm === 'leads' && (() => { - const leadsFiltrados = aplicarFiltros( - resumen.misLeads.filter((l) => l.fuente === 'lead'), - filtros, - { - texto: (l) => `${l.nombre} ${l.telefono} ${l.email}`, - origen: (l) => l.origen, - fecha: (l) => l.updatedAt.slice(0, 10), - }, - ) - return ( - <> + {tabCrm === 'leads' && ( + <> + {leadsFiltrados.length === 0 ? ( + + ) : vistaKanban ? ( + { + if (estado === 'DESCARTADO') { + const lead = resumen.misLeads.find((l) => l.id === id) + if (lead) setDescartando(lead) + return + } + if (estado === 'NUEVO') return + void ejecutar(() => cambiarEstadoLead(id, estado, undefined, 'lead'), 'No se pudo actualizar') + }} + /> + ) : ( +
      + {leadsFiltrados.map((lead) => ( + 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)} + acciones={acciones} + /> + ))} +
    + )} + + )} - {leadsFiltrados.length === 0 ? ( - - ) : vistaKanban ? ( - void ejecutar(() => cambiarEstadoLead(id, estado, 'lead'), 'No se pudo actualizar')} - /> - ) : ( -
      - {leadsFiltrados.map((lead) => ( - 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)} - acciones={acciones} - /> - ))} -
    - )} - - ) - })()} - - {tabCrm === 'oportunidades' && (() => { - const opsFiltradas = aplicarFiltros( - resumen.misOportunidades.items, - filtros, - { - texto: (o) => `${o.nombre} ${o.telefono} ${o.email}`, - desarrollo: (o) => o.desarrollo, - fecha: (o) => o.updatedAt.slice(0, 10), - }, - ) - return ( - <> - - {opsFiltradas.length === 0 ? ( - - ) : vistaKanban ? ( - void ejecutar(() => cambiarEtapaOportunidad(id, estado), 'No se pudo actualizar')} - /> - ) : ( -
      - {opsFiltradas.map((op) => ( - + {opsFiltradas.length === 0 ? ( + + ) : vistaKanban ? ( + { + if (estado === 'PERDIDO') { + const op = resumen.misOportunidades.items.find((o) => o.id === id) + if (op) setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' }) + return + } + void ejecutar(() => cambiarEtapaOportunidad(id, estado), 'No se pudo actualizar') + }} + /> + ) : ( +
        + {opsFiltradas.map((op) => ( + void cotizarOportunidad(op)} onAfluencia={() => { - window.location.href = urlAfluenciaParaOportunidad(op) + window.open(urlAfluenciaParaOportunidad(op), '_blank') }} onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })} onVerDetalle={() => setLeadSeleccionado(op)} @@ -1129,57 +1207,45 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones ))}
      )} - - ) - })()} + + )} - {tabCrm === 'apartados' && (() => { - const apartadosFiltrados = aplicarFiltros( - resumen.misApartados, - filtros, - { - texto: (a) => a.nombre, - fecha: (a) => a.fechaLimite, - }, - ) - return ( - <> - - {apartadosFiltrados.length === 0 ? ( - - ) : vistaKanban ? ( - void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')} - /> - ) : ( -
        - {apartadosFiltrados.map((a) => ( -
      • - setLeadSeleccionado(a)} - onFase={(fase) => { - if (fase === 'Firma Cliente') { - // Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración - 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') - }} - /> -
      • - ))} -
      - )} - - ) - })()} + {tabCrm === 'apartados' && ( + <> + {apartadosFiltrados.length === 0 ? ( + + ) : vistaKanban ? ( + void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')} + /> + ) : ( +
        + {apartadosFiltrados.map((a) => ( +
      • + setLeadSeleccionado(a)} + onFase={(fase) => { + if (fase === 'Firma Cliente') { + // Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración + 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') + }} + /> +
      • + ))} +
      + )} + + )} {tabCrm === 'escrituracion' && (
      @@ -1191,18 +1257,34 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones // Filtrar: solo apartados en fases de escrituración o Firma Cliente // Solo fases activas (Cancelo y Escriturado son terminales) // Fases activas de escrituración (excluye Cancelo y Escriturado que son terminales) - const apartadosEscrituracion = (resumen.todosApartados || resumen.misApartados || []).filter((a) => { + const enEscrituracion = (resumen.todosApartados || resumen.misApartados || []).filter((a) => { const e = (a.estado ?? '').toUpperCase() - return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('X ESCRITURAR') + return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('AUTORIZADO') || e.includes('X ESCRITURAR') }) + const apartadosEscrituracion = aplicarFiltros( + enEscrituracion, + filtros, + { + texto: (a) => a.nombre, + estado: (a) => a.estado, + fecha: (a) => a.fechaLimite, + }, + ) return apartadosEscrituracion.length === 0 ? ( ) : vistaKanban ? ( void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')} + onCambiarEstado={(id, estado) => { + if (estado === 'Cancelo') { + const a = apartadosEscrituracion.find((x) => x.id === id) + if (a) setApartadoCancelando({ id: a.id, nombre: a.nombre }) + return + } + void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar') + }} /> ) : (
        @@ -1253,6 +1335,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones setLeadSeleccionado(op)} />
      )} @@ -1552,15 +1635,22 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones )} - {descartando && (() => { - const lead = descartando - setDescartando(null) - void ejecutar( - () => descartarLead(lead.id, '', undefined, lead.fuente, undefined), - 'No se pudo descartar el lead', - ) - return null - })()} + {descartando && ( + setDescartando(null)} + onConfirmar={(motivo, subcategoria, comentario) => { + const item = descartando + setDescartando(null) + void ejecutar( + () => descartarLead(item.id, motivo, comentario, item.fuente, subcategoria), + 'No se pudo descartar el lead', + ) + }} + /> + )} diff --git a/apps/mi-dia/src/services/recordatorios.ts b/apps/mi-dia/src/services/recordatorios.ts index 8661ead..baeb749 100644 --- a/apps/mi-dia/src/services/recordatorios.ts +++ b/apps/mi-dia/src/services/recordatorios.ts @@ -44,3 +44,16 @@ export async function crearRecordatorio(data: { } return (await res.json()) as Recordatorio } + +/** PATCH /api/recordatorios/:id/completar — marcar un recordatorio como completado */ +export async function completarRecordatorio(id: string): Promise { + const res = await fetch(`${API_URL}/recordatorios/${encodeURIComponent(id)}/completar`, conAuth({ + method: 'PATCH', + })) + manejar401(res) + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(body?.error ?? `Error ${res.status}`) + } + return (await res.json()) as Recordatorio +}