Factura Global & fecha_efectiva: - Migracion 045_factura_global.sql: periodicidad, meses_global, año_global, fecha_efectiva - sat-parser.service.ts: extrae InformacionGlobal del XML - sat.service.ts: calcFechaEfectiva con soporte bimestral (periodicidad 05) - metricas-compute, dashboard, impuestos, cfdi, export, conciliacion, alertas: reemplaza fecha_emision-1h por COALESCE(fecha_efectiva, fecha_emision-1h) - Script recalc-metricas.ts para recalculo manual Fallback datos fiscales tenant → contribuyente: - contribuyente.service.ts: fetchTenantFiscalData + mergeContribuyenteWithTenant rellena regimenFiscal, codigoPostal y domicilio cuando el contribuyente tiene el mismo RFC que el tenant y sus campos estan vacios - contribuyente.controller.ts y contribuyente-config.controller.ts: pasan req.user!.tenantId al servicio Fix critico SAT sync: - sat.service.ts: anio_global → año_global en INSERT/UPDATE de CFDIs (la migracion creo 'año_global' con tilde; el codigo usaba 'anio_global', causando fallo en 100% de inserciones de CFDI) - determineChunkMonths: salta sondeo si existe job previo con requestIds - MAX_POLL_ATTEMPTS: 45 → 500 (~8h) para syncs iniciales grandes Docs: - docs/sessions/2026-05-22-factura-global-contribuyente-fallback.md
180 lines
9.1 KiB
TypeScript
180 lines
9.1 KiB
TypeScript
'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, toCfdiDate } 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: toCfdiDate(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) => toCfdiDate(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">{toCfdiDate(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>
|
||
);
|
||
}
|