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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user