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'; import { orderAutomat } from '../../automats/orderAutomat'; import SkeleHashImage from './SkeleHashImage'; 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' ); } function shortHash(hash) { if (typeof hash !== 'string') return ''; return hash.length > 10 ? `${hash.slice(0, 8)}…` : hash; } // Counter-based phases emitted by the `progress` event. const PROGRESS_PHASES = [ { key: 'categories', label: 'Kategorien', totalKey: 'categoriestotal', doneKey: 'categoriesdone' }, { key: 'articles', label: 'Artikel', totalKey: 'articlestotal', doneKey: 'articlesdone' }, { key: 'images', label: 'Medien', totalKey: 'imagestotal', doneKey: 'imagesdone' }, { key: 'receipts', label: 'Belege', totalKey: 'receiptstotal', doneKey: 'receiptsdone' }, ]; 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, importProgress: null, // raw counters from the `progress` event // Latest SkeleHash shown (first hash of the most recent images batch) streamedHash: null, importedImageCount: 0, 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, importProgress: null, streamedHash: null, importedImageCount: 0, 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) => { if (!progress || typeof progress !== 'object') return; // Counter-based protocol: { categoriestotal, categoriesdone, ... } this.setState({ uploadStage: 'server', importProgress: progress }); }, onImages: (images) => { const list = Array.isArray(images) ? images.filter(Boolean) : []; if (!list.length) return; // Only the first hash of each batch; it replaces the previously // shown placeholder. const first = list[0]; const hash = typeof first === 'string' ? first : (first?.skelehash ?? null); this.setState((prev) => ({ uploadStage: 'server', streamedHash: hash, importedImageCount: prev.importedImageCount + list.length, statusMessage: null, })); }, onDone: (stats) => { this.setState({ uploading: false, uploadStage: 'idle', importStats: stats, uploadSuccess: 'Import erfolgreich abgeschlossen.', statusMessage: null, importProgress: 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?.(); if (stats?.ordersImported) { void orderAutomat.actions.reload?.(); } }, 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, importProgress, streamedHash, importedImageCount, statusMessage, importStats, uploadError, uploadSuccess, } = this.state; const phaseRows = PROGRESS_PHASES.map((phase) => { const total = importProgress?.[phase.totalKey] ?? 0; const done = importProgress?.[phase.doneKey] ?? 0; return { ...phase, total, done, percent: total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0, }; }).filter((row) => row.total > 0); const totalUnits = phaseRows.reduce((sum, row) => sum + row.total, 0); const doneUnits = phaseRows.reduce((sum, row) => sum + Math.min(row.done, row.total), 0); const overallPercent = totalUnits > 0 ? Math.min(100, Math.round((doneUnits / totalUnits) * 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)` : ''} )} {importStats.ordersImported != null && ( Aufträge {importStats.ordersImported} {importStats.ordersSkipped ? `+${importStats.ordersSkipped} übersprungen` : 'importiert'} )} )} {!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)}) ) : ( <> {statusMessage || 'Daten werden verarbeitet…'} {overallPercent != null ? ` · ${overallPercent}%` : ''} {phaseRows.length > 0 ? ( {phaseRows.map((row) => ( {row.label} {row.done} / {row.total} ({row.percent}%) ))} ) : ( <> {statusMessage && ( {statusMessage} )} )} {/* Latest decoded SkeleHash placeholder (replaces previous) */} {streamedHash && ( {shortHash(streamedHash)} {importedImageCount} Bild(er) verarbeitet )} )} )} ); } }