u
This commit is contained in:
@@ -4,13 +4,8 @@ import Box from '@mui/material/Box';
|
|||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Divider from '@mui/material/Divider';
|
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 IconButton from '@mui/material/IconButton';
|
||||||
import LinearProgress from '@mui/material/LinearProgress';
|
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 Stack from '@mui/material/Stack';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import CloudDownloadOutlinedIcon from '@mui/icons-material/CloudDownloadOutlined';
|
import CloudDownloadOutlinedIcon from '@mui/icons-material/CloudDownloadOutlined';
|
||||||
@@ -51,42 +46,14 @@ const PROGRESS_PHASES = [
|
|||||||
{ key: 'receipts', label: 'Belege', totalKey: 'receiptstotal', doneKey: 'receiptsdone' },
|
{ 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 {
|
export default class ImportExportSettingsPanel extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
// Export state
|
// Export state
|
||||||
exportType: 'articles',
|
|
||||||
downloading: false,
|
downloading: false,
|
||||||
downloadProgress: null, // null = unknown, number 0-100
|
downloadProgress: null, // null = unknown, number 0-100
|
||||||
downloadStatus: null,
|
downloadStatus: null,
|
||||||
downloadLoaded: 0,
|
|
||||||
downloadTotal: 0,
|
|
||||||
downloadError: null,
|
downloadError: null,
|
||||||
|
|
||||||
// Import state (SSE streaming & upload tracking)
|
// Import state (SSE streaming & upload tracking)
|
||||||
@@ -127,20 +94,15 @@ export default class ImportExportSettingsPanel extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleStartDownload() {
|
async handleStartDownload() {
|
||||||
const { exportType } = this.state;
|
|
||||||
const option = EXPORT_OPTIONS.find((opt) => opt.id === exportType) ?? EXPORT_OPTIONS[0];
|
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
downloading: true,
|
downloading: true,
|
||||||
downloadProgress: null,
|
downloadProgress: null,
|
||||||
downloadLoaded: 0,
|
downloadStatus: null,
|
||||||
downloadTotal: 0,
|
|
||||||
downloadError: null,
|
downloadError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
|
|
||||||
if (option.isSse || option.id === 'company') {
|
|
||||||
try {
|
try {
|
||||||
await company.exportCatalog(
|
await company.exportCatalog(
|
||||||
{
|
{
|
||||||
@@ -164,7 +126,7 @@ export default class ImportExportSettingsPanel extends Component {
|
|||||||
{ signal: this.abortController.signal, triggerDownload: true }
|
{ signal: this.abortController.signal, triggerDownload: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
enqueueSnackbar(`${option.label} erfolgreich heruntergeladen.`, { variant: 'success' });
|
enqueueSnackbar('Katalog-Export erfolgreich heruntergeladen.', { variant: 'success' });
|
||||||
this.setState({ downloading: false, downloadProgress: 100, downloadStatus: null });
|
this.setState({ downloading: false, downloadProgress: 100, downloadStatus: null });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
@@ -176,78 +138,6 @@ export default class ImportExportSettingsPanel extends Component {
|
|||||||
this.setState({ downloading: false });
|
this.setState({ downloading: false });
|
||||||
this.abortController = null;
|
this.abortController = null;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
handleCancelDownload() {
|
||||||
@@ -392,12 +282,9 @@ export default class ImportExportSettingsPanel extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
exportType,
|
|
||||||
downloading,
|
downloading,
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
downloadStatus,
|
downloadStatus,
|
||||||
downloadLoaded,
|
|
||||||
downloadTotal,
|
|
||||||
downloadError,
|
downloadError,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
uploading,
|
uploading,
|
||||||
@@ -447,10 +334,10 @@ export default class ImportExportSettingsPanel extends Component {
|
|||||||
<CloudDownloadOutlinedIcon color="primary" sx={{ fontSize: 28 }} />
|
<CloudDownloadOutlinedIcon color="primary" sx={{ fontSize: 28 }} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle1" fontWeight={600}>
|
<Typography variant="subtitle1" fontWeight={600}>
|
||||||
Daten exportieren (Download)
|
Katalog exportieren (Download)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -463,44 +350,13 @@ export default class ImportExportSettingsPanel extends Component {
|
|||||||
</Alert>
|
</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 && (
|
{downloading && (
|
||||||
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1.5 }}>
|
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{downloadStatus
|
{downloadStatus
|
||||||
? (downloadProgress != null ? `${downloadStatus} (${downloadProgress}%)` : downloadStatus)
|
? (downloadProgress != null ? `${downloadStatus} (${downloadProgress}%)` : downloadStatus)
|
||||||
: downloadProgress != null
|
: (downloadProgress != null ? `${downloadProgress}% geladen...` : 'Export wird vorbereitet...')}
|
||||||
? `${downloadProgress}% (${formatBytes(downloadLoaded)} von ${formatBytes(downloadTotal)})`
|
|
||||||
: `${formatBytes(downloadLoaded)} geladen (wird gestreamt...)`}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -531,7 +387,7 @@ export default class ImportExportSettingsPanel extends Component {
|
|||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
onClick={this.handleStartDownload}
|
onClick={this.handleStartDownload}
|
||||||
>
|
>
|
||||||
{downloading ? 'Herunterladen...' : 'Download starten'}
|
{downloading ? 'Exportieren...' : 'Katalog exportieren'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user