Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/dashboard/page.tsx
Horux Dev ab6b76fcb8 ui(dashboard): reordenar scorecards de notas de crédito
- NCs Emitidas ahora aparece después de Ingresos del Mes.
- NCs Recibidas ahora aparece después de Gastos del Mes.
2026-06-13 20:54:40 +00:00

484 lines
21 KiB
TypeScript

'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;
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 });
// 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="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 || 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>
</>
);
}