This commit is contained in:
seb
2026-10-08 04:41:27 +02:00
parent ab2e8c92fb
commit b01ebccf90
6 changed files with 796 additions and 362 deletions

View File

@@ -16,8 +16,13 @@ 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';
@@ -36,6 +41,14 @@ function isZipFile(file) {
);
}
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',
@@ -72,19 +85,25 @@ export default class ImportExportSettingsPanel extends Component {
downloadTotal: 0,
downloadError: null,
// Import state
// Import state (SSE streaming & upload tracking)
selectedFile: null,
uploading: false,
uploadProgress: 0, // 0-100
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,
isDraggingOver: false,
};
this.fileInputRef = createRef();
this.xhrRef = null;
this.uploadAbortController = null;
this.abortController = null;
this.handleStartDownload = this.handleStartDownload.bind(this);
@@ -92,14 +111,11 @@ export default class ImportExportSettingsPanel extends Component {
this.handleFileChange = this.handleFileChange.bind(this);
this.handleStartUpload = this.handleStartUpload.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.handleDragOver = this.handleDragOver.bind(this);
this.handleDragLeave = this.handleDragLeave.bind(this);
this.handleDrop = this.handleDrop.bind(this);
}
componentWillUnmount() {
if (this.xhrRef) {
this.xhrRef.abort();
if (this.uploadAbortController) {
this.uploadAbortController.abort();
}
if (this.abortController) {
this.abortController.abort();
@@ -219,133 +235,103 @@ export default class ImportExportSettingsPanel extends Component {
}
}
handleDragOver(e) {
e.preventDefault();
e.stopPropagation();
if (!this.state.isDraggingOver) {
this.setState({ isDraggingOver: true });
}
}
handleDragLeave(e) {
e.preventDefault();
e.stopPropagation();
this.setState({ isDraggingOver: false });
}
handleDrop(e) {
e.preventDefault();
e.stopPropagation();
this.setState({ isDraggingOver: false });
const file = e.dataTransfer?.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,
});
}
}
handleStartUpload() {
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,
});
const formData = new FormData();
formData.append('file', selectedFile);
const xhr = new XMLHttpRequest();
this.xhrRef = xhr;
xhr.open('POST', '/api/company/import');
xhr.withCredentials = true;
// Track large upload progress in real-time
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.min(100, Math.round((event.loaded / event.total) * 100));
this.setState({
uploadProgress: percent,
uploadLoaded: event.loaded,
uploadTotal: event.total,
});
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;
}
};
xhr.onload = () => {
this.xhrRef = null;
if (xhr.status >= 200 && xhr.status < 300) {
let msg = 'Import erfolgreich abgeschlossen.';
try {
const json = JSON.parse(xhr.responseText);
msg = json.message || msg;
} catch {
// ignore
}
enqueueSnackbar(msg, { variant: 'success' });
this.setState({
uploading: false,
uploadProgress: 100,
uploadSuccess: msg,
selectedFile: null,
});
if (this.fileInputRef.current) {
this.fileInputRef.current.value = '';
}
} else {
let errMessage = `Upload fehlgeschlagen (${xhr.status})`;
try {
const json = JSON.parse(xhr.responseText);
errMessage = json.error || json.message || errMessage;
} catch {
// ignore
}
this.setState({
uploading: false,
uploadError: errMessage,
});
}
};
xhr.onerror = () => {
this.xhrRef = null;
this.setState({
uploading: false,
uploadError: 'Netzwerkfehler beim Upload der Datei.',
uploadStage: 'idle',
uploadError: err?.message || 'Upload fehlgeschlagen.',
});
};
xhr.onabort = () => {
this.xhrRef = null;
this.setState({ uploading: false });
enqueueSnackbar('Upload abgebrochen.', { variant: 'info' });
};
xhr.send(formData);
enqueueSnackbar(err?.message || 'Upload fehlgeschlagen.', { variant: 'error' });
} finally {
this.uploadAbortController = null;
}
}
handleCancelUpload() {
if (this.xhrRef) {
this.xhrRef.abort();
if (this.uploadAbortController) {
this.uploadAbortController.abort();
this.uploadAbortController = null;
}
}
@@ -359,14 +345,25 @@ export default class ImportExportSettingsPanel extends Component {
downloadError,
selectedFile,
uploading,
uploadStage,
uploadProgress,
uploadLoaded,
uploadTotal,
importPhase,
importPhaseCurrent,
importPhaseTotal,
importItem,
statusMessage,
importStats,
uploadError,
uploadSuccess,
isDraggingOver,
} = this.state;
const progressPercent =
importPhaseTotal > 0
? Math.min(100, Math.round((importPhaseCurrent / importPhaseTotal) * 100))
: null;
return (
<Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}>
<Stack spacing={4}>
@@ -483,14 +480,9 @@ export default class ImportExportSettingsPanel extends Component {
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1.5 }}>
<CloudUploadOutlinedIcon color="primary" sx={{ fontSize: 28 }} />
<Box>
<Typography variant="subtitle1" fontWeight={600}>
Daten importieren (Upload)
</Typography>
<Typography variant="caption" color="text.secondary">
Große Archive werden direkt per Stream übertragen und blockieren Ihren Browser nicht.
</Typography>
</Box>
<Typography variant="subtitle1" fontWeight={600}>
Import
</Typography>
</Box>
<Divider sx={{ my: 1.5 }} />
@@ -507,6 +499,139 @@ export default class ImportExportSettingsPanel extends Component {
</Alert>
)}
{/* Import statistics breakdown on done */}
{importStats && (
<Box
sx={{
p: 2,
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
mb: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<CheckCircleOutlineIcon color="success" />
<Typography variant="subtitle2" fontWeight={600}>
Import-Statistik
</Typography>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
gap: 1.5,
}}
>
<Box
sx={{
p: 1.5,
bgcolor: 'background.paper',
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Kategorien
</Typography>
<Typography variant="h6" fontWeight={700}>
{importStats.categories ?? 0}
</Typography>
{importStats.categoriesCreated != null && (
<Typography variant="caption" color="success.main" display="block">
+{importStats.categoriesCreated} neu
</Typography>
)}
</Box>
<Box
sx={{
p: 1.5,
bgcolor: 'background.paper',
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Artikel
</Typography>
<Typography variant="h6" fontWeight={700}>
{importStats.articlesTotal ??
((importStats.articlesImported ?? 0) +
(importStats.articlesUpdated ?? 0))}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
{importStats.articlesImported ?? 0} neu / {importStats.articlesUpdated ?? 0} akt.
</Typography>
</Box>
<Box
sx={{
p: 1.5,
bgcolor: 'background.paper',
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Varianten
</Typography>
<Typography variant="h6" fontWeight={700}>
{importStats.variantsLinked ?? 0}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
verknüpft
</Typography>
</Box>
<Box
sx={{
p: 1.5,
bgcolor: 'background.paper',
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Bilder
</Typography>
<Typography variant="h6" fontWeight={700}>
{importStats.articlePictures ?? 0}
</Typography>
{importStats.categoryPictures != null && (
<Typography variant="caption" color="text.secondary" display="block">
+{importStats.categoryPictures} Kat-Bilder
</Typography>
)}
</Box>
{importStats.brands != null && (
<Box
sx={{
p: 1.5,
bgcolor: 'background.paper',
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Marken
</Typography>
<Typography variant="h6" fontWeight={700}>
{importStats.brands ?? 0}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
{importStats.brandsCreated != null ? `+${importStats.brandsCreated} neu` : ''}
{importStats.brandPictures ? ` (${importStats.brandPictures} Logos)` : ''}
</Typography>
</Box>
)}
</Box>
</Box>
)}
<input
ref={this.fileInputRef}
type="file"
@@ -517,34 +642,15 @@ export default class ImportExportSettingsPanel extends Component {
/>
{!selectedFile ? (
<Box
onDragOver={this.handleDragOver}
onDragLeave={this.handleDragLeave}
onDrop={this.handleDrop}
onClick={() => this.fileInputRef.current?.click()}
sx={{
border: '2px dashed',
borderColor: isDraggingOver ? 'primary.main' : 'divider',
bgcolor: isDraggingOver ? 'action.hover' : 'background.default',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
mb: 2,
'&:hover': {
borderColor: 'primary.main',
bgcolor: 'action.hover',
},
}}
>
<CloudUploadOutlinedIcon sx={{ fontSize: 44, color: 'text.secondary', mb: 1 }} />
<Typography variant="body2" fontWeight={600}>
ZIP-Archiv hier ablegen
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
oder klicken, um eine ZIP-Datei auszuwählen (auch große Archive &gt; 1 GB)
</Typography>
<Box sx={{ mb: 2 }}>
<Button
variant="outlined"
startIcon={<InsertDriveFileOutlinedIcon />}
onClick={() => this.fileInputRef.current?.click()}
disabled={uploading}
>
ZIP-Datei auswählen
</Button>
</Box>
) : (
<Box
@@ -560,10 +666,10 @@ export default class ImportExportSettingsPanel extends Component {
bgcolor: 'background.default',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<InsertDriveFileOutlinedIcon color="primary" />
<Box>
<Typography variant="body2" fontWeight={600}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" fontWeight={600} noWrap>
{selectedFile.name}
</Typography>
<Typography variant="caption" color="text.secondary">
@@ -585,22 +691,88 @@ export default class ImportExportSettingsPanel extends Component {
</Box>
)}
{/* Real-time Streaming Progress Ticker */}
{uploading && (
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{uploadProgress}% ({formatBytes(uploadLoaded)} von {formatBytes(uploadTotal)})
</Typography>
<Button
size="small"
color="error"
onClick={this.handleCancelUpload}
sx={{ p: 0, minWidth: 'auto', fontSize: '0.75rem' }}
>
Abbrechen
</Button>
</Box>
<LinearProgress variant="determinate" value={uploadProgress} />
<Box sx={{ mb: 2, p: 2, bgcolor: 'action.hover', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
{uploadStage === 'upload' ? (
<>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" fontWeight={600} color="primary.main">
Datei wird übertragen…
</Typography>
<Button
size="small"
color="error"
onClick={this.handleCancelUpload}
sx={{ minWidth: 'auto', fontSize: '0.75rem', px: 1, py: 0.25 }}
>
Abbrechen
</Button>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{uploadProgress}% ({formatBytes(uploadLoaded)} von {formatBytes(uploadTotal)})
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={uploadProgress}
sx={{ height: 8, borderRadius: 1 }}
/>
</>
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" fontWeight={600} color="primary.main">
{(importPhase && PHASE_LABELS[importPhase]) || statusMessage || 'Verarbeitung läuft…'}
</Typography>
<Button
size="small"
color="error"
onClick={this.handleCancelUpload}
sx={{ minWidth: 'auto', fontSize: '0.75rem', px: 1, py: 0.25 }}
>
Abbrechen
</Button>
</Box>
{importPhaseTotal > 0 ? (
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography
variant="caption"
color="text.secondary"
sx={{
maxWidth: '75%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontFamily: 'monospace',
}}
>
{importItem || 'Fortschritt'}
</Typography>
<Typography variant="caption" fontWeight={600} color="text.secondary">
{importPhaseCurrent} / {importPhaseTotal} ({progressPercent}%)
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progressPercent ?? 0}
sx={{ height: 8, borderRadius: 1 }}
/>
</>
) : (
<>
{statusMessage && (
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
{statusMessage}
</Typography>
)}
<LinearProgress sx={{ height: 8, borderRadius: 1 }} />
</>
)}
</>
)}
</Box>
)}
@@ -617,7 +789,7 @@ export default class ImportExportSettingsPanel extends Component {
disabled={uploading || !selectedFile}
onClick={this.handleStartUpload}
>
{uploading ? 'Wird hochgeladen...' : 'Upload starten'}
{uploading ? 'Import läuft…' : 'Upload & Import starten'}
</Button>
</Box>
</Box>