Files
dashfront/src/components/settings/ImportExportSettingsPanel.jsx
2026-10-10 18:26:15 +02:00

887 lines
32 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';
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 (
<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>
)}
{importStats.ordersImported != null && (
<Box
sx={{
p: 1.5,
bgcolor: 'background.paper',
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
Aufträge
</Typography>
<Typography variant="h6" fontWeight={700}>
{importStats.ordersImported}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
{importStats.ordersSkipped ? `+${importStats.ordersSkipped} übersprungen` : 'importiert'}
</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">
{statusMessage || 'Daten werden verarbeitet…'}
{overallPercent != null ? ` · ${overallPercent}%` : ''}
</Typography>
<Button
size="small"
color="error"
onClick={this.handleCancelUpload}
sx={{ minWidth: 'auto', fontSize: '0.75rem', px: 1, py: 0.25 }}
>
Abbrechen
</Button>
</Box>
{phaseRows.length > 0 ? (
<Stack spacing={1}>
{phaseRows.map((row) => (
<Box key={row.key}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary">
{row.label}
</Typography>
<Typography variant="caption" fontWeight={600} color="text.secondary">
{row.done} / {row.total} ({row.percent}%)
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={row.percent}
sx={{ height: 6, borderRadius: 1 }}
/>
</Box>
))}
</Stack>
) : (
<>
{statusMessage && (
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
{statusMessage}
</Typography>
)}
<LinearProgress sx={{ height: 8, borderRadius: 1 }} />
</>
)}
{/* Latest decoded SkeleHash placeholder (replaces previous) */}
{streamedHash && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mt: 2,
pt: 2,
borderTop: '1px dashed',
borderColor: 'divider',
}}
>
<Box
title={streamedHash}
sx={{
width: 36,
height: 36,
flex: '0 0 auto',
borderRadius: 1,
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
<SkeleHashImage hash={streamedHash} />
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontFamily: 'monospace' }}
>
{shortHash(streamedHash)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{importedImageCount} Bild(er) verarbeitet
</Typography>
</Box>
)}
</>
)}
</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>
);
}
}