From a0d71f70b7835c2c8c0a411ce85beaf18c6a1e6b Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 14 Oct 2026 15:07:47 +0200 Subject: [PATCH] u --- src/api/company.js | 140 ++++++++++++++++++ src/api/index.js | 2 +- .../settings/ImportExportSettingsPanel.jsx | 48 +++++- 3 files changed, 187 insertions(+), 3 deletions(-) diff --git a/src/api/company.js b/src/api/company.js index e107f85..9bcf2ae 100644 --- a/src/api/company.js +++ b/src/api/company.js @@ -193,4 +193,144 @@ export const company = { }); }, // #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); + diff --git a/src/api/index.js b/src/api/index.js index f51f868..a444375 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -4,7 +4,7 @@ export { attributename } from './attributename.js'; export { attributevalue } from './attributevalue.js'; export { brand } from './brand.js'; export { category } from './category.js'; -export { company } from './company.js'; +export { company, exportCompanyCatalog } from './company.js'; export { customergroup } from './customergroup.js'; export { delivery } from './delivery.js'; export { entity } from './entity.js'; diff --git a/src/components/settings/ImportExportSettingsPanel.jsx b/src/components/settings/ImportExportSettingsPanel.jsx index 546e239..10ee7d0 100644 --- a/src/components/settings/ImportExportSettingsPanel.jsx +++ b/src/components/settings/ImportExportSettingsPanel.jsx @@ -70,8 +70,9 @@ const EXPORT_OPTIONS = [ id: 'company', label: 'Gesamtsicherung / Mandant (ZIP)', desc: 'Vollständiger Datenbestand (Artikel, Kategorien, Kunden, Belege und Medien).', - endpoint: '/api/company/export.zip', + endpoint: '/api/company/export', defaultFilename: 'mandant-gesamtsicherung.zip', + isSse: true, }, ]; @@ -83,6 +84,7 @@ export default class ImportExportSettingsPanel extends Component { exportType: 'articles', downloading: false, downloadProgress: null, // null = unknown, number 0-100 + downloadStatus: null, downloadLoaded: 0, downloadTotal: 0, downloadError: null, @@ -138,6 +140,45 @@ export default class ImportExportSettingsPanel extends Component { 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', @@ -354,6 +395,7 @@ export default class ImportExportSettingsPanel extends Component { exportType, downloading, downloadProgress, + downloadStatus, downloadLoaded, downloadTotal, downloadError, @@ -454,7 +496,9 @@ export default class ImportExportSettingsPanel extends Component { - {downloadProgress != null + {downloadStatus + ? (downloadProgress != null ? `${downloadStatus} (${downloadProgress}%)` : downloadStatus) + : downloadProgress != null ? `${downloadProgress}% (${formatBytes(downloadLoaded)} von ${formatBytes(downloadTotal)})` : `${formatBytes(downloadLoaded)} geladen (wird gestreamt...)`}