feat(reportes): rediseño Estado de Resultados vertical con drill-down, análisis horizontal/vertical y export Excel
- Nuevo endpoint GET /reportes/estado-resultados-detallado con cálculo contable:
* Ventas, Devoluciones, Ventas netas, Costo de ventas, Utilidad bruta,
Gastos operativos, Utilidad de la operación
* Fórmula: subtotal_mxn - descuento_mxn (sin impuestos), nómina usa total_mxn
* Excluye anticipos (uso_cfdi=P01 o clave_prod_serv=84111506)
* Filtro por régimen fiscal opcional
* Año anterior calculado automáticamente
- Nuevo endpoint GET /reportes/estado-resultados/drill-down:
* Nivel 1: resumen agrupado por RFC
* Nivel 2: CFDIs individuales filtrados por categoría
* Categorías: ventas, devoluciones, costo-ventas, gastos-operativos
- Nuevo endpoint GET /reportes/estado-resultados/export:
* Genera Excel con formato condicional (verde/rojo, negritas)
- Frontend:
* Tabla vertical con % vertical, año anterior y variación %
* Filas clickeables para drill-down modal de 2 niveles
* Top 10 Clientes/Proveedores mantenidos debajo
* Selector de régimen conectado al reporte
- Fix: NaN en total de drill-down nivel 2 por numeric como string en pg
* Agregado ::float en queries SQL de CFDIs individuales
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Button,
|
||||
} from '@horux/shared-ui';
|
||||
import { useEstadoResultadosDrillDown } from '@/lib/hooks/use-reportes';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { ArrowLeft, Download, Eye } from 'lucide-react';
|
||||
import type { DrillDownResumenItem, DrillDownCfdiItem } from '@/lib/api/reportes';
|
||||
|
||||
interface Props {
|
||||
categoria: string;
|
||||
fechaInicio: string;
|
||||
fechaFin: string;
|
||||
regimen?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CATEGORIA_TITULO: Record<string, string> = {
|
||||
ventas: 'Ventas',
|
||||
devoluciones: 'Devoluciones y cancelaciones',
|
||||
'costo-ventas': 'Costo de ventas',
|
||||
'gastos-operativos': 'Gastos operativos',
|
||||
};
|
||||
|
||||
const RFC_COLUMNS = [
|
||||
{ header: 'RFC', key: 'rfc', width: 15 },
|
||||
{ header: 'Nombre', key: 'nombre', width: 35 },
|
||||
{ header: 'CFDIs', key: 'cantidad', width: 10 },
|
||||
{ header: 'Monto', key: '_monto', width: 20 },
|
||||
];
|
||||
|
||||
const CFDI_COLUMNS = [
|
||||
{ header: 'UUID', key: 'uuid', width: 40 },
|
||||
{ header: 'Comp.', key: 'tipoComprobante', width: 10 },
|
||||
{ header: 'Fecha', key: '_fecha', width: 15 },
|
||||
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
|
||||
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
|
||||
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
|
||||
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
|
||||
{ header: 'Monto', key: '_monto', width: 15 },
|
||||
];
|
||||
|
||||
function isResumen(data: unknown[]): data is DrillDownResumenItem[] {
|
||||
return data.length > 0 && 'cantidad' in (data[0] as any);
|
||||
}
|
||||
|
||||
function isCfdis(data: unknown[]): data is DrillDownCfdiItem[] {
|
||||
return data.length > 0 && 'uuid' in (data[0] as any);
|
||||
}
|
||||
|
||||
export function EstadoResultadosDrillDownModal({
|
||||
categoria,
|
||||
fechaInicio,
|
||||
fechaFin,
|
||||
regimen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [selectedRfc, setSelectedRfc] = useState<string | null>(null);
|
||||
const [selectedNombre, setSelectedNombre] = useState<string>('');
|
||||
|
||||
const { data, isLoading } = useEstadoResultadosDrillDown(
|
||||
categoria,
|
||||
fechaInicio,
|
||||
fechaFin,
|
||||
regimen || undefined,
|
||||
selectedRfc || undefined,
|
||||
);
|
||||
|
||||
const resumen = useMemo(() => (data && isResumen(data) ? data : []), [data]);
|
||||
const cfdis = useMemo(() => (data && isCfdis(data) ? data : []), [data]);
|
||||
|
||||
const totalMonto = useMemo(() => {
|
||||
if (resumen.length > 0) return resumen.reduce((s, r) => s + Number(r.monto || 0), 0);
|
||||
if (cfdis.length > 0) return cfdis.reduce((s, c) => s + Number(c.monto || 0), 0);
|
||||
return 0;
|
||||
}, [resumen, cfdis]);
|
||||
|
||||
const handleExport = () => {
|
||||
if (resumen.length > 0) {
|
||||
const rows = resumen.map((r) => ({
|
||||
...r,
|
||||
_monto: r.monto,
|
||||
}));
|
||||
exportToExcel(rows, RFC_COLUMNS, `drill-down-${categoria}-rfc`);
|
||||
} else if (cfdis.length > 0) {
|
||||
const rows = cfdis.map((c) => ({
|
||||
...c,
|
||||
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_monto: c.monto,
|
||||
}));
|
||||
exportToExcel(rows, CFDI_COLUMNS, `drill-down-${categoria}-cfdis`);
|
||||
}
|
||||
};
|
||||
|
||||
const titulo = CATEGORIA_TITULO[categoria] || categoria;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedRfc && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedRfc(null)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DialogTitle>
|
||||
{selectedRfc ? `${titulo} — ${selectedNombre}` : titulo}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No hay datos para esta categoría
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{resumen.length > 0
|
||||
? `${resumen.length} RFCs encontrados`
|
||||
: `${cfdis.length} CFDIs encontrados`}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span>
|
||||
Total: <strong>{formatCurrency(totalMonto)}</strong>
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Excel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
{resumen.length > 0 ? (
|
||||
<>
|
||||
<th className="pb-3 font-medium">RFC</th>
|
||||
<th className="pb-3 font-medium">Nombre</th>
|
||||
<th className="pb-3 font-medium text-right">CFDIs</th>
|
||||
<th className="pb-3 font-medium text-right">Monto</th>
|
||||
<th className="pb-3 w-8"></th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th className="pb-3 font-medium">UUID</th>
|
||||
<th className="pb-3 font-medium">Comp.</th>
|
||||
<th className="pb-3 font-medium">Fecha</th>
|
||||
<th className="pb-3 font-medium">RFC Emisor</th>
|
||||
<th className="pb-3 font-medium">Nombre Emisor</th>
|
||||
<th className="pb-3 font-medium">RFC Receptor</th>
|
||||
<th className="pb-3 font-medium">Nombre Receptor</th>
|
||||
<th className="pb-3 font-medium text-right">Monto</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resumen.length > 0
|
||||
? resumen.map((item) => (
|
||||
<tr
|
||||
key={item.rfc}
|
||||
className="border-b hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedRfc(item.rfc);
|
||||
setSelectedNombre(item.nombre);
|
||||
}}
|
||||
>
|
||||
<td className="py-2 font-mono text-xs">{item.rfc}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[200px]">{item.nombre}</td>
|
||||
<td className="py-2 text-right text-xs">{item.cantidad}</td>
|
||||
<td className="py-2 text-right text-xs font-medium">
|
||||
{formatCurrency(item.monto)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
: cfdis.map((item) => (
|
||||
<tr key={item.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-2 font-mono text-xs" title={item.uuid}>
|
||||
{item.uuid?.substring(0, 8)}
|
||||
</td>
|
||||
<td className="py-2 text-xs font-mono">{item.tipoComprobante}</td>
|
||||
<td className="py-2 text-xs">
|
||||
{new Date(item.fechaEmision).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs">{item.rfcEmisor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px]">
|
||||
{item.nombreEmisor}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs">{item.rfcReceptor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px]">
|
||||
{item.nombreReceptor}
|
||||
</td>
|
||||
<td className="py-2 text-right text-xs font-medium">
|
||||
{formatCurrency(item.monto)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { Download, ChevronRight } from 'lucide-react';
|
||||
import type { EstadoResultadosDetallado } from '@horux/shared';
|
||||
import { getExportEstadoResultadosUrl } from '@/lib/api/reportes';
|
||||
import { EstadoResultadosDrillDownModal } from './drill-down-modal';
|
||||
|
||||
interface Props {
|
||||
data: EstadoResultadosDetallado;
|
||||
fechaInicio: string;
|
||||
fechaFin: string;
|
||||
regimen?: string | null;
|
||||
}
|
||||
|
||||
type FilaConcepto = {
|
||||
concepto: string;
|
||||
monto: number;
|
||||
vertical: number;
|
||||
anterior: number;
|
||||
variacion: number;
|
||||
isTotal: boolean;
|
||||
categoria?: string;
|
||||
};
|
||||
|
||||
export function EstadoResultadosTable({ data, fechaInicio, fechaFin, regimen }: Props) {
|
||||
const [drillDownCategoria, setDrillDownCategoria] = useState<string | null>(null);
|
||||
|
||||
const ventas = data.ventas || 1;
|
||||
|
||||
const filas: FilaConcepto[] = [
|
||||
{
|
||||
concepto: 'Ventas',
|
||||
monto: data.ventas,
|
||||
vertical: (data.ventas / ventas) * 100,
|
||||
anterior: data.anterior.ventas,
|
||||
variacion: data.anterior.ventas ? ((data.ventas - data.anterior.ventas) / data.anterior.ventas) * 100 : 0,
|
||||
isTotal: false,
|
||||
categoria: 'ventas',
|
||||
},
|
||||
{
|
||||
concepto: 'Devoluciones y cancelaciones',
|
||||
monto: -data.devoluciones,
|
||||
vertical: -(data.devoluciones / ventas) * 100,
|
||||
anterior: -data.anterior.devoluciones,
|
||||
variacion: data.anterior.devoluciones
|
||||
? ((-data.devoluciones + data.anterior.devoluciones) / data.anterior.devoluciones) * 100
|
||||
: 0,
|
||||
isTotal: false,
|
||||
categoria: 'devoluciones',
|
||||
},
|
||||
{
|
||||
concepto: 'Ventas netas',
|
||||
monto: data.ventasNetas,
|
||||
vertical: (data.ventasNetas / ventas) * 100,
|
||||
anterior: data.anterior.ventasNetas,
|
||||
variacion: data.anterior.ventasNetas
|
||||
? ((data.ventasNetas - data.anterior.ventasNetas) / data.anterior.ventasNetas) * 100
|
||||
: 0,
|
||||
isTotal: true,
|
||||
},
|
||||
{
|
||||
concepto: 'Costo de ventas',
|
||||
monto: -data.costoVentas,
|
||||
vertical: -(data.costoVentas / ventas) * 100,
|
||||
anterior: -data.anterior.costoVentas,
|
||||
variacion: data.anterior.costoVentas
|
||||
? ((-data.costoVentas + data.anterior.costoVentas) / data.anterior.costoVentas) * 100
|
||||
: 0,
|
||||
isTotal: false,
|
||||
categoria: 'costo-ventas',
|
||||
},
|
||||
{
|
||||
concepto: 'Utilidad bruta',
|
||||
monto: data.utilidadBruta,
|
||||
vertical: (data.utilidadBruta / ventas) * 100,
|
||||
anterior: data.anterior.utilidadBruta,
|
||||
variacion: data.anterior.utilidadBruta
|
||||
? ((data.utilidadBruta - data.anterior.utilidadBruta) / data.anterior.utilidadBruta) * 100
|
||||
: 0,
|
||||
isTotal: true,
|
||||
},
|
||||
{
|
||||
concepto: 'Gastos operativos',
|
||||
monto: -data.gastosOperativos,
|
||||
vertical: -(data.gastosOperativos / ventas) * 100,
|
||||
anterior: -data.anterior.gastosOperativos,
|
||||
variacion: data.anterior.gastosOperativos
|
||||
? ((-data.gastosOperativos + data.anterior.gastosOperativos) / data.anterior.gastosOperativos) * 100
|
||||
: 0,
|
||||
isTotal: false,
|
||||
categoria: 'gastos-operativos',
|
||||
},
|
||||
{
|
||||
concepto: 'Utilidad de la operación',
|
||||
monto: data.utilidadOperacion,
|
||||
vertical: (data.utilidadOperacion / ventas) * 100,
|
||||
anterior: data.anterior.utilidadOperacion,
|
||||
variacion: data.anterior.utilidadOperacion
|
||||
? ((data.utilidadOperacion - data.anterior.utilidadOperacion) / data.anterior.utilidadOperacion) * 100
|
||||
: 0,
|
||||
isTotal: true,
|
||||
},
|
||||
];
|
||||
|
||||
const handleExport = () => {
|
||||
const url = getExportEstadoResultadosUrl(fechaInicio, fechaFin, regimen || undefined);
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Estado de Resultados</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Exportar Excel
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-3 font-medium">Concepto</th>
|
||||
<th className="pb-3 font-medium text-right">Monto</th>
|
||||
<th className="pb-3 font-medium text-right">% Vertical</th>
|
||||
<th className="pb-3 font-medium text-right">Año Anterior</th>
|
||||
<th className="pb-3 font-medium text-right">Var. %</th>
|
||||
<th className="pb-3 w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filas.map((fila) => (
|
||||
<tr
|
||||
key={fila.concepto}
|
||||
className={`border-b ${
|
||||
fila.isTotal ? 'font-bold bg-muted/30' : 'hover:bg-muted/50'
|
||||
} ${fila.categoria ? 'cursor-pointer' : ''}`}
|
||||
onClick={() => fila.categoria && setDrillDownCategoria(fila.categoria)}
|
||||
>
|
||||
<td className="py-3">{fila.concepto}</td>
|
||||
<td
|
||||
className={`py-3 text-right ${
|
||||
fila.monto >= 0 ? 'text-success' : 'text-destructive'
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(fila.monto)}
|
||||
</td>
|
||||
<td className="py-3 text-right text-muted-foreground">
|
||||
{fila.vertical.toFixed(1)}%
|
||||
</td>
|
||||
<td className="py-3 text-right text-muted-foreground">
|
||||
{formatCurrency(fila.anterior)}
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
<span className={fila.variacion >= 0 ? 'text-success' : 'text-destructive'}>
|
||||
{fila.variacion >= 0 ? '+' : ''}
|
||||
{fila.variacion.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
{fila.categoria && (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{drillDownCategoria && (
|
||||
<EstadoResultadosDrillDownModal
|
||||
categoria={drillDownCategoria}
|
||||
fechaInicio={fechaInicio}
|
||||
fechaFin={fechaFin}
|
||||
regimen={regimen}
|
||||
onClose={() => setDrillDownCategoria(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user