diff --git a/api-gateway/src/config/vocabulario.ts b/api-gateway/src/config/vocabulario.ts index 5e3cbc2..35c4fd5 100644 --- a/api-gateway/src/config/vocabulario.ts +++ b/api-gateway/src/config/vocabulario.ts @@ -13,7 +13,7 @@ export const VOCABULARIO = { // Estados de la columna status del board Leads: ESTADOS_LEAD_PERMITIDOS del // API + NUEVO (captura) y DESCARTADO (terminal) - FASES_LEAD: ['NUEVO', 'CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'], + FASES_LEAD: ['NUEVO', 'Intento de contacto', 'CONTACTADO', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'], // Etapas del funnel de Oportunidades (labels REALES de la columna status 'Estado') FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'], // Fases del pipeline Enkontrol del tablero Apartado (labels REALES de la diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 01632d5..95161fa 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -85,6 +85,8 @@ export interface ActividadMiDia { tipo: string fecha: string estado: string + /** Captura (lead/oportunidad/apartado) a la que pertenece la actividad; null si no está ligada */ + leadCapturaId: string | null } export interface ApartadoPorVencer { @@ -450,6 +452,7 @@ export function resumenMiDiaLocal( tipo: a.tipo, fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : '', estado: a.estado, + leadCapturaId: a.leadCapturaId, })) const todasMisActividades: ActividadMiDia[] = actividadesAbiertasOrdenadas const misActividadesHoy: ActividadMiDia[] = actividadesAbiertasOrdenadas diff --git a/apps/mi-dia/src/components/DetalleLeadModal.tsx b/apps/mi-dia/src/components/DetalleLeadModal.tsx index d2b0502..8b0e36e 100644 --- a/apps/mi-dia/src/components/DetalleLeadModal.tsx +++ b/apps/mi-dia/src/components/DetalleLeadModal.tsx @@ -1,7 +1,7 @@ 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 { formatoFechaCorta, urlWhatsApp } from '../lib/mi-dia' +import { formatoFechaCorta, formatoFechaHora, urlWhatsApp } from '../lib/mi-dia' import { listarRecordatorios, crearRecordatorio, completarRecordatorio, type Recordatorio } from '../services/recordatorios' import { getSesion } from '../services/auth' @@ -251,7 +251,7 @@ export default function DetalleLeadModal({ onChange={() => void completarTarea(n.id)} /> {n.titulo} - {formatoFechaCorta(n.createdAt.slice(0, 10))} + {formatoFechaHora(n.createdAt)} ))} @@ -311,7 +311,7 @@ export default function DetalleLeadModal({
  • {n.titulo}

    {n.descripcion &&

    {n.descripcion}

    } -

    {formatoFechaCorta(n.createdAt.slice(0, 10))}

    +

    {formatoFechaHora(n.createdAt)}

  • ))} diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts index ac3d8ad..afc5ab8 100644 --- a/apps/mi-dia/src/lib/mi-dia.ts +++ b/apps/mi-dia/src/lib/mi-dia.ts @@ -23,6 +23,8 @@ export interface ActividadMiDia { tipo: string fecha: string // 'YYYY-MM-DD' estado: string + /** Captura (lead/oportunidad/apartado) ligada a la actividad; null si no aplica */ + leadCapturaId: string | null } export interface ApartadoPorVencer { @@ -127,7 +129,7 @@ export interface ResumenMiDia { export const VOCABULARIO = { // Estados de la columna status del board Leads (ESTADOS_LEAD_PERMITIDOS del // API + NUEVO/DESCARTADO) - FASES_LEAD: ['NUEVO', 'CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'], + FASES_LEAD: ['NUEVO', 'Intento de contacto', 'CONTACTADO', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'], // Etapas del funnel de Oportunidades (labels REALES del board; el API las valida tal cual) FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'], // Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board) @@ -303,6 +305,18 @@ export function formatoFechaCorta(fecha: string): string { return `${d}/${m}` } +/** ISO → 'dd/mm/aaaa hh:mm' (hora local): bitácoras y notas con hora exacta */ +export function formatoFechaHora(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + const dd = String(d.getDate()).padStart(2, '0') + const m = String(d.getMonth() + 1).padStart(2, '0') + const y = d.getFullYear() + const hh = String(d.getHours()).padStart(2, '0') + const mm = String(d.getMinutes()).padStart(2, '0') + return `${dd}/${m}/${y} ${hh}:${mm}` +} + /** Texto de urgencia de un apartado según los días que faltan */ export function textoDiasRestantes(dias: number): string { if (dias <= 0) return 'Vence hoy' diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index b234c49..a617b8e 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -761,12 +761,12 @@ function FilaLead({
    - + @@ -844,6 +844,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones }) const filtros = filtrosPorTab[tabCrm] const actualizarFiltros = (f: FiltrosCRM) => setFiltrosPorTab((prev) => ({ ...prev, [tabCrm]: f })) + // Filtro por vendedor: SOLO administradores. Un solo selector que aplica a + // las cuatro pestañas (leads / oportunidades / apartados / escrituración). + const esAdmin = rol === 'admin' || rol === 'manager' + const [filtroVendedor, setFiltroVendedor] = useState('') // Estados posibles por tab: alimenta el select "Estado" de FiltrosAvanzados const estadosPorTab: Partial> = { leads: ESTADOS_LEAD, @@ -861,21 +865,22 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones // columnas de solo-display: su drop abre el modal de descarte o no hace nada. const columnasLeads = [ { id: 'NUEVO', titulo: 'Nuevo', color: 'border-t-gray-400' }, - { id: 'CONTACTADO', titulo: 'Contactado', color: 'border-t-blue-500' }, { id: 'Intento de contacto', titulo: 'Intento de contacto', color: 'border-t-sky-500' }, + { id: 'CONTACTADO', titulo: 'Contactado', color: 'border-t-blue-500' }, { id: 'INTERESADO', titulo: 'Interesado', color: 'border-t-yellow-500' }, { id: 'AGENDA CITA', titulo: 'Agenda Cita', color: 'border-t-green-500' }, { id: 'CALIFICADO', titulo: 'Calificado', color: 'border-t-teal-500' }, { id: 'DESCARTADO', titulo: 'Descartado', color: 'border-t-red-500' }, ] + // Mismo orden y mismas etapas que la vista de lista: Confirmó Cita → + // Afluencia → Cotizar → Apartó → Descartar. PERDIDO es solo-display: su + // drop abre el modal de descarte (mismo comportamiento que el botón). const columnasOportunidades = [ + { id: 'Agendo Cita', titulo: 'Confirmó Cita', color: 'border-t-green-500' }, { id: 'Afluencia', titulo: 'Afluencia', color: 'border-t-purple-500' }, - { id: 'Cotizador', titulo: 'Cotizador', color: 'border-t-blue-500' }, - { id: 'Agendo Cita', titulo: 'Agendó Cita', color: 'border-t-green-500' }, + { id: 'Cotizador', titulo: 'Cotizar', color: 'border-t-blue-500' }, { id: 'Aparto', titulo: 'Apartó', color: 'border-t-emerald-600' }, - { id: 'No Asistio', titulo: 'No Asistió', color: 'border-t-orange-500' }, - { id: 'Abandono', titulo: 'Abandono', color: 'border-t-yellow-600' }, - { id: 'PERDIDO', titulo: 'Perdido', color: 'border-t-red-500' }, + { id: 'PERDIDO', titulo: 'Descartar', color: 'border-t-red-500' }, ] const columnasApartados = [ { id: 'Pago Apartado', titulo: 'Pago Apartado', color: 'border-t-blue-500' }, @@ -910,11 +915,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones const obtenerColumnaOp = (op: OportunidadMiDia) => { const e = (op.etapa ?? '').toUpperCase() if (e.includes('PERDIDO') || e.includes('PERDI')) return 'PERDIDO' - if (e.includes('ASISTIO')) return 'No Asistio' - if (e.includes('ABANDONO')) return 'Abandono' if (e.includes('APARTO')) return 'Aparto' if (e.includes('COTIZ')) return 'Cotizador' - if (e.includes('AGENDO') || e.includes('CITA')) return 'Agendo Cita' + // 'No Asistio' vuelve a Confirmó Cita (se reagenda); 'Abandono' es + // terminal y ya no aparece en la lista de trabajo + if (e.includes('AGENDO') || e.includes('CITA') || e.includes('ASISTIO')) return 'Agendo Cita' return 'Afluencia' } const obtenerColumnaApartado = (a: ApartadoMiDia) => { @@ -1074,6 +1079,24 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones }, 'No se pudo mover el lead') } + /** + * Click en una actividad de "Mis actividades": abre la bitácora del cliente + * ligado (lead, oportunidad o apartado). Si no hay ficha ligada, solo avisa. + */ + function abrirDetalleDesdeActividad(leadCapturaId: string | null) { + if (!resumen) return + const item = + leadCapturaId !== null + ? resumen.misLeads.find((l) => l.id === leadCapturaId) ?? + resumen.misOportunidades.items.find((o) => o.id === leadCapturaId) ?? + resumen.misApartados.find((a) => a.id === leadCapturaId) ?? + (resumen.todosApartados ?? []).find((a) => a.id === leadCapturaId) ?? + null + : null + if (item) setLeadSeleccionado(item) + else toast.error('Esta actividad no tiene una ficha de cliente ligada') + } + async function enviarCaptura(e: FormEvent) { e.preventDefault() setCapEnviando(true) @@ -1111,9 +1134,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones } // Listas filtradas por los filtros avanzados; las usan el render por pestaña - // Y el botón de exportar (para que la exportación respete los filtros activos) + // Y el botón de exportar (para que la exportación respete los filtros activos). + // Admin: filtroVendedor ('' = todos) recorta además por vendedor asignado. + const coincideVendedor = (v: string) => filtroVendedor === '' || v === filtroVendedor const leadsFiltrados = resumen - ? aplicarFiltros(resumen.misLeads.filter((l) => l.fuente === 'lead'), filtros, { + ? aplicarFiltros(resumen.misLeads.filter((l) => l.fuente === 'lead' && coincideVendedor(l.vendedor)), filtros, { texto: (l) => `${l.nombre} ${l.telefono} ${l.email}`, origen: (l) => l.origen, estado: (l) => l.estado, @@ -1121,7 +1146,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones }) : [] const opsFiltradas = resumen - ? aplicarFiltros(resumen.misOportunidades.items, filtros, { + ? aplicarFiltros(resumen.misOportunidades.items.filter((o) => coincideVendedor(o.vendedor)), filtros, { texto: (o) => `${o.nombre} ${o.telefono} ${o.email}`, desarrollo: (o) => o.desarrollo, estado: (o) => o.etapa, @@ -1129,7 +1154,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones }) : [] const apartadosFiltrados = resumen - ? aplicarFiltros(resumen.misApartados, filtros, { + ? aplicarFiltros(resumen.misApartados.filter((a) => coincideVendedor(a.vendedor)), filtros, { texto: (a) => a.nombre, estado: (a) => a.estado, fecha: (a) => a.fechaLimite, @@ -1140,6 +1165,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones // para que el conteo del tab refleje la lista filtrada de escrituración. const enEscrituracion = resumen ? (resumen.todosApartados || resumen.misApartados).filter((a) => { + if (!coincideVendedor(a.vendedor)) return false const e = (a.estado ?? '').toUpperCase() return ( e.includes('FIRMA') || @@ -1163,6 +1189,20 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones fecha: (a) => a.fechaLimite, }) + // Vendedores con registros visibles: alimenta el filtro por vendedor (admin) + const vendedoresOpciones = resumen + ? [ + ...new Set( + [ + ...resumen.misLeads.map((l) => l.vendedor), + ...resumen.misOportunidades.items.map((o) => o.vendedor), + ...resumen.misApartados.map((a) => a.vendedor), + ...(resumen.todosApartados ?? []).map((a) => a.vendedor), + ].filter((v) => v.trim() !== ''), + ), + ].sort((a, b) => a.localeCompare(b)) + : [] + const hoy = hoyLocal() const iniciales = nombreVendedor .split(' ') @@ -1294,6 +1334,19 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
    + {esAdmin && ( + + )}