u
This commit is contained in:
@@ -193,4 +193,144 @@ export const company = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/company/export
|
||||||
|
* Packages company catalog and streams export (.zip) archive via Server-Sent Events with base64 chunks.
|
||||||
|
*
|
||||||
|
* @param {object} [callbacks]
|
||||||
|
* @param {(message: string) => void} [callbacks.onStatus]
|
||||||
|
* @param {(progress: { stage: string, percent?: number, [key: string]: any }) => void} [callbacks.onProgress]
|
||||||
|
* @param {(chunk: string) => void} [callbacks.onChunk]
|
||||||
|
* @param {(donePayload: { ok: boolean, filename: string, size: number, totalChunks: number, stats?: object }) => void} [callbacks.onDone]
|
||||||
|
* @param {(error: string) => void} [callbacks.onError]
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {AbortSignal} [options.signal]
|
||||||
|
* @param {boolean} [options.triggerDownload=true] Automatically trigger browser download when complete
|
||||||
|
* @returns {Promise<{ blob: Blob, filename: string, size: number, totalChunks: number, stats?: object }>}
|
||||||
|
*/
|
||||||
|
// #region GET /api/company/export — exportCatalog
|
||||||
|
async exportCatalog(callbacks = {}, { signal, triggerDownload = true } = {}) {
|
||||||
|
const response = await fetch('/api/company/export', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'text/event-stream' },
|
||||||
|
credentials: 'include',
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errMessage = 'Export failed';
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errMessage = error.error || error.message || errMessage;
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse error
|
||||||
|
}
|
||||||
|
throw new Error(errMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
const base64Chunks = [];
|
||||||
|
let filename = 'company-export.zip';
|
||||||
|
let donePayload = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parts = buffer.split('\n\n');
|
||||||
|
buffer = parts.pop() ?? ''; // keep trailing incomplete message
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const lines = part.split('\n');
|
||||||
|
let event = 'message';
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) event = line.slice(7).trim();
|
||||||
|
else if (line.startsWith('data: ')) data = line.slice(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'status') {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data);
|
||||||
|
callbacks.onStatus?.(payload.message || data);
|
||||||
|
} catch {
|
||||||
|
callbacks.onStatus?.(data);
|
||||||
|
}
|
||||||
|
} else if (event === 'progress') {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data);
|
||||||
|
callbacks.onProgress?.(payload);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
} else if (event === 'chunk') {
|
||||||
|
base64Chunks.push(data);
|
||||||
|
callbacks.onChunk?.(data);
|
||||||
|
} else if (event === 'done') {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data);
|
||||||
|
donePayload = payload;
|
||||||
|
if (payload.filename) filename = payload.filename;
|
||||||
|
callbacks.onDone?.(payload);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
} else if (event === 'error') {
|
||||||
|
let errorMsg = 'Export error';
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data);
|
||||||
|
errorMsg = payload.message || errorMsg;
|
||||||
|
} catch {
|
||||||
|
errorMsg = data || errorMsg;
|
||||||
|
}
|
||||||
|
callbacks.onError?.(errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct Blob from base64 chunks
|
||||||
|
const byteArrays = base64Chunks.map((b64) => {
|
||||||
|
const binaryStr = atob(b64);
|
||||||
|
const bytes = new Uint8Array(binaryStr.length);
|
||||||
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob(byteArrays, { type: 'application/zip' });
|
||||||
|
|
||||||
|
if (triggerDownload) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
filename,
|
||||||
|
size: blob.size,
|
||||||
|
totalChunks: base64Chunks.length,
|
||||||
|
stats: donePayload?.stats,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const exportCompanyCatalog = (callbacks, options) => company.exportCatalog(callbacks, options);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export { attributename } from './attributename.js';
|
|||||||
export { attributevalue } from './attributevalue.js';
|
export { attributevalue } from './attributevalue.js';
|
||||||
export { brand } from './brand.js';
|
export { brand } from './brand.js';
|
||||||
export { category } from './category.js';
|
export { category } from './category.js';
|
||||||
export { company } from './company.js';
|
export { company, exportCompanyCatalog } from './company.js';
|
||||||
export { customergroup } from './customergroup.js';
|
export { customergroup } from './customergroup.js';
|
||||||
export { delivery } from './delivery.js';
|
export { delivery } from './delivery.js';
|
||||||
export { entity } from './entity.js';
|
export { entity } from './entity.js';
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ const EXPORT_OPTIONS = [
|
|||||||
id: 'company',
|
id: 'company',
|
||||||
label: 'Gesamtsicherung / Mandant (ZIP)',
|
label: 'Gesamtsicherung / Mandant (ZIP)',
|
||||||
desc: 'Vollständiger Datenbestand (Artikel, Kategorien, Kunden, Belege und Medien).',
|
desc: 'Vollständiger Datenbestand (Artikel, Kategorien, Kunden, Belege und Medien).',
|
||||||
endpoint: '/api/company/export.zip',
|
endpoint: '/api/company/export',
|
||||||
defaultFilename: 'mandant-gesamtsicherung.zip',
|
defaultFilename: 'mandant-gesamtsicherung.zip',
|
||||||
|
isSse: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ export default class ImportExportSettingsPanel extends Component {
|
|||||||
exportType: 'articles',
|
exportType: 'articles',
|
||||||
downloading: false,
|
downloading: false,
|
||||||
downloadProgress: null, // null = unknown, number 0-100
|
downloadProgress: null, // null = unknown, number 0-100
|
||||||
|
downloadStatus: null,
|
||||||
downloadLoaded: 0,
|
downloadLoaded: 0,
|
||||||
downloadTotal: 0,
|
downloadTotal: 0,
|
||||||
downloadError: null,
|
downloadError: null,
|
||||||
@@ -138,6 +140,45 @@ export default class ImportExportSettingsPanel extends Component {
|
|||||||
|
|
||||||
this.abortController = new AbortController();
|
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 {
|
try {
|
||||||
const response = await fetch(option.endpoint, {
|
const response = await fetch(option.endpoint, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -354,6 +395,7 @@ export default class ImportExportSettingsPanel extends Component {
|
|||||||
exportType,
|
exportType,
|
||||||
downloading,
|
downloading,
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
|
downloadStatus,
|
||||||
downloadLoaded,
|
downloadLoaded,
|
||||||
downloadTotal,
|
downloadTotal,
|
||||||
downloadError,
|
downloadError,
|
||||||
@@ -454,7 +496,9 @@ export default class ImportExportSettingsPanel extends Component {
|
|||||||
<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">
|
||||||
{downloadProgress != null
|
{downloadStatus
|
||||||
|
? (downloadProgress != null ? `${downloadStatus} (${downloadProgress}%)` : downloadStatus)
|
||||||
|
: downloadProgress != null
|
||||||
? `${downloadProgress}% (${formatBytes(downloadLoaded)} von ${formatBytes(downloadTotal)})`
|
? `${downloadProgress}% (${formatBytes(downloadLoaded)} von ${formatBytes(downloadTotal)})`
|
||||||
: `${formatBytes(downloadLoaded)} geladen (wird gestreamt...)`}
|
: `${formatBytes(downloadLoaded)} geladen (wird gestreamt...)`}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
Reference in New Issue
Block a user