337 lines
11 KiB
JavaScript
337 lines
11 KiB
JavaScript
import { http } from './http.js';
|
|
|
|
export const company = {
|
|
/** GET /api/company → { companies: [...] } */
|
|
// #region GET /api/company — list
|
|
list() {
|
|
return http('/api/company');
|
|
},
|
|
// #endregion
|
|
|
|
/** Uses GET /api/company until a dedicated endpoint exists. */
|
|
// #region GET /api/company — getById
|
|
async getById(companyId) {
|
|
const data = await http('/api/company');
|
|
const found = data.companies?.find((item) => item.id === companyId);
|
|
if (!found) throw new Error('Unternehmen nicht gefunden.');
|
|
return { company: found };
|
|
},
|
|
// #endregion
|
|
|
|
// #region POST /api/company — create
|
|
create({ name, iso639, taxrate, reducedtaxrate }) {
|
|
return http('/api/company', {
|
|
method: 'POST',
|
|
body: { name, iso639, taxrate, reducedtaxrate },
|
|
});
|
|
},
|
|
// #endregion
|
|
|
|
/**
|
|
* PUT /api/company/:companyId
|
|
* Body: { name, iso639, taxrate, reducedtaxrate }
|
|
*/
|
|
// #region PUT /api/company/:companyId — update
|
|
update(companyId, { name, iso639, taxrate, reducedtaxrate }) {
|
|
return http(`/api/company/${companyId}`, {
|
|
method: 'PUT',
|
|
body: { name, iso639, taxrate, reducedtaxrate },
|
|
});
|
|
},
|
|
// #endregion
|
|
|
|
// #region PUT /api/auth/selected-company — select
|
|
select(companyId) {
|
|
return http('/api/auth/selected-company', {
|
|
method: 'PUT',
|
|
body: { companyId },
|
|
});
|
|
},
|
|
// #endregion
|
|
|
|
/**
|
|
* POST /api/company/import
|
|
* Uploads and streams catalog import (.zip) archive via Server-Sent Events.
|
|
* Tracks client upload progress (bytes/percentage) and processes incoming SSE events in real-time.
|
|
*
|
|
* @param {File|Blob|null} file
|
|
* @param {object} [callbacks]
|
|
* @param {(progress: { loaded: number, total: number, percent: number }) => void} [callbacks.onUploadProgress]
|
|
* @param {(message: string) => void} [callbacks.onStatus]
|
|
* @param {(progress: { categoriestotal: number, categoriesdone: number, articlestotal: number, articlesdone: number, imagestotal: number, imagesdone: number, receiptstotal: number, receiptsdone: number }) => void} [callbacks.onProgress] Counter-based progress snapshot across all phases.
|
|
* @param {(images: string[]) => void} [callbacks.onImages] Batched `images` events (~every 100ms) carrying Base62 SkeleHash strings.
|
|
* @param {(stats: object) => void} [callbacks.onDone]
|
|
* @param {(error: string) => void} [callbacks.onError]
|
|
* @param {object} [options]
|
|
* @param {AbortSignal} [options.signal]
|
|
*/
|
|
// #region POST /api/company/import — importCatalog
|
|
importCatalog(file, callbacks = {}, { signal } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', '/api/company/import');
|
|
xhr.withCredentials = true;
|
|
xhr.setRequestHeader('Accept', 'text/event-stream');
|
|
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
return reject(new DOMException('Aborted', 'AbortError'));
|
|
}
|
|
signal.addEventListener('abort', () => {
|
|
xhr.abort();
|
|
reject(new DOMException('Aborted', 'AbortError'));
|
|
});
|
|
}
|
|
|
|
// Track request body upload progress
|
|
if (file && xhr.upload) {
|
|
xhr.upload.onprogress = (event) => {
|
|
if (event.lengthComputable && event.total > 0) {
|
|
const percent = Math.min(100, Math.round((event.loaded / event.total) * 100));
|
|
callbacks.onUploadProgress?.({
|
|
loaded: event.loaded,
|
|
total: event.total,
|
|
percent,
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
// Stream and parse incoming SSE chunks as the server processes the archive
|
|
let processedIndex = 0;
|
|
let buffer = '';
|
|
|
|
const processChunks = () => {
|
|
const text = xhr.responseText || '';
|
|
if (text.length <= processedIndex) return;
|
|
const newChunk = text.slice(processedIndex);
|
|
processedIndex = text.length;
|
|
|
|
buffer += newChunk;
|
|
const messages = buffer.split('\n\n');
|
|
buffer = messages.pop() || '';
|
|
|
|
for (const msg of messages) {
|
|
if (!msg.trim()) continue;
|
|
|
|
const eventMatch = msg.match(/^event:\s*(\w+)/m);
|
|
const dataMatch = msg.match(/^data:\s*(.+)$/m);
|
|
|
|
const eventType = eventMatch ? eventMatch[1] : 'message';
|
|
let eventData = null;
|
|
if (dataMatch) {
|
|
try {
|
|
eventData = JSON.parse(dataMatch[1]);
|
|
} catch {
|
|
eventData = dataMatch[1];
|
|
}
|
|
}
|
|
|
|
switch (eventType) {
|
|
case 'status':
|
|
callbacks.onStatus?.(eventData?.message);
|
|
break;
|
|
case 'progress':
|
|
callbacks.onProgress?.(eventData);
|
|
break;
|
|
case 'images': {
|
|
// Payload is a plain array of Base62 SkeleHash strings;
|
|
// also tolerate the { images: [...] } wrapper shape.
|
|
const list = Array.isArray(eventData)
|
|
? eventData
|
|
: Array.isArray(eventData?.images)
|
|
? eventData.images
|
|
: [];
|
|
if (list.length) callbacks.onImages?.(list);
|
|
break;
|
|
}
|
|
case 'image':
|
|
// Legacy: single image event (pre-batching).
|
|
if (eventData) callbacks.onImages?.([eventData]);
|
|
break;
|
|
case 'done':
|
|
callbacks.onDone?.(eventData?.stats ?? eventData);
|
|
break;
|
|
case 'error':
|
|
callbacks.onError?.(eventData?.message || 'Fehler beim Importieren');
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
xhr.onprogress = processChunks;
|
|
|
|
xhr.onload = () => {
|
|
processChunks();
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
resolve();
|
|
} else {
|
|
let errMsg = `Upload fehlgeschlagen (HTTP ${xhr.status})`;
|
|
try {
|
|
const parsed = JSON.parse(xhr.responseText);
|
|
errMsg = parsed.error || parsed.message || errMsg;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
reject(new Error(errMsg));
|
|
}
|
|
};
|
|
|
|
xhr.onerror = () => {
|
|
reject(new Error('Netzwerkfehler beim Upload der Datei'));
|
|
};
|
|
|
|
xhr.onabort = () => {
|
|
reject(new DOMException('Aborted', 'AbortError'));
|
|
};
|
|
|
|
const formData = new FormData();
|
|
if (file) {
|
|
formData.append('file', file);
|
|
}
|
|
xhr.send(formData);
|
|
});
|
|
},
|
|
// #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);
|
|
|