Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/dashboard/page.tsx
Horux Dev ed6cfed312 feat(dashboard): utilidad neta ajustada por notas de crédito
- La utilidad del dashboard ahora descuenta NCs emitidas de ingresos y NCs recibidas de gastos.
- El margen se calcula sobre ingresos netos.
- Solo afecta la UI del dashboard; no modifica el backend ni otros reportes.
2026-06-13 21:04:25 +00:00

490 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { useTenantViewStore } from '@/stores/tenant-view-store';
import { isGlobalAdminRfc } from '@horux/shared';
import {
TrendingUp,
TrendingDown,
Wallet,
Receipt,
AlertTriangle,
ShoppingCart,
CheckSquare,
FileMinus,
FilePlus,
} 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();
const { viewingTenantId } = useTenantViewStore();
// Admin global no opera sobre datos de despacho propios — su home natural
// es `/clientes`. EXCEPCIÓN: si está impersonando un tenant (vía botón "Ver"
// en /clientes), sí entra al dashboard para validar lo que ve el cliente.
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
useEffect(() => {
if (isGlobalAdmin && !viewingTenantId) router.replace('/clientes');
}, [isGlobalAdmin, viewingTenantId, 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;
// Notas de crédito
const ncsEmitidasDisplay = regimenSeleccionado
? kpis?.ncsEmitidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ncsEmitidas || 0;
const ncsRecibidasDisplay = regimenSeleccionado
? kpis?.ncsRecibidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ncsRecibidas || 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;
// Utilidad ajustada por notas de crédito:
// Ingresos netos = Ingresos NCs emitidas
// Egresos netos = Gastos NCs recibidas
// Utilidad neta = Ingresos netos Egresos netos
const ingresosNetosDisplay = ingresosDisplay - ncsEmitidasDisplay;
const egresosNetosDisplay = egresosDisplay - ncsRecibidasDisplay;
const utilidadDisplay = ingresosNetosDisplay - egresosNetosDisplay;
const margenDisplay = ingresosNetosDisplay > 0
? Math.round((utilidadDisplay / ingresosNetosDisplay) * 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 });
// Dashboard no tiene toggles considerarActivos/considerarNCs — siempre
// pasa los defaults true (omitir = backend usa true). Si en el futuro se
// agregan toggles aquí, propagarlos como hace /impuestos.
if (regimenSeleccionado) {
if (filters.type === 'EMITIDO') p.set('regimenEmisor', regimenSeleccionado);
else if (filters.type === 'RECIBIDO') p.set('regimenReceptor', regimenSeleccionado);
// Por bucket — 605 es receptor en bucket=ingresos (nómina recibida).
else if (filters.bucket === 'ingresos') {
if (regimenSeleccionado === '605') p.set('regimenReceptor', '605');
else p.set('regimenEmisor', regimenSeleccionado);
}
else if (filters.bucket === 'causado') p.set('regimenEmisor', regimenSeleccionado);
else if (filters.bucket === 'gastos' || filters.bucket === 'acreditable') 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-3">
<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 ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
value={ncsEmitidasDisplay}
icon={<FileMinus className="h-4 w-4" />}
trend="neutral"
trendValue="Notas de crédito emitidas"
/>
<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={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
value={ncsRecibidasDisplay}
icon={<FilePlus className="h-4 w-4" />}
trend="neutral"
trendValue="Notas de crédito recibidas"
/>
<KpiCard
title={regimenSeleccionado ? `Utilidad Neta (${regimenSeleccionado})` : 'Utilidad Neta'}
value={utilidadDisplay}
icon={<Wallet className="h-4 w-4" />}
trend={utilidadDisplay > 0 ? 'up' : 'down'}
trendValue={`${margenDisplay}% margen · incluye NCs`}
/>
<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 || kpis.ncsEmitidasPorRegimen.length > 1 || kpis.ncsRecibidasPorRegimen.length > 1) && (
<div className="grid gap-4 md:grid-cols-2 3xl:grid-cols-3">
{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>
)}
{kpis.ncsEmitidasPorRegimen.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">NCs Emitidas por Regimen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{kpis.ncsEmitidasPorRegimen.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.ncsRecibidasPorRegimen.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">NCs Recibidas por Regimen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{kpis.ncsRecibidasPorRegimen.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>
)}
</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>
</>
);
}