feat(mi-dia): captura rapida arriba de actividades, filtro admin por vendedor, hora en notas, orden Intento->Contactado y kanban de oportunidades alineado

This commit is contained in:
2026-09-25 09:21:23 +00:00
parent 8a2584438d
commit 8a5db114ec
5 changed files with 113 additions and 36 deletions

View File

@@ -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)}
/>
<span className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</span>
<span className="ml-auto text-[10px] text-[#94A3B8]">{formatoFechaCorta(n.createdAt.slice(0, 10))}</span>
<span className="ml-auto text-[10px] text-[#94A3B8]">{formatoFechaHora(n.createdAt)}</span>
</li>
))}
</ul>
@@ -311,7 +311,7 @@ export default function DetalleLeadModal({
<li key={n.id} className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] px-4 py-3">
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</p>
{n.descripcion && <p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{n.descripcion}</p>}
<p className="mt-1.5 text-[10px] text-[#94A3B8]">{formatoFechaCorta(n.createdAt.slice(0, 10))}</p>
<p className="mt-1.5 text-[10px] text-[#94A3B8]">{formatoFechaHora(n.createdAt)}</p>
</li>
))}
</ul>

View File

@@ -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'

View File

@@ -761,12 +761,12 @@ function FilaLead({
</span>
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<button type="button" disabled={enviando} onClick={() => onAccion('CONTACTADO')} className={CLASE_BOTON_ACCION}>
Contactado
</button>
<button type="button" disabled={enviando} onClick={() => onAccion('Intento de contacto')} className={CLASE_BOTON_ACCION}>
Intento
</button>
<button type="button" disabled={enviando} onClick={() => onAccion('CONTACTADO')} className={CLASE_BOTON_ACCION}>
Contactado
</button>
<button type="button" disabled={enviando} onClick={() => onAccion('INTERESADO')} className={CLASE_BOTON_ACCION}>
Interesado
</button>
@@ -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<Record<TabCrm, readonly string[]>> = {
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
<div className="flex-1">
<FiltrosAvanzados valor={filtros} onChange={actualizarFiltros} estados={estadosPorTab[tabCrm]} />
</div>
{esAdmin && (
<select
aria-label="Filtrar por vendedor"
value={filtroVendedor}
onChange={(e) => setFiltroVendedor(e.target.value)}
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-2 text-xs font-medium text-[#374151] shadow-sm transition-colors focus:border-accent focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1]"
>
<option value="">Todos los vendedores</option>
{vendedoresOpciones.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
)}
<button
type="button"
onClick={() => {
@@ -1340,7 +1393,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
onDescartar={() => setDescartando(lead)}
onVerDetalle={() => setLeadSeleccionado(lead)}
acciones={acciones}
esAdmin={rol === 'admin' || rol === 'manager'}
esAdmin={esAdmin}
vendedores={resumen.equipo?.porVendedor.map((v) => ({ id: v.usuarioId, nombre: v.nombre ?? '' }))}
onReasignar={(usuarioId) => void reasignar(lead, usuarioId)}
onMover={() => void mover(lead)}
@@ -1615,7 +1668,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</div>
<aside className={`order-1 flex flex-col gap-6 ${vistaKanban && tabCrm === 'leads' ? 'hidden' : 'md:order-2 lg:sticky lg:top-20 lg:self-start'}`}>
<section aria-label="Mis actividades">
<section aria-label="Mis actividades" className="order-2">
<TituloSeccion>Mis actividades</TituloSeccion>
{resumen.misActividadesHoy.length === 0 ? (
<EstadoVacio mensaje="Sin actividades para hoy" />
@@ -1624,19 +1677,26 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{resumen.misActividadesHoy.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm dark:bg-[#1E293B] dark:border-[#1E293B]"
className="rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:bg-[#1E293B] dark:border-[#1E293B]"
>
<p className="text-sm">
{a.nombre}
{a.tipo ? <span className="ml-2 text-xs uppercase tracking-wider text-[#94A3B8]">{a.tipo}</span> : null}
</p>
<span
className={`text-xs font-medium ${
esActividadVencida(a.fecha, hoy) ? 'text-red-600' : 'text-[#64748B] dark:text-[#94A3B8]'
}`}
<button
type="button"
onClick={() => abrirDetalleDesdeActividad(a.leadCapturaId ?? null)}
title={a.leadCapturaId ? 'Ver información del cliente' : 'Actividad sin cliente ligado'}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-[#F9FAFB] dark:hover:bg-[#0F172A]"
>
{formatoFechaCorta(a.fecha)}
</span>
<p className="text-sm">
{a.nombre}
{a.tipo ? <span className="ml-2 text-xs uppercase tracking-wider text-[#94A3B8]">{a.tipo}</span> : null}
</p>
<span
className={`shrink-0 text-xs font-medium ${
esActividadVencida(a.fecha, hoy) ? 'text-red-600' : 'text-[#64748B] dark:text-[#94A3B8]'
}`}
>
{formatoFechaCorta(a.fecha)}
</span>
</button>
</li>
))}
</ul>
@@ -1644,7 +1704,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</section>
{resumen.apartadosPorVencer.length > 0 && (
<section aria-label="Apartados por vencer">
<section aria-label="Apartados por vencer" className="order-3">
<TituloSeccion>Apartados por vencer</TituloSeccion>
<ul className="space-y-2">
{resumen.apartadosPorVencer.map((a) => (
@@ -1669,7 +1729,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</section>
)}
<section aria-label="Captura rápida de lead">
<section aria-label="Captura rápida de lead" className="order-1">
<TituloSeccion>Captura rápida de lead</TituloSeccion>
<form
onSubmit={enviarCaptura}