Files
dashfront/src/components/settings/ImportExportSettingsPanel.jsx
2026-10-08 04:41:27 +02:00

801 lines
28 KiB
JavaScript

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 (
<Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}>
<Stack spacing={4}>
{/* SECTION 1: EXPORT (DOWNLOAD) */}
<Box
sx={{
p: 2.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1.5 }}>
<CloudDownloadOutlinedIcon color="primary" sx={{ fontSize: 28 }} />
<Box>
<Typography variant="subtitle1" fontWeight={600}>
Daten exportieren (Download)
</Typography>
<Typography variant="caption" color="text.secondary">
Große Datenmengen werden als ZIP-Archiv gestreamt.
</Typography>
</Box>
</Box>
<Divider sx={{ my: 1.5 }} />
{downloadError && (
<Alert severity="error" sx={{ mb: 2 }}>
{downloadError}
</Alert>
)}
<FormControl component="fieldset" fullWidth sx={{ mb: 2 }}>
<FormLabel component="legend" sx={{ mb: 1, fontSize: '0.85rem' }}>
Export-Umfang auswählen
</FormLabel>
<RadioGroup
value={exportType}
onChange={(e) => this.setState({ exportType: e.target.value })}
>
{EXPORT_OPTIONS.map((opt) => (
<FormControlLabel
key={opt.id}
value={opt.id}
disabled={downloading}
control={<Radio size="small" />}
label={
<Box sx={{ py: 0.5 }}>
<Typography variant="body2" fontWeight={500}>
{opt.label}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
{opt.desc}
</Typography>
</Box>
}
/>
))}
</RadioGroup>
</FormControl>
{downloading && (
<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">
{downloadProgress != null
? `${downloadProgress}% (${formatBytes(downloadLoaded)} von ${formatBytes(downloadTotal)})`
: `${formatBytes(downloadLoaded)} geladen (wird gestreamt...)`}
</Typography>
<Button
size="small"
color="error"
onClick={this.handleCancelDownload}
sx={{ p: 0, minWidth: 'auto', fontSize: '0.75rem' }}
>
Abbrechen
</Button>
</Box>
<LinearProgress
variant={downloadProgress != null ? 'determinate' : 'indeterminate'}
value={downloadProgress ?? undefined}
/>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button
variant="contained"
startIcon={
downloading ? (
<CircularProgress size={16} color="inherit" />
) : (
<CloudDownloadOutlinedIcon />
)
}
disabled={downloading}
onClick={this.handleStartDownload}
>
{downloading ? 'Herunterladen...' : 'Download starten'}
</Button>
</Box>
</Box>
{/* SECTION 2: IMPORT (UPLOAD) */}
<Box
sx={{
p: 2.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1.5 }}>
<CloudUploadOutlinedIcon color="primary" sx={{ fontSize: 28 }} />
<Typography variant="subtitle1" fontWeight={600}>
Import
</Typography>
</Box>
<Divider sx={{ my: 1.5 }} />
{uploadError && (
<Alert severity="error" sx={{ mb: 2 }}>
{uploadError}
</Alert>
)}
{uploadSuccess && (
<Alert severity="success" sx={{ mb: 2 }}>
{uploadSuccess}
</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"
accept=".zip,application/zip,application/x-zip-compressed"
style={{ display: 'none' }}
onChange={this.handleFileChange}
disabled={uploading}
/>
{!selectedFile ? (
<Box sx={{ mb: 2 }}>
<Button
variant="outlined"
startIcon={<InsertDriveFileOutlinedIcon />}
onClick={() => this.fileInputRef.current?.click()}
disabled={uploading}
>
ZIP-Datei auswählen
</Button>
</Box>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 1.5,
mb: 2,
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.default',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<InsertDriveFileOutlinedIcon color="primary" />
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" fontWeight={600} noWrap>
{selectedFile.name}
</Typography>
<Typography variant="caption" color="text.secondary">
Größe: {formatBytes(selectedFile.size)}
</Typography>
</Box>
</Box>
{!uploading && (
<IconButton
size="small"
onClick={() => {
this.setState({ selectedFile: null });
if (this.fileInputRef.current) this.fileInputRef.current.value = '';
}}
>
<CloseIcon fontSize="small" />
</IconButton>
)}
</Box>
)}
{/* Real-time Streaming Progress Ticker */}
{uploading && (
<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>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button
variant="contained"
startIcon={
uploading ? (
<CircularProgress size={16} color="inherit" />
) : (
<CloudUploadOutlinedIcon />
)
}
disabled={uploading || !selectedFile}
onClick={this.handleStartUpload}
>
{uploading ? 'Import läuft…' : 'Upload & Import starten'}
</Button>
</Box>
</Box>
</Stack>
</Box>
);
}
}