887 lines
32 KiB
JavaScript
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>
|
|
);
|
|
}
|
|
}
|