feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday

Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario
Mi día: modal español real, cita desde modal, score falso eliminado
Cotizador: barra sticky, persistencia local con folio
Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas
Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp
Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado
Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas
Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones

69 archivos, 7 apps construidas
This commit is contained in:
2026-09-11 06:52:37 +00:00
parent 075d47c304
commit 64b01246d6
69 changed files with 1600 additions and 1133 deletions

View File

@@ -13,6 +13,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.18.3",
"recharts": "^2.15.0"
},
"devDependencies": {
@@ -2128,6 +2129,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -3271,6 +3285,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
@@ -3424,6 +3476,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",

View File

@@ -15,6 +15,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.18.3",
"recharts": "^2.15.0"
},
"devDependencies": {

View File

@@ -1,11 +1,32 @@
import type { ReactNode } from 'react'
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md">
const CLASE_BASE =
'rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md'
interface Props {
titulo: string
valor: ReactNode
detalle?: string
/** Presente = KPI clickeable (drill-down): se renderiza como botón */
onClick?: () => void
}
export default function KpiCard({ titulo, valor, detalle, onClick }: Props) {
const contenido = (
<>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
<p className="mt-2 text-3xl font-light text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
{detalle && <p className="mt-2 text-xs text-[#4B5563] dark:text-[#CBD5E1]">{detalle}</p>}
</div>
</>
)
if (!onClick) return <div className={CLASE_BASE}>{contenido}</div>
return (
<button
type="button"
onClick={onClick}
className={`${CLASE_BASE} w-full cursor-pointer text-left transition-colors hover:border-accent`}
>
{contenido}
</button>
)
}

View File

@@ -3,26 +3,28 @@ import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } fr
import { tooltipProps } from '../lib/chart-theme'
import KpiCard from './KpiCard'
import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes'
import { funnelMisLeads, type ResumenMiDia } from '../lib/mi-tablero'
import { fetchResumenMiDia } from '../services/mi-dia'
import { funnelMiTablero, type MiTablero } from '../lib/mi-tablero'
import { fetchMiTablero } from '../services/reportes'
// Misma paleta del funnel del tablero general (NovaSpark: azules + neutros)
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB']
// Misma paleta del funnel del tablero general (NovaSpark: azules + neutros);
// rojo para la etapa final "Descartados"
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB', '#EF4444']
/**
* "Mi tablero": KPIs personales del vendedor logueado (mis leads, actividades,
* apartados por vencer) y un funnel simple de sus leads por estado.
* Fuente: GET /api/mi-dia/resumen (filtra por su mondayUserId en el API).
* "Mi tablero": KPIs personales del vendedor logueado y su funnel de conversión
* (Leads → Oportunidades → Apartados → Vendidos → Descartados).
* Fuente: GET /api/reportes/mi-tablero, el mismo endpoint que usa el panel
* "Mi día" (DashboardVendedor).
*/
export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (acciones: AccionesTablero) => void }) {
const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
const [tablero, setTablero] = useState<MiTablero | null>(null)
const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true)
const cargar = useCallback(async () => {
setCargando(true)
try {
setResumen(await fetchResumenMiDia())
setTablero(await fetchMiTablero())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
@@ -40,15 +42,20 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
}, [alCambiarAcciones, cargar, cargando])
const funnel = resumen ? funnelMisLeads(resumen.misLeads) : []
const funnel = tablero ? funnelMiTablero(tablero) : []
const conversion = tablero?.conversion
const tasaConversion =
conversion && conversion.leads > 0
? `${((conversion.oportunidades / conversion.leads) * 100).toFixed(1)}%`
: '—'
return (
<main className="px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Mi tablero</h1>
{resumen && (
{tablero && (
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
Actualizado: {new Date(tablero.generadoEn).toLocaleString('es-MX')}
</p>
)}
</div>
@@ -59,39 +66,43 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
</p>
)}
{resumen?.sinMapeoMonday && (
<p className="mb-8 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
Tu usuario aún no está ligado a Monday: las listas personales aparecen vacías
</p>
)}
{!tablero && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
{!tablero && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
{!resumen && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
{!resumen && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
{resumen && (
{tablero && conversion && (
<>
<section aria-label="Mis indicadores" className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<KpiCard
titulo="Mis leads activos"
valor={resumen.misLeads.length}
detalle={`${resumen.conteos.leadsNuevosSemana} nuevo(s) esta semana`}
titulo="Leads totales"
valor={conversion.leads}
detalle={`${conversion.oportunidades} oportunidades · ${conversion.vendidos} vendidos`}
/>
<KpiCard
titulo="Mis actividades abiertas"
valor={resumen.conteos.actividadesAbiertas}
detalle={`${resumen.misActividadesHoy.length} con fecha de hoy o antes`}
titulo="Tasa de conversión"
valor={tasaConversion}
detalle={`${conversion.oportunidades} de ${conversion.leads} leads`}
/>
<KpiCard
titulo="Apartados por vencer"
valor={resumen.apartadosPorVencer.length}
detalle="próximos 7 días (pipeline completo)"
titulo="Ventas del mes"
valor={conversion.vendidos}
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN · ${tablero.ventasMes.unidades} cotizada(s)`}
/>
<KpiCard
titulo="Actividades abiertas"
valor={tablero.actividadesPendientes}
detalle={`${tablero.actividadesHoy} con fecha de hoy o antes`}
/>
<KpiCard
titulo="Leads descartados"
valor={conversion.descartados}
detalle="etapa terminal DESCARTADO"
/>
</section>
<section aria-label="Mis leads por estado" className="mt-10">
<TarjetaGrafica titulo="Mis leads por estado">
{funnel.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" />
<section aria-label="Funnel de conversión" className="mt-10">
<TarjetaGrafica titulo="Funnel de conversión (incluye descartados)">
{funnel.every((e) => e.valor === 0) ? (
<EstadoVacio mensaje="Sin movimiento comercial" />
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
Bar,
BarChart,
@@ -43,10 +44,28 @@ const COLORES_STATUS: Record<string, string> = {
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB']
const COLORES_COBRANZA = ['#2563EB', '#F59E0B']
/** Rangos de fecha disponibles para filtrar las gráficas (días hacia atrás) */
const RANGOS_DIAS = [7, 30, 90] as const
/** Rutas de drill-down a otras apps del ecosistema (NGINX las sirve como SPAs aparte) */
const RUTA_PLANES_VENCIDOS = '/pay/?vista=todos&status=ACTIVO&sort=atraso&dir=desc'
const RUTA_MI_DIA = '/hoy/'
/**
* Drill-down cruzado de apps: /pay (cobranza) y /hoy (mi-dia) son SPAs
* distintas detrás de NGINX; navigate() de react-router no puede salir del
* basename de esta app, así que el salto es a nivel document.
*/
function irAApp(ruta: string): void {
window.location.assign(ruta)
}
/**
* Vista "General": el tablero comercial de todo el ecosistema (KPIs, inventario,
* pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/
* admin) agrega la sección Equipo con leads por vendedor.
* El rango de fechas (?dias=7|30|90) vive en la URL y filtra leads,
* oportunidades y perfilamiento; inventario y cobranza son posición actual.
*/
export default function TableroGeneral({
mostrarEquipo,
@@ -58,23 +77,39 @@ export default function TableroGeneral({
const [resumen, setResumen] = useState<ResumenReportes | null>(null)
const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true)
// Rango de fechas persistido en la URL: ?dias=7|30|90 (default 30)
const [searchParams, setSearchParams] = useSearchParams()
const paramDias = Number(searchParams.get('dias'))
const dias = (RANGOS_DIAS as readonly number[]).includes(paramDias) ? paramDias : 30
const cargar = useCallback(async () => {
setCargando(true)
try {
setResumen(await fetchResumen())
setResumen(await fetchResumen(dias))
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
} finally {
setCargando(false)
}
}, [])
}, [dias])
useEffect(() => {
void cargar()
}, [cargar])
/** Cambia el rango y lo persiste en la URL (compartible) */
const cambiarRango = (nuevo: number) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
next.set('dias', String(nuevo))
return next
},
{ replace: true },
)
}
// Expone "Actualizar" al header sticky de DashboardPage
useEffect(() => {
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
@@ -89,13 +124,33 @@ export default function TableroGeneral({
return (
<main className="px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Tablero comercial</h1>
{resumen && (
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
</p>
)}
<div className="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Tablero comercial</h1>
{resumen && (
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
</p>
)}
</div>
<div className="flex items-center gap-1" role="group" aria-label="Rango de fechas de las gráficas">
<span className="mr-1 text-xs font-medium uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Rango:</span>
{RANGOS_DIAS.map((r) => (
<button
key={r}
type="button"
onClick={() => cambiarRango(r)}
aria-pressed={dias === r}
className={
dias === r
? 'rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors'
: 'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#64748B] transition-colors hover:text-[#111827] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#94A3B8] dark:hover:text-[#E2E8F0]'
}
>
{r} días
</button>
))}
</div>
</div>
{error && (
@@ -114,6 +169,7 @@ export default function TableroGeneral({
titulo="Leads totales"
valor={resumen.leads.total}
detalle={`${resumen.leads.nuevosHoy} hoy · ${resumen.leads.nuevosSemana} esta semana`}
onClick={() => irAApp(RUTA_MI_DIA)}
/>
<KpiCard
titulo="Oportunidades activas"
@@ -132,7 +188,8 @@ export default function TableroGeneral({
<KpiCard
titulo="Cartera vencida"
valor={formatoMoneda(resumen.cobranza.montoVencido)}
detalle={`${resumen.cobranza.pagosVencidos} pago(s) vencido(s)`}
detalle={`${resumen.cobranza.pagosVencidos} pago(s) vencido(s) · click para ver planes`}
onClick={() => irAApp(RUTA_PLANES_VENCIDOS)}
/>
<KpiCard
titulo="Planes activos"

View File

@@ -79,3 +79,40 @@ export function funnelMisLeads(misLeads: LeadMiDia[]): EtapaFunnelPersonal[] {
.map(([etapa, valor]) => ({ etapa, valor }))
.sort((a, b) => b.valor - a.valor || a.etapa.localeCompare(b.etapa))
}
// ---------- Tablero personal (GET /api/reportes/mi-tablero) ----------
// Mismo endpoint que consume DashboardVendedor en la app de Mi día.
export interface MiTablero {
generadoEn: string
leadsPorDia: { fecha: string; cantidad: number }[]
conversion: {
leads: number
oportunidades: number
apartados: number
vendidos: number
/** Leads con etapa terminal DESCARTADO; etapa extra al final del funnel */
descartados: number
}
ventasMes: { monto: number; unidades: number }
porOrigen: { origen: string; total: number; convertidos: number }[]
porDesarrollo: { desarrollo: string; ventas: number }[]
actividadesHoy: number
actividadesPendientes: number
}
/**
* Etapas del funnel personal desde el endpoint unificado: Leads →
* Oportunidades → Apartados → Vendidos, con "Descartados" al final (fuera del
* embudo: leads terminados sin conversión) para que no se omitan.
*/
export function funnelMiTablero(tablero: MiTablero): EtapaFunnelPersonal[] {
const c = tablero.conversion
return [
{ etapa: 'Leads', valor: c.leads },
{ etapa: 'Oportunidades', valor: c.oportunidades },
{ etapa: 'Apartados', valor: c.apartados },
{ etapa: 'Vendidos', valor: c.vendidos },
{ etapa: 'Descartados', valor: c.descartados },
]
}

View File

@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
@@ -11,9 +12,13 @@ import DashboardPage from './pages/DashboardPage'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthGate>
<DashboardPage />
</AuthGate>
{/* La app vive bajo el prefijo /dash/ de NGINX (vite base); el rango de
fechas del tablero general se persiste en la URL (?dias=7|30|90) */}
<BrowserRouter basename={import.meta.env.BASE_URL}>
<AuthGate>
<DashboardPage />
</AuthGate>
</BrowserRouter>
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
</StrictMode>,
)

View File

@@ -1,18 +1,32 @@
// Cliente del endpoint de reportes del API Gateway (misma red, same-origin tras NGINX)
import { conAuth, manejar401 } from './auth'
import type { ResumenReportes } from '../lib/reportes'
import type { MiTablero } from '../lib/mi-tablero'
const API_URL = import.meta.env.VITE_API_URL || '/api'
/** GET /reportes/resumen; lanza Error con mensaje legible si el API falla */
export async function fetchResumen(): Promise<ResumenReportes> {
async function obtener<T>(path: string): Promise<T> {
let res: Response
try {
res = await fetch(`${API_URL}/reportes/resumen`, conAuth())
res = await fetch(`${API_URL}${path}`, conAuth())
} catch {
throw new Error('No se pudo conectar con el servidor')
}
manejar401(res)
if (!res.ok) throw new Error(`El API respondió ${res.status}`)
return (await res.json()) as ResumenReportes
return (await res.json()) as T
}
/**
* GET /reportes/resumen; `dias` (7/30/90) acota leads y oportunidades al rango.
* Lanza Error con mensaje legible si el API falla.
*/
export async function fetchResumen(dias?: number): Promise<ResumenReportes> {
const query = dias !== undefined && dias > 0 ? `?dias=${Math.floor(dias)}` : ''
return obtener<ResumenReportes>(`/reportes/resumen${query}`)
}
/** GET /reportes/mi-tablero — tablero personal del vendedor (mismo que Mi día) */
export async function fetchMiTablero(): Promise<MiTablero> {
return obtener<MiTablero>('/reportes/mi-tablero')
}