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