This commit is contained in:
seb
2026-10-14 15:20:39 +02:00
parent a0d71f70b7
commit c54d932e36

View File

@@ -4,13 +4,8 @@ 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';
@@ -51,42 +46,14 @@ const PROGRESS_PHASES = [
{ 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',
defaultFilename: 'mandant-gesamtsicherung.zip',
isSse: true,
},
];
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
downloadStatus: null,
downloadLoaded: 0,
downloadTotal: 0,
downloadError: null,
// Import state (SSE streaming & upload tracking)
@@ -127,117 +94,40 @@ export default class ImportExportSettingsPanel extends Component {
}
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,
downloadStatus: null,
downloadError: null,
});
this.abortController = new AbortController();
if (option.isSse || option.id === 'company') {
try {
await company.exportCatalog(
{
onStatus: (message) => {
this.setState({ downloadStatus: message });
},
onProgress: (progress) => {
const percent = typeof progress?.percent === 'number' ? progress.percent : null;
const stage = progress?.stage;
this.setState((prev) => ({
downloadProgress: percent ?? prev.downloadProgress,
downloadStatus: stage
? `Verarbeite ${stage} (${percent != null ? `${percent}%` : '...'})`
: prev.downloadStatus,
}));
},
onError: (errMessage) => {
this.setState({ downloadError: errMessage });
},
},
{ signal: this.abortController.signal, triggerDownload: true }
);
enqueueSnackbar(`${option.label} erfolgreich heruntergeladen.`, { variant: 'success' });
this.setState({ downloading: false, downloadProgress: 100, downloadStatus: null });
} 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;
}
return;
}
try {
const response = await fetch(option.endpoint, {
method: 'GET',
credentials: 'include',
signal: this.abortController.signal,
});
await company.exportCatalog(
{
onStatus: (message) => {
this.setState({ downloadStatus: message });
},
onProgress: (progress) => {
const percent = typeof progress?.percent === 'number' ? progress.percent : null;
const stage = progress?.stage;
this.setState((prev) => ({
downloadProgress: percent ?? prev.downloadProgress,
downloadStatus: stage
? `Verarbeite ${stage} (${percent != null ? `${percent}%` : '...'})`
: prev.downloadStatus,
}));
},
onError: (errMessage) => {
this.setState({ downloadError: errMessage });
},
},
{ signal: this.abortController.signal, triggerDownload: true }
);
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 });
enqueueSnackbar('Katalog-Export erfolgreich heruntergeladen.', { variant: 'success' });
this.setState({ downloading: false, downloadProgress: 100, downloadStatus: null });
} catch (err) {
if (err.name === 'AbortError') {
enqueueSnackbar('Download abgebrochen.', { variant: 'info' });
@@ -392,12 +282,9 @@ export default class ImportExportSettingsPanel extends Component {
render() {
const {
exportType,
downloading,
downloadProgress,
downloadStatus,
downloadLoaded,
downloadTotal,
downloadError,
selectedFile,
uploading,
@@ -447,10 +334,10 @@ export default class ImportExportSettingsPanel extends Component {
<CloudDownloadOutlinedIcon color="primary" sx={{ fontSize: 28 }} />
<Box>
<Typography variant="subtitle1" fontWeight={600}>
Daten exportieren (Download)
Katalog exportieren (Download)
</Typography>
<Typography variant="caption" color="text.secondary">
Große Datenmengen werden als ZIP-Archiv gestreamt.
Exportiert den vollständigen Mandantenbestand (Kategorien, Artikel, Varianten, Marken, Pfand und Bilder) als ZIP-Archiv.
</Typography>
</Box>
</Box>
@@ -463,44 +350,13 @@ export default class ImportExportSettingsPanel extends Component {
</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">
{downloadStatus
? (downloadProgress != null ? `${downloadStatus} (${downloadProgress}%)` : downloadStatus)
: downloadProgress != null
? `${downloadProgress}% (${formatBytes(downloadLoaded)} von ${formatBytes(downloadTotal)})`
: `${formatBytes(downloadLoaded)} geladen (wird gestreamt...)`}
: (downloadProgress != null ? `${downloadProgress}% geladen...` : 'Export wird vorbereitet...')}
</Typography>
<Button
size="small"
@@ -531,7 +387,7 @@ export default class ImportExportSettingsPanel extends Component {
disabled={downloading}
onClick={this.handleStartDownload}
>
{downloading ? 'Herunterladen...' : 'Download starten'}
{downloading ? 'Exportieren...' : 'Katalog exportieren'}
</Button>
</Box>
</Box>