Update: nueva version Horux Despachos
This commit is contained in:
405
apps/web/app/(dashboard)/dashboard/page.tsx
Normal file
405
apps/web/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { KpiCard } from '@horux/shared-ui';
|
||||
import { BarChart } from '@/components/charts/bar-chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
|
||||
import { useKpis, useIngresosEgresos, useAlertas, useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { isGlobalAdminRfc } from '@horux/shared';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Wallet,
|
||||
Receipt,
|
||||
AlertTriangle,
|
||||
ShoppingCart,
|
||||
CheckSquare,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
|
||||
|
||||
function getMonthRange(year: number, month: number) {
|
||||
const start = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const end = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function shiftDatesOneYear(fechaInicio: string, fechaFin: string, delta: number) {
|
||||
const s = new Date(fechaInicio + 'T00:00:00');
|
||||
const e = new Date(fechaFin + 'T00:00:00');
|
||||
s.setFullYear(s.getFullYear() + delta);
|
||||
e.setFullYear(e.getFullYear() + delta);
|
||||
// Ajustar último día del mes si cambió
|
||||
const lastDay = new Date(e.getFullYear(), e.getMonth() + 1, 0).getDate();
|
||||
if (e.getDate() > lastDay) e.setDate(lastDay);
|
||||
return {
|
||||
fechaInicio: s.toISOString().split('T')[0],
|
||||
fechaFin: e.toISOString().split('T')[0],
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuthStore();
|
||||
// Admin global no opera sobre datos de despacho — su home natural es
|
||||
// `/clientes` (gestión de tenants). Redirige al primer render.
|
||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
|
||||
useEffect(() => {
|
||||
if (isGlobalAdmin) router.replace('/clientes');
|
||||
}, [isGlobalAdmin, router]);
|
||||
|
||||
const now = new Date();
|
||||
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
|
||||
|
||||
const [fechaInicio, setFechaInicio] = useState(defaultRange.start);
|
||||
const [fechaFin, setFechaFin] = useState(defaultRange.end);
|
||||
const [regimenSeleccionado, setRegimenSeleccionado] = useState<string | null>(null);
|
||||
const [conciliacion, setConciliacion] = useState(false);
|
||||
|
||||
// Periodo anterior (mismo rango, un año atrás)
|
||||
const anterior = shiftDatesOneYear(fechaInicio, fechaFin, -1);
|
||||
|
||||
// Año del inicio para el chart anual
|
||||
const añoChart = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
||||
const mesResumen = new Date(fechaInicio + 'T00:00:00').getMonth() + 1;
|
||||
|
||||
const { data: kpis } = useKpis(fechaInicio, fechaFin, conciliacion);
|
||||
const { data: kpisAnterior } = useKpis(anterior.fechaInicio, anterior.fechaFin, conciliacion);
|
||||
const { data: chartData } = useIngresosEgresos(añoChart, conciliacion);
|
||||
const { data: alertas, isLoading: alertasLoading } = useAlertas(5);
|
||||
const { data: regimenesPeriodo, isLoading: regimenesLoading } = useRegimenesDelPeriodo(fechaInicio, fechaFin, conciliacion);
|
||||
|
||||
const handlePeriodChange = (inicio: string, fin: string) => {
|
||||
setFechaInicio(inicio);
|
||||
setFechaFin(fin);
|
||||
};
|
||||
|
||||
// Filtrar ingresos por régimen seleccionado
|
||||
const ingresosDisplay = regimenSeleccionado
|
||||
? kpis?.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpis?.ingresos || 0;
|
||||
|
||||
const ingresosAnterior = regimenSeleccionado
|
||||
? kpisAnterior?.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpisAnterior?.ingresos || 0;
|
||||
|
||||
const ingresosVariacion = ingresosAnterior > 0
|
||||
? Math.round(((ingresosDisplay - ingresosAnterior) / ingresosAnterior) * 10000) / 100
|
||||
: null;
|
||||
|
||||
// Filtrar egresos por régimen seleccionado
|
||||
const egresosDisplay = regimenSeleccionado
|
||||
? kpis?.egresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpis?.egresos || 0;
|
||||
|
||||
const egresosAnterior = regimenSeleccionado
|
||||
? kpisAnterior?.egresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpisAnterior?.egresos || 0;
|
||||
|
||||
const egresosVariacion = egresosAnterior > 0
|
||||
? Math.round(((egresosDisplay - egresosAnterior) / egresosAnterior) * 10000) / 100
|
||||
: null;
|
||||
|
||||
// Adquisición de mercancías
|
||||
const adquisicionDisplay = regimenSeleccionado
|
||||
? kpis?.adquisicionMercanciasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpis?.adquisicionMercancias || 0;
|
||||
|
||||
// Filtrar IVA por régimen seleccionado
|
||||
const ivaDisplay = regimenSeleccionado
|
||||
? kpis?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpis?.ivaBalance || 0;
|
||||
|
||||
const ivaAnterior = regimenSeleccionado
|
||||
? kpisAnterior?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpisAnterior?.ivaBalance || 0;
|
||||
|
||||
const ivaVariacion = ivaAnterior !== 0
|
||||
? Math.round(((ivaDisplay - ivaAnterior) / Math.abs(ivaAnterior)) * 10000) / 100
|
||||
: null;
|
||||
|
||||
const utilidadDisplay = ingresosDisplay - egresosDisplay;
|
||||
const margenDisplay = ingresosDisplay > 0
|
||||
? Math.round((utilidadDisplay / ingresosDisplay) * 10000) / 100
|
||||
: 0;
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
|
||||
// Helper para construir URLs de drill-down
|
||||
const drillUrl = (titulo: string, filters: Record<string, string>) => {
|
||||
const p = new URLSearchParams({ titulo, fechaInicio, fechaFin, status: 'vigente', ...filters });
|
||||
if (regimenSeleccionado) {
|
||||
if (filters.type === 'EMITIDO') p.set('regimenEmisor', regimenSeleccionado);
|
||||
if (filters.type === 'RECIBIDO') p.set('regimenReceptor', regimenSeleccionado);
|
||||
}
|
||||
return `/drill-down?${p}`;
|
||||
};
|
||||
|
||||
// Año anterior para labels
|
||||
const añoAnterior = new Date(anterior.fechaInicio + 'T00:00:00').getFullYear();
|
||||
|
||||
// Reset régimen si ya no existe en el periodo
|
||||
const regimenesDisponibles = regimenesPeriodo || [];
|
||||
if (regimenSeleccionado && regimenesDisponibles.length > 0 &&
|
||||
!regimenesDisponibles.find(r => r.clave === regimenSeleccionado)) {
|
||||
setRegimenSeleccionado(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Dashboard">
|
||||
<PeriodSelector
|
||||
fechaInicio={fechaInicio}
|
||||
fechaFin={fechaFin}
|
||||
onChange={handlePeriodChange}
|
||||
/>
|
||||
</Header>
|
||||
<main className="p-6 space-y-6">
|
||||
{/* Filtros */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<RegimenSelector
|
||||
regimenes={regimenesDisponibles}
|
||||
selected={regimenSeleccionado}
|
||||
onChange={setRegimenSeleccionado}
|
||||
isLoading={regimenesLoading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setConciliacion(!conciliacion)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
conciliacion
|
||||
? 'bg-primary/10 text-primary border border-primary/30'
|
||||
: 'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
Conciliación
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `Ingresos del Mes (${regimenSeleccionado})` : 'Ingresos del Mes'}
|
||||
value={ingresosDisplay}
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
trend={ingresosVariacion !== null ? (ingresosVariacion >= 0 ? 'up' : 'down') : 'neutral'}
|
||||
trendValue={
|
||||
ingresosVariacion !== null
|
||||
? `${ingresosVariacion >= 0 ? '+' : ''}${ingresosVariacion}% vs ${añoAnterior}`
|
||||
: 'Sin datos del periodo anterior'
|
||||
}
|
||||
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })}
|
||||
/>
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `Gastos del Mes (${regimenSeleccionado})` : 'Gastos del Mes'}
|
||||
value={egresosDisplay}
|
||||
icon={<TrendingDown className="h-4 w-4" />}
|
||||
trend={egresosVariacion !== null ? (egresosVariacion >= 0 ? 'up' : 'down') : 'neutral'}
|
||||
trendValue={
|
||||
egresosVariacion !== null
|
||||
? `${egresosVariacion >= 0 ? '+' : ''}${egresosVariacion}% vs ${añoAnterior}`
|
||||
: 'Sin datos del periodo anterior'
|
||||
}
|
||||
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Utilidad"
|
||||
value={utilidadDisplay}
|
||||
icon={<Wallet className="h-4 w-4" />}
|
||||
trend={utilidadDisplay > 0 ? 'up' : 'down'}
|
||||
trendValue={`${margenDisplay}% margen`}
|
||||
/>
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `Balance IVA (${regimenSeleccionado})` : 'Balance IVA'}
|
||||
value={ivaDisplay}
|
||||
icon={<Receipt className="h-4 w-4" />}
|
||||
trend={ivaDisplay > 0 ? 'up' : ivaDisplay < 0 ? 'down' : 'neutral'}
|
||||
trendValue={ivaDisplay > 0 ? 'Por pagar' : ivaDisplay < 0 ? 'A favor' : 'Neutro'}
|
||||
subtitle={
|
||||
ivaVariacion !== null
|
||||
? `${ivaVariacion >= 0 ? '+' : ''}${ivaVariacion}% vs ${añoAnterior}`
|
||||
: undefined
|
||||
}
|
||||
href={drillUrl('Balance IVA - CFDIs', {})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desglose por régimen */}
|
||||
{!regimenSeleccionado && kpis && (
|
||||
(kpis.ingresosPorRegimen.length > 1 || kpis.egresosPorRegimen.length > 1 || kpis.ivaBalancePorRegimen.length > 1) && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{kpis.ingresosPorRegimen.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium">Ingresos por Regimen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{kpis.ingresosPorRegimen.map((r) => (
|
||||
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
|
||||
<span className="text-sm">{r.regimenDescripcion}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{kpis.egresosPorRegimen.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium">Gastos por Regimen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{kpis.egresosPorRegimen.map((r) => (
|
||||
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
|
||||
<span className="text-sm">{r.regimenDescripcion}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{kpis.ivaBalancePorRegimen.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium">Balance IVA por Regimen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{kpis.ivaBalancePorRegimen.map((r) => (
|
||||
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
|
||||
<span className="text-sm">{r.regimenDescripcion}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-semibold ${r.monto > 0 ? 'text-destructive' : 'text-success'}`}>
|
||||
{formatCurrency(r.monto)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Charts and Alerts */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<BarChart
|
||||
title="Ingresos vs Egresos"
|
||||
data={chartData || []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base font-medium">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Alertas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{alertasLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||
) : alertas?.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No hay alertas pendientes</p>
|
||||
) : (
|
||||
alertas?.map((alerta) => (
|
||||
<div
|
||||
key={alerta.id}
|
||||
className={`p-3 rounded-lg border ${
|
||||
alerta.prioridad === 'alta'
|
||||
? 'border-destructive/50 bg-destructive/10'
|
||||
: 'border-border bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">{alerta.titulo}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{alerta.mensaje}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Resumen Fiscal */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-muted-foreground">CFDIs Emitidos</p>
|
||||
<p className="text-2xl font-bold">{
|
||||
regimenSeleccionado
|
||||
? kpis?.cfdisEmitidosPorRegimen?.find(r => r.regimen === regimenSeleccionado)?.total || 0
|
||||
: kpis?.cfdisEmitidos || 0
|
||||
}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-muted-foreground">CFDIs Recibidos</p>
|
||||
<p className="text-2xl font-bold">{
|
||||
regimenSeleccionado
|
||||
? kpis?.cfdisRecibidosPorRegimen?.find(r => r.regimen === regimenSeleccionado)?.total || 0
|
||||
: kpis?.cfdisRecibidos || 0
|
||||
}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Adquisición de Mercancías</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{formatCurrency(adquisicionDisplay)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Uso CFDI G01</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-muted-foreground">IVA a Favor ({añoChart})</p>
|
||||
<p className="text-2xl font-bold text-success">
|
||||
{formatCurrency(kpis?.ivaAFavorAcumulado || 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-muted-foreground">IVA a Favor Historico</p>
|
||||
<p className="text-2xl font-bold text-success">
|
||||
{formatCurrency(kpis?.ivaAFavorHistorico || 0)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{añoChart - 5} — {añoChart}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<FiscalDisclaimer />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user