This commit is contained in:
seb
2026-10-08 02:00:08 +02:00
parent 243eba5fc6
commit ab2e8c92fb
4 changed files with 732 additions and 27 deletions

View File

@@ -0,0 +1,628 @@
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 CloseIcon from '@mui/icons-material/Close';
import { enqueueSnackbar } from 'notistack';
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 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
selectedFile: null,
uploading: false,
uploadProgress: 0, // 0-100
uploadLoaded: 0,
uploadTotal: 0,
uploadError: null,
uploadSuccess: null,
isDraggingOver: false,
};
this.fileInputRef = createRef();
this.xhrRef = 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);
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.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,
});
}
}
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() {
const { selectedFile } = this.state;
if (!selectedFile) return;
this.setState({
uploading: true,
uploadProgress: 0,
uploadLoaded: 0,
uploadTotal: selectedFile.size,
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,
});
}
};
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.',
});
};
xhr.onabort = () => {
this.xhrRef = null;
this.setState({ uploading: false });
enqueueSnackbar('Upload abgebrochen.', { variant: 'info' });
};
xhr.send(formData);
}
handleCancelUpload() {
if (this.xhrRef) {
this.xhrRef.abort();
}
}
render() {
const {
exportType,
downloading,
downloadProgress,
downloadLoaded,
downloadTotal,
downloadError,
selectedFile,
uploading,
uploadProgress,
uploadLoaded,
uploadTotal,
uploadError,
uploadSuccess,
isDraggingOver,
} = this.state;
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 }} />
<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>
</Box>
<Divider sx={{ my: 1.5 }} />
{uploadError && (
<Alert severity="error" sx={{ mb: 2 }}>
{uploadError}
</Alert>
)}
{uploadSuccess && (
<Alert severity="success" sx={{ mb: 2 }}>
{uploadSuccess}
</Alert>
)}
<input
ref={this.fileInputRef}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
style={{ display: 'none' }}
onChange={this.handleFileChange}
disabled={uploading}
/>
{!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>
) : (
<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 }}>
<InsertDriveFileOutlinedIcon color="primary" />
<Box>
<Typography variant="body2" fontWeight={600}>
{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>
)}
{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>
)}
<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 ? 'Wird hochgeladen...' : 'Upload starten'}
</Button>
</Box>
</Box>
</Stack>
</Box>
);
}
}