feat(sat): factura global + fecha_efectiva, fallback tenant-contribuyente, fix anio_global typo

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
This commit is contained in:
Horux Dev
2026-05-22 15:52:10 +00:00
parent ba6004ebd6
commit 46846200da
33 changed files with 1128 additions and 171 deletions

View File

@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
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';
@@ -27,8 +27,8 @@ const EXCEL_COLUMNS = [
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
_fechaEmision: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_fechaCancelacion: c.fechaCancelacion ? toCfdiDate(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
_totalMxn: Number(c.totalMxn || 0),
}));
}
@@ -50,8 +50,8 @@ export default function CancelacionesPeriodoAnteriorPage() {
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'cancelacion' | 'total'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0,
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
cancelacion: (c: any) => c.fechaCancelacion ? toCfdiDate(c.fechaCancelacion).getTime() : 0,
total: (c) => Number(c.totalMxn || 0),
},
'cancelacion',
@@ -97,8 +97,8 @@ export default function CancelacionesPeriodoAnteriorPage() {
{(sortedData || []).map((cfdi: any) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{cfdi.fechaCancelacion ? toCfdiDate(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>

View File

@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
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';
@@ -26,8 +26,8 @@ const EXCEL_COLUMNS = [
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
_fechaEmision: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_fechaCancelacion: c.fechaCancelacion ? toCfdiDate(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
_totalMxn: Number(c.totalMxn || 0),
}));
}
@@ -46,8 +46,8 @@ export default function CancelacionesPage() {
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'cancelacion' | 'total'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0,
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
cancelacion: (c: any) => c.fechaCancelacion ? toCfdiDate(c.fechaCancelacion).getTime() : 0,
total: (c) => Number(c.totalMxn || 0),
},
'cancelacion',
@@ -91,8 +91,8 @@ export default function CancelacionesPage() {
{(sortedData || []).map((cfdi: any) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{cfdi.fechaCancelacion ? toCfdiDate(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>

View File

@@ -5,7 +5,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
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';
@@ -29,7 +29,7 @@ const EXCEL_COLUMNS = [
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
regimenReceptor: c.regimenReceptor || c.regimenFiscalReceptor || '',
}));
@@ -91,10 +91,10 @@ export default function DiscrepanciaRegimenPage() {
let filtered = data;
if (fechaDesde) {
filtered = filtered.filter(c => c.fechaEmision >= fechaDesde);
filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() >= fechaDesde);
}
if (fechaHasta) {
filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59');
filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() <= fechaHasta + 'T23:59:59');
}
if (regimenFilter) {
filtered = filtered.filter((c: any) => (c.regimenReceptor || c.regimenFiscalReceptor) === regimenFilter);
@@ -106,7 +106,7 @@ export default function DiscrepanciaRegimenPage() {
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
visibleData,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
},
'fecha',
@@ -311,7 +311,7 @@ export default function DiscrepanciaRegimenPage() {
</button>
</td>
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 truncate max-w-[200px]">{cfdi.nombreEmisor}</td>
<td className="py-3 font-mono font-bold text-destructive">{cfdi.regimenReceptor}</td>

View File

@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
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';
@@ -26,7 +26,7 @@ const EXCEL_COLUMNS = [
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
}));
}
@@ -45,7 +45,7 @@ export default function EfectivoPage() {
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
},
'fecha',
@@ -89,7 +89,7 @@ export default function EfectivoPage() {
{(sortedData || []).map((cfdi: any) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 truncate max-w-[200px]">{cfdi.nombreEmisor}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>

View File

@@ -5,7 +5,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
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';
@@ -30,7 +30,7 @@ const EXCEL_COLUMNS = [
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
}));
}
@@ -83,8 +83,8 @@ export default function TipoRelacionSospechosaPage() {
const visibleData = useMemo(() => {
if (!data) return [];
let filtered = data;
if (fechaDesde) filtered = filtered.filter(c => c.fechaEmision >= fechaDesde);
if (fechaHasta) filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59');
if (fechaDesde) filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() >= fechaDesde);
if (fechaHasta) filtered = filtered.filter(c => toCfdiDate(c.fechaEmision).toISOString() <= fechaHasta + 'T23:59:59');
if (tipoRelFilter) filtered = filtered.filter((c: any) => c.cfdiTipoRelacion === tipoRelFilter);
return filtered;
}, [data, fechaDesde, fechaHasta, tipoRelFilter]);
@@ -92,7 +92,7 @@ export default function TipoRelacionSospechosaPage() {
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
visibleData,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
},
'fecha',
@@ -296,7 +296,7 @@ export default function TipoRelacionSospechosaPage() {
</button>
</td>
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{toCfdiDate(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3 truncate max-w-[180px]">
<div className="font-mono text-xs">{cfdi.rfcEmisor}</div>
<div className="text-xs text-muted-foreground truncate">{cfdi.nombreEmisor}</div>

View File

@@ -421,7 +421,7 @@ export default function CfdiPage() {
}
const exportData = allRows.map(cfdi => ({
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision),
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
'Uso CFDI': (cfdi as any).usoCfdi || '',
'Serie': cfdi.serie || '',
@@ -442,9 +442,7 @@ export default function CfdiPage() {
// vacío en Excel para no confundir "0 = pagado" con "no aplica".
'Saldo Pendiente': cfdi.saldoPendienteMxn ?? '',
'Estatus': cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado',
'Fecha Cancelación': cfdi.fechaCancelacion
? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX')
: '',
'Fecha Cancelación': formatCfdiDate(cfdi.fechaCancelacion),
'UUID': cfdi.uuid,
}));
@@ -509,7 +507,7 @@ export default function CfdiPage() {
if (key.endsWith('_mxn') || key === 'id' || key === 'cfdi_id') continue;
// Formatear fecha si aplica
if (key === 'fechaEmision' && typeof val === 'string') {
out['Fecha Emisión'] = new Date(val).toLocaleDateString('es-MX');
out['Fecha Emisión'] = formatCfdiDate(val);
} else {
out[key] = val;
}
@@ -539,7 +537,7 @@ export default function CfdiPage() {
const exportSingleCfdiToExcel = (cfdi: Cfdi) => {
const row = {
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision),
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
'Uso CFDI': (cfdi as any).usoCfdi || '',
'Serie': cfdi.serie || '',
@@ -560,9 +558,7 @@ export default function CfdiPage() {
// vacío en Excel para no confundir "0 = pagado" con "no aplica".
'Saldo Pendiente': cfdi.saldoPendienteMxn ?? '',
'Estatus': cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado',
'Fecha Cancelación': cfdi.fechaCancelacion
? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX')
: '',
'Fecha Cancelación': formatCfdiDate(cfdi.fechaCancelacion),
'UUID': cfdi.uuid,
};
@@ -935,12 +931,22 @@ export default function CfdiPage() {
currency: 'MXN',
}).format(value);
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString('es-MX', {
const formatDate = (dateString: string) => {
const d = new Date(dateString);
d.setHours(d.getHours() - 1);
return d.toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
};
const formatCfdiDate = (dateString: string | null | undefined) => {
if (!dateString) return '-';
const d = new Date(dateString);
d.setHours(d.getHours() - 1);
return d.toLocaleDateString('es-MX');
};
const generateUUID = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
@@ -1697,7 +1703,7 @@ export default function CfdiPage() {
<tbody className="text-sm text-center">
{conceptosQuery.data.data.map((row, idx) => (
<tr key={`${row.cfdi_id}-${row.id}-${idx}`} className="border-b hover:bg-muted/50">
<td className="py-2">{new Date(row.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-2">{formatCfdiDate(row.fechaEmision)}</td>
<td className="py-2 font-mono text-xs" title={row.uuid}>{row.uuid?.substring(0, 8) || '-'}</td>
<td className="py-2 font-mono text-xs">{row.clave_prod_serv || '-'}</td>
<td className="py-2 text-left max-w-[280px] truncate" title={row.descripcion}>{row.descripcion}</td>

View File

@@ -7,9 +7,9 @@ import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Input } from '@horux/shared-ui';
import { Card, CardContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Input, Label, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { formatCurrency } from '@/lib/utils';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
function formatCurrencyConciliacion(value: number): string {
return new Intl.NumberFormat('es-MX', {
@@ -20,7 +20,7 @@ function formatCurrencyConciliacion(value: number): string {
}).format(value);
}
import { exportToExcel } from '@/lib/export-excel';
import { Eye, Download, X, CheckCircle } from 'lucide-react';
import { Eye, Download, X, CheckCircle, Search, ArrowUpDown, Filter } from 'lucide-react';
function getMonthRange(year: number, month: number) {
const start = `${year}-${String(month).padStart(2, '0')}-01`;
@@ -42,6 +42,20 @@ export default function ConciliacionPage() {
const [bancoId, setBancoId] = useState<string>('');
const [selectedCfdi, setSelectedCfdi] = useState<any>(null);
// Ordenación — Por conciliar
const [sortPendientes, setSortPendientes] = useState<{ field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null>(null);
// Ordenación — Conciliadas
const [sortConciliadas, setSortConciliadas] = useState<{ field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null>(null);
// Filtros por columna — Por conciliar
const [filtersPendientes, setFiltersPendientes] = useState({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
const [openFilterPendientes, setOpenFilterPendientes] = useState<string | null>(null);
// Filtros por columna — Conciliadas
const [filtersConciliadas, setFiltersConciliadas] = useState({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
const [openFilterConciliadas, setOpenFilterConciliadas] = useState<string | null>(null);
const { user } = useAuthStore();
const isVisor = user?.role === 'visor';
@@ -66,9 +80,15 @@ export default function ConciliacionPage() {
const montoConciliado = conciliadas.reduce((s, c) => s + getMonto(c), 0);
const montoPendiente = pendientes.reduce((s, c) => s + getMonto(c), 0);
// Reset selection on tab/filter change
// Reset selection + ordenación + filtros on tab/filter change
useEffect(() => {
setSelected(new Set());
setSortPendientes(null);
setSortConciliadas(null);
setFiltersPendientes({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
setOpenFilterPendientes(null);
setFiltersConciliadas({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
setOpenFilterConciliadas(null);
}, [activeTab, fechaInicio, fechaFin, regimenSeleccionado]);
// Handlers
@@ -85,10 +105,10 @@ export default function ConciliacionPage() {
};
const toggleSelectAll = () => {
if (selected.size === pendientes.length && pendientes.length > 0) {
if (selected.size === pendientesOrdenados.length && pendientesOrdenados.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(pendientes.map((c) => c.id)));
setSelected(new Set(pendientesOrdenados.map((c) => c.id)));
}
};
@@ -117,12 +137,100 @@ export default function ConciliacionPage() {
}
};
function matchesColumnFilters(c: any, filters: { rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string }) {
const rfcEmisorMatch = !filters.rfcEmisor || (c.rfcEmisor || '').toLowerCase().includes(filters.rfcEmisor.toLowerCase());
const nombreEmisorMatch = !filters.nombreEmisor || (c.nombreEmisor || '').toLowerCase().includes(filters.nombreEmisor.toLowerCase());
const rfcReceptorMatch = !filters.rfcReceptor || (c.rfcReceptor || '').toLowerCase().includes(filters.rfcReceptor.toLowerCase());
const nombreReceptorMatch = !filters.nombreReceptor || (c.nombreReceptor || '').toLowerCase().includes(filters.nombreReceptor.toLowerCase());
return rfcEmisorMatch && nombreEmisorMatch && rfcReceptorMatch && nombreReceptorMatch;
}
function sortCfdis(list: any[], sort: { field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null) {
if (!sort) return list;
const sorted = [...list].sort((a, b) => {
if (sort.field === 'fecha') {
const da = toCfdiDate(a.fechaPagoP || a.fechaEmision).getTime();
const db = toCfdiDate(b.fechaPagoP || b.fechaEmision).getTime();
return sort.dir === 'asc' ? da - db : db - da;
}
if (sort.field === 'total') {
const ta = getMonto(a);
const tb = getMonto(b);
return sort.dir === 'asc' ? ta - tb : tb - ta;
}
return 0;
});
return sorted;
}
function FilterHeader({
label,
filterKey,
filters,
setFilters,
openFilter,
setOpenFilter,
}: {
label: string;
filterKey: string;
filters: { rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string };
setFilters: React.Dispatch<React.SetStateAction<{ rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string }>>;
openFilter: string | null;
setOpenFilter: (v: string | null) => void;
}) {
const hasFilter = !!(filters as any)[filterKey];
return (
<div className="flex items-center justify-center gap-1">
{label}
<Popover open={openFilter === filterKey} onOpenChange={(open) => setOpenFilter(open ? filterKey : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasFilter ? 'text-primary' : ''}`}>
<Filter className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-64" align="start">
<div className="space-y-3">
<h4 className="font-medium text-sm">Filtrar por {label}</h4>
<div>
<Label className="text-xs">Contiene</Label>
<Input
placeholder={`Buscar ${label.toLowerCase()}...`}
className="h-8 text-sm"
value={(filters as any)[filterKey]}
onChange={(e) => setFilters((prev: any) => ({ ...prev, [filterKey]: e.target.value }))}
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => setOpenFilter(null)}>Aplicar</Button>
{hasFilter && (
<Button size="sm" variant="outline" onClick={() => { setFilters((prev: any) => ({ ...prev, [filterKey]: '' })); setOpenFilter(null); }}>
Limpiar
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}
const pendientesOrdenados = sortCfdis(
pendientes.filter((c) => matchesColumnFilters(c, filtersPendientes)),
sortPendientes
);
const conciliadasOrdenadas = sortCfdis(
conciliadas.filter((c) => matchesColumnFilters(c, filtersConciliadas)),
sortConciliadas
);
const handleExport = () => {
if (!cfdis?.length) return;
const allVisible = [...pendientesOrdenados, ...conciliadasOrdenadas];
if (!allVisible.length) return;
exportToExcel(
cfdis.map((c) => ({
allVisible.map((c) => ({
...c,
_fecha: new Date(c.fechaPagoP || c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: toCfdiDate(c.fechaPagoP || c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: getMonto(c),
_estado: c.conciliado === 'true' ? 'Conciliado' : 'Pendiente',
_fechaPago: c.conciliacion?.fechaDePago || '',
@@ -212,8 +320,8 @@ export default function ConciliacionPage() {
{/* Por conciliar */}
<Card>
<CardContent className="pt-6">
<h3 className="font-medium mb-4">Por conciliar ({pendientes.length})</h3>
{pendientes.length === 0 ? (
<h3 className="font-medium mb-4">Por conciliar ({pendientesOrdenados.length})</h3>
{pendientesOrdenados.length === 0 ? (
<p className="text-sm text-muted-foreground">
No hay CFDIs pendientes de conciliar
</p>
@@ -221,31 +329,35 @@ export default function ConciliacionPage() {
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<tr className="border-b text-center text-muted-foreground">
{!isVisor && (
<th className="pb-3 w-8">
<input
type="checkbox"
checked={
selected.size === pendientes.length && pendientes.length > 0
selected.size === pendientesOrdenados.length && pendientesOrdenados.length > 0
}
onChange={toggleSelectAll}
/>
</th>
)}
<th className="pb-3 font-medium">UUID</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">Total MXN</th>
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortPendientes(prev => prev?.field === 'fecha' ? { field: 'fecha', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'fecha', dir: 'asc' })}>
<span className="flex items-center justify-center gap-1">Fecha <ArrowUpDown className="h-3 w-3" /></span>
</th>
<th className="pb-3 font-medium"><FilterHeader label="RFC Emisor" filterKey="rfcEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
<th className="pb-3 font-medium"><FilterHeader label="Nombre Emisor" filterKey="nombreEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
<th className="pb-3 font-medium"><FilterHeader label="RFC Receptor" filterKey="rfcReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
<th className="pb-3 font-medium"><FilterHeader label="Nombre Receptor" filterKey="nombreReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortPendientes(prev => prev?.field === 'total' ? { field: 'total', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'total', dir: 'asc' })}>
<span className="flex items-center justify-center gap-1">Total MXN <ArrowUpDown className="h-3 w-3" /></span>
</th>
<th className="pb-3 font-medium">M. Pago</th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{pendientes.map((cfdi) => (
{pendientesOrdenados.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
{!isVisor && (
<td className="py-2">
@@ -256,25 +368,25 @@ export default function ConciliacionPage() {
/>
</td>
)}
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
<td className="py-2 font-mono text-xs text-center" title={cfdi.uuid}>
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-xs">
{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
<td className="py-2 text-xs text-center">
{toCfdiDate(cfdi.fechaPagoP || 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]">
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px] text-center">
{cfdi.nombreEmisor}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcReceptor}</td>
<td className="py-2 text-xs truncate max-w-[120px] text-center">
{cfdi.nombreReceptor}
</td>
<td className="py-2 text-right text-xs font-medium">
<td className="py-2 text-xs font-medium text-center">
{formatCurrencyConciliacion(getMonto(cfdi))}
</td>
<td className="py-2 text-xs">{cfdi.metodoPago || '-'}</td>
<td className="py-2">
<td className="py-2 text-xs text-center">{cfdi.metodoPago || '-'}</td>
<td className="py-2 text-center">
<Button
variant="ghost"
size="sm"
@@ -340,53 +452,57 @@ export default function ConciliacionPage() {
{/* Conciliadas */}
<Card>
<CardContent className="pt-6">
<h3 className="font-medium mb-4">Conciliadas ({conciliadas.length})</h3>
{conciliadas.length === 0 ? (
<h3 className="font-medium mb-4">Conciliadas ({conciliadasOrdenadas.length})</h3>
{conciliadasOrdenadas.length === 0 ? (
<p className="text-sm text-muted-foreground">No hay CFDIs conciliados</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<tr className="border-b text-center text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Fecha Emisión</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 text-right">Total MXN</th>
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortConciliadas(prev => prev?.field === 'fecha' ? { field: 'fecha', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'fecha', dir: 'asc' })}>
<span className="flex items-center justify-center gap-1">Fecha Emisión <ArrowUpDown className="h-3 w-3" /></span>
</th>
<th className="pb-3 font-medium"><FilterHeader label="RFC Emisor" filterKey="rfcEmisor" filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} /></th>
<th className="pb-3 font-medium"><FilterHeader label="Nombre Emisor" filterKey="nombreEmisor" filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} /></th>
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortConciliadas(prev => prev?.field === 'total' ? { field: 'total', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'total', dir: 'asc' })}>
<span className="flex items-center justify-center gap-1">Total MXN <ArrowUpDown className="h-3 w-3" /></span>
</th>
<th className="pb-3 font-medium">Fecha Pago</th>
<th className="pb-3 font-medium">Banco</th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{conciliadas.map((cfdi) => (
{conciliadasOrdenadas.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
<td className="py-2 font-mono text-xs text-center" title={cfdi.uuid}>
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-xs">
{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
<td className="py-2 text-xs text-center">
{toCfdiDate(cfdi.fechaPagoP || 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]">
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px] text-center">
{cfdi.nombreEmisor}
</td>
<td className="py-2 text-right text-xs font-medium">
<td className="py-2 text-xs font-medium text-center">
{formatCurrencyConciliacion(getMonto(cfdi))}
</td>
<td className="py-2 text-xs">
<td className="py-2 text-xs text-center">
{cfdi.conciliacion?.fechaDePago
? new Date(
cfdi.conciliacion.fechaDePago + 'T12:00:00',
(cfdi.conciliacion.fechaDePago.split('T')[0]) + 'T12:00:00',
).toLocaleDateString('es-MX')
: '-'}
</td>
<td className="py-2 text-xs">
<td className="py-2 text-xs text-center">
{cfdi.conciliacion
? `${cfdi.conciliacion.banco} ****${cfdi.conciliacion.terminacionCuenta}`
: '-'}
</td>
<td className="py-2 flex gap-1">
<td className="py-2 flex gap-1 justify-center">
<Button
variant="ghost"
size="sm"

View File

@@ -7,7 +7,7 @@ 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 { 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';
@@ -33,7 +33,7 @@ const EXCEL_COLUMNS = [
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
_montoPagoMxn: Number(c.montoPagoMxn || 0),
_ivaMxn: Number(c.ivaTrasladoMxn || 0),
@@ -69,7 +69,7 @@ export default function DrillDownPage() {
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total' | 'pago' | 'iva'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
pago: (c) => Number(c.montoPagoMxn || 0),
iva: (c) => Number(c.ivaTrasladoMxn || 0),
@@ -142,7 +142,7 @@ export default function DrillDownPage() {
<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 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>

View File

@@ -17,6 +17,7 @@ import type { InvoiceData, InvoiceLineItem, RfcSearchResult, CfdiPpdPendiente, C
import { searchRfcs, getCfdisPpd, searchConceptos, getCfdisRelacionables, downloadPdf, downloadXml } from '@/lib/api/facturacion';
import { Plus, Trash2, Send, Receipt, Search, Check, X, FileSearch, AlertTriangle } from 'lucide-react';
import { cn } from '@horux/shared-ui';
import { toCfdiDate } from '@/lib/utils';
interface TaxLine {
category: 'traslado' | 'retencion';
@@ -843,7 +844,7 @@ export default function FacturacionPage() {
<p className="text-xs text-muted-foreground mt-0.5">
{cp.tipoCfdi === 'EMITIDO' ? cp.nombreReceptor : cp.nombreEmisor}
{' · '}
{new Date(cp.fechaEmision).toLocaleDateString('es-MX')}
{toCfdiDate(cp.fechaEmision).toLocaleDateString('es-MX')}
</p>
</div>
<div className="text-right flex-shrink-0 ml-3">
@@ -1237,7 +1238,7 @@ export default function FacturacionPage() {
<span className="font-bold">${Number(c.saldoPendiente).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{new Date(c.fechaEmision).toLocaleDateString('es-MX')} · Total: ${Number(c.totalMxn).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
{toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX')} · Total: ${Number(c.totalMxn).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</div>
</button>
))}
@@ -1540,7 +1541,7 @@ export default function FacturacionPage() {
<span className="px-1.5 py-0.5 rounded bg-muted">{c.tipoComprobante}</span>
{c.serie || c.folio ? <span>{c.serie || ''}{c.folio ? `-${c.folio}` : ''}</span> : null}
<span>${Number(c.totalMxn).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
<span>{new Date(c.fechaEmision).toLocaleDateString('es-MX')}</span>
<span>{toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX')}</span>
</div>
</button>
))}

View File

@@ -9,7 +9,7 @@ import {
Button,
} from '@horux/shared-ui';
import { useEstadoResultadosDrillDown } from '@/lib/hooks/use-reportes';
import { formatCurrency } from '@/lib/utils';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { ArrowLeft, Download, Eye } from 'lucide-react';
import type { DrillDownResumenItem, DrillDownCfdiItem } from '@/lib/api/reportes';
@@ -92,7 +92,7 @@ export function EstadoResultadosDrillDownModal({
} else if (cfdis.length > 0) {
const rows = cfdis.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_monto: c.monto,
}));
exportToExcel(rows, CFDI_COLUMNS, `drill-down-${categoria}-cfdis`);
@@ -197,7 +197,7 @@ export function EstadoResultadosDrillDownModal({
</td>
<td className="py-3 text-sm font-mono">{item.tipoComprobante}</td>
<td className="py-3 text-sm">
{new Date(item.fechaEmision).toLocaleDateString('es-MX')}
{toCfdiDate(item.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-3 font-mono text-sm">{item.rfcEmisor}</td>
<td className="py-3 text-sm truncate max-w-[180px]">

View File

@@ -24,21 +24,27 @@ const formatCurrency = (value: number) =>
currency: 'MXN',
}).format(value);
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString('es-MX', {
const formatDate = (dateString: string) => {
const d = new Date(dateString);
d.setHours(d.getHours() - 1);
return d.toLocaleDateString('es-MX', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
};
const formatDateTime = (dateString: string) =>
new Date(dateString).toLocaleString('es-MX', {
const formatDateTime = (dateString: string) => {
const d = new Date(dateString);
d.setHours(d.getHours() - 1);
return d.toLocaleString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const typeLabels: Record<string, string> = {
EMITIDO: 'Emitido',

View File

@@ -14,7 +14,7 @@ import {
Wallet, Calendar, AlertTriangle, CheckCircle2, Trash2, RotateCcw,
Building2, TrendingUp, Clock, CircleSlash, Filter,
} from 'lucide-react';
import { formatCurrency } from '@/lib/utils';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
interface ActivoFijoItem {
cfdiId: number;
@@ -238,7 +238,7 @@ export function ActivosFijosTab({ año, mes }: { año: number; mes: number }) {
return (
<tr key={a.cfdiId} className="border-b hover:bg-muted/30">
<td className="px-3 py-2 whitespace-nowrap">
{new Date(a.fechaEmision).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })}
{toCfdiDate(a.fechaEmision).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })}
</td>
<td className="px-3 py-2">
<div className="font-mono text-xs">{a.rfcEmisor}</div>

View File

@@ -6,3 +6,11 @@ export function formatCurrency(value: number): string {
maximumFractionDigits: 0,
}).format(value);
}
/** Ajusta una fecha CFDI restando 1 hora para corregir el offset del SAT/Facturapi. */
export function toCfdiDate(dateString: string | null | undefined): Date {
if (!dateString) return new Date(0);
const d = new Date(dateString);
d.setHours(d.getHours() - 1);
return d;
}