2324 lines
106 KiB
TypeScript
2324 lines
106 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, downloadXmlsZip, 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);
|
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
|
const [downloadingXmls, setDownloadingXmls] = 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;
|
|
noIdentificacion: string;
|
|
orderBy?: 'fecha' | 'importe';
|
|
orderDir?: 'asc' | 'desc';
|
|
}>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '', noIdentificacion: '' });
|
|
const [conceptosOpenFilter, setConceptosOpenFilter] = useState<'uuid' | 'clave' | 'descripcion' | 'noIdentificacion' | 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,
|
|
noIdentificacion: conceptosFilters.noIdentificacion || 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': formatCfdiDate(cfdi.fechaEmision),
|
|
'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': formatCfdiDate(cfdi.fechaCancelacion),
|
|
'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,
|
|
noIdentificacion: conceptosFilters.noIdentificacion || 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'] = formatCfdiDate(val);
|
|
} 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': formatCfdiDate(cfdi.fechaEmision),
|
|
'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': formatCfdiDate(cfdi.fechaCancelacion),
|
|
'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, selectedContribuyenteId || 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) => {
|
|
const d = new Date(dateString);
|
|
d.setHours(d.getHours() - 1);
|
|
return d.toLocaleDateString('es-MX', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
});
|
|
};
|
|
|
|
const formatCfdiDate = (dateString: string | null | undefined) => {
|
|
if (!dateString) return '-';
|
|
const d = new Date(dateString);
|
|
d.setHours(d.getHours() - 1);
|
|
return d.toLocaleDateString('es-MX');
|
|
};
|
|
|
|
const generateUUID = () => {
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
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">
|
|
<div className="flex items-center gap-1 justify-center">
|
|
No. Identificación
|
|
<Popover open={conceptosOpenFilter === 'noIdentificacion'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'noIdentificacion' : null)}>
|
|
<PopoverTrigger asChild>
|
|
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.noIdentificacion ? '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 No. Identificación</h4>
|
|
<Input className="h-8 text-sm font-mono" placeholder="Ej: PROD-001" value={conceptosFilters.noIdentificacion} onChange={(e) => setConceptosFilters({ ...conceptosFilters, noIdentificacion: e.target.value })} />
|
|
<div className="flex gap-2">
|
|
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button>
|
|
{conceptosFilters.noIdentificacion && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, noIdentificacion: '' }); 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">{formatCfdiDate(row.fechaEmision)}</td>
|
|
<td className="py-2 font-mono text-xs" title={row.uuid}>{row.uuid?.substring(0, 8) || '-'}</td>
|
|
<td className="py-2 font-mono text-xs">{row.clave_prod_serv || '-'}</td>
|
|
<td className="py-2 text-left max-w-[280px] truncate" title={row.descripcion}>{row.descripcion}</td>
|
|
<td className="py-2 font-mono text-xs" title={row.no_identificacion || ''}>{row.no_identificacion || '-'}</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>
|
|
<div className="flex items-center gap-2">
|
|
{selectedIds.size > 0 && (
|
|
<>
|
|
<span className="text-xs text-muted-foreground">
|
|
{selectedIds.size} seleccionados
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={async () => {
|
|
if (selectedIds.size > 1000) {
|
|
alert('Máximo 1,000 CFDIs por descarga');
|
|
return;
|
|
}
|
|
try {
|
|
setDownloadingXmls(true);
|
|
const blob = await downloadXmlsZip(Array.from(selectedIds));
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `cfdis-xml-${Date.now()}.zip`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (err: any) {
|
|
alert(err?.response?.data?.message || err?.message || 'Error al descargar XMLs');
|
|
} finally {
|
|
setDownloadingXmls(false);
|
|
}
|
|
}}
|
|
disabled={downloadingXmls}
|
|
>
|
|
{downloadingXmls ? (
|
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
|
) : (
|
|
<Download className="h-4 w-4 mr-1" />
|
|
)}
|
|
Descargar XMLs
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSelectedIds(new Set())}
|
|
>
|
|
Limpiar
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{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 w-8">
|
|
<input
|
|
type="checkbox"
|
|
checked={data?.data.length ? selectedIds.size === data.data.length : false}
|
|
onChange={() => {
|
|
if (selectedIds.size === data?.data.length) {
|
|
setSelectedIds(new Set());
|
|
} else {
|
|
setSelectedIds(new Set(data?.data.map((c: any) => c.id) || []));
|
|
}
|
|
}}
|
|
/>
|
|
</th>
|
|
<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">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.has(cfdi.id)}
|
|
onChange={() => {
|
|
setSelectedIds(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(cfdi.id)) next.delete(cfdi.id);
|
|
else next.add(cfdi.id);
|
|
return next;
|
|
});
|
|
}}
|
|
/>
|
|
</td>
|
|
<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') && (
|
|
// Solo el contribuyente emisor puede cancelar su propia factura
|
|
((!selectedContribuyenteId && !cfdi.contribuyenteId) ||
|
|
(selectedContribuyenteId && cfdi.contribuyenteId === selectedContribuyenteId)) && (
|
|
<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>
|
|
</>
|
|
);
|
|
}
|