Files
HoruxDespachos/apps/web/app/(dashboard)/cfdi/page.tsx
Horux Dev e7dbae1ab7 feat: conceptos tab, filters, backfill, facturapi live keys, fixes
- Add Conceptos tab in CFDI page with column filters, sorting, pagination
- Add GET /cfdi/conceptos endpoint with filters and orderBy
- Backfill cfdi_conceptos from legacy XMLs (824k concepts inserted)
- Fix CFDI delete button (bypass subscription check, add alerts)
- Fix export to Excel (fetch all filtered results, limit 10k)
- Fix facturacion page concepto delete bug (immutable updates, unique ids)
- Add Facturapi live key auto-generation and caching
- Fix SAT fechaPagoP parsing
- Add metrics cache support for current year
- Increase DB pool max to 15
2026-04-29 21:03:41 +00:00

2207 lines
98 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, useCfdiConceptos, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
import { getCfdis, createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
import { cancelarFactura, downloadPdf, downloadXml } from '@/lib/api/facturacion';
import type { CfdiFilters, CfdiConceptoFilters, 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, ArrowUpDown, ArrowUp, ArrowDown } 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);
// Tabs: CFDIs vs Conceptos
const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis');
// Conceptos filters & state
const [conceptoFilters, setConceptoFilters] = useState<CfdiConceptoFilters>({
page: 1,
limit: 20,
});
const [conceptoSearch, setConceptoSearch] = useState('');
const [conceptoColumnFilters, setConceptoColumnFilters] = useState({
fechaInicio: '',
fechaFin: '',
uuid: '',
claveProdServ: '',
descripcion: '',
});
const [conceptoOrder, setConceptoOrder] = useState<{ by: 'fecha' | 'importe'; dir: 'asc' | 'desc' }>({ by: 'fecha', dir: 'desc' });
const [openConceptoFilter, setOpenConceptoFilter] = useState<'fecha' | 'uuid' | 'clave' | 'descripcion' | null>(null);
const hasConceptoDateFilter = !!conceptoColumnFilters.fechaInicio || !!conceptoColumnFilters.fechaFin;
const hasConceptoUuidFilter = !!conceptoColumnFilters.uuid;
const hasConceptoClaveFilter = !!conceptoColumnFilters.claveProdServ;
const hasConceptoDescFilter = !!conceptoColumnFilters.descripcion;
const { data: conceptosData, isLoading: conceptosLoading } = useCfdiConceptos(conceptoFilters);
// 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);
// Sync shared filters between CFDIs and Conceptos tabs
useEffect(() => {
setConceptoFilters(prev => ({
...prev,
fechaInicio: columnFilters.fechaInicio || undefined,
fechaFin: columnFilters.fechaFin || undefined,
tipo: filters.tipo,
tipoComprobante: filters.tipoComprobante,
estado: filters.estado,
rfc: filters.rfc,
search: searchTerm || undefined,
page: 1,
}));
}, [filters.tipo, filters.tipoComprobante, filters.estado, filters.rfc, columnFilters.fechaInicio, columnFilters.fechaFin, searchTerm]);
// 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 | number) => {
const idStr = String(id);
setLoadingCfdi(idStr);
try {
const cfdi = await getCfdiById(idStr);
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 canDelete = user?.role === 'owner' || user?.role === 'contador';
const handleSearch = () => {
setFilters({ ...filters, search: searchTerm, page: 1 });
};
// Conceptos column filters & sorting
const applyConceptoColumnFilters = () => {
setConceptoFilters(prev => ({
...prev,
fechaInicio: conceptoColumnFilters.fechaInicio || undefined,
fechaFin: conceptoColumnFilters.fechaFin || undefined,
uuid: conceptoColumnFilters.uuid || undefined,
claveProdServ: conceptoColumnFilters.claveProdServ || undefined,
descripcion: conceptoColumnFilters.descripcion || undefined,
orderBy: conceptoOrder.by,
orderDir: conceptoOrder.dir,
page: 1,
}));
};
const toggleConceptoOrder = (by: 'fecha' | 'importe') => {
setConceptoOrder(prev => {
const dir = prev.by === by && prev.dir === 'desc' ? 'asc' : 'desc';
return { by, dir };
});
// Apply after state update using the new values
setConceptoFilters(prev => ({
...prev,
orderBy: by,
orderDir: by === conceptoOrder.by && conceptoOrder.dir === 'desc' ? 'asc' : 'desc',
page: 1,
}));
};
const clearConceptoDateFilter = () => {
setConceptoColumnFilters(prev => ({ ...prev, fechaInicio: '', fechaFin: '' }));
setConceptoFilters(prev => ({ ...prev, fechaInicio: undefined, fechaFin: undefined, page: 1 }));
};
const clearConceptoUuidFilter = () => {
setConceptoColumnFilters(prev => ({ ...prev, uuid: '' }));
setConceptoFilters(prev => ({ ...prev, uuid: undefined, page: 1 }));
};
const clearConceptoClaveFilter = () => {
setConceptoColumnFilters(prev => ({ ...prev, claveProdServ: '' }));
setConceptoFilters(prev => ({ ...prev, claveProdServ: undefined, page: 1 }));
};
const clearConceptoDescFilter = () => {
setConceptoColumnFilters(prev => ({ ...prev, descripcion: '' }));
setConceptoFilters(prev => ({ ...prev, descripcion: undefined, page: 1 }));
};
// Export to Excel
const [exporting, setExporting] = useState(false);
const exportToExcel = async () => {
if (!data?.data.length) return;
setExporting(true);
try {
// Traer TODOS los CFDIs que coinciden con los filtros (sin paginación)
const allFilters: CfdiFilters = { ...filters, page: 1, limit: 10000 };
const allData = await getCfdis(allFilters);
const rows = allData.data;
if (!rows.length) {
alert('No hay datos para exportar');
return;
}
const exportData = rows.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 handleDownloadPdf = async (facturapiId: string | null) => {
if (!facturapiId) return;
try {
const blob = await downloadPdf(facturapiId);
const url = window.URL.createObjectURL(new Blob([blob]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `factura-${facturapiId}.pdf`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (error: any) {
alert('Error al descargar PDF: ' + (error?.message || 'Desconocido'));
}
};
const handleDownloadXml = async (facturapiId: string | null) => {
if (!facturapiId) return;
try {
const blob = await downloadXml(facturapiId);
const url = window.URL.createObjectURL(new Blob([blob]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `factura-${facturapiId}.xml`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (error: any) {
alert('Error al descargar XML: ' + (error?.message || 'Desconocido'));
}
};
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 | number) => {
const idStr = String(id);
console.log('[DeleteCFDI] Intentando eliminar ID:', idStr, 'tipo:', typeof id);
if (!id || idStr === 'undefined' || idStr === 'null' || idStr === '0') {
alert('ID de CFDI inválido: ' + idStr);
return;
}
if (confirm('¿Eliminar este CFDI?')) {
try {
await deleteCfdi.mutateAsync(idStr);
alert('CFDI eliminado correctamente');
} catch (error: any) {
console.error('Error deleting CFDI:', error);
const msg = error?.response?.data?.message || error?.message || 'Error al eliminar el CFDI';
alert('Error: ' + msg);
}
}
};
const openCancelDialog = (cfdi: any) => {
setCancelTarget(cfdi);
setCancelMotive('02');
setCancelSubstitution('');
};
const handleCancelFactura = async () => {
if (!cancelTarget) return;
if (cancelMotive === '01' && cancelSubstitution.trim().length !== 36) {
alert('El motivo 01 requiere el UUID completo (36 caracteres) de la factura que sustituye a esta.');
return;
}
setCancelling(true);
try {
await cancelarFactura(cancelTarget.uuid, cancelMotive, cancelMotive === '01' ? cancelSubstitution.trim() : undefined);
await queryClient.invalidateQueries({ queryKey: ['cfdis'] });
setCancelTarget(null);
alert('Factura cancelada. El estatus final depende del SAT (puede quedar en "pendiente" si requiere aceptación del receptor).');
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cancelar la factura');
} finally {
setCancelling(false);
}
};
const calculateTotal = () => {
const subtotal = formData.subtotal || 0;
const descuento = formData.descuento || 0;
const iva = formData.ivaTraslado || 0;
const isrRetencion = formData.isrRetencion || 0;
const ivaRetencion = formData.ivaRetencion || 0;
return subtotal - descuento + iva - isrRetencion - ivaRetencion;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
const generateUUID = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
}).toUpperCase();
};
return (
<>
<Header title="Gestion de CFDI" />
<main className="p-6 space-y-6">
{/* Tabs */}
<div className="flex gap-2 border-b pb-2">
<button
onClick={() => setActiveTab('cfdis')}
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${activeTab === 'cfdis' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`}
>
CFDIs
</button>
<button
onClick={() => setActiveTab('conceptos')}
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${activeTab === 'conceptos' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`}
>
Conceptos
</button>
</div>
{/* 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>
{activeTab === 'cfdis' && (
<>
{/* 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">Uso CFDI</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">
<span className="text-xs text-muted-foreground">{cfdi.usoCfdi || '-'}</span>
</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 === String(cfdi.id)}
title="Ver factura"
>
{loadingCfdi === String(cfdi.id) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</td>
{(cfdi as any).source === 'facturapi' && cfdi.facturapiId && (
<>
<td className="py-3">
<Button
variant="ghost"
size="icon"
onClick={() => handleDownloadPdf(cfdi.facturapiId)}
title="Descargar PDF (Facturapi)"
>
<FileText className="h-4 w-4" />
</Button>
</td>
{/* XML download hidden */}
</>
)}
<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>
)}
{canDelete && (
<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>
</>
)}
{activeTab === 'conceptos' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
Conceptos ({conceptosData?.total || 0})
</CardTitle>
</CardHeader>
<CardContent>
{conceptosLoading ? (
<div className="space-y-3">
{[...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-4 bg-muted rounded flex-1 max-w-[200px]"></div>
<div className="h-4 bg-muted rounded w-16"></div>
<div className="h-4 bg-muted rounded w-16"></div>
<div className="h-4 bg-muted rounded w-24"></div>
</div>
))}
</div>
) : conceptosData?.data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No se encontraron conceptos
</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">
<button onClick={() => toggleConceptoOrder('fecha')} className="flex items-center gap-1 hover:text-foreground transition-colors">
Fecha CFDI
{conceptoOrder.by === 'fecha' ? (
conceptoOrder.dir === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 opacity-50" />
)}
</button>
<Popover open={openConceptoFilter === 'fecha'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'fecha' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasConceptoDateFilter ? '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={conceptoColumnFilters.fechaInicio} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, fechaInicio: e.target.value }))} />
</div>
<div>
<Label className="text-xs">Hasta</Label>
<Input type="date" className="h-8 text-sm" value={conceptoColumnFilters.fechaFin} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, fechaFin: e.target.value }))} />
</div>
</div>
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
{hasConceptoDateFilter && <Button size="sm" variant="outline" onClick={clearConceptoDateFilter}>Limpiar</Button>}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1">
UUID
<Popover open={openConceptoFilter === 'uuid'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'uuid' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasConceptoUuidFilter ? '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 placeholder="UUID..." className="h-8 text-sm" value={conceptoColumnFilters.uuid} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, uuid: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} />
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
{hasConceptoUuidFilter && <Button size="sm" variant="outline" onClick={clearConceptoUuidFilter}>Limpiar</Button>}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1">
Clave
<Popover open={openConceptoFilter === 'clave'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'clave' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasConceptoClaveFilter ? '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</h4>
<Input placeholder="Clave prod/serv..." className="h-8 text-sm" value={conceptoColumnFilters.claveProdServ} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, claveProdServ: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} />
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
{hasConceptoClaveFilter && <Button size="sm" variant="outline" onClick={clearConceptoClaveFilter}>Limpiar</Button>}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1">
Descripción
<Popover open={openConceptoFilter === 'descripcion'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'descripcion' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasConceptoDescFilter ? '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 placeholder="Descripción..." className="h-8 text-sm" value={conceptoColumnFilters.descripcion} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, descripcion: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} />
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
{hasConceptoDescFilter && <Button size="sm" variant="outline" onClick={clearConceptoDescFilter}>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">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 onClick={() => toggleConceptoOrder('importe')} className="flex items-center gap-1 hover:text-foreground transition-colors ml-auto">
Importe
{conceptoOrder.by === 'importe' ? (
conceptoOrder.dir === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 opacity-50" />
)}
</button>
</th>
{/* Descuento and IVA columns hidden */}
</tr>
</thead>
<tbody>
{conceptosData?.data.map((c) => (
<tr key={c.id} className="border-b hover:bg-muted/50">
<td className="py-3 text-sm">
{c.cfdiFechaEmision ? new Date(c.cfdiFechaEmision).toLocaleDateString('es-MX') : '-'}
</td>
<td className="py-3 text-xs font-mono text-muted-foreground max-w-[120px] truncate" title={c.cfdiUuid || ''}>
{c.cfdiUuid || '-'}
</td>
<td className="py-3 text-xs text-muted-foreground">{c.claveProdServ || '-'}</td>
<td className="py-3 text-sm max-w-[250px] truncate" title={c.descripcion}>{c.descripcion}</td>
<td className="py-3 text-xs text-muted-foreground">{c.cfdiRfcEmisor || '-'}</td>
<td className="py-3 text-xs text-muted-foreground">{c.cfdiRfcReceptor || '-'}</td>
<td className="py-3 text-sm">{c.cantidad}</td>
<td className="py-3 text-xs text-muted-foreground">{c.claveUnidad || c.unidad || '-'}</td>
<td className="py-3 text-sm text-right">${Number(c.valorUnitario).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
<td className="py-3 text-sm text-right">${Number(c.importe).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
{/* Descuento and IVA cells hidden */}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination for conceptos */}
{conceptosData && conceptosData.totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<p className="text-sm text-muted-foreground">
Pagina {conceptosData.page} de {conceptosData.totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={conceptosData.page <= 1}
onClick={() =>
setConceptoFilters({ ...conceptoFilters, page: (conceptoFilters.page || 1) - 1 })
}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={conceptosData.page >= conceptosData.totalPages}
onClick={() =>
setConceptoFilters({ ...conceptoFilters, page: (conceptoFilters.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>
</>
);
}