1816 lines
77 KiB
TypeScript
1816 lines
77 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, type EmisorReceptor } from '@/lib/api/cfdi';
|
|
import { cancelarFactura } 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 } 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);
|
|
const createCfdi = useCreateCfdi();
|
|
const deleteCfdi = useDeleteCfdi();
|
|
|
|
// CFDI Viewer state
|
|
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
|
|
const [loadingCfdi, setLoadingCfdi] = useState<string | 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: string) => {
|
|
setLoadingCfdi(id);
|
|
try {
|
|
const cfdi = await getCfdiById(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 {
|
|
const exportData = data.data.map(cfdi => ({
|
|
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
|
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
|
|
'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,
|
|
'IVA': cfdi.ivaTraslado,
|
|
'Total': cfdi.total,
|
|
'Moneda': cfdi.moneda,
|
|
'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);
|
|
}
|
|
};
|
|
|
|
const exportSingleCfdiToExcel = (cfdi: Cfdi) => {
|
|
const row = {
|
|
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
|
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
|
|
'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,
|
|
'IVA': cfdi.ivaTraslado,
|
|
'Total': cfdi.total,
|
|
'Moneda': cfdi.moneda,
|
|
'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: string) => {
|
|
if (confirm('¿Eliminar este CFDI?')) {
|
|
try {
|
|
await deleteCfdi.mutateAsync(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.ivaTrasladoTraslado || 0;
|
|
const isrRetencion = formData.isrRetencion || 0;
|
|
const ivaRetencion = formData.ivaTrasladoRetencion || 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 flex-1 min-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">
|
|
{data && data.data.length > 0 && (
|
|
<Button variant="outline" onClick={exportToExcel} disabled={exporting}>
|
|
{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>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<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-left text-sm text-muted-foreground">
|
|
<th className="pb-3 font-medium">
|
|
<div className="flex items-center gap-1">
|
|
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">UUID</th>
|
|
<th className="pb-3 font-medium">
|
|
<div className="flex items-center gap-1">
|
|
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">
|
|
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>
|
|
{canEdit && <th className="pb-3 font-medium"></th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="text-sm">
|
|
{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" 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>
|
|
{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>
|
|
</>
|
|
);
|
|
}
|