feat(mejoras): 14 mejoras al CRM Frasa

Fase 1 - UI: dark mode, skeletons, toasts
Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K
Fase 3 - Datos: export Excel, notas apartados, auditoría
Fase 4 - Dashboard: gráficas vendedor + métricas uso
Fase 5 - Responsive: tablets optimizadas
Fase 6 - PWA: IndexedDB offline, push notifications

Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos
721 API tests + fronts verdes.
This commit is contained in:
2026-09-07 06:04:49 +00:00
parent 388e1da338
commit c77b4c0628
170 changed files with 5802 additions and 637 deletions

View File

@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import KpiCard from '../components/KpiCard'
import BannerModoLocal from '../components/BannerModoLocal'
import DarkModeToggle from '../components/DarkModeToggle'
import { SkeletonList } from "../components/Skeleton"
import toast from "react-hot-toast"
import ChipPendienteSync from '../components/ChipPendienteSync'
import type { AccionesSesion } from '../components/AuthGate'
import {
@@ -42,27 +45,33 @@ import {
subirDocumentoApartado,
type DocumentoApartado,
} from '../services/mi-dia'
import { guardarResumen, cargarResumenLocal } from '../services/offline-db'
import { suscribirPush } from '../services/push'
import { exportarLeads, exportarOportunidades, exportarApartados } from '../services/exportar'
import { NotasApartado } from '../components/NotasApartado'
import { FiltrosAvanzados, aplicarFiltros, type FiltrosCRM } from '../components/FiltrosAvanzados'
import { getSesion } from '../services/auth'
import DashboardVendedor from '../components/DashboardVendedor'
function TituloSeccion({ children }: { children: string }) {
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{children}</h2>
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{children}</h2>
}
function EstadoVacio({ mensaje }: { mensaje: string }) {
return (
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white">
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white dark:border-[#334155] dark:bg-[#1E293B]">
<p className="text-xs font-semibold uppercase tracking-wider text-[#94A3B8]">{mensaje}</p>
</div>
)
}
const CLASE_BOTON_ACCION =
'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 disabled:opacity-50'
'rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50'
const CLASE_INPUT =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563]'
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
/** Modal de descarte: motivo + subcategoría (catálogo Monday) + comentario opcional */
function ModalDescartar({
@@ -82,9 +91,9 @@ function ModalDescartar({
const subcategorias = SUBCATEGORIAS_NO_COMPRA[motivo]
return (
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">Descartar lead</p>
<p className="mt-2 text-lg font-medium text-[#111827]">{lead.nombre}</p>
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Descartar lead</p>
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{lead.nombre}</p>
<form
className="mt-6 space-y-4"
onSubmit={(e) => {
@@ -147,7 +156,7 @@ function ModalDescartar({
type="button"
onClick={onCancelar}
disabled={enviando}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50"
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50"
>
Cancelar
</button>
@@ -188,21 +197,21 @@ function FilaOportunidad({
// Sin 'No Asistio' ni 'Abandono': son redundantes con Descartar
const fases = ['Agendo Cita', 'Aparto'] as const
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
<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">
<div>
<p className="flex items-center gap-2 text-sm font-medium">
{op.nombre}
{op.pendienteSync === true && <ChipPendienteSync />}
</p>
<p className="mt-1 text-xs text-[#64748B]">
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{op.telefono || 'Sin teléfono'}
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
</p>
{op.ultimoCambio && (
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Último cambio: {op.ultimoCambio.detalle} · {op.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(op.ultimoCambio.fechaISO.slice(0, 10))}
</p>
@@ -212,7 +221,7 @@ function FilaOportunidad({
{op.etapa || 'Sin etapa'}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{/* Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */}
<button
type="button"
@@ -248,7 +257,7 @@ function FilaOportunidad({
aria-label="Fecha de la cita"
value={fechaCita}
onChange={(e) => setFechaCita(e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
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"
/>
)}
</span>
@@ -289,7 +298,7 @@ function FilaApartado({
}) {
const [docsAbiertos, setDocsAbiertos] = useState(false)
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
<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">
<div>
<p className="flex items-center gap-2 text-sm font-medium">
@@ -299,11 +308,11 @@ function FilaApartado({
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
) : null}
</p>
<p className="mt-1 text-xs text-[#64748B]">
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{apartado.fechaLimite !== '' ? `Fecha límite: ${formatoFechaCorta(apartado.fechaLimite)}` : 'Sin fecha límite'}
</p>
{apartado.ultimoCambio && (
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Último cambio: {apartado.ultimoCambio.detalle} · {apartado.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(apartado.ultimoCambio.fechaISO.slice(0, 10))}
</p>
@@ -313,7 +322,7 @@ function FilaApartado({
{apartado.estado || 'Sin estado'}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{FASES_APARTADO.map((fase) => (
<button
key={fase}
@@ -345,7 +354,7 @@ function FilaApartado({
type="button"
onClick={() => setDocsAbiertos((v) => !v)}
aria-expanded={docsAbiertos}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
{docsAbiertos ? 'Cerrar documentos' : 'Documentos'}
</button>
@@ -393,8 +402,8 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
}
return (
<div className="mt-3 rounded-lg border border-dashed border-[#D1D5DB] bg-[#F8FAFC] p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
<div className="mt-3 rounded-lg border border-dashed border-[#D1D5DB] bg-[#F8FAFC] dark:border-[#334155] dark:bg-[#0F172A] p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Expediente del cliente
</p>
{error && (
@@ -408,7 +417,7 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
return (
<li key={slot.tipo}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-medium text-[#374151]">
<p className="text-xs font-medium text-[#374151] dark:text-[#CBD5E1]">
{slot.etiqueta}
{slot.obligatorio && <span className="ml-1 text-red-600">*</span>}
</p>
@@ -437,7 +446,7 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
) : (
<ul className="mt-1 space-y-0.5">
{delSlot.map((d) => (
<li key={d.id} className="text-[11px] text-[#64748B]">
<li key={d.id} className="text-[11px] text-[#64748B] dark:text-[#94A3B8]">
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
</li>
))}
@@ -454,8 +463,8 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
)
}
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados */
type TabCrm = 'leads' | 'oportunidades' | 'apartados'
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero */
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'tablero'
function TabsCrm({
activa,
@@ -470,9 +479,10 @@ function TabsCrm({
{ id: 'leads', titulo: 'Mis leads' },
{ id: 'oportunidades', titulo: 'Mis oportunidades' },
{ id: 'apartados', titulo: 'Mis apartados' },
{ id: 'tablero', titulo: 'Mi tablero' },
]
return (
<div className="mb-4 flex flex-wrap gap-1 rounded-xl border border-[#E5E7EB] bg-white p-1 shadow-sm" role="tablist">
<div className="mb-4 flex flex-wrap gap-1 rounded-xl border border-[#E5E7EB] bg-white p-1 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm md:flex-nowrap md:gap-2" role="tablist">
{tabs.map((tab) => {
const activo = tab.id === activa
return (
@@ -482,14 +492,16 @@ function TabsCrm({
role="tab"
aria-selected={activo}
onClick={() => onCambiar(tab.id)}
className={`rounded-lg px-4 py-2 text-xs font-semibold uppercase tracking-wider transition-colors duration-100 ${
activo ? 'bg-accent text-white' : 'text-[#64748B] hover:bg-[#F9FAFB] hover:text-accent'
className={`rounded-lg px-3 py-1.5 text-xs font-semibold uppercase tracking-wider transition-colors duration-100 md:px-4 md:py-2 ${
activo ? 'bg-accent text-white' : 'text-[#64748B] dark:text-[#94A3B8] hover:bg-[#F9FAFB] hover:text-accent'
}`}
>
{tab.titulo}
<span className={`ml-1.5 font-normal ${activo ? 'text-blue-100' : 'text-[#94A3B8]'}`}>
{conteos[tab.id]}
</span>
{conteos[tab.id] != null && (
<span className={`ml-1.5 font-normal ${activo ? 'text-blue-100' : 'text-[#94A3B8]'}`}>
{conteos[tab.id]}
</span>
)}
</button>
)
})}
@@ -515,7 +527,7 @@ function FilaLead({
acciones?: string[]
}) {
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
<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">
<div>
<p className="flex items-center gap-2 text-sm font-medium">
@@ -532,12 +544,12 @@ function FilaLead({
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
</span>
</p>
<p className="mt-1 text-xs text-[#64748B]">
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{lead.telefono || 'Sin teléfono'}
{lead.origen ? ` · ${lead.origen}` : ''}
</p>
{lead.ultimoCambio && (
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Último cambio: {lead.ultimoCambio.detalle} · {lead.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(lead.ultimoCambio.fechaISO.slice(0, 10))}
</p>
@@ -547,7 +559,7 @@ function FilaLead({
{lead.estado || 'Sin estado'}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<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>
@@ -595,6 +607,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
} | null>(null)
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
const [nota, setNota] = useState<string | null>(null)
const [apartadoSeleccionado, setApartadoSeleccionado] = useState<ApartadoMiDia | null>(null)
const [filtros, setFiltros] = useState<FiltrosCRM>({ texto: '', desarrollo: '', origen: '', fechaDesde: '', fechaHasta: '' })
const [offline, setOffline] = useState(!navigator.onLine)
const [offlineFecha, setOfflineFecha] = useState<string | null>(null)
// Captura rápida
const [capNombre, setCapNombre] = useState('')
@@ -610,10 +626,22 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
const cargar = useCallback(async () => {
setCargando(true)
try {
setResumen(await fetchResumenMiDia())
const datos = await fetchResumenMiDia()
setResumen(datos)
setError(null)
setOfflineFecha(null)
// Fire-and-forget: guardar en IDB para uso offline
void guardarResumen(datos)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar tu día')
// Si falla el fetch, intentar datos cacheados
const cached = await cargarResumenLocal()
if (cached) {
setResumen(cached)
setOfflineFecha(new Date().toLocaleDateString('es-MX'))
setError(null)
} else {
setError(err instanceof Error ? err.message : 'No se pudo cargar tu día')
}
} finally {
setCargando(false)
}
@@ -623,6 +651,29 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
void cargar()
}, [cargar])
// Detectar online/offline
useEffect(() => {
const onOnline = () => { setOffline(false); void cargar() }
const onOffline = () => setOffline(true)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [cargar])
// Suscribir a push notifications (fire-and-forget)
useEffect(() => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().then((perm) => {
if (perm === 'granted') void suscribirPush()
})
} else if ('Notification' in window && Notification.permission === 'granted') {
void suscribirPush()
}
}, [])
/** Ejecuta una acción sobre un lead y refresca el resumen */
async function ejecutar(accion: () => Promise<unknown>, mensajeError: string) {
setEnviando(true)
@@ -631,7 +682,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
await accion()
await cargar()
} catch (err) {
setAviso(err instanceof Error ? err.message : mensajeError)
toast.error(mensajeError); setAviso(err instanceof Error ? err.message : mensajeError)
} finally {
setEnviando(false)
}
@@ -694,6 +745,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
? 'Lead registrado y asignado a ti'
: 'Lead registrado, se asignará por round-robin',
})
toast.success('Lead registrado correctamente')
setCapNombre('')
setCapTelefono('')
setCapEmail('')
@@ -719,12 +771,12 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
return (
<>
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
<div className="mx-auto flex h-14 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<a
href="/"
className="shrink-0 rounded-lg px-2 py-1.5 text-xs font-medium text-[#4B5563] transition-colors hover:bg-[#F9FAFB] hover:text-accent"
className="shrink-0 rounded-lg px-2 py-1.5 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1] transition-colors hover:bg-[#F9FAFB] hover:text-accent"
>
← Menú
</a>
@@ -739,14 +791,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
>
{iniciales}
</span>
<span className="hidden truncate text-sm font-medium text-[#111827] sm:block">{nombreVendedor}</span>
<span className="hidden truncate text-sm font-medium text-[#111827] dark:text-[#E2E8F0] sm:block">{nombreVendedor}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
<DarkModeToggle />
<button
type="button"
onClick={accionesSesion.onCambiarPassword}
className="rounded-md px-2 py-1.5 text-xs font-medium text-[#4B5563] transition-all duration-100 ease-out hover:text-[#111827]"
className="hidden rounded-md px-2 py-1.5 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0] md:inline-block"
>
Contraseña
</button>
@@ -761,7 +814,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
type="button"
onClick={() => void cargar()}
disabled={cargando}
className="rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50 sm:text-sm"
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50 md:px-4 md:py-2 md:text-sm"
>
{cargando ? 'Actualizando…' : 'Actualizar'}
</button>
@@ -771,10 +824,18 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<BannerModoLocal />
{offline && (
<div className="mx-auto max-w-[1280px] px-4 pt-4 sm:px-6">
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
Modo offline{offlineFecha ? ` — datos de ${offlineFecha}` : ''}
</div>
</div>
)}
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
<div className="mb-6">
<h1 className="text-xl font-semibold text-[#111827]">{nombreVendedor}</h1>
<p className="mt-1 text-sm text-[#64748B]">{formatoFechaCorta(hoy)}</p>
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{nombreVendedor}</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">{formatoFechaCorta(hoy)}</p>
</div>
{error && (
@@ -793,16 +854,16 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</p>
)}
{resumen?.sinMapeoMonday && (
<p className="mb-6 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] shadow-sm">
<p className="mb-6 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] dark:text-[#94A3B8] shadow-sm">
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
</p>
)}
{!resumen && cargando && <EstadoVacio mensaje="Cargando…" />}
{!resumen && cargando && <SkeletonList count={4} />}
{resumen && (
<>
<section aria-label="Indicadores del día" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<section aria-label="Indicadores del día" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard titulo="Leads asignados" valor={resumen.misLeads.length} />
<KpiCard
titulo="Actividades de hoy"
@@ -813,79 +874,161 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<KpiCard titulo="Leads nuevos esta semana" valor={resumen.conteos.leadsNuevosSemana} />
</section>
<div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-3 lg:gap-6">
<div className="order-2 flex flex-col gap-6 lg:order-1 lg:col-span-2">
<div className="mt-6 flex flex-col gap-6 md:grid md:grid-cols-2 md:gap-6 lg:grid-cols-3">
<div className="order-2 flex flex-col gap-6 md:order-1 md:col-span-2 lg:col-span-2">
<section aria-label="CRM personal">
<TabsCrm
activa={tabCrm}
conteos={{
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length,
oportunidades: resumen.misOportunidades.items.length,
apartados: resumen.misApartados.length,
apartados: resumen.misApartados.length, tablero: 0,
}}
onCambiar={setTabCrm}
/>
{tabCrm === 'leads' &&
(resumen.misLeads.filter((l) => l.fuente === 'lead').length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" />
) : (
<ul className="space-y-2">
{resumen.misLeads
.filter((l) => l.fuente === 'lead')
.map((lead) => (
<FilaLead
key={`${lead.fuente}-${lead.id}`}
lead={lead}
{tabCrm !== 'tablero' && (
<FiltrosAvanzados valor={filtros} onChange={setFiltros} />
)}
{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 (
<>
<div className="mb-2 flex justify-end">
<button
type="button"
onClick={() => exportarLeads(leadsFiltrados)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Exportar
</button>
</div>
{leadsFiltrados.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" />
) : (
<ul className="space-y-2">
{leadsFiltrados.map((lead) => (
<FilaLead
key={`${lead.fuente}-${lead.id}`}
lead={lead}
enviando={enviando}
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
onConvertir={() => void convertir(lead)}
onDescartar={() => setDescartando(lead)}
acciones={acciones}
/>
))}
</ul>
)}
</>
)
})()}
{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 (
<>
<div className="mb-2 flex justify-end">
<button
type="button"
onClick={() => exportarOportunidades(opsFiltradas)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Exportar
</button>
</div>
{opsFiltradas.length === 0 ? (
<EstadoVacio mensaje="Sin oportunidades asignadas" />
) : (
<ul className="space-y-2">
{opsFiltradas.map((op) => (
<FilaOportunidad
key={op.id}
op={op}
enviando={enviando}
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
onConvertir={() => void convertir(lead)}
onDescartar={() => setDescartando(lead)}
onEtapa={(etapa, fecha) =>
void ejecutar(() => cambiarEtapaOportunidad(op.id, etapa, fecha), 'No se pudo actualizar la oportunidad')
}
onCotizar={() => void cotizarOportunidad(op)}
onAfluencia={() => {
window.location.href = urlAfluenciaParaOportunidad(op)
}}
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
acciones={acciones}
/>
))}
</ul>
))}
</ul>
)}
</>
)
})()}
{tabCrm === 'oportunidades' &&
(resumen.misOportunidades.items.length === 0 ? (
<EstadoVacio mensaje="Sin oportunidades asignadas" />
) : (
<ul className="space-y-2">
{resumen.misOportunidades.items.map((op) => (
<FilaOportunidad
key={op.id}
op={op}
enviando={enviando}
onEtapa={(etapa, fecha) =>
void ejecutar(() => cambiarEtapaOportunidad(op.id, etapa, fecha), 'No se pudo actualizar la oportunidad')
}
onCotizar={() => void cotizarOportunidad(op)}
onAfluencia={() => {
window.location.href = urlAfluenciaParaOportunidad(op)
}}
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
acciones={acciones}
/>
))}
</ul>
))}
{tabCrm === 'apartados' && (() => {
const apartadosFiltrados = aplicarFiltros(
resumen.misApartados,
filtros,
{
texto: (a) => a.nombre,
fecha: (a) => a.fechaLimite,
},
)
return (
<>
<div className="mb-2 flex justify-end">
<button
type="button"
onClick={() => exportarApartados(apartadosFiltrados)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Exportar
</button>
</div>
{apartadosFiltrados.length === 0 ? (
<EstadoVacio mensaje="Sin apartados asignados" />
) : (
<ul className="space-y-2">
{apartadosFiltrados.map((a) => (
<li
key={a.id}
className="cursor-pointer"
onClick={() => setApartadoSeleccionado(a)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setApartadoSeleccionado(a) }}
>
<FilaApartado
apartado={a}
enviando={enviando}
onFase={(fase) => void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')}
/>
</li>
))}
</ul>
)}
</>
)
})()}
{tabCrm === 'apartados' &&
(resumen.misApartados.length === 0 ? (
<EstadoVacio mensaje="Sin apartados asignados" />
) : (
<ul className="space-y-2">
{resumen.misApartados.map((a) => (
<FilaApartado
key={a.id}
apartado={a}
enviando={enviando}
onFase={(fase) => void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')}
/>
))}
</ul>
))}
{tabCrm === 'tablero' && (
<DashboardVendedor />
)}
</section>
<section aria-label="Mis capturas de afluencia">
@@ -904,7 +1047,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{c.nombre}
{c.pendienteSync === true && <ChipPendienteSync />}
</p>
<p className="mt-1 text-xs text-[#64748B]">
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{c.telefono || 'Sin teléfono'}
{c.origen ? ` · ${c.origen}` : ''}
</p>
@@ -913,7 +1056,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
{c.estado || '—'}
</span>
<span className="text-xs font-medium text-[#64748B]">
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
</span>
</div>
@@ -936,7 +1079,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
>
<div>
<p className="text-sm font-medium">{c.nombre}</p>
<p className="mt-1 text-xs text-[#64748B]">
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{[c.proyecto, c.unidadCodigo].filter((p) => p !== '').join(' · ') || 'Alta sin proyecto'}
</p>
</div>
@@ -956,7 +1099,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
>
{c.completado ? 'Cliente' : 'En captura'}
</span>
<span className="text-xs font-medium text-[#64748B]">
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
</span>
</div>
@@ -967,7 +1110,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</section>
</div>
<aside className="order-1 flex flex-col gap-6 lg:order-2 lg:sticky lg:top-20 lg:self-start">
<aside className="order-1 flex flex-col gap-6 md:order-2 lg:sticky lg:top-20 lg:self-start">
<section aria-label="Mis actividades">
<TituloSeccion>Mis actividades</TituloSeccion>
{resumen.misActividadesHoy.length === 0 ? (
@@ -985,7 +1128,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</p>
<span
className={`text-xs font-medium ${
esActividadVencida(a.fecha, hoy) ? 'text-red-600' : 'text-[#64748B]'
esActividadVencida(a.fecha, hoy) ? 'text-red-600' : 'text-[#64748B] dark:text-[#94A3B8]'
}`}
>
{formatoFechaCorta(a.fecha)}
@@ -1026,7 +1169,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<TituloSeccion>Captura rápida de lead</TituloSeccion>
<form
onSubmit={enviarCaptura}
className="grid gap-4 rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm"
className="grid gap-4 rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
>
<div>
<label htmlFor="cap-nombre" className={CLASE_LABEL}>
@@ -1050,7 +1193,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
aria-label="Lada"
value={capLada}
onChange={(e) => setCapLada(e.target.value as ClaveLada)}
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
>
{LADAS.map((l) => (
<option key={l.clave} value={l.clave}>
@@ -1156,7 +1299,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{esRolConEquipo(rol) && resumen.equipo && (
<section aria-label="Equipo" className="mt-6">
<TituloSeccion>Equipo</TituloSeccion>
<p className="mb-4 text-sm text-[#64748B]">
<p className="mb-4 text-sm text-[#64748B] dark:text-[#94A3B8]">
Leads nuevos sin asignar: <strong>{resumen.equipo.leadsNuevosSinAsignar}</strong>
</p>
{resumen.equipo.porVendedor.length === 0 ? (
@@ -1197,6 +1340,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
}}
/>
)}
{apartadoSeleccionado && (
<NotasApartado
apartado={apartadoSeleccionado}
onCerrar={() => setApartadoSeleccionado(null)}
/>
)}
</>
)
}