Mi día: cancelación con motivo, conteo escrituración real, cita sin retroceder, calendario completo Cobranza: MoraDashboard respeta alcance, hoy dinámico, cancelar con motivo Landing: contactos paginados, error vs vacío diferenciado Dashboards: KPI renombrado, drill-down planes activos Chat: historial recarga, preguntas rotativas, error solo toast Transversal: shadow-sm corrupto corregido en 9 archivos 553 API tests + 7 apps construidas
320 lines
13 KiB
TypeScript
320 lines
13 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { useSearchParams } from 'react-router-dom'
|
|
import {
|
|
Bar,
|
|
BarChart,
|
|
CartesianGrid,
|
|
Cell,
|
|
Funnel,
|
|
FunnelChart,
|
|
LabelList,
|
|
Legend,
|
|
Pie,
|
|
PieChart,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from 'recharts'
|
|
import { axisTextColor, gridStroke, tooltipProps } from '../lib/chart-theme'
|
|
import KpiCard from './KpiCard'
|
|
import Perfilamiento from './Perfilamiento'
|
|
import SeccionEquipo from './SeccionEquipo'
|
|
import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes'
|
|
import {
|
|
datosCobranza,
|
|
datosFunnel,
|
|
datosPipeline,
|
|
formatoMoneda,
|
|
formatoPorcentaje,
|
|
unidadesApiladas,
|
|
unidadesEnEtapa,
|
|
type ResumenReportes,
|
|
} from '../lib/reportes'
|
|
import { fetchResumen } from '../services/reportes'
|
|
|
|
// Paleta NovaSpark: acento azul + semánticos (sin gradientes)
|
|
const COLORES_STATUS: Record<string, string> = {
|
|
DISPONIBLE: '#2563EB',
|
|
APARTADO: '#F59E0B',
|
|
VENDIDO: '#525252',
|
|
BLOQUEADO: '#EF4444',
|
|
OTROS: '#d4d4d4',
|
|
}
|
|
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_PLANES_ACTIVOS = '/pay/?vista=todos&status=ACTIVO'
|
|
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,
|
|
alCambiarAcciones,
|
|
}: {
|
|
mostrarEquipo: boolean
|
|
alCambiarAcciones: (acciones: AccionesTablero) => void
|
|
}) {
|
|
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(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 })
|
|
}, [alCambiarAcciones, cargar, cargando])
|
|
|
|
const unidades = resumen ? unidadesApiladas(resumen) : []
|
|
const funnel = resumen ? datosFunnel(resumen) : []
|
|
const cobranza = resumen ? datosCobranza(resumen) : []
|
|
const pipeline = resumen ? datosPipeline(resumen) : []
|
|
const sinPipeline = pipeline.every((e) => e.valor === 0)
|
|
const sinCobranza = resumen !== null && resumen.cobranza.planesAlCorriente + resumen.cobranza.planesConVencidos === 0
|
|
|
|
return (
|
|
<main className="px-4 py-8 sm:px-6 lg:px-8">
|
|
<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 && (
|
|
<p role="alert" className="mb-8 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
|
|
Sin datos del API — {error}
|
|
</p>
|
|
)}
|
|
|
|
{!resumen && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
|
|
{!resumen && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
|
|
|
|
{resumen && (
|
|
<>
|
|
<section aria-label="Indicadores clave" className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
|
<KpiCard
|
|
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"
|
|
valor={resumen.oportunidades.activas}
|
|
detalle={`${resumen.oportunidades.total} en total`}
|
|
/>
|
|
<KpiCard
|
|
titulo="Conversión Aparto / Ganado"
|
|
valor={`${formatoPorcentaje(resumen.oportunidades.tasaAparto)} / ${formatoPorcentaje(resumen.oportunidades.tasaGanado)}`}
|
|
/>
|
|
<KpiCard
|
|
titulo="Unidades disponibles"
|
|
valor={resumen.unidades.disponibles}
|
|
detalle={`${resumen.unidades.total} en inventario`}
|
|
/>
|
|
<KpiCard
|
|
titulo="Cartera vencida"
|
|
valor={formatoMoneda(resumen.cobranza.montoVencido)}
|
|
detalle={`${resumen.cobranza.pagosVencidos} pago(s) vencido(s) · click para ver planes`}
|
|
onClick={() => irAApp(RUTA_PLANES_VENCIDOS)}
|
|
/>
|
|
<KpiCard
|
|
titulo="Planes activos"
|
|
valor={resumen.cobranza.planes.activos}
|
|
detalle={`${resumen.cobranza.planes.liquidados} liquidados · ${resumen.cobranza.planes.cancelados} cancelados · click para ver planes`}
|
|
onClick={() => irAApp(RUTA_PLANES_ACTIVOS)}
|
|
/>
|
|
<KpiCard titulo="Actividades abiertas" valor={resumen.actividadesAbiertas} />
|
|
<KpiCard
|
|
titulo="En notaría"
|
|
valor={unidadesEnEtapa(resumen, 'Notaría')}
|
|
detalle="unidades en etapa Notaría"
|
|
/>
|
|
<KpiCard
|
|
titulo="Leads por contactar"
|
|
valor={resumen.leads.porEstado['NUEVO'] ?? 0}
|
|
detalle="en estado NUEVO"
|
|
/>
|
|
</section>
|
|
|
|
<div className="mt-10 grid gap-6 md:grid-cols-2">
|
|
<section aria-label="Inventario por desarrollo" className="lg:col-span-2">
|
|
<TarjetaGrafica titulo="Unidades por desarrollo y status">
|
|
{unidades.length === 0 ? (
|
|
<EstadoVacio mensaje="Sin inventario" />
|
|
) : (
|
|
<div className="h-72">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={unidades} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke()} vertical={false} />
|
|
<XAxis dataKey="desarrollo" tick={{ fontSize: 11, fill: axisTextColor() }} />
|
|
<YAxis allowDecimals={false} tick={{ fontSize: 11, fill: axisTextColor() }} />
|
|
<Tooltip {...tooltipProps()} />
|
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
|
{Object.keys(COLORES_STATUS).map((status) => (
|
|
<Bar key={status} dataKey={status} stackId="unidades" fill={COLORES_STATUS[status]} />
|
|
))}
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</TarjetaGrafica>
|
|
</section>
|
|
|
|
<section aria-label="Pipeline de ventas por etapa" className="lg:col-span-2">
|
|
<TarjetaGrafica titulo="Pipeline de ventas por etapa">
|
|
{sinPipeline ? (
|
|
<EstadoVacio mensaje="Sin unidades en el pipeline" />
|
|
) : (
|
|
<div className="h-80">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={pipeline} layout="vertical" margin={{ top: 8, right: 24, left: 24, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke()} horizontal={false} />
|
|
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11, fill: axisTextColor() }} />
|
|
<YAxis type="category" dataKey="etapa" width={90} tick={{ fontSize: 11, fill: axisTextColor() }} />
|
|
<Tooltip {...tooltipProps()} />
|
|
<Bar dataKey="valor" name="Unidades" fill="#2563EB" isAnimationActive={false} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</TarjetaGrafica>
|
|
</section>
|
|
|
|
<section aria-label="Embudo comercial">
|
|
<TarjetaGrafica titulo="Embudo: leads → oportunidades → ganado">
|
|
{funnel.every((e) => e.valor === 0) ? (
|
|
<EstadoVacio mensaje="Sin movimiento comercial" />
|
|
) : (
|
|
<div className="h-72">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<FunnelChart>
|
|
<Tooltip {...tooltipProps()} />
|
|
<Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}>
|
|
{funnel.map((etapa, i) => (
|
|
<Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} />
|
|
))}
|
|
<LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} />
|
|
<LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} />
|
|
</Funnel>
|
|
</FunnelChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</TarjetaGrafica>
|
|
</section>
|
|
|
|
<section aria-label="Estado de la cobranza">
|
|
<TarjetaGrafica titulo="Cobranza: al corriente vs vencidos">
|
|
{sinCobranza ? (
|
|
<EstadoVacio mensaje="Sin planes activos" />
|
|
) : (
|
|
<div className="h-72">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Tooltip {...tooltipProps()} />
|
|
<Pie
|
|
data={cobranza}
|
|
dataKey="valor"
|
|
nameKey="nombre"
|
|
innerRadius={60}
|
|
outerRadius={90}
|
|
isAnimationActive={false}
|
|
label={({ name, value }) => `${name}: ${value}`}
|
|
fontSize={11}
|
|
>
|
|
{cobranza.map((rebanada, i) => (
|
|
<Cell key={rebanada.nombre} fill={COLORES_COBRANZA[i % COLORES_COBRANZA.length]} />
|
|
))}
|
|
</Pie>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</TarjetaGrafica>
|
|
</section>
|
|
</div>
|
|
|
|
<Perfilamiento perfil={resumen.leads.perfil} />
|
|
|
|
{mostrarEquipo && <SeccionEquipo />}
|
|
</>
|
|
)}
|
|
</main>
|
|
)
|
|
}
|