Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/cfdi/page.tsx
Horux Dev 9f11a0ba39 feat: facturación primer pago, fixes SAT/MP, autocompletado RFCs/conceptos
Backend:
- Notificación email al admin cuando llega primer pago aprobado (sin factura auto)
- Endpoints GET /pagos-sin-factura y POST /emitir-factura-pago para admin global
- Fix vinculación org Facturapi Horux 360 (69f23a5a242e0af47a41fa0d)
- Fix webhook MP: validación defensiva de x-signature header
- Fix autocompleto RFCs: eliminado filtro por contribuyenteId
- Fix autocompleto conceptos: eliminado filtro por contribuyenteId
- SAT fixes: anti-bot CSF scraper, request reuse, date range fix, stale job thresholds
- SAT sync request reuse across jobs para evitar agotar cuota diaria
- Typo fix MP_ACCESS_TOKEN en .env
- Trial invitations system backend

Frontend:
- Nueva página /admin/facturas-pendientes con tabla y emisión manual
- Métrica 'Facturas pendientes' en /clientes (clickable)
- Navegación onboarding FIEL/CSD corregida
- Sidebar themes sincronizados
- Fix SAT portal migration scraper (NetIQ)
- Trial invitation acceptance pages
2026-05-09 21:56:42 +00:00

2212 lines
100 KiB
TypeScript

'use client';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useDebounce } from '@horux/shared-ui';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
import { createManyCfdis, searchEmisores, searchReceptores, getCfdis, getConceptosList, type EmisorReceptor } from '@/lib/api/cfdi';
import { cancelarFactura, downloadPdf } from '@/lib/api/facturacion';
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
import type { CreateCfdiData } from '@/lib/api/cfdi';
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer } from 'lucide-react';
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { getCfdiById } from '@/lib/api/cfdi';
import { useAuthStore } from '@/stores/auth-store';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { useQueryClient, useQuery } from '@tanstack/react-query';
// Upload progress state
interface UploadProgress {
status: 'idle' | 'parsing' | 'uploading' | 'complete' | 'error';
totalFiles: number;
parsedFiles: number;
validFiles: number;
currentBatch: number;
totalBatches: number;
uploaded: number;
duplicates: number;
errors: number;
errorMessages: string[];
}
type CfdiTipo = 'EMITIDO' | 'RECIBIDO';
const initialFormData: CreateCfdiData = {
uuid: '',
type: 'EMITIDO',
serie: '',
folio: '',
fechaEmision: new Date().toISOString().split('T')[0],
rfcEmisor: '',
nombreEmisor: '',
rfcReceptor: '',
nombreReceptor: '',
subtotal: 0,
descuento: 0,
ivaTraslado: 0,
isrRetencion: 0,
ivaRetencion: 0,
total: 0,
moneda: 'MXN',
metodoPago: 'PUE',
formaPago: '03',
usoCfdi: 'G03',
};
// Helper function to find element regardless of namespace prefix
function findElement(doc: Document, localName: string): Element | null {
// Try common prefixes first (most reliable for CFDI)
const prefixes = ['cfdi', 'tfd', 'pago20', 'pago10', 'nomina12', ''];
for (const prefix of prefixes) {
const tagName = prefix ? `${prefix}:${localName}` : localName;
const el = doc.getElementsByTagName(tagName)[0] as Element;
if (el) return el;
}
// Try with wildcard - search all elements by localName
const elements = doc.getElementsByTagName('*');
for (let i = 0; i < elements.length; i++) {
if (elements[i].localName === localName) {
return elements[i];
}
}
return null;
}
// Parse CFDI XML and extract data
function parseCfdiXml(xmlString: string, tenantRfc: string): CreateCfdiData | null {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, 'text/xml');
// Check for parse errors
const parseError = doc.querySelector('parsererror');
if (parseError) {
console.error('XML parse error:', parseError.textContent);
return null;
}
// Get the Comprobante element (root)
const comprobante = findElement(doc, 'Comprobante');
if (!comprobante) {
console.error('No se encontro elemento Comprobante');
return null;
}
// Get TimbreFiscalDigital for UUID
const timbre = findElement(doc, 'TimbreFiscalDigital');
const uuid = timbre?.getAttribute('UUID') || '';
const fechaTimbradoRaw = timbre?.getAttribute('FechaTimbrado') || '';
// Get Emisor
const emisor = findElement(doc, 'Emisor');
const rfcEmisor = emisor?.getAttribute('Rfc') || emisor?.getAttribute('rfc') || '';
const nombreEmisor = emisor?.getAttribute('Nombre') || emisor?.getAttribute('nombre') || '';
// Get Receptor
const receptor = findElement(doc, 'Receptor');
const rfcReceptor = receptor?.getAttribute('Rfc') || receptor?.getAttribute('rfc') || '';
const nombreReceptor = receptor?.getAttribute('Nombre') || receptor?.getAttribute('nombre') || '';
const usoCfdi = receptor?.getAttribute('UsoCFDI') || '';
// Determine type based on tenant RFC
// If tenant is emisor -> ingreso (we issued the invoice)
// If tenant is receptor -> egreso (we received the invoice)
const tenantRfcUpper = tenantRfc.toUpperCase();
let tipoFinal: CreateCfdiData['type'];
if (rfcEmisor.toUpperCase() === tenantRfcUpper) {
tipoFinal = 'EMITIDO';
} else {
tipoFinal = 'RECIBIDO';
}
// Get impuestos - search for the Impuestos element that is direct child of Comprobante
// (not the ones inside Conceptos)
let totalImpuestosTrasladados = 0;
let totalImpuestosRetenidos = 0;
// Try to get TotalImpuestosTrasladados from Comprobante's direct Impuestos child
const allImpuestos = doc.getElementsByTagName('*');
for (let i = 0; i < allImpuestos.length; i++) {
const el = allImpuestos[i];
if (el.localName === 'Impuestos' && el.parentElement?.localName === 'Comprobante') {
totalImpuestosTrasladados = parseFloat(el.getAttribute('TotalImpuestosTrasladados') || '0');
totalImpuestosRetenidos = parseFloat(el.getAttribute('TotalImpuestosRetenidos') || '0');
break;
}
}
// Fallback: calculate IVA from total - subtotal if not found
const subtotal = parseFloat(comprobante.getAttribute('SubTotal') || '0');
const descuento = parseFloat(comprobante.getAttribute('Descuento') || '0');
const total = parseFloat(comprobante.getAttribute('Total') || '0');
if (totalImpuestosTrasladados === 0 && total > subtotal) {
totalImpuestosTrasladados = Math.max(0, total - subtotal + descuento + totalImpuestosRetenidos);
}
// Get retenciones breakdown
let isrRetenido = 0;
let ivaRetenido = 0;
const retenciones = doc.querySelectorAll('[localName="Retencion"], Retencion, cfdi\\:Retencion');
retenciones.forEach((ret: Element) => {
const impuesto = ret.getAttribute('Impuesto');
const importe = parseFloat(ret.getAttribute('Importe') || '0');
if (impuesto === '001') isrRetenido = importe; // ISR
if (impuesto === '002') ivaRetenido = importe; // IVA
});
// Parse dates - handle both ISO format and datetime format
const fechaEmisionRaw = comprobante.getAttribute('Fecha') || '';
const fechaEmision = fechaEmisionRaw.includes('T') ? fechaEmisionRaw.split('T')[0] : fechaEmisionRaw;
const fechaTimbrado = fechaTimbradoRaw.includes('T') ? fechaTimbradoRaw.split('T')[0] : fechaTimbradoRaw;
// Validate required fields
if (!uuid) {
console.error('UUID no encontrado en el XML');
return null;
}
if (!rfcEmisor || !rfcReceptor) {
console.error('RFC emisor o receptor no encontrado');
return null;
}
if (!fechaEmision) {
console.error('Fecha de emision no encontrada');
return null;
}
return {
uuid: uuid.toUpperCase(),
type: tipoFinal,
serie: comprobante.getAttribute('Serie') || '',
folio: comprobante.getAttribute('Folio') || '',
fechaEmision,
rfcEmisor,
nombreEmisor: nombreEmisor || 'Sin nombre',
rfcReceptor,
nombreReceptor: nombreReceptor || 'Sin nombre',
subtotal,
descuento,
ivaTraslado: totalImpuestosTrasladados,
isrRetencion: isrRetenido,
ivaRetencion: ivaRetenido,
total,
moneda: comprobante.getAttribute('Moneda') || 'MXN',
tipoCambio: parseFloat(comprobante.getAttribute('TipoCambio') || '1'),
tipoComprobante: comprobante.getAttribute('TipoDeComprobante') || '',
metodoPago: comprobante.getAttribute('MetodoPago') || '',
formaPago: comprobante.getAttribute('FormaPago') || '',
usoCfdi,
};
} catch (error) {
console.error('Error parsing XML:', error);
return null;
}
}
const TIPO_COMPROBANTE_LABELS: Record<string, string> = {
I: 'Ingreso',
E: 'Egreso',
P: 'Pago',
T: 'Traslado',
N: 'Nómina',
};
function formatTipoComprobante(value: string | null | undefined): string {
if (!value) return '';
const upper = value.toUpperCase();
return TIPO_COMPROBANTE_LABELS[upper] ? `${upper} - ${TIPO_COMPROBANTE_LABELS[upper]}` : upper;
}
// Chunk size for batch uploads
const PARSE_CHUNK_SIZE = 500; // Parse 500 files at a time
const UPLOAD_CHUNK_SIZE = 200; // Upload 200 CFDIs per request
export default function CfdiPage() {
const { user } = useAuthStore();
const { viewingTenantRfc } = useTenantViewStore();
const { selectedContribuyenteId } = useContribuyenteStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
// Get the effective tenant RFC (viewing tenant or user's tenant)
const tenantRfc = viewingTenantRfc || user?.tenantRfc || '';
const [filters, setFilters] = useState<CfdiFilters>({
page: 1,
limit: 20,
});
const [searchTerm, setSearchTerm] = useState('');
const [columnFilters, setColumnFilters] = useState({
fechaInicio: '',
fechaFin: '',
emisor: '',
receptor: '',
});
// Reset pagination and filters when contribuyente changes
useEffect(() => {
setFilters({ page: 1, limit: 20 });
setSearchTerm('');
setColumnFilters({ fechaInicio: '', fechaFin: '', emisor: '', receptor: '' });
}, [selectedContribuyenteId]);
const [openFilter, setOpenFilter] = useState<'fecha' | 'emisor' | 'receptor' | null>(null);
const [emisorSuggestions, setEmisorSuggestions] = useState<EmisorReceptor[]>([]);
const [receptorSuggestions, setReceptorSuggestions] = useState<EmisorReceptor[]>([]);
const [loadingEmisor, setLoadingEmisor] = useState(false);
const [loadingReceptor, setLoadingReceptor] = useState(false);
const [showForm, setShowForm] = useState(false);
// Debounced values for autocomplete
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
const debouncedReceptor = useDebounce(columnFilters.receptor, 300);
// Fetch emisor suggestions when debounced value changes
useEffect(() => {
if (debouncedEmisor.length < 2) {
setEmisorSuggestions([]);
return;
}
setLoadingEmisor(true);
searchEmisores(debouncedEmisor)
.then(setEmisorSuggestions)
.catch(() => setEmisorSuggestions([]))
.finally(() => setLoadingEmisor(false));
}, [debouncedEmisor]);
// Fetch receptor suggestions when debounced value changes
useEffect(() => {
if (debouncedReceptor.length < 2) {
setReceptorSuggestions([]);
return;
}
setLoadingReceptor(true);
searchReceptores(debouncedReceptor)
.then(setReceptorSuggestions)
.catch(() => setReceptorSuggestions([]))
.finally(() => setLoadingReceptor(false));
}, [debouncedReceptor]);
const [showBulkForm, setShowBulkForm] = useState(false);
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
const [bulkData, setBulkData] = useState('');
const [uploadMode, setUploadMode] = useState<'xml' | 'json'>('xml');
const [jsonUploading, setJsonUploading] = useState(false);
// Optimized upload state
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({
status: 'idle',
totalFiles: 0,
parsedFiles: 0,
validFiles: 0,
currentBatch: 0,
totalBatches: 0,
uploaded: 0,
duplicates: 0,
errors: 0,
errorMessages: []
});
const [parsedCfdis, setParsedCfdis] = useState<CreateCfdiData[]>([]);
const uploadAbortRef = useRef(false);
const { data, isLoading } = useCfdis(filters);
// Pestañas: CFDIs (lista actual) | Conceptos (tabla cross-CFDI con conceptos).
// Conceptos hereda los mismos filtros aplicados a CFDIs + tiene filtros propios.
const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis');
// Filtros locales de la pestaña Conceptos (no compartidos con CFDIs).
// Popovers en headers UUID, Clave, Descripción + ordenamiento por importe.
const [conceptosFilters, setConceptosFilters] = useState<{
uuidLike: string;
claveProdServ: string;
descripcionConcepto: string;
orderBy?: 'fecha' | 'importe';
orderDir?: 'asc' | 'desc';
}>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '' });
const [conceptosOpenFilter, setConceptosOpenFilter] = useState<'uuid' | 'clave' | 'descripcion' | null>(null);
const conceptosQuery = useQuery({
queryKey: ['cfdi-conceptos', filters, selectedContribuyenteId, conceptosFilters],
queryFn: () => getConceptosList({
...filters,
contribuyenteId: selectedContribuyenteId || undefined,
uuidLike: conceptosFilters.uuidLike || undefined,
claveProdServ: conceptosFilters.claveProdServ || undefined,
descripcionConcepto: conceptosFilters.descripcionConcepto || undefined,
orderBy: conceptosFilters.orderBy,
orderDir: conceptosFilters.orderDir,
}),
enabled: activeTab === 'conceptos',
});
const toggleImporteSort = () => {
setConceptosFilters(prev => {
// null → asc → desc → null (o ciclo simple asc ↔ desc si prefieres)
const isImporte = prev.orderBy === 'importe';
if (!isImporte) return { ...prev, orderBy: 'importe', orderDir: 'desc' };
if (prev.orderDir === 'desc') return { ...prev, orderBy: 'importe', orderDir: 'asc' };
return { ...prev, orderBy: undefined, orderDir: undefined };
});
setFilters(f => ({ ...f, page: 1 }));
};
const createCfdi = useCreateCfdi();
const deleteCfdi = useDeleteCfdi();
// CFDI Viewer state
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
const [loadingCfdi, setLoadingCfdi] = useState<number | null>(null);
// Cancelación Facturapi state
const [cancelTarget, setCancelTarget] = useState<any | null>(null);
const [cancelMotive, setCancelMotive] = useState<'01' | '02' | '03' | '04'>('02');
const [cancelSubstitution, setCancelSubstitution] = useState('');
const [cancelling, setCancelling] = useState(false);
const handleViewCfdi = async (id: number) => {
setLoadingCfdi(id);
try {
const cfdi = await getCfdiById(String(id));
setViewingCfdi(cfdi);
} catch (error) {
console.error('Error loading CFDI:', error);
alert('Error al cargar el CFDI');
} finally {
setLoadingCfdi(null);
}
};
const canEdit = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'contador' || user?.role === 'auxiliar';
const handleSearch = () => {
setFilters({ ...filters, search: searchTerm, page: 1 });
};
// Export to Excel
const [exporting, setExporting] = useState(false);
const exportToExcel = async () => {
if (!data?.data.length) return;
setExporting(true);
try {
// Fetch TODOS los CFDIs que cumplen los filtros (no solo la página visible).
// Topamos a 10,000 filas — Excel maneja 1M, pero >10k es más reporte que
// exploración, conviene empujar al user a filtrar más fino.
// NOTA: el hook `useCfdis` inyecta contribuyenteId automáticamente; al
// bypassearlo aquí (fetch directo) hay que inyectarlo manualmente o el
// export trae CFDIs de TODO el despacho en lugar del contribuyente activo.
const EXPORT_MAX = 10_000;
const fullResponse = await getCfdis({
...filters,
contribuyenteId: selectedContribuyenteId || undefined,
page: 1,
limit: EXPORT_MAX,
});
const allRows = fullResponse.data;
if (fullResponse.total > EXPORT_MAX) {
const proceed = confirm(
`Hay ${fullResponse.total.toLocaleString('es-MX')} CFDIs que cumplen los filtros, ` +
`pero el export está topado a ${EXPORT_MAX.toLocaleString('es-MX')} filas (los más recientes). ` +
`Ajusta filtros para precisar. ¿Continuar con las primeras ${EXPORT_MAX.toLocaleString('es-MX')} filas?`
);
if (!proceed) { setExporting(false); return; }
}
const exportData = allRows.map(cfdi => ({
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
'Uso CFDI': (cfdi as any).usoCfdi || '',
'Serie': cfdi.serie || '',
'Folio': cfdi.folio || '',
'RFC Emisor': cfdi.rfcEmisor,
'Nombre Emisor': cfdi.nombreEmisor,
'RFC Receptor': cfdi.rfcReceptor,
'Nombre Receptor': cfdi.nombreReceptor,
'Subtotal': cfdi.subtotal,
'Descuento': cfdi.descuento || 0,
'IVA': cfdi.ivaTraslado,
'Total': cfdi.total,
'Moneda': cfdi.moneda,
'Método Pago': cfdi.metodoPago || '',
// Usamos `saldoPendienteMxn` (valor calculado por utils/saldo.ts) porque
// `saldoPendiente` (moneda original) no se backfilleó — todos NULL.
// PUE / P / E no tienen saldo conceptual → null en BD; lo dejamos
// 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')
: '',
'UUID': cfdi.uuid,
}));
const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'CFDIs');
// Auto-size columns
const colWidths = Object.keys(exportData[0]).map(key => ({
wch: Math.max(key.length, ...exportData.map(row => String(row[key as keyof typeof row]).length))
}));
ws['!cols'] = colWidths;
const excelBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const fileName = `cfdis_${new Date().toISOString().split('T')[0]}.xlsx`;
saveAs(blob, fileName);
} catch (error) {
console.error('Error exporting:', error);
alert('Error al exportar');
} finally {
setExporting(false);
}
};
// Export de la pestaña Conceptos: trae todos los conceptos que cumplen los
// filtros actuales, descartando todas las columnas que terminan en `_mxn`.
const exportConceptosToExcel = async () => {
setExporting(true);
try {
const EXPORT_MAX = 10_000;
const fullResponse = await getConceptosList({
...filters,
contribuyenteId: selectedContribuyenteId || undefined,
uuidLike: conceptosFilters.uuidLike || undefined,
claveProdServ: conceptosFilters.claveProdServ || undefined,
descripcionConcepto: conceptosFilters.descripcionConcepto || undefined,
orderBy: conceptosFilters.orderBy,
orderDir: conceptosFilters.orderDir,
page: 1,
limit: EXPORT_MAX,
});
const allRows = fullResponse.data;
if (!allRows.length) { alert('No hay conceptos que cumplan los filtros'); setExporting(false); return; }
if (fullResponse.total > EXPORT_MAX) {
const proceed = confirm(
`Hay ${fullResponse.total.toLocaleString('es-MX')} conceptos que cumplen los filtros, ` +
`pero el export está topado a ${EXPORT_MAX.toLocaleString('es-MX')} filas. ` +
`¿Continuar con las primeras ${EXPORT_MAX.toLocaleString('es-MX')}?`
);
if (!proceed) { setExporting(false); return; }
}
// Filtrar columnas: quitar todas las que terminan en _mxn (per requerimiento).
// También quitamos `id`/`cfdi_id` (internas, sin valor para el contador).
const exportData = allRows.map(row => {
const out: Record<string, any> = {};
for (const [key, val] of Object.entries(row)) {
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');
} else {
out[key] = val;
}
}
return out;
});
const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Conceptos');
const colWidths = Object.keys(exportData[0]).map(key => ({
wch: Math.max(key.length, ...exportData.map(row => String(row[key as keyof typeof row] ?? '').length))
}));
ws['!cols'] = colWidths;
const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
saveAs(blob, `cfdi_conceptos_${new Date().toISOString().split('T')[0]}.xlsx`);
} catch (error) {
console.error('Error exportando conceptos:', error);
alert('Error al exportar conceptos');
} finally {
setExporting(false);
}
};
const exportSingleCfdiToExcel = (cfdi: Cfdi) => {
const row = {
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
'Uso CFDI': (cfdi as any).usoCfdi || '',
'Serie': cfdi.serie || '',
'Folio': cfdi.folio || '',
'RFC Emisor': cfdi.rfcEmisor,
'Nombre Emisor': cfdi.nombreEmisor,
'RFC Receptor': cfdi.rfcReceptor,
'Nombre Receptor': cfdi.nombreReceptor,
'Subtotal': cfdi.subtotal,
'Descuento': cfdi.descuento || 0,
'IVA': cfdi.ivaTraslado,
'Total': cfdi.total,
'Moneda': cfdi.moneda,
'Método Pago': cfdi.metodoPago || '',
// Usamos `saldoPendienteMxn` (valor calculado por utils/saldo.ts) porque
// `saldoPendiente` (moneda original) no se backfilleó — todos NULL.
// PUE / P / E no tienen saldo conceptual → null en BD; lo dejamos
// 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')
: '',
'UUID': cfdi.uuid,
};
const ws = XLSX.utils.json_to_sheet([row]);
ws['!cols'] = Object.keys(row).map((key) => ({
wch: Math.max(key.length, String(row[key as keyof typeof row] ?? '').length),
}));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'CFDI');
const buffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const idParte = [cfdi.serie, cfdi.folio].filter(Boolean).join('-') || cfdi.uuid.slice(0, 8);
saveAs(blob, `cfdi_${idParte}.xlsx`);
};
const selectEmisor = (emisor: EmisorReceptor) => {
setColumnFilters(prev => ({ ...prev, emisor: emisor.nombre }));
setEmisorSuggestions([]);
};
const selectReceptor = (receptor: EmisorReceptor) => {
setColumnFilters(prev => ({ ...prev, receptor: receptor.nombre }));
setReceptorSuggestions([]);
};
const applyDateFilter = () => {
setFilters({
...filters,
fechaInicio: columnFilters.fechaInicio || undefined,
fechaFin: columnFilters.fechaFin || undefined,
page: 1,
});
setOpenFilter(null);
};
const applyEmisorFilter = () => {
setFilters({
...filters,
emisor: columnFilters.emisor || undefined,
page: 1,
});
setOpenFilter(null);
};
const applyReceptorFilter = () => {
setFilters({
...filters,
receptor: columnFilters.receptor || undefined,
page: 1,
});
setOpenFilter(null);
};
const clearDateFilter = () => {
setColumnFilters({ ...columnFilters, fechaInicio: '', fechaFin: '' });
setFilters({ ...filters, fechaInicio: undefined, fechaFin: undefined, page: 1 });
setOpenFilter(null);
};
const clearEmisorFilter = () => {
setColumnFilters({ ...columnFilters, emisor: '' });
setFilters({ ...filters, emisor: undefined, page: 1 });
setOpenFilter(null);
};
const clearReceptorFilter = () => {
setColumnFilters({ ...columnFilters, receptor: '' });
setFilters({ ...filters, receptor: undefined, page: 1 });
setOpenFilter(null);
};
const hasDateFilter = filters.fechaInicio || filters.fechaFin;
const hasEmisorFilter = filters.emisor;
const hasReceptorFilter = filters.receptor;
const hasActiveColumnFilters = hasDateFilter || hasEmisorFilter || hasReceptorFilter;
const handleFilterType = (tipo?: TipoCfdi) => {
setFilters({ ...filters, tipo, page: 1 });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createCfdi.mutateAsync(formData);
setFormData(initialFormData);
setShowForm(false);
} catch (error: any) {
alert(error.response?.data?.message || 'Error al crear CFDI');
}
};
const handleBulkSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setJsonUploading(true);
try {
const cfdis = JSON.parse(bulkData);
if (!Array.isArray(cfdis)) {
throw new Error('El formato debe ser un array de CFDIs');
}
const result = await createManyCfdis(cfdis);
alert(`Se crearon ${result.inserted} CFDIs exitosamente`);
setBulkData('');
setShowBulkForm(false);
queryClient.invalidateQueries({ queryKey: ['cfdis'] });
} catch (error: any) {
alert(error.message || 'Error al procesar CFDIs');
} finally {
setJsonUploading(false);
}
};
// Optimized: Parse files in chunks to prevent memory issues
const handleXmlFilesChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
uploadAbortRef.current = false;
setUploadProgress({
status: 'parsing',
totalFiles: files.length,
parsedFiles: 0,
validFiles: 0,
currentBatch: 0,
totalBatches: 0,
uploaded: 0,
duplicates: 0,
errors: 0,
errorMessages: []
});
setParsedCfdis([]);
const validCfdis: CreateCfdiData[] = [];
let parsedCount = 0;
let errorCount = 0;
// Process in chunks to prevent memory issues
for (let i = 0; i < files.length; i += PARSE_CHUNK_SIZE) {
if (uploadAbortRef.current) break;
const chunk = files.slice(i, i + PARSE_CHUNK_SIZE);
// Parse chunk in parallel
const results = await Promise.all(
chunk.map(async (file) => {
try {
const text = await file.text();
const data = parseCfdiXml(text, tenantRfc);
return data;
} catch {
return null;
}
})
);
// Collect valid results
results.forEach((data) => {
if (data && data.uuid) {
validCfdis.push(data);
} else {
errorCount++;
}
});
parsedCount += chunk.length;
setUploadProgress(prev => ({
...prev,
parsedFiles: parsedCount,
validFiles: validCfdis.length,
errors: errorCount
}));
// Small delay to allow UI to update
await new Promise(r => setTimeout(r, 10));
}
setParsedCfdis(validCfdis);
setUploadProgress(prev => ({
...prev,
status: uploadAbortRef.current ? 'idle' : 'idle',
totalBatches: Math.ceil(validCfdis.length / UPLOAD_CHUNK_SIZE)
}));
}, [tenantRfc]);
// Optimized: Upload in batches with progress
const handleXmlBulkSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (parsedCfdis.length === 0) {
alert('No hay CFDIs validos para cargar');
return;
}
uploadAbortRef.current = false;
const totalBatches = Math.ceil(parsedCfdis.length / UPLOAD_CHUNK_SIZE);
setUploadProgress(prev => ({
...prev,
status: 'uploading',
currentBatch: 0,
totalBatches,
uploaded: 0,
duplicates: 0,
errors: 0,
errorMessages: []
}));
let totalUploaded = 0;
let totalDuplicates = 0;
let totalErrors = 0;
const allErrors: string[] = [];
// Upload in batches
for (let i = 0; i < parsedCfdis.length; i += UPLOAD_CHUNK_SIZE) {
if (uploadAbortRef.current) break;
const batchNumber = Math.floor(i / UPLOAD_CHUNK_SIZE) + 1;
const chunk = parsedCfdis.slice(i, i + UPLOAD_CHUNK_SIZE);
setUploadProgress(prev => ({
...prev,
currentBatch: batchNumber
}));
try {
const result = await createManyCfdis(chunk, batchNumber, totalBatches, parsedCfdis.length);
totalUploaded += result.inserted;
totalDuplicates += result.duplicates;
totalErrors += result.errors;
if (result.errorMessages) {
allErrors.push(...result.errorMessages);
}
setUploadProgress(prev => ({
...prev,
uploaded: totalUploaded,
duplicates: totalDuplicates,
errors: prev.errors + result.errors,
errorMessages: allErrors.slice(0, 20) // Limit error messages
}));
} catch (error: any) {
console.error(`Error en lote ${batchNumber}:`, error);
totalErrors += chunk.length;
allErrors.push(`Lote ${batchNumber}: ${error.message || 'Error desconocido'}`);
setUploadProgress(prev => ({
...prev,
errors: prev.errors + chunk.length,
errorMessages: allErrors.slice(0, 20)
}));
}
// Small delay between batches
await new Promise(r => setTimeout(r, 100));
}
setUploadProgress(prev => ({
...prev,
status: 'complete'
}));
// Invalidate queries to refresh the list
queryClient.invalidateQueries({ queryKey: ['cfdis'] });
};
const clearXmlFiles = () => {
uploadAbortRef.current = true;
setParsedCfdis([]);
setUploadProgress({
status: 'idle',
totalFiles: 0,
parsedFiles: 0,
validFiles: 0,
currentBatch: 0,
totalBatches: 0,
uploaded: 0,
duplicates: 0,
errors: 0,
errorMessages: []
});
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Keyboard shortcuts - Esc to close popovers and forms
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
// Close open filter popovers
if (openFilter !== null) {
setOpenFilter(null);
return;
}
// Close forms
if (showForm) {
setShowForm(false);
return;
}
if (showBulkForm) {
setShowBulkForm(false);
clearXmlFiles();
return;
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [openFilter, showForm, showBulkForm]);
const cancelUpload = () => {
uploadAbortRef.current = true;
setUploadProgress(prev => ({ ...prev, status: 'idle' }));
};
const handleDelete = async (id: number) => {
if (confirm('¿Eliminar este CFDI?')) {
try {
await deleteCfdi.mutateAsync(String(id));
} catch (error) {
console.error('Error deleting CFDI:', error);
}
}
};
const openCancelDialog = (cfdi: any) => {
setCancelTarget(cfdi);
setCancelMotive('02');
setCancelSubstitution('');
};
const handleCancelFactura = async () => {
if (!cancelTarget) return;
if (cancelMotive === '01' && cancelSubstitution.trim().length !== 36) {
alert('El motivo 01 requiere el UUID completo (36 caracteres) de la factura que sustituye a esta.');
return;
}
setCancelling(true);
try {
await cancelarFactura(cancelTarget.uuid, cancelMotive, cancelMotive === '01' ? cancelSubstitution.trim() : undefined);
await queryClient.invalidateQueries({ queryKey: ['cfdis'] });
setCancelTarget(null);
alert('Factura cancelada. El estatus final depende del SAT (puede quedar en "pendiente" si requiere aceptación del receptor).');
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cancelar la factura');
} finally {
setCancelling(false);
}
};
const calculateTotal = () => {
const subtotal = formData.subtotal || 0;
const descuento = formData.descuento || 0;
const iva = formData.ivaTraslado || 0;
const isrRetencion = formData.isrRetencion || 0;
const ivaRetencion = formData.ivaRetencion || 0;
return subtotal - descuento + iva - isrRetencion - ivaRetencion;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
const generateUUID = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
}).toUpperCase();
};
return (
<>
<Header title="Gestion de CFDI" />
<main className="p-6 space-y-6">
{/* Filters */}
<Card>
<CardContent className="p-4">
<div className="flex flex-wrap gap-4">
<div className="flex gap-2 w-[300px]">
<Input
placeholder="Buscar por UUID, RFC o nombre..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Button onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-2">
<Button
variant={filters.tipo === undefined ? 'default' : 'outline'}
size="sm"
onClick={() => handleFilterType(undefined)}
>
Todos
</Button>
<Button
variant={filters.tipo === 'EMITIDO' ? 'default' : 'outline'}
size="sm"
onClick={() => handleFilterType('EMITIDO')}
>
Emitidos
</Button>
<Button
variant={filters.tipo === 'RECIBIDO' ? 'default' : 'outline'}
size="sm"
onClick={() => handleFilterType('RECIBIDO')}
>
Recibidos
</Button>
</div>
<div className="flex items-center gap-2">
<Select
value={filters.tipoComprobante ?? 'ALL'}
onValueChange={(value) =>
setFilters({
...filters,
tipoComprobante: value === 'ALL' ? undefined : (value as 'I' | 'E' | 'P' | 'T' | 'N'),
page: 1,
})
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Tipo de Comprobante" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Todos los comprobantes</SelectItem>
<SelectItem value="I">I - Ingreso</SelectItem>
<SelectItem value="E">E - Egreso</SelectItem>
<SelectItem value="P">P - Pago</SelectItem>
<SelectItem value="T">T - Traslado</SelectItem>
<SelectItem value="N">N - Nómina</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={activeTab === 'conceptos' ? exportConceptosToExcel : exportToExcel}
disabled={exporting || (activeTab === 'cfdis' && !data?.data.length)}
>
{exporting ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
Exportar
</Button>
{canEdit && (
<>
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
<Plus className="h-4 w-4 mr-1" />
Agregar
</Button>
<Button variant="outline" onClick={() => { setShowBulkForm(true); setShowForm(false); }}>
<Upload className="h-4 w-4 mr-1" />
Carga Masiva
</Button>
</>
)}
</div>
</div>
</CardContent>
</Card>
{/* Add CFDI Form */}
{showForm && canEdit && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Agregar CFDI</CardTitle>
<CardDescription>Ingresa los datos del comprobante fiscal</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={() => setShowForm(false)}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>UUID Fiscal</Label>
<div className="flex gap-2">
<Input
value={formData.uuid}
onChange={(e) => setFormData({ ...formData, uuid: e.target.value.toUpperCase() })}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
required
/>
<Button type="button" variant="outline" onClick={() => setFormData({ ...formData, uuid: generateUUID() })}>
Gen
</Button>
</div>
</div>
<div className="space-y-2">
<Label>Tipo</Label>
<Select
value={formData.type}
onValueChange={(v) => setFormData({ ...formData, type: v as CfdiTipo })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EMITIDO">Emitido</SelectItem>
<SelectItem value="RECIBIDO">Recibido</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label>Serie</Label>
<Input
value={formData.serie}
onChange={(e) => setFormData({ ...formData, serie: e.target.value })}
placeholder="A"
/>
</div>
<div className="space-y-2">
<Label>Folio</Label>
<Input
value={formData.folio}
onChange={(e) => setFormData({ ...formData, folio: e.target.value })}
placeholder="001"
/>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Fecha Emision</Label>
<Input
type="date"
value={formData.fechaEmision}
onChange={(e) => setFormData({ ...formData, fechaEmision: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label>Moneda</Label>
<Input
value={formData.moneda}
onChange={(e) => setFormData({ ...formData, moneda: e.target.value.toUpperCase() })}
placeholder="MXN"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="p-4 border rounded-lg space-y-3">
<h4 className="font-medium">Emisor</h4>
<div className="space-y-2">
<Label>RFC Emisor</Label>
<Input
value={formData.rfcEmisor}
onChange={(e) => setFormData({ ...formData, rfcEmisor: e.target.value.toUpperCase() })}
placeholder="XAXX010101000"
maxLength={13}
required
/>
</div>
<div className="space-y-2">
<Label>Nombre Emisor</Label>
<Input
value={formData.nombreEmisor}
onChange={(e) => setFormData({ ...formData, nombreEmisor: e.target.value })}
placeholder="Empresa Emisora SA de CV"
required
/>
</div>
</div>
<div className="p-4 border rounded-lg space-y-3">
<h4 className="font-medium">Receptor</h4>
<div className="space-y-2">
<Label>RFC Receptor</Label>
<Input
value={formData.rfcReceptor}
onChange={(e) => setFormData({ ...formData, rfcReceptor: e.target.value.toUpperCase() })}
placeholder="XAXX010101000"
maxLength={13}
required
/>
</div>
<div className="space-y-2">
<Label>Nombre Receptor</Label>
<Input
value={formData.nombreReceptor}
onChange={(e) => setFormData({ ...formData, nombreReceptor: e.target.value })}
placeholder="Empresa Receptora SA de CV"
required
/>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-6">
<div className="space-y-2">
<Label>Subtotal</Label>
<Input
type="number"
step="0.01"
value={formData.subtotal}
onChange={(e) => setFormData({ ...formData, subtotal: parseFloat(e.target.value) || 0 })}
required
/>
</div>
<div className="space-y-2">
<Label>Descuento</Label>
<Input
type="number"
step="0.01"
value={formData.descuento}
onChange={(e) => setFormData({ ...formData, descuento: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label>IVA</Label>
<Input
type="number"
step="0.01"
value={formData.ivaTraslado}
onChange={(e) => setFormData({ ...formData, ivaTraslado: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label>ISR Ret.</Label>
<Input
type="number"
step="0.01"
value={formData.isrRetencion}
onChange={(e) => setFormData({ ...formData, isrRetencion: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label>IVA Ret.</Label>
<Input
type="number"
step="0.01"
value={formData.ivaRetencion}
onChange={(e) => setFormData({ ...formData, ivaRetencion: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label>Total</Label>
<Input
type="number"
step="0.01"
value={formData.total || calculateTotal()}
onChange={(e) => setFormData({ ...formData, total: parseFloat(e.target.value) || 0 })}
required
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>
Cancelar
</Button>
<Button type="submit" disabled={createCfdi.isPending}>
{createCfdi.isPending ? 'Guardando...' : 'Guardar CFDI'}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Bulk Upload Form */}
{showBulkForm && canEdit && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Carga Masiva de CFDIs</CardTitle>
<CardDescription>Sube archivos XML o pega datos en formato JSON</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Mode selector */}
<div className="flex gap-2 mb-4">
<Button
type="button"
variant={uploadMode === 'xml' ? 'default' : 'outline'}
size="sm"
onClick={() => setUploadMode('xml')}
>
<FileUp className="h-4 w-4 mr-1" />
Subir XMLs
</Button>
<Button
type="button"
variant={uploadMode === 'json' ? 'default' : 'outline'}
size="sm"
onClick={() => setUploadMode('json')}
>
JSON
</Button>
</div>
{uploadMode === 'xml' ? (
<form onSubmit={handleXmlBulkSubmit} className="space-y-4">
{/* File input - only show when idle */}
{uploadProgress.status === 'idle' && (
<div className="space-y-2">
<Label>Archivos XML de CFDI</Label>
<div className="border-2 border-dashed rounded-lg p-6 text-center">
<input
ref={fileInputRef}
type="file"
accept=".xml"
multiple
onChange={handleXmlFilesChange}
className="hidden"
id="xml-upload"
/>
<label htmlFor="xml-upload" className="cursor-pointer">
<FileUp className="h-10 w-10 mx-auto text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
Haz clic para seleccionar archivos XML
</p>
<p className="text-xs text-muted-foreground mt-1">
Optimizado para cargas masivas (+100,000 archivos)
</p>
</label>
</div>
</div>
)}
{/* Parsing progress */}
{uploadProgress.status === 'parsing' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<div className="flex-1">
<p className="font-medium">Analizando archivos XML...</p>
<p className="text-sm text-muted-foreground">
{uploadProgress.parsedFiles.toLocaleString()} de {uploadProgress.totalFiles.toLocaleString()} archivos
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={cancelUpload}>
Cancelar
</Button>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${(uploadProgress.parsedFiles / uploadProgress.totalFiles) * 100}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{uploadProgress.validFiles.toLocaleString()} validos</span>
<span>{Math.round((uploadProgress.parsedFiles / uploadProgress.totalFiles) * 100)}%</span>
</div>
</div>
)}
{/* Upload progress */}
{uploadProgress.status === 'uploading' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<div className="flex-1">
<p className="font-medium">Subiendo CFDIs al servidor...</p>
<p className="text-sm text-muted-foreground">
Lote {uploadProgress.currentBatch} de {uploadProgress.totalBatches}
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={cancelUpload}>
Cancelar
</Button>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${(uploadProgress.currentBatch / uploadProgress.totalBatches) * 100}%` }}
/>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-3 bg-success/10 rounded-lg">
<p className="text-2xl font-bold text-success">{uploadProgress.uploaded.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Cargados</p>
</div>
<div className="p-3 bg-warning/10 rounded-lg">
<p className="text-2xl font-bold text-warning">{uploadProgress.duplicates.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Duplicados</p>
</div>
<div className="p-3 bg-destructive/10 rounded-lg">
<p className="text-2xl font-bold text-destructive">{uploadProgress.errors.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Errores</p>
</div>
</div>
</div>
)}
{/* Upload complete */}
{uploadProgress.status === 'complete' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<CheckCircle className="h-6 w-6 text-success" />
<div className="flex-1">
<p className="font-medium text-success">Carga completada</p>
<p className="text-sm text-muted-foreground">
Se procesaron {uploadProgress.validFiles.toLocaleString()} archivos
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-3 bg-success/10 rounded-lg">
<p className="text-2xl font-bold text-success">{uploadProgress.uploaded.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Cargados</p>
</div>
<div className="p-3 bg-warning/10 rounded-lg">
<p className="text-2xl font-bold text-warning">{uploadProgress.duplicates.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Duplicados</p>
</div>
<div className="p-3 bg-destructive/10 rounded-lg">
<p className="text-2xl font-bold text-destructive">{uploadProgress.errors.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Errores</p>
</div>
</div>
{uploadProgress.errorMessages.length > 0 && (
<div className="p-3 bg-destructive/10 rounded-lg">
<p className="text-sm font-medium text-destructive mb-2">Errores:</p>
<ul className="text-xs text-destructive space-y-1 max-h-24 overflow-y-auto">
{uploadProgress.errorMessages.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={clearXmlFiles}>
Cargar mas archivos
</Button>
<Button type="button" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
Cerrar
</Button>
</div>
</div>
)}
{/* Ready to upload - show summary and upload button */}
{uploadProgress.status === 'idle' && parsedCfdis.length > 0 && (
<div className="space-y-4">
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{parsedCfdis.length.toLocaleString()} CFDIs listos para cargar</p>
<p className="text-sm text-muted-foreground">
Se enviaran en {Math.ceil(parsedCfdis.length / UPLOAD_CHUNK_SIZE)} lotes de {UPLOAD_CHUNK_SIZE} registros
</p>
</div>
<Button type="button" variant="ghost" size="sm" onClick={clearXmlFiles}>
Limpiar
</Button>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
Cancelar
</Button>
<Button type="submit">
<Upload className="h-4 w-4 mr-2" />
Iniciar carga
</Button>
</div>
</div>
)}
{/* Initial state - no files */}
{uploadProgress.status === 'idle' && parsedCfdis.length === 0 && uploadProgress.totalFiles === 0 && (
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowBulkForm(false)}>
Cancelar
</Button>
</div>
)}
</form>
) : (
<form onSubmit={handleBulkSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Datos JSON</Label>
<textarea
className="w-full h-48 p-3 border rounded-lg font-mono text-sm bg-background"
value={bulkData}
onChange={(e) => setBulkData(e.target.value)}
placeholder={`[
{
"uuid": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"type": "ingreso",
"fechaEmision": "2025-01-15",
"rfcEmisor": "XAXX010101000",
"nombreEmisor": "Empresa SA de CV",
"rfcReceptor": "XAXX010101001",
"nombreReceptor": "Cliente SA de CV",
"subtotal": 10000,
"ivaTraslado": 1600,
"total": 11600
}
]`}
required
/>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowBulkForm(false)}>
Cancelar
</Button>
<Button type="submit" disabled={jsonUploading}>
{jsonUploading ? 'Procesando...' : 'Cargar CFDIs'}
</Button>
</div>
</form>
)}
</CardContent>
</Card>
)}
{/* Pestañas CFDIs / Conceptos */}
<div className="flex gap-2 border-b">
<button
type="button"
onClick={() => { setActiveTab('cfdis'); setFilters({ ...filters, page: 1 }); }}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'cfdis'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
CFDIs {activeTab === 'cfdis' && data?.total ? `(${data.total.toLocaleString('es-MX')})` : ''}
</button>
<button
type="button"
onClick={() => { setActiveTab('conceptos'); setFilters({ ...filters, page: 1 }); }}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'conceptos'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Conceptos {activeTab === 'conceptos' && conceptosQuery.data?.total ? `(${conceptosQuery.data.total.toLocaleString('es-MX')})` : ''}
</button>
</div>
{/* Tabla Conceptos */}
{activeTab === 'conceptos' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
Conceptos ({conceptosQuery.data?.total?.toLocaleString('es-MX') || 0})
</CardTitle>
</CardHeader>
<CardContent>
{conceptosQuery.isLoading ? (
<div className="py-8 text-center text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
Cargando conceptos
</div>
) : !conceptosQuery.data?.data?.length ? (
<div className="py-8 text-center text-muted-foreground">No hay conceptos que cumplan los filtros.</div>
) : (<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-center text-sm text-muted-foreground">
<th className="pb-3 font-medium">
<div className="flex items-center gap-1 justify-center">
Fecha
<Popover open={openFilter === 'fecha'} onOpenChange={(open) => setOpenFilter(open ? 'fecha' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasDateFilter ? '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 fecha</h4>
<div className="space-y-2">
<div>
<Label className="text-xs">Desde</Label>
<Input type="date" className="h-8 text-sm" value={columnFilters.fechaInicio} onChange={(e) => setColumnFilters({ ...columnFilters, fechaInicio: e.target.value })} />
</div>
<div>
<Label className="text-xs">Hasta</Label>
<Input type="date" className="h-8 text-sm" value={columnFilters.fechaFin} onChange={(e) => setColumnFilters({ ...columnFilters, fechaFin: e.target.value })} />
</div>
</div>
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyDateFilter}>Aplicar</Button>
{hasDateFilter && <Button size="sm" variant="outline" onClick={clearDateFilter}>Limpiar</Button>}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1 justify-center">
UUID
<Popover open={conceptosOpenFilter === 'uuid'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'uuid' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.uuidLike ? '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 UUID</h4>
<Input className="h-8 text-sm font-mono" placeholder="Fragmento del UUID..." value={conceptosFilters.uuidLike} onChange={(e) => setConceptosFilters({ ...conceptosFilters, uuidLike: e.target.value })} />
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button>
{conceptosFilters.uuidLike && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, uuidLike: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1 justify-center">
Clave
<Popover open={conceptosOpenFilter === 'clave'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'clave' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.claveProdServ ? '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 Clave SAT</h4>
<Input className="h-8 text-sm font-mono" placeholder="Ej: 81112502" value={conceptosFilters.claveProdServ} onChange={(e) => setConceptosFilters({ ...conceptosFilters, claveProdServ: e.target.value })} />
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button>
{conceptosFilters.claveProdServ && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, claveProdServ: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium text-left">
<div className="flex items-center gap-1">
Descripción
<Popover open={conceptosOpenFilter === 'descripcion'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'descripcion' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.descripcionConcepto ? 'text-primary' : ''}`}>
<Filter className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72" align="start">
<div className="space-y-3">
<h4 className="font-medium text-sm">Filtrar por descripción</h4>
<Input className="h-8 text-sm" placeholder="Texto contenido en la descripción..." value={conceptosFilters.descripcionConcepto} onChange={(e) => setConceptosFilters({ ...conceptosFilters, descripcionConcepto: e.target.value })} />
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button>
{conceptosFilters.descripcionConcepto && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, descripcionConcepto: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<th className="pb-3 font-medium text-right">Cantidad</th>
<th className="pb-3 font-medium">Unidad</th>
<th className="pb-3 font-medium text-right">V. Unitario</th>
<th className="pb-3 font-medium text-right">
<button
type="button"
onClick={toggleImporteSort}
className="inline-flex items-center gap-1 hover:text-foreground"
title="Ordenar por importe"
>
Importe
{conceptosFilters.orderBy === 'importe' ? (
<span className="text-primary">{conceptosFilters.orderDir === 'asc' ? '▲' : '▼'}</span>
) : (
<span className="text-muted-foreground/40"></span>
)}
</button>
</th>
</tr>
</thead>
<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 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>
<td className="py-2 font-mono text-xs">{row.rfcEmisor}</td>
<td className="py-2 font-mono text-xs">{row.rfcReceptor}</td>
<td className="py-2 text-right">{Number(row.cantidad ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
<td className="py-2 text-xs" title={row.unidad || ''}>{row.clave_unidad || '-'}</td>
<td className="py-2 text-right">${Number(row.valor_unitario ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
<td className="py-2 text-right font-medium">${Number(row.importe ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination Conceptos */}
{conceptosQuery.data && conceptosQuery.data.totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<p className="text-sm text-muted-foreground">
Pagina {conceptosQuery.data.page} de {conceptosQuery.data.totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={conceptosQuery.data.page <= 1}
onClick={() => setFilters({ ...filters, page: (filters.page || 1) - 1 })}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={conceptosQuery.data.page >= conceptosQuery.data.totalPages}
onClick={() => setFilters({ ...filters, page: (filters.page || 1) + 1 })}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>)}
</CardContent>
</Card>
)}
{/* Tabla CFDIs */}
{activeTab === 'cfdis' && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
CFDIs ({data?.total || 0})
</CardTitle>
{hasActiveColumnFilters && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Filtros activos:</span>
{hasDateFilter && (
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-full flex items-center gap-1">
Fecha
<button onClick={clearDateFilter} className="hover:text-destructive">
<X className="h-3 w-3" />
</button>
</span>
)}
{hasEmisorFilter && (
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-full flex items-center gap-1">
Emisor: {filters.emisor}
<button onClick={clearEmisorFilter} className="hover:text-destructive">
<X className="h-3 w-3" />
</button>
</span>
)}
{hasReceptorFilter && (
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-full flex items-center gap-1">
Receptor: {filters.receptor}
<button onClick={clearReceptorFilter} className="hover:text-destructive">
<X className="h-3 w-3" />
</button>
</span>
)}
</div>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{/* Skeleton loader */}
{[...Array(8)].map((_, i) => (
<div key={i} className="flex items-center gap-4 animate-pulse">
<div className="h-4 bg-muted rounded w-20"></div>
<div className="h-5 bg-muted rounded w-16"></div>
<div className="h-4 bg-muted rounded w-12"></div>
<div className="h-4 bg-muted rounded flex-1 max-w-[180px]"></div>
<div className="h-4 bg-muted rounded flex-1 max-w-[180px]"></div>
<div className="h-4 bg-muted rounded w-24 ml-auto"></div>
<div className="h-5 bg-muted rounded w-16"></div>
<div className="h-8 bg-muted rounded w-8"></div>
</div>
))}
</div>
) : data?.data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No se encontraron CFDIs
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-center text-sm text-muted-foreground">
<th className="pb-3 font-medium">
<div className="flex items-center gap-1 justify-center">
Fecha
<Popover open={openFilter === 'fecha'} onOpenChange={(open) => setOpenFilter(open ? 'fecha' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasDateFilter ? '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 fecha</h4>
<div className="space-y-2">
<div>
<Label className="text-xs">Desde</Label>
<Input
type="date"
className="h-8 text-sm"
value={columnFilters.fechaInicio}
onChange={(e) => setColumnFilters({ ...columnFilters, fechaInicio: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">Hasta</Label>
<Input
type="date"
className="h-8 text-sm"
value={columnFilters.fechaFin}
onChange={(e) => setColumnFilters({ ...columnFilters, fechaFin: e.target.value })}
/>
</div>
</div>
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyDateFilter}>
Aplicar
</Button>
{hasDateFilter && (
<Button size="sm" variant="outline" onClick={clearDateFilter}>
Limpiar
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">Tipo Comp.</th>
<th className="pb-3 font-medium">Uso CFDI</th>
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1 justify-center">
Emisor
<Popover open={openFilter === 'emisor'} onOpenChange={(open) => setOpenFilter(open ? 'emisor' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasEmisorFilter ? 'text-primary' : ''}`}>
<Filter className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72" align="start">
<div className="space-y-3">
<h4 className="font-medium text-sm">Filtrar por emisor</h4>
<div className="relative">
<Label className="text-xs">RFC o Nombre</Label>
<Input
placeholder="Buscar emisor..."
className="h-8 text-sm"
value={columnFilters.emisor}
onChange={(e) => setColumnFilters(prev => ({ ...prev, emisor: e.target.value }))}
onKeyDown={(e) => e.key === 'Enter' && applyEmisorFilter()}
/>
{emisorSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
{emisorSuggestions.map((emisor, idx) => (
<button
key={idx}
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors border-b last:border-b-0"
onClick={() => selectEmisor(emisor)}
>
<p className="font-medium truncate">{emisor.nombre}</p>
<p className="text-xs text-muted-foreground">{emisor.rfc}</p>
</button>
))}
</div>
)}
{loadingEmisor && columnFilters.emisor.length >= 2 && emisorSuggestions.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg p-2 text-center text-sm text-muted-foreground">
Buscando...
</div>
)}
</div>
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyEmisorFilter}>
Aplicar
</Button>
{hasEmisorFilter && (
<Button size="sm" variant="outline" onClick={clearEmisorFilter}>
Limpiar
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1 justify-center">
Receptor
<Popover open={openFilter === 'receptor'} onOpenChange={(open) => setOpenFilter(open ? 'receptor' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasReceptorFilter ? 'text-primary' : ''}`}>
<Filter className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72" align="start">
<div className="space-y-3">
<h4 className="font-medium text-sm">Filtrar por receptor</h4>
<div className="relative">
<Label className="text-xs">RFC o Nombre</Label>
<Input
placeholder="Buscar receptor..."
className="h-8 text-sm"
value={columnFilters.receptor}
onChange={(e) => setColumnFilters(prev => ({ ...prev, receptor: e.target.value }))}
onKeyDown={(e) => e.key === 'Enter' && applyReceptorFilter()}
/>
{receptorSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
{receptorSuggestions.map((receptor, idx) => (
<button
key={idx}
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors border-b last:border-b-0"
onClick={() => selectReceptor(receptor)}
>
<p className="font-medium truncate">{receptor.nombre}</p>
<p className="text-xs text-muted-foreground">{receptor.rfc}</p>
</button>
))}
</div>
)}
{loadingReceptor && columnFilters.receptor.length >= 2 && receptorSuggestions.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg p-2 text-center text-sm text-muted-foreground">
Buscando...
</div>
)}
</div>
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyReceptorFilter}>
Aplicar
</Button>
{hasReceptorFilter && (
<Button size="sm" variant="outline" onClick={clearReceptorFilter}>
Limpiar
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium text-right">Total</th>
<th className="pb-3 font-medium">Estado</th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
{canEdit && <th className="pb-3 font-medium"></th>}
</tr>
</thead>
<tbody className="text-sm text-center">
{data?.data.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3">{formatDate(cfdi.fechaEmision)}</td>
<td className="py-3">
<span className="text-xs" title={formatTipoComprobante(cfdi.tipoComprobante)}>
{cfdi.tipoComprobante ? cfdi.tipoComprobante.toUpperCase() : '-'}
</span>
</td>
<td className="py-3">
<span className="font-mono text-xs text-muted-foreground" title={(cfdi as any).usoCfdi || ''}>
{(cfdi as any).usoCfdi || '-'}
</span>
</td>
<td className="py-3">
<span className="font-mono text-xs" title={cfdi.uuid}>{cfdi.uuid?.substring(0, 8) || '-'}</span>
</td>
<td className="py-3">
<div>
<p className="font-medium truncate max-w-[180px]" title={cfdi.nombreEmisor}>
{cfdi.nombreEmisor}
</p>
<p className="text-xs text-muted-foreground">
{cfdi.rfcEmisor}
</p>
</div>
</td>
<td className="py-3">
<div>
<p className="font-medium truncate max-w-[180px]" title={cfdi.nombreReceptor}>
{cfdi.nombreReceptor}
</p>
<p className="text-xs text-muted-foreground">
{cfdi.rfcReceptor}
</p>
</div>
</td>
<td className="py-3 text-right font-medium">
{formatCurrency(cfdi.total)}
</td>
<td className="py-3">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
cfdi.status === 'Vigente' || cfdi.status === '1'
? 'bg-success/10 text-success'
: 'bg-muted text-muted-foreground'
}`}
>
{cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado'}
</span>
</td>
<td className="py-3">
<Button
variant="ghost"
size="icon"
onClick={() => handleViewCfdi(cfdi.id)}
disabled={loadingCfdi === cfdi.id}
title="Ver factura"
>
{loadingCfdi === cfdi.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</td>
<td className="py-3">
<Button
variant="ghost"
size="icon"
onClick={() => exportSingleCfdiToExcel(cfdi)}
title="Descargar Excel"
>
<Download className="h-4 w-4" />
</Button>
</td>
<td className="py-3">
{(cfdi as any).source === 'facturapi' && (cfdi as any).facturapiId && (
<Button
variant="ghost"
size="icon"
title="Descargar PDF (formato Facturapi)"
onClick={async () => {
try {
const blob = await downloadPdf((cfdi as any).facturapiId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `factura-${cfdi.uuid || (cfdi as any).facturapiId}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al descargar PDF');
}
}}
>
<Printer className="h-4 w-4" />
</Button>
)}
</td>
{canEdit && (
<td className="py-3">
<div className="flex items-center gap-0">
{(cfdi as any).source === 'facturapi' && (cfdi.status === 'Vigente' || cfdi.status === '1') && (
<Button
variant="ghost"
size="icon"
onClick={() => openCancelDialog(cfdi)}
className="text-destructive hover:text-destructive"
title="Cancelar factura en Facturapi/SAT"
>
<XCircle className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(cfdi.id)}
className="text-destructive hover:text-destructive"
title="Eliminar registro (solo local)"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<p className="text-sm text-muted-foreground">
Pagina {data.page} de {data.totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onClick={() =>
setFilters({ ...filters, page: (filters.page || 1) - 1 })
}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={data.page >= data.totalPages}
onClick={() =>
setFilters({ ...filters, page: (filters.page || 1) + 1 })
}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
</main>
<CfdiViewerModal
cfdi={viewingCfdi}
open={viewingCfdi !== null}
onClose={() => setViewingCfdi(null)}
/>
{/* Cancelación de factura Facturapi */}
<Dialog open={!!cancelTarget} onOpenChange={(open) => !open && setCancelTarget(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Cancelar factura en el SAT</DialogTitle>
<DialogDescription>
Esta acción solicita la cancelación del CFDI ante el SAT vía Facturapi. Dependiendo del motivo y la antigüedad de la factura, podría requerir aceptación del receptor (proceso que toma hasta 72h).
</DialogDescription>
</DialogHeader>
{cancelTarget && (
<div className="space-y-4 py-2">
<div className="rounded border p-3 bg-muted/30 text-sm">
<div className="font-mono text-xs break-all">{cancelTarget.uuid}</div>
<div className="text-muted-foreground text-xs mt-1">
{cancelTarget.rfcReceptor} · {cancelTarget.nombreReceptor}
</div>
</div>
<div className="space-y-2">
<Label>Motivo de cancelación</Label>
<div className="grid gap-2">
<label className={`rounded border-2 p-3 cursor-pointer transition-all ${cancelMotive === '02' ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'}`}>
<input type="radio" name="motive" value="02" checked={cancelMotive === '02'} onChange={() => setCancelMotive('02')} className="sr-only" />
<div className="font-medium text-sm">02 Comprobante emitido con errores sin relación</div>
<p className="text-xs text-muted-foreground mt-0.5">El más común. La factura tiene errores y no la vas a reemplazar.</p>
</label>
<label className={`rounded border-2 p-3 cursor-pointer transition-all ${cancelMotive === '01' ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'}`}>
<input type="radio" name="motive" value="01" checked={cancelMotive === '01'} onChange={() => setCancelMotive('01')} className="sr-only" />
<div className="font-medium text-sm">01 Comprobante emitido con errores con relación</div>
<p className="text-xs text-muted-foreground mt-0.5">La factura tiene errores y la reemplazas por otra que ya emitiste. Requiere el UUID de la sustituta.</p>
</label>
<label className={`rounded border-2 p-3 cursor-pointer transition-all ${cancelMotive === '03' ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'}`}>
<input type="radio" name="motive" value="03" checked={cancelMotive === '03'} onChange={() => setCancelMotive('03')} className="sr-only" />
<div className="font-medium text-sm">03 No se llevó a cabo la operación</div>
<p className="text-xs text-muted-foreground mt-0.5">El cliente no compró/no se prestó el servicio.</p>
</label>
<label className={`rounded border-2 p-3 cursor-pointer transition-all ${cancelMotive === '04' ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'}`}>
<input type="radio" name="motive" value="04" checked={cancelMotive === '04'} onChange={() => setCancelMotive('04')} className="sr-only" />
<div className="font-medium text-sm">04 Operación nominativa relacionada en factura global</div>
<p className="text-xs text-muted-foreground mt-0.5">La operación se incluyó después en una factura global (público en general).</p>
</label>
</div>
</div>
{cancelMotive === '01' && (
<div className="space-y-1">
<Label htmlFor="substitution">UUID de la factura que sustituye a esta</Label>
<Input
id="substitution"
value={cancelSubstitution}
onChange={(e) => setCancelSubstitution(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
className="font-mono text-xs"
/>
</div>
)}
<div className="rounded border border-amber-300 bg-amber-50 text-amber-900 p-3 text-xs">
<strong>Importante:</strong> una vez solicitada la cancelación, la factura puede quedar en estatus "pendiente" si el SAT requiere aceptación del receptor. El cambio en nuestra BD se refleja al confirmarse en el SAT. Revisa el estatus en unos minutos.
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setCancelTarget(null)} disabled={cancelling}>
Cerrar
</Button>
<Button onClick={handleCancelFactura} disabled={cancelling} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{cancelling && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Solicitar cancelación
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}