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 IconButton from '@mui/material/IconButton'; import LinearProgress from '@mui/material/LinearProgress'; 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 companyAutomat from '../../automats/companyAutomat'; 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' ); } // 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' }, ]; export default class ImportExportSettingsPanel extends Component { constructor(props) { super(props); this.state = { isAdmin: companyAutomat.state.current?.role === 'admin', // Export state downloading: false, downloadProgress: null, // null = unknown, number 0-100 downloadStatus: null, 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); } componentDidMount() { this.unsub = companyAutomat.subscribe(this, (state) => ({ isAdmin: state.current?.role === 'admin', })); } componentWillUnmount() { this.unsub?.(); if (this.uploadAbortController) { this.uploadAbortController.abort(); } if (this.abortController) { this.abortController.abort(); } } async handleStartDownload() { this.setState({ downloading: true, downloadProgress: null, downloadStatus: null, downloadError: null, }); this.abortController = new AbortController(); try { await company.exportCatalog( { onStatus: (message) => { this.setState({ downloadStatus: message }); }, onProgress: (progress) => { const percent = typeof progress?.percent === 'number' ? progress.percent : null; const stage = progress?.stage; this.setState((prev) => ({ downloadProgress: percent ?? prev.downloadProgress, downloadStatus: stage ? `Verarbeite ${stage} (${percent != null ? `${percent}%` : '...'})` : prev.downloadStatus, })); }, onError: (errMessage) => { this.setState({ downloadError: errMessage }); }, }, { signal: this.abortController.signal, triggerDownload: true } ); enqueueSnackbar('Katalog-Export erfolgreich heruntergeladen.', { variant: 'success' }); this.setState({ downloading: false, downloadProgress: 100, downloadStatus: null }); } 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) { if (!this.state.isAdmin) return; 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() { if (!this.state.isAdmin) return; 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 companyAutomat.actions.loadBrands?.(); 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 { isAdmin, downloading, downloadProgress, downloadStatus, 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) */} Katalog exportieren (Download) Exportiert den vollständigen Mandantenbestand (Kategorien, Artikel, Varianten, Marken, Pfand und Bilder) als ZIP-Archiv. {downloadError && ( {downloadError} )} {downloading && ( {downloadStatus ? (downloadProgress != null ? `${downloadStatus} (${downloadProgress}%)` : downloadStatus) : (downloadProgress != null ? `${downloadProgress}% geladen...` : 'Export wird vorbereitet...')} )} {/* 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 && ( {importedImageCount} Bild(er) verarbeitet )} )} )} ); } }