import { Component, createRef } from 'react'; import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; import Divider from '@mui/material/Divider'; import FormControl from '@mui/material/FormControl'; import FormControlLabel from '@mui/material/FormControlLabel'; import FormLabel from '@mui/material/FormLabel'; import IconButton from '@mui/material/IconButton'; import LinearProgress from '@mui/material/LinearProgress'; import Radio from '@mui/material/Radio'; import RadioGroup from '@mui/material/RadioGroup'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import CloudDownloadOutlinedIcon from '@mui/icons-material/CloudDownloadOutlined'; import CloudUploadOutlinedIcon from '@mui/icons-material/CloudUploadOutlined'; import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined'; import CloseIcon from '@mui/icons-material/Close'; import { enqueueSnackbar } from 'notistack'; import { company } from '../../api'; import categoryAutomat from '../../automats/categoryAutomat'; import articleIndexAutomat from '../../automats/articleIndexAutomat'; import brandAutomat from '../../automats/brandAutomat'; function formatBytes(bytes) { if (bytes == null || isNaN(bytes) || bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; } function isZipFile(file) { if (!file) return false; const name = (file.name || '').toLowerCase(); return ( name.endsWith('.zip') || file.type === 'application/zip' || file.type === 'application/x-zip-compressed' ); } const PHASE_LABELS = { brands: 'Marken werden importiert…', categories: 'Kategorien werden importiert…', articles: 'Artikel werden abgeglichen…', variants: 'Varianten werden verknüpft…', images: 'Bilder werden optimiert…', }; const EXPORT_OPTIONS = [ { id: 'articles', label: 'Artikel & Medien (ZIP)', desc: 'artikel.csv inklusive aller referenzierten AVIF-Bilder und Preise.', endpoint: '/api/article/export.zip', defaultFilename: 'artikel-export.zip', }, { id: 'categories', label: 'Kategorien & Bilder (ZIP)', desc: 'kategorien.csv inklusive Kategoriebäumen und AVIF-Vorschaubildern.', endpoint: '/api/category/export.zip', defaultFilename: 'kategorien-export.zip', }, { id: 'company', label: 'Gesamtsicherung / Mandant (ZIP)', desc: 'Vollständiger Datenbestand (Artikel, Kategorien, Kunden, Belege und Medien).', endpoint: '/api/company/export.zip', defaultFilename: 'mandant-gesamtsicherung.zip', }, ]; export default class ImportExportSettingsPanel extends Component { constructor(props) { super(props); this.state = { // Export state exportType: 'articles', downloading: false, downloadProgress: null, // null = unknown, number 0-100 downloadLoaded: 0, downloadTotal: 0, downloadError: null, // Import state (SSE streaming & upload tracking) selectedFile: null, uploading: false, uploadStage: 'idle', // 'idle' | 'upload' | 'server' uploadProgress: 0, uploadLoaded: 0, uploadTotal: 0, importPhase: null, // 'categories' | 'articles' | 'variants' | 'images' | null importPhaseCurrent: 0, importPhaseTotal: 0, importItem: null, statusMessage: null, importStats: null, uploadError: null, uploadSuccess: null, }; this.fileInputRef = createRef(); this.uploadAbortController = null; this.abortController = null; this.handleStartDownload = this.handleStartDownload.bind(this); this.handleCancelDownload = this.handleCancelDownload.bind(this); this.handleFileChange = this.handleFileChange.bind(this); this.handleStartUpload = this.handleStartUpload.bind(this); this.handleCancelUpload = this.handleCancelUpload.bind(this); } componentWillUnmount() { if (this.uploadAbortController) { this.uploadAbortController.abort(); } if (this.abortController) { this.abortController.abort(); } } async handleStartDownload() { const { exportType } = this.state; const option = EXPORT_OPTIONS.find((opt) => opt.id === exportType) ?? EXPORT_OPTIONS[0]; this.setState({ downloading: true, downloadProgress: null, downloadLoaded: 0, downloadTotal: 0, downloadError: null, }); this.abortController = new AbortController(); try { const response = await fetch(option.endpoint, { method: 'GET', credentials: 'include', signal: this.abortController.signal, }); if (!response.ok) { let errMessage = `Fehler beim Herunterladen (${response.status})`; try { const json = await response.json(); errMessage = json.error || json.message || errMessage; } catch { // Ignore json parse error } throw new Error(errMessage); } const contentLengthHeader = response.headers.get('content-length'); const totalBytes = contentLengthHeader ? parseInt(contentLengthHeader, 10) : 0; const reader = response.body.getReader(); const chunks = []; let loadedBytes = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); loadedBytes += value.length; if (totalBytes > 0) { const percent = Math.min(100, Math.round((loadedBytes / totalBytes) * 100)); this.setState({ downloadProgress: percent, downloadLoaded: loadedBytes, downloadTotal: totalBytes, }); } else { this.setState({ downloadProgress: null, downloadLoaded: loadedBytes, }); } } // Stream completed — trigger browser file save const blob = new Blob(chunks, { type: 'application/zip' }); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = option.defaultFilename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); enqueueSnackbar(`${option.label} erfolgreich heruntergeladen.`, { variant: 'success' }); this.setState({ downloading: false, downloadProgress: 100 }); } catch (err) { if (err.name === 'AbortError') { enqueueSnackbar('Download abgebrochen.', { variant: 'info' }); } else { this.setState({ downloadError: err.message || String(err) }); } } finally { this.setState({ downloading: false }); this.abortController = null; } } handleCancelDownload() { if (this.abortController) { this.abortController.abort(); } } handleFileChange(event) { const file = event.target.files?.[0]; if (file) { if (!isZipFile(file)) { this.setState({ selectedFile: null, uploadError: 'Nur ZIP-Dateien (.zip) sind für den Import zulässig.', uploadSuccess: null, uploadProgress: 0, }); if (this.fileInputRef.current) this.fileInputRef.current.value = ''; return; } this.setState({ selectedFile: file, uploadError: null, uploadSuccess: null, uploadProgress: 0, }); } } async handleStartUpload() { const { selectedFile } = this.state; if (!selectedFile) return; this.uploadAbortController = new AbortController(); this.setState({ uploading: true, uploadStage: 'upload', uploadProgress: 0, uploadLoaded: 0, uploadTotal: selectedFile.size, importPhase: null, importPhaseCurrent: 0, importPhaseTotal: 0, importItem: null, statusMessage: null, importStats: null, uploadError: null, uploadSuccess: null, }); try { await company.importCatalog( selectedFile, { onUploadProgress: (p) => { this.setState({ uploadStage: 'upload', uploadProgress: p.percent, uploadLoaded: p.loaded, uploadTotal: p.total, }); }, onStatus: (message) => { this.setState({ uploadStage: 'server', statusMessage: message, }); }, onProgress: (progress) => { this.setState({ uploadStage: 'server', importPhase: progress?.phase ?? null, importPhaseCurrent: progress?.current ?? 0, importPhaseTotal: progress?.total ?? 0, importItem: progress?.item ?? null, }); }, onDone: (stats) => { this.setState({ uploading: false, uploadStage: 'idle', importStats: stats, uploadSuccess: 'Import erfolgreich abgeschlossen.', statusMessage: null, importItem: null, selectedFile: null, }); if (this.fileInputRef.current) this.fileInputRef.current.value = ''; enqueueSnackbar('Import erfolgreich abgeschlossen.', { variant: 'success' }); void categoryAutomat.actions.loadTree(); void articleIndexAutomat.actions.reload?.(); void brandAutomat.actions.load?.(); }, onError: (message) => { this.setState({ uploading: false, uploadStage: 'idle', uploadError: message, }); enqueueSnackbar(message || 'Fehler beim Importieren', { variant: 'error' }); }, }, { signal: this.uploadAbortController.signal }, ); } catch (err) { if (err.name === 'AbortError') { this.setState({ uploading: false, uploadStage: 'idle', statusMessage: null }); enqueueSnackbar('Import abgebrochen.', { variant: 'info' }); return; } this.setState({ uploading: false, uploadStage: 'idle', uploadError: err?.message || 'Upload fehlgeschlagen.', }); enqueueSnackbar(err?.message || 'Upload fehlgeschlagen.', { variant: 'error' }); } finally { this.uploadAbortController = null; } } handleCancelUpload() { if (this.uploadAbortController) { this.uploadAbortController.abort(); this.uploadAbortController = null; } } render() { const { exportType, downloading, downloadProgress, downloadLoaded, downloadTotal, downloadError, selectedFile, uploading, uploadStage, uploadProgress, uploadLoaded, uploadTotal, importPhase, importPhaseCurrent, importPhaseTotal, importItem, statusMessage, importStats, uploadError, uploadSuccess, } = this.state; const progressPercent = importPhaseTotal > 0 ? Math.min(100, Math.round((importPhaseCurrent / importPhaseTotal) * 100)) : null; return ( {/* SECTION 1: EXPORT (DOWNLOAD) */} Daten exportieren (Download) Große Datenmengen werden als ZIP-Archiv gestreamt. {downloadError && ( {downloadError} )} Export-Umfang auswählen this.setState({ exportType: e.target.value })} > {EXPORT_OPTIONS.map((opt) => ( } label={ {opt.label} {opt.desc} } /> ))} {downloading && ( {downloadProgress != null ? `${downloadProgress}% (${formatBytes(downloadLoaded)} von ${formatBytes(downloadTotal)})` : `${formatBytes(downloadLoaded)} geladen (wird gestreamt...)`} )} {/* SECTION 2: IMPORT (UPLOAD) */} Import {uploadError && ( {uploadError} )} {uploadSuccess && ( {uploadSuccess} )} {/* Import statistics breakdown on done */} {importStats && ( Import-Statistik Kategorien {importStats.categories ?? 0} {importStats.categoriesCreated != null && ( +{importStats.categoriesCreated} neu )} Artikel {importStats.articlesTotal ?? ((importStats.articlesImported ?? 0) + (importStats.articlesUpdated ?? 0))} {importStats.articlesImported ?? 0} neu / {importStats.articlesUpdated ?? 0} akt. Varianten {importStats.variantsLinked ?? 0} verknüpft Bilder {importStats.articlePictures ?? 0} {importStats.categoryPictures != null && ( +{importStats.categoryPictures} Kat-Bilder )} {importStats.brands != null && ( Marken {importStats.brands ?? 0} {importStats.brandsCreated != null ? `+${importStats.brandsCreated} neu` : ''} {importStats.brandPictures ? ` (${importStats.brandPictures} Logos)` : ''} )} )} {!selectedFile ? ( ) : ( {selectedFile.name} Größe: {formatBytes(selectedFile.size)} {!uploading && ( { this.setState({ selectedFile: null }); if (this.fileInputRef.current) this.fileInputRef.current.value = ''; }} > )} )} {/* Real-time Streaming Progress Ticker */} {uploading && ( {uploadStage === 'upload' ? ( <> Datei wird übertragen… {uploadProgress}% ({formatBytes(uploadLoaded)} von {formatBytes(uploadTotal)}) ) : ( <> {(importPhase && PHASE_LABELS[importPhase]) || statusMessage || 'Verarbeitung läuft…'} {importPhaseTotal > 0 ? ( <> {importItem || 'Fortschritt'} {importPhaseCurrent} / {importPhaseTotal} ({progressPercent}%) ) : ( <> {statusMessage && ( {statusMessage} )} )} )} )} ); } }