Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/drill-down/page.tsx

180 lines
9.0 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 { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader, cn } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Comprobante', key: 'tipoComprobante', width: 12 },
{ header: 'Fecha Emision', 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: 'Total MXN', key: '_totalMxn', width: 15 },
{ header: 'Monto Pago MXN', key: '_montoPagoMxn', width: 15 },
{ header: 'IVA Trasladado MXN', key: '_ivaMxn', width: 18 },
{ header: 'Metodo Pago', key: 'metodoPago', width: 12 },
{ header: 'Regimen Emisor', key: 'regimenEmisor', width: 15 },
{ header: 'Regimen Receptor', key: 'regimenReceptor', width: 15 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
_montoPagoMxn: Number(c.montoPagoMxn || 0),
_ivaMxn: Number(c.ivaTrasladoMxn || 0),
}));
}
export default function DrillDownPage() {
const searchParams = useSearchParams();
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const { selectedContribuyenteId } = useContribuyenteStore();
const params = new URLSearchParams();
for (const [key, value] of searchParams.entries()) {
if (key !== 'titulo') params.set(key, value);
}
// Respetar contribuyente seleccionado globalmente — así cualquier drillUrl
// construido desde dashboard/impuestos/etc queda automáticamente filtrado
// sin tener que acordarse de pasarlo en cada call-site. El URLSearchParams
// de entrada gana si el caller sí lo pasó explícitamente.
if (selectedContribuyenteId && !params.has('contribuyenteId')) {
params.set('contribuyenteId', selectedContribuyenteId);
}
const { data, isLoading } = useQuery({
queryKey: ['drill-down', params.toString()],
queryFn: async () => {
const res = await apiClient.get<Cfdi[]>(`/cfdi/drill-down?${params}`);
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total' | 'pago' | 'iva'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
pago: (c) => Number(c.montoPagoMxn || 0),
iva: (c) => Number(c.ivaTrasladoMxn || 0),
},
'fecha',
);
// Total con signo: tipo E resta (es una nota de crédito que reduce el bucket).
// Tipo I/N suman total_mxn; tipo P suma monto_pago_mxn (su total es 0 por convención
// del complemento). Así el total del header coincide con los KPIs del dashboard.
const totalMxn = data?.reduce((s, r) => {
const sign = r.tipoComprobante === 'E' ? -1 : 1;
const amount = r.tipoComprobante === 'P'
? Number(r.montoPagoMxn || 0)
: Number(r.totalMxn || 0);
return s + sign * amount;
}, 0) || 0;
const totalPagos = data?.reduce((s, r) => s + Number(r.montoPagoMxn || 0), 0) || 0;
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'drill-down-cfdis');
};
return (
<DashboardShell title={titulo}>
<Card>
<CardContent className="pt-6">
{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 CFDIs que coincidan con los filtros</div>
) : (
<>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-muted-foreground">{data.length} CFDIs encontrados</p>
<div className="flex items-center gap-4 text-sm">
<span>Total MXN: <strong>{formatCurrency(totalMxn)}</strong></span>
{totalPagos > 0 && <span>Pagos MXN: <strong>{formatCurrency(totalPagos)}</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">
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Comp.</th>
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<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>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<SortableHeader label="Monto Pago" align="right" active={getSortIndicator('pago')} onClick={() => toggleSort('pago')} />
<SortableHeader label="IVA Trasl." align="right" active={getSortIndicator('iva')} onClick={() => toggleSort('iva')} />
<th className="pb-3 font-medium">M. Pago</th>
<th className="pb-3 font-medium">Reg. E</th>
<th className="pb-3 font-medium">Reg. R</th>
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((cfdi: any) => {
const isNC = cfdi.tipoComprobante === 'E';
return (
<tr key={cfdi.id} className={cn('border-b hover:bg-muted/50', isNC && 'bg-red-50/50 dark:bg-red-950/20')}>
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>{cfdi.uuid?.substring(0, 8)}</td>
<td className={cn('py-2 text-xs font-mono', isNC && 'text-red-600 dark:text-red-400 font-semibold')} title={isNC ? 'Nota de crédito — resta del total' : undefined}>{cfdi.tipoComprobante}</td>
<td className="py-2 text-xs">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]" title={cfdi.nombreEmisor}>{cfdi.nombreEmisor}</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-2 text-xs truncate max-w-[120px]" title={cfdi.nombreReceptor}>{cfdi.nombreReceptor}</td>
<td className={cn('py-2 text-right text-xs font-medium', isNC && 'text-red-600 dark:text-red-400')}>{isNC ? '' : ''}{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-2 text-right text-xs">{cfdi.tipoComprobante === 'P' && cfdi.montoPagoMxn ? formatCurrency(Number(cfdi.montoPagoMxn)) : '-'}</td>
<td className="py-2 text-right text-xs">{cfdi.ivaTrasladoMxn ? formatCurrency(Number(cfdi.ivaTrasladoMxn)) : '-'}</td>
<td className="py-2 text-xs">{cfdi.metodoPago || '-'}</td>
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
<td className="py-2">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}