u
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user