Files
dashfront/src/api/index.js
2026-10-09 19:44:55 +02:00

2257 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Single source of truth for all backend communication.
/** WebView consoles stringify objects as [object Object]; log JSON text. */
function logApi(tag, details) {
let text;
try {
text = JSON.stringify(details, null, 2);
} catch {
text = String(details);
}
console[tag === 'error-body' ? 'error' : 'log'](`[api] ${tag} ${text}`);
}
function readableHeaders(headers) {
const out = {};
headers.forEach((value, key) => {
out[key] = value;
});
return out;
}
const SSE_CLIENT_ID_KEY = 'sse.cid';
const CLIENT_ID_HEADER = 'X-Client-Id';
function getStoredClientId() {
try {
if (typeof window === 'undefined') return null;
return window.sessionStorage.getItem(SSE_CLIENT_ID_KEY);
} catch {
return null;
}
}
function clientIdHeaders() {
const cid = getStoredClientId();
return cid ? { [CLIENT_ID_HEADER]: cid } : {};
}
function isMutatingMethod(method) {
const upper = String(method || 'GET').toUpperCase();
return upper !== 'GET' && upper !== 'HEAD';
}
async function http(path, { method = 'GET', body } = {}) {
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
const headers = {};
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
if (isMutatingMethod(method)) {
Object.assign(headers, clientIdHeaders());
}
const url = new URL(path, window.location.origin).href;
const res = await fetch(path, {
method,
headers,
credentials: 'include',
body: isFormData ? body : (body ? JSON.stringify(body) : undefined),
});
if (!res.ok) {
let message = res.statusText;
let data;
try {
data = await res.json();
message = data.error || data.message || message;
} catch {
message = (await res.text().catch(() => message)) || message;
}
logApi('error-body', { method, path, status: res.status, message, data });
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
err.path = path;
err.method = method;
err.data = data;
throw err;
}
if (res.status === 204) return null;
const json = await res.json();
return json;
}
async function httpBinary(path, { method = 'POST', body, contentType, headers: extraHeaders } = {}) {
const url = new URL(path, window.location.origin).href;
logApi('request', {
method,
path,
url,
origin: window.location.origin,
credentials: 'include',
contentType,
bodyBytes: body?.byteLength ?? body?.size ?? null,
});
const res = await fetch(path, {
method,
headers: { 'Content-Type': contentType, ...clientIdHeaders(), ...(extraHeaders || {}) },
credentials: 'include',
body,
});
const responseHeaders = readableHeaders(res.headers);
logApi('response', {
method,
path,
status: res.status,
statusText: res.statusText,
ok: res.ok,
responseHeaders,
});
if (!res.ok) {
let message = res.statusText;
let data;
try {
data = await res.json();
message = data.error || data.message || message;
} catch {
message = (await res.text().catch(() => message)) || message;
}
logApi('error-body', { method, path, status: res.status, message, data });
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
err.path = path;
err.method = method;
err.data = data;
throw err;
}
if (res.status === 204) return null;
const json = await res.json();
logApi('ok-body', {
method,
path,
keys: json && typeof json === 'object' ? Object.keys(json) : typeof json,
hasArticle: Boolean(json?.article),
});
return json;
}
/** GET a file (e.g. a ZIP download). Returns a Blob on success; JSON errors on failure. */
async function httpGetBinary(path) {
logApi('request', {
method: 'GET',
path,
url: new URL(path, window.location.origin).href,
origin: window.location.origin,
credentials: 'include',
});
const res = await fetch(path, { method: 'GET', credentials: 'include' });
logApi('response', {
method: 'GET',
path,
status: res.status,
statusText: res.statusText,
ok: res.ok,
contentType: res.headers.get('content-type'),
responseHeaders: readableHeaders(res.headers),
});
if (!res.ok) {
let message = res.statusText;
let data;
try {
data = await res.json();
message = data.error || data.message || message;
} catch {
message = (await res.text().catch(() => message)) || message;
}
logApi('error-body', { method: 'GET', path, status: res.status, message, data });
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
err.path = path;
err.method = 'GET';
err.data = data;
throw err;
}
const blob = await res.blob();
return blob;
}
/**
* NOTE: there is no `articleIndex` namespace anymore.
*
* The whole-company search index (`GET /api/articleIndex`) was abandoned. Article
* lists are fetched per category via `category.getArticles(categoryId)`
* (`GET /api/category/:id/articles`); `categoryAutomat.loadTree()` warms every
* category view right after the tree loads, and `articleIndexAutomat` derives the
* client-side search index from those views.
*/
export const article = {
search({ target, filter, offset, maxCount } = {}) {
const params = new URLSearchParams({ target });
if (filter) params.set('filter', filter);
if (offset) params.set('offset', String(offset));
if (maxCount != null) params.set('maxCount', String(maxCount));
return http(`/api/article/search?${params}`);
},
/**
* GET /api/article/:sku → { article }
* Detail: prices[], photos[], eans[], brand, unit, categoryIds, …
* (Flat net/taxRate/fullHash/thumb* only on category tree / getCategoryArticles.)
*/
getBySku(sku) {
return http(`/api/article/${encodeURIComponent(sku)}`);
},
/**
* GET /api/article/history
* History of all articles with paging/sorting/filtering
*/
getHistory({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
const params = new URLSearchParams();
if (startRow != null) params.set('startRow', String(startRow));
if (endRow != null) params.set('endRow', String(endRow));
if (offset != null) params.set('offset', String(offset));
if (limit != null) params.set('limit', String(limit));
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
const qs = params.toString();
return http(`/api/article/history${qs ? `?${qs}` : ''}`);
},
/**
* GET /api/article/:sku/history
*/
getBySkuHistory(sku, { limit } = {}) {
const params = new URLSearchParams();
if (limit != null) params.set('limit', String(limit));
const qs = params.toString();
return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`);
},
/** POST /api/article → 201 { article } */
create({ categoryId, name, track_stock }) {
return http('/api/article', {
method: 'POST',
body: {
categoryId: Number(categoryId),
name,
...(track_stock !== undefined ? { track_stock: Boolean(track_stock) } : {}),
},
});
},
/**
* PATCH /api/article/:sku
* Body: at least one of { name, unitamount, unitSi, track_stock, desc, seo }
* unitSi must exist for selected company → 404 Unit not found
* → 200 { article } (same detail shape as GET: includes desc, seo)
*/
update(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}`, {
method: 'PATCH',
body,
});
},
/**
* PUT /api/article/:sku/track_stock
* Body: { track_stock: boolean }
* → 200 { article }
*/
setTrackStock(sku, body) {
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
return http(`/api/article/${encodeURIComponent(sku)}/track_stock`, {
method: 'PUT',
body: payload,
});
},
/**
* DELETE /api/article/:sku → 204
* Errors: 400 Article has children, 401, 403, 404 Article not found
*/
delete(sku, { cascade = false } = {}) {
const qs = cascade ? '?cascade=true' : '';
return http(`/api/article/${encodeURIComponent(sku)}${qs}`, {
method: 'DELETE',
});
},
/**
* POST /api/article/:sku/image
* Body: raw image bytes (Content-Type: image/webp, max 1000×1000)
* → 201 { image: { picId, sort, full, thumb, fullHash, thumbHash } }
*/
uploadImage(sku, file) {
return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, {
method: 'POST',
contentType: file?.type || 'image/webp',
body: file,
});
},
/**
* DELETE /api/article/:sku/image/:fullHash → 204
*/
deleteImage(sku, fullHash) {
return http(
`/api/article/${encodeURIComponent(sku)}/image/${encodeURIComponent(fullHash)}`,
{ method: 'DELETE' },
);
},
/**
* PUT /api/article/:sku/images/order
* Body: { fullHashes: string[] } (every image exactly once)
* → 200 { fullHashes: string[] }
*/
orderImages(sku, fullHashes) {
return http(`/api/article/${encodeURIComponent(sku)}/images/order`, {
method: 'PUT',
body: { fullHashes },
});
},
/**
* PUT /api/article/:sku/prices
* Body: { prices: [{ price (net), tax (rate), minmultiplier? }] }
* Replace-all POS customer group prices. Empty prices clears all POS prices.
* → 200 { article } (same shape as GET by SKU)
*/
setPrices(sku, prices) {
return http(`/api/article/${encodeURIComponent(sku)}/prices`, {
method: 'PUT',
body: { prices },
});
},
/**
* PUT /api/article/:sku/weight
* Body: { weight } → 200 { article }
*/
setWeight(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}/weight`, {
method: 'PUT',
body,
});
},
/**
* PUT /api/article/:sku/brand
* Body: { brandId: number | null } — null clears the brand.
* brandId must belong to the article's company → 404 Article or brand not found.
* → 200 { article } (same shape as GET by SKU)
*/
setBrandIdBySku(sku, brandId) {
return http(`/api/article/${encodeURIComponent(sku)}/brand`, {
method: 'PUT',
body: { brandId: brandId != null ? Number(brandId) : null },
});
},
/**
* PUT /api/article/:sku/eans
* Body: { eans: string[] } — replace-all. Empty array clears all.
* EANs must be non-empty unique strings → 400 Invalid eans.
* → 200 { article } (same shape as GET by SKU)
*/
setEansBySku(sku, eans) {
return http(`/api/article/${encodeURIComponent(sku)}/eans`, {
method: 'PUT',
body: { eans },
});
},
/**
* PUT /api/article/:sku/categories
* Body: { categoryIds: number[] } — replace-all. Empty array clears all.
* Categories must belong to the article's company; ids must be unique.
* → 200 { article } (same shape as GET by SKU)
*/
setCategories(sku, categoryIds) {
return http(`/api/article/${encodeURIComponent(sku)}/categories`, {
method: 'PUT',
body: { categoryIds },
});
},
/**
* GET /api/article/:sku/supplier → { suppliers: ArticleSupplier[] }
*/
getSuppliers(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/supplier`);
},
/**
* POST /api/article/:sku/supplier
* Body: { entityId, sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }
* → 201 { supplier: ArticleSupplier }
*/
addSupplier(sku, body) {
const payload = {
...body,
entityId: Number(body?.entityId),
};
return http(`/api/article/${encodeURIComponent(sku)}/supplier`, {
method: 'POST',
body: payload,
});
},
/**
* PATCH /api/article/:sku/supplier/:id
* Body: { sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }
* → 200 { supplier: ArticleSupplier }
*/
updateSupplier(sku, id, body) {
return http(
`/api/article/${encodeURIComponent(sku)}/supplier/${encodeURIComponent(id)}`,
{
method: 'PATCH',
body,
},
);
},
/**
* PUT /api/article/:sku/supplier/order
* Body: { ids: number[] }
* → 200 { ids: number[] }
*/
orderSuppliers(sku, ids) {
return http(`/api/article/${encodeURIComponent(sku)}/supplier/order`, {
method: 'PUT',
body: { ids },
});
},
/**
* DELETE /api/article/:sku/supplier/:id → 204
*/
deleteSupplier(sku, id) {
return http(
`/api/article/${encodeURIComponent(sku)}/supplier/${encodeURIComponent(id)}`,
{
method: 'DELETE',
},
);
},
/**
* GET /api/article/export.zip → application/zip (binary download)
* `artikel.zip` = artikel.csv (semicolon-separated, Windows-1252) +
* one `<fullHash>.avif` per article image (content-addressed, deduplicated).
* Optional `categoryId` restricts the export to that category and below.
* Errors: 400 No company selected, 401, 403, 404 (JSON { error }).
*/
exportZip(categoryId) {
const params = new URLSearchParams();
if (categoryId != null) params.set('categoryId', String(categoryId));
const qs = params.toString();
return httpGetBinary(`/api/article/export.zip${qs ? `?${qs}` : ''}`);
},
/**
* POST /api/article/:sku/print
* Body: { printerId, templateId, quantity }
* Prints the given template to the specified printer for this article.
*/
printLabel(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}/print`, {
method: 'POST',
body,
});
},
/**
* GET /api/article/:sku/attributevalue → { attributevalues: ArticleAttributeValue[] }
*/
getAttributeValues(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/attributevalue`);
},
/**
* PUT /api/article/:sku/attributevalue
* Body: { attributevalues: number[] | { attributeValueId: number, sort?: number }[] }
* → 200 { attributevalues: ArticleAttributeValue[] }
*/
setAttributeValues(sku, attributevalues) {
return http(`/api/article/${encodeURIComponent(sku)}/attributevalue`, {
method: 'PUT',
body: { attributevalues },
});
},
/**
* GET /api/article/:sku/property → { properties: ArticleProperty[] }
*/
getProperties(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/property`);
},
/**
* PUT /api/article/:sku/property
* Body: { properties: { propertyId: number, value: string, sort?: number }[] }
* → 200 { properties: ArticleProperty[] }
*/
setProperties(sku, properties) {
return http(`/api/article/${encodeURIComponent(sku)}/property`, {
method: 'PUT',
body: { properties },
});
},
/**
* DELETE /api/article/:sku/property/:propertyId → 200 { ok: true }
*/
deleteProperty(sku, propertyId) {
return http(
`/api/article/${encodeURIComponent(sku)}/property/${encodeURIComponent(propertyId)}`,
{ method: 'DELETE' }
);
},
/**
* POST /api/article/:sku/parent
* Body: { isParent: boolean, attributeNameId?: number }
*/
toggleParent(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}/parent`, {
method: 'POST',
body,
});
},
/**
* GET /api/article/:sku/children → { children: [...] }
*/
getChildren(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/children`);
},
/**
* POST /api/article/:sku/children
* Body: { attributeValueId?: number, value?: string, name?: string }
*/
addChild(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}/children`, {
method: 'POST',
body,
});
},
/**
* DELETE /api/article/:sku/children/:childSku
*/
removeChild(sku, childSku, options = {}) {
const qs = options?.deleteArticle ? '?deleteArticle=true' : '';
return http(
`/api/article/${encodeURIComponent(sku)}/children/${encodeURIComponent(childSku)}${qs}`,
{ method: 'DELETE' }
);
},
/**
* GET /api/article/:sku/articlelist → { items: [...] }
*/
getArticlelist(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/articlelist`);
},
/**
* PUT /api/article/:sku/articlelist
* Body: { items: [{ articleId, sku, count, sort }] }
*/
setArticlelist(sku, items) {
return http(`/api/article/${encodeURIComponent(sku)}/articlelist`, {
method: 'PUT',
body: Array.isArray(items) ? { items } : items,
});
},
/**
* GET /api/article/:sku/optionslist → { options: [...] }
*/
getOptionslist(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/optionslist`);
},
/**
* PUT /api/article/:sku/optionslist
* Body: { options: [{ articleId, sku, count, sort, mandated }] }
*/
setOptionslist(sku, options) {
return http(`/api/article/${encodeURIComponent(sku)}/optionslist`, {
method: 'PUT',
body: Array.isArray(options) ? { options } : options,
});
},
};
/**
* Company-scoped printers (selectedCompanyId from session).
* Shape: Printer = { id, address, type, model, serial, sort, desc }
*/
export const printer = {
/** GET /api/printer → { printers: Printer[] } */
list() {
return http('/api/printer');
},
/** GET /api/printer/:id → { printer: Printer } */
getById(id) {
return http(`/api/printer/${id}`);
},
/**
* POST /api/printer
* Body: { address, type, model, serial, sort, desc }
* → 201 { printer: Printer }
*/
create(body) {
return http('/api/printer', { method: 'POST', body });
},
/**
* PATCH /api/printer/:id
* Body: partial printer fields
* → 200 { printer: Printer }
*/
update(id, body) {
return http(`/api/printer/${id}`, { method: 'PATCH', body });
},
/** DELETE /api/printer/:id → 204 */
delete(id) {
return http(`/api/printer/${id}`, { method: 'DELETE' });
},
/**
* PUT /api/printer/order
* Body: { printerIds: number[] }
* → 200 { printerIds: number[] }
*/
order({ printerIds }) {
return http('/api/printer/order', { method: 'PUT', body: { printerIds } });
},
};
/**
* Company-scoped key/value settings (selectedCompanyId from session).
* Shape: Setting = { key, value, frontend }
*/
export const setting = {
/**
* GET /api/company/setting
* Optional query: { frontendOnly } → 200 { settings: Setting[] }
*/
list({ frontendOnly } = {}) {
const qs = frontendOnly ? '?frontendOnly=1' : '';
return http(`/api/company/setting${qs}`);
},
/** GET /api/company/setting/:key → { setting: Setting } */
get(key) {
return http(`/api/company/setting/${encodeURIComponent(key)}`);
},
/**
* PUT /api/company/setting/:key
* Body: { value, frontend } → 200 { setting: Setting }
*/
upsert(key, body) {
return http(`/api/company/setting/${encodeURIComponent(key)}`, {
method: 'PUT',
body,
});
},
};
/**
* Content-addressed thumb AVIF by fullHash (8-char alphanumeric, see dashback pic.js).
* Auth: session cookie. Browser-cacheable (private, immutable, ETag).
*/
export const thumb = {
/**
* GET /api/thumb/:fullHash → image/avif (thumb bytes)
* Use as <img src={thumb.url(fullHash)} /> (same-origin; cookies sent).
*/
url(fullHash) {
return `/api/thumb/${encodeURIComponent(fullHash)}`;
},
};
/**
* Company-scoped units (selectedCompanyId from session).
* Shape: { si, basesi, basesifactor, symbol, name }
*/
export const unit = {
/** GET /api/unit → { units: [...] } · 400 No company selected */
list() {
return http('/api/unit');
},
/**
* PUT /api/unit
* Body: { si, basesi, basesifactor, symbol, name }
* → 200 { unit }
*/
upsert(body) {
return http('/api/unit', { method: 'PUT', body });
},
/**
* DELETE /api/unit?si=… → 204
* 409 Unit in use
*/
delete(si) {
const params = new URLSearchParams({ si });
return http(`/api/unit?${params}`, { method: 'DELETE' });
},
};
export const category = {
/** GET /api/category/tree → { languageId, tree, articles } (company from session) */
getTree() {
return http('/api/category/tree');
},
/**
* GET /api/category/:id/articles → { articles: [...] }
* Article rows: sku, name, net, taxRate, fullHash, thumbW, thumbH, thumbHash, childCount, …
*/
getArticles(categoryId) {
return http(`/api/category/${categoryId}/articles`);
},
/** POST /api/category → 201 { category } */
create({ parentId, name }) {
return http('/api/category', {
method: 'POST',
body: { parentId: Number(parentId), name },
});
},
/**
* DELETE /api/category/:id → 204
* Query: ?recursive=true (cascades deletion of subcategories and articles)
*/
delete(categoryId, { recursive = false } = {}) {
const qs = recursive ? '?recursive=true' : '';
return http(`/api/category/${categoryId}${qs}`, { method: 'DELETE' });
},
/**
* PATCH /api/category/:id
* Body: { name } — renames and re-slugifies in the company default language.
* Empty name → 400 name is required.
* → 200 { category: { id, name, slug } }
*/
updateName(categoryId, name) {
return http(`/api/category/${categoryId}`, {
method: 'PATCH',
body: { name },
});
},
/**
* PUT /api/category/order
* Body: { parentId, categoryIds: number[] } — sibling order of the children
* under `parentId` (top-down). categoryIds must be exactly the current child
* set of parentId (same count, no foreign/duplicate ids) → 400 Invalid category order.
* → 200 { categoryIds: number[] }
*/
reorder(parentId, categoryIds) {
return http('/api/category/order', {
method: 'PUT',
body: { parentId, categoryIds },
});
},
/**
* PATCH /api/category/:id
* Body: { parentId } — moves the category under a new parent.
* → 200 { category: { id, parentId, sort } } (kein name/slug im Response)
*/
move(categoryId, parentId) {
return http(`/api/category/${categoryId}`, {
method: 'PATCH',
body: { parentId },
});
},
/**
* PATCH /api/category/:id
* Body: { pos: boolean } — toggles the POS (Point of Sale) flag.
* → 200 { category: { id, pos } }
*
* PROPOSED — backend needs to implement this field.
*/
updatePos(categoryId, pos) {
return http(`/api/category/${categoryId}`, {
method: 'PATCH',
body: { pos },
});
},
/**
* POST /api/category/:id/image
* Body: raw image bytes (Content-Type: image/webp)
* → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } }
*/
uploadImage(categoryId, file) {
return httpBinary(`/api/category/${categoryId}/image`, {
method: 'POST',
contentType: file?.type || 'image/webp',
body: file,
});
},
/**
* DELETE /api/category/:id/image
* → 200 { category: { id, thumbW: null, thumbH: null, thumbHash: null } }
*/
deleteImage(categoryId) {
return http(`/api/category/${categoryId}/image`, { method: 'DELETE' });
},
/**
* GET /api/category/export.zip → application/zip (binary download)
* `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252,
* depth-first, `Ebene N` name column per depth) + one `<fullHash>.avif`
* entry per category image (content-addressed, deduplicated).
* Errors: 400 No company selected, 401, 403, 404 (JSON { error }).
*/
exportZip() {
return httpGetBinary('/api/category/export.zip');
},
};
/**
* Company-scoped brands (selectedCompanyId from session).
* Shape: Brand = { id, name, gsprpinfo | null, pics: BrandPic[] }
*/
export const brand = {
/**
* GET /api/brand → { brands: Brand[], total, offset, limit }
* @param {{ offset?: number, limit?: number, includeSmallThumbs?: boolean }} [params]
*/
list(params) {
const offset = Number(params?.offset ?? 0);
const limit = Number(params?.limit ?? 0);
const includeSmallThumbs = params?.includeSmallThumbs;
const query = new URLSearchParams();
if (Number.isFinite(offset) && offset > 0) query.set('offset', String(offset));
if (Number.isFinite(limit) && limit > 0) query.set('limit', String(limit));
if (includeSmallThumbs != null) query.set('includeSmallThumbs', includeSmallThumbs ? 'true' : 'false');
const qs = query.toString();
return http(qs ? `/api/brand?${qs}` : '/api/brand');
},
/**
* POST /api/brand
* Body: { name, gsprpinfo? } — insert for the selected company.
* Empty name ⇒ 400 Invalid brand.
* → 201 { brand: Brand } · 409 Brand already exists
*/
create(body) {
return http('/api/brand', { method: 'POST', body });
},
/**
* PATCH /api/brand/:id
* Body: { name, gsprpinfo? } — rename / update info.
* → 200 { id, name, gsprpinfo, touchedArticleCount } · 404 Brand not found
*/
rename(id, body) {
return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'PATCH', body });
},
/**
* DELETE /api/brand/:id
* → 204 No Content · 404 Brand not found
*/
delete(id) {
return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
/**
* POST /api/brand/:id/image
* Accepts full-size image body (Content-Type: image/webp).
* → 201 { brand: { id, fullHash, thumbW, thumbH, thumbHash, pics } }
*/
addImage(brandId, file) {
return httpBinary(`/api/brand/${brandId}/image`, {
method: 'POST',
contentType: file?.type || 'image/webp',
body: file,
});
},
/**
* DELETE /api/brand/:id/image
* → 200 { brand: { id, thumbW: null, thumbH: null, thumbHash: null, pics: [] } }
*/
deleteImage(brandId) {
return http(`/api/brand/${brandId}/image`, { method: 'DELETE' });
},
};
export const attributename = {
/** GET /api/attributename → { attributenames: Attributename[] } */
list() {
return http('/api/attributename');
},
/** POST /api/attributename → 201 { attributename } */
create(body) {
return http('/api/attributename', { method: 'POST', body });
},
/** PATCH /api/attributename/:id → 200 { attributename } */
update(id, body) {
return http(`/api/attributename/${encodeURIComponent(id)}`, { method: 'PATCH', body });
},
/** DELETE /api/attributename/:id → 200 */
delete(id) {
return http(`/api/attributename/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
/** PUT /api/attributename/order → 200 */
order(attributenameIds) {
return http('/api/attributename/order', { method: 'PUT', body: { attributenameIds } });
},
};
export const attributevalue = {
/** GET /api/attributename/:attributeId/value → { values: AttributeValue[] } */
listByAttributeId(attributeId) {
return http(`/api/attributename/${encodeURIComponent(attributeId)}/value`);
},
/** POST /api/attributename/:attributeId/value → 201 { attributevalue } */
create(attributeId, body) {
return http(`/api/attributename/${encodeURIComponent(attributeId)}/value`, {
method: 'POST',
body,
});
},
/** PATCH /api/attributevalue/:id → 200 { attributevalue } */
update(id, body) {
return http(`/api/attributevalue/${encodeURIComponent(id)}`, { method: 'PATCH', body });
},
/** DELETE /api/attributevalue/:id → 200 */
delete(id) {
return http(`/api/attributevalue/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
/** PUT /api/attributename/:attributeId/value/order → 200 */
order(attributeId, attributevalueIds) {
return http(`/api/attributename/${encodeURIComponent(attributeId)}/value/order`, {
method: 'PUT',
body: { attributevalueIds },
});
},
};
export const propertyname = {
/** GET /api/propertyname → { propertynames: Propertyname[] } */
list() {
return http('/api/propertyname');
},
/** POST /api/propertyname → 201 { propertyname } */
create(body) {
return http('/api/propertyname', { method: 'POST', body });
},
/** PATCH /api/propertyname/:id → 200 { propertyname } */
update(id, body) {
return http(`/api/propertyname/${encodeURIComponent(id)}`, { method: 'PATCH', body });
},
/** DELETE /api/propertyname/:id → 200 */
delete(id) {
return http(`/api/propertyname/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
/** PUT /api/propertyname/order → 200 */
order(propertynameIds) {
return http('/api/propertyname/order', { method: 'PUT', body: { propertynameIds } });
},
};
/**
* Company-scoped suppliers / Lieferanten (selectedCompanyId from session).
* Shape: Supplier = { id, name, createdAt, updatedAt }
*/
export const supplier = {
/** GET /api/supplier → { suppliers: Supplier[] } */
list() {
return http('/api/supplier');
},
/**
* POST /api/supplier
* Body: { name } — empty name ⇒ 400 Name required.
* → 201 { supplier: Supplier }
*/
create(body) {
return http('/api/supplier', { method: 'POST', body });
},
/**
* PATCH /api/supplier/:id
* Body: { name } — empty name ⇒ 400 Name required.
* → 200 { supplier: Supplier }
*/
update(id, body) {
return http(`/api/supplier/${id}`, { method: 'PATCH', body });
},
/** DELETE /api/supplier/:id → 204 */
delete(id) {
return http(`/api/supplier/${id}`, { method: 'DELETE' });
},
/**
* GET /api/supplier/:id/articles?skus=...
* → 200 { articles: SupplierArticle[] }
*/
getArticles(id, skus) {
let query = '';
if (skus != null) {
const list = Array.isArray(skus) ? skus : [skus];
const filtered = list.map((s) => String(s).trim()).filter(Boolean);
if (filtered.length > 0) {
query = `?skus=${encodeURIComponent(filtered.join(','))}`;
}
}
return http(`/api/supplier/${encodeURIComponent(id)}/articles${query}`);
},
};
/**
* Company-scoped warehouses / Lager (selectedCompanyId from session).
* Shape: Warehouse = { id, name }
*/
export const warehouse = {
/** GET /api/warehouse → { warehouses: Warehouse[] } */
list() {
return http('/api/warehouse');
},
/** GET /api/warehouse/:id → { warehouse: Warehouse } */
getById(id) {
return http(`/api/warehouse/${id}`);
},
/**
* POST /api/warehouse
* Body: { name, sort? } — empty name ⇒ 400 Name required.
* → 201 { warehouse: Warehouse }
*/
create(body) {
return http('/api/warehouse', { method: 'POST', body });
},
/**
* PATCH /api/warehouse/:id
* Body: { name?, sort? }
* → 200 { warehouse: Warehouse }
*/
update(id, body) {
return http(`/api/warehouse/${id}`, { method: 'PATCH', body });
},
/**
* PUT /api/warehouse/order
* Body: { warehouseIds: number[] }
* → 200 { warehouseIds: number[] }
*/
order(body) {
return http('/api/warehouse/order', { method: 'PUT', body });
},
/** DELETE /api/warehouse/:id → 204 */
delete(id) {
return http(`/api/warehouse/${id}`, { method: 'DELETE' });
},
};
/**
* Stock operations (manual stock adjustment).
*/
export const stock = {
/**
* POST /api/stock/adjust
* Body: { sku, warehouseId, amount, text?, date?, externalNumber?, lines? }
*/
adjust(body) {
return http('/api/stock/adjust', { method: 'POST', body });
},
};
/**
* Company-scoped goods receipts (manifest type = delivery).
* DeliverySummary = { id, number, externalNumber, type: 'delivery', supplierId, date }
* - number: server-allocated, read-only manifest number (from nextNumber('delivery')).
* - externalNumber: user-entered reference (nullable).
* DeliveryLine = { id, articleId, sku, amount, net, transactedAmount, rebate, tax, comment }
* Delivery = DeliverySummary & { lines: DeliveryLine[] }
* DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? }
*/
export const delivery = {
/** GET /api/delivery → { deliveries: DeliverySummary[] } */
list() {
return http('/api/delivery');
},
/** GET /api/delivery/:id → { delivery: Delivery } */
get(id) {
return http(`/api/delivery/${id}`);
},
/**
* POST /api/delivery
* Body: { externalNumber?, date, supplierId?, lines: DeliveryLineInput[] }
* number is NOT sent — the server allocates it automatically. The
* caller-supplied reference goes in externalNumber (nullable).
* Line requires sku, amount, net, tax. rebate→0, transactedAmount→amount.
* → 201 { delivery: Delivery }
*/
create(body) {
return http('/api/delivery', { method: 'POST', body });
},
/**
* PATCH /api/delivery/:id
* Body: partial { externalNumber?, date?, supplierId?, lines? }.
* number is read-only and cannot be edited. externalNumber is editable and
* can be cleared by sending null or an empty string.
* If lines present, replaces all lines; if omitted, lines unchanged.
* → 200 { delivery: Delivery }
*/
update(id, body) {
return http(`/api/delivery/${id}`, { method: 'PATCH', body });
},
/** DELETE /api/delivery/:id → 204 */
delete(id) {
return http(`/api/delivery/${id}`, { method: 'DELETE' });
},
/**
* GET /api/delivery/:id/file/:fileId → Blob
* Full binary content of attached file (PDF, text, etc.)
*/
async getFile(id, fileId, { download = false } = {}) {
const query = download ? '?download=true' : '';
const res = await fetch(`/api/delivery/${encodeURIComponent(id)}/file/${encodeURIComponent(fileId)}${query}`, {
method: 'GET',
credentials: 'include',
});
if (!res.ok) {
let message = res.statusText;
try {
const data = await res.json();
message = data.error || data.message || message;
} catch { /* ignore */ }
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
return res.blob();
},
/**
* POST /api/delivery/parse — multipart/form-data, field "file"
* Accepts PDF, TXT or CSV document (max 50 MB)
* Streams SSE events: status, llm_call, tool_call, usage, progress, result, error
*/
async *parseSse(file, { signal } = {}) {
const formData = new FormData();
formData.append('file', file, file.name);
const res = await fetch('/api/delivery/parse', {
method: 'POST',
headers: { ...clientIdHeaders() },
credentials: 'include',
body: formData,
signal,
});
if (!res.ok) {
let message = res.statusText;
try {
const data = await res.json();
message = data.error || data.message || message;
} catch { /* ignore */ }
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let currentEvent = 'message';
const onAbort = () => {
try {
reader.cancel();
} catch { /* ignore */ }
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const raw = line.slice(5).trim();
let data;
try { data = JSON.parse(raw); } catch { data = raw; }
yield { event: currentEvent, data };
currentEvent = 'message';
}
}
}
} catch (err) {
if (signal?.aborted || err.name === 'AbortError') {
return;
}
throw err;
} finally {
if (signal) {
signal.removeEventListener('abort', onAbort);
}
try {
reader.releaseLock();
} catch { /* ignore */ }
}
},
};
/**
* Serper Google Images search and WebP thumbnail streaming.
*/
export const serper = {
/**
* POST /api/serper/images
* Body: { q, gl, hl, num, preferSource }
* Streams SSE events: images, thumb, done, error
*
* @param {object} params
* @param {string} params.q - Search query
* @param {string} [params.gl='de'] - Country code
* @param {string} [params.hl='de'] - Language code
* @param {number} [params.num=100] - Result count (1-100)
* @param {boolean} [params.preferSource=false] - Prefer high-res source image
* @param {object} [options]
* @param {AbortSignal} [options.signal] - Abort controller signal
* @returns {AsyncGenerator<{event: string, data: any}>}
*/
async *searchImages(
{ q, gl = 'de', hl = 'de', num = 100, preferSource = false } = {},
{ signal } = {},
) {
const res = await fetch('/api/serper/images', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...clientIdHeaders(),
},
credentials: 'include',
body: JSON.stringify({ q, gl, hl, num, preferSource }),
signal,
});
if (!res.ok) {
let message = res.statusText;
try {
const data = await res.json();
message = data.error || data.message || message;
} catch {
/* ignore */
}
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let currentEvent = 'message';
const onAbort = () => {
try {
reader.cancel();
} catch {
/* ignore */
}
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const raw = line.slice(5).trim();
let data;
try {
data = JSON.parse(raw);
} catch {
data = raw;
}
yield { event: currentEvent, data };
currentEvent = 'message';
}
}
}
} catch (err) {
if (signal?.aborted || err.name === 'AbortError') {
return;
}
throw err;
} finally {
if (signal) {
signal.removeEventListener('abort', onAbort);
}
try {
reader.releaseLock();
} catch {
/* ignore */
}
}
},
/**
* POST /api/serper/image
* Crawl image from URL, crop white/transparency margins, resize to max 1000px, return AVIF Blob.
*
* @param {string} url - External image URL to crawl and process
* @param {object} [options]
* @param {AbortSignal} [options.signal] - Abort controller signal
* @returns {Promise<Blob>} AVIF image blob
*/
async fetchImage(url, { signal } = {}) {
const res = await fetch('/api/serper/image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...clientIdHeaders(),
},
credentials: 'include',
body: JSON.stringify({ url }),
signal,
});
if (!res.ok) {
let message = res.statusText;
try {
const data = await res.json();
message = data.error || data.message || message;
} catch {
/* ignore */
}
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
return await res.blob();
},
};
/**
* Append-only stock and payment ledger.
* Entry = { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }
* Input = { manifestarticle_id, transaction_type, amount, transaction_text }
*/
export const ledger = {
/**
* POST /api/ledger
* Body: { manifestarticle_id, transaction_type, amount, transaction_text }
* → 201 { ledger: LedgerEntry }
*/
add(body) {
return http('/api/ledger', { method: 'POST', body });
},
/**
* GET /api/ledger/manifestarticle/:manifestarticleId
* Query: ?transaction_type=...
* → 200 { ledger: LedgerEntry[] }
*/
listByManifestarticleId(manifestarticleId, transactionType) {
const query = transactionType ? `?transaction_type=${encodeURIComponent(transactionType)}` : '';
return http(`/api/ledger/manifestarticle/${encodeURIComponent(manifestarticleId)}${query}`);
},
/**
* GET /api/ledger/sku/:sku
* Query: ?transaction_type=...
* → 200 { ledger: LedgerEntry[] }
*/
listBySku(sku, transactionType) {
const query = transactionType ? `?transaction_type=${encodeURIComponent(transactionType)}` : '';
return http(`/api/ledger/sku/${encodeURIComponent(sku)}${query}`);
},
/**
* GET /api/ledger
* Query: { manifestarticle_id?, sku?, transaction_type? }
* → 200 { ledger: LedgerEntry[] }
*/
list(query) {
const params = new URLSearchParams();
if (query?.manifestarticle_id != null) params.set('manifestarticle_id', query.manifestarticle_id);
if (query?.manifestarticleId != null) params.set('manifestarticle_id', query.manifestarticleId);
if (query?.sku != null) params.set('sku', query.sku);
if (query?.transaction_type != null) params.set('transaction_type', query.transaction_type);
if (query?.transactionType != null) params.set('transaction_type', query.transactionType);
const qs = params.toString();
return http(`/api/ledger${qs ? `?${qs}` : ''}`);
},
};
/**
* Sales quotes (manifest type = quote).
* Summary = { id, number, type: 'quote', entityId, date }
* Line = { id, articleId, sku, amount, net, rebate, tax, comment } — no transactedAmount
* LineInput = { sku, amount, net, tax, rebate?, comment? }
*/
export const quote = {
/** GET /api/quote → { quotes: QuoteSummary[] } */
list() {
return http('/api/quote');
},
/** GET /api/quote/:id → { quote: Quote } */
get(id) {
return http(`/api/quote/${id}`);
},
/**
* POST /api/quote
* Body: { number, date, entityId?, lines }
* → 201 { quote: Quote }
*/
create(body) {
return http('/api/quote', { method: 'POST', body });
},
/**
* PATCH /api/quote/:id
* Body: partial { number?, date?, entityId?, lines? }
* → 200 { quote: Quote }
*/
update(id, body) {
return http(`/api/quote/${id}`, { method: 'PATCH', body });
},
/** DELETE /api/quote/:id → 204 */
delete(id) {
return http(`/api/quote/${id}`, { method: 'DELETE' });
},
};
/**
* Sales orders (manifest type = order).
* Summary = { id, number, type: 'order', entityId, date }
* Line shape same as quote (no transactedAmount).
*/
export const order = {
/**
* GET /api/order?offset=0&limit=20
* @param {{ offset?: number, limit?: number }} [params]
* → { orders: Order[], total: number, offset: number, limit: number }
*/
list({ offset = 0, limit = 20 } = {}) {
const params = new URLSearchParams();
if (offset != null) params.set('offset', String(offset));
if (limit != null) params.set('limit', String(limit));
const qs = params.toString();
return http(`/api/order${qs ? `?${qs}` : ''}`);
},
/** GET /api/order/:id → { order: Order } */
get(id) {
return http(`/api/order/${id}`);
},
/**
* POST /api/order
* Body: { number, date, entityId?, lines }
* → 201 { order: Order }
*/
create(body) {
return http('/api/order', { method: 'POST', body });
},
/**
* PATCH /api/order/:id
* Body: partial { number?, date?, entityId?, lines? }
* → 200 { order: Order }
*/
update(id, body) {
return http(`/api/order/${id}`, { method: 'PATCH', body });
},
/** DELETE /api/order/:id → 204 */
delete(id) {
return http(`/api/order/${id}`, { method: 'DELETE' });
},
};
/**
* Company-scoped customer groups.
* Shape: CustomerGroup = { id, name, sort }
* List item: CustomerGroup & { editable } — editable false for POS / Lieferanten
* (name locked). Reorder via PUT /api/customergroup/order.
*/
export const customergroup = {
/** GET /api/customergroup → { customergroups: (CustomerGroup & { editable })[] } ordered by sort */
list() {
return http('/api/customergroup');
},
/**
* POST /api/customergroup
* Body: { name } — empty name ⇒ 400 Name required;
* reserved POS / Lieferanten ⇒ 400 Name not allowed; sort appends next index.
* → 201 { customergroup: CustomerGroup }
*/
create(body) {
return http('/api/customergroup', { method: 'POST', body });
},
/**
* PATCH /api/customergroup/:id
* Body: { name } — locked groups cannot be renamed (Name cannot be changed).
* → 200 { customergroup: CustomerGroup }
*/
update(id, body) {
return http(`/api/customergroup/${id}`, { method: 'PATCH', body });
},
/**
* PUT /api/customergroup/order
* Body: { customergroupIds: number[] } — must be exactly the company set (same ids, no dups)
* else 400 Invalid customer group order. Sets sort to each id’s index 0…n-1.
* → 200 { customergroupIds: number[] }
*/
order(body) {
return http('/api/customergroup/order', { method: 'PUT', body });
},
/** DELETE /api/customergroup/:id → 204 · 409 Customer group in use */
delete(id) {
return http(`/api/customergroup/${id}`, { method: 'DELETE' });
},
};
/**
* Company-scoped entities (customers / contacts in a customergroup).
* Shape: Entity = { id, customergroupId, firstName, name, createdAt, updatedAt }
*/
export const entity = {
/**
* GET /api/entity → { entities: Entity[] }
* Optional query: customergroupId
*/
list({ customergroupId } = {}) {
const qs =
customergroupId != null
? `?customergroupId=${encodeURIComponent(customergroupId)}`
: '';
return http(`/api/entity${qs}`);
},
/** GET /api/entity/:id → { entity: Entity } */
get(id) {
return http(`/api/entity/${id}`);
},
/**
* POST /api/entity
* Body: { customergroupId, name, firstName? }
* → 201 { entity: Entity }
*/
create(body) {
return http('/api/entity', { method: 'POST', body });
},
/**
* PATCH /api/entity/:id
* Body: { name?, firstName? }
* → 200 { entity: Entity }
*/
update(id, body) {
return http(`/api/entity/${id}`, { method: 'PATCH', body });
},
/** DELETE /api/entity/:id → 204 */
delete(id) {
return http(`/api/entity/${id}`, { method: 'DELETE' });
},
/** GET /api/entity/next-number → { number: string } */
nextNumber() {
return http('/api/entity/next-number');
},
/** GET /api/entity/:entityId/address → { addresses: EntityAddress[] } */
listAddresses(entityId) {
return http(`/api/entity/${entityId}/address`);
},
/** POST /api/entity/:entityId/address → 201 { address } */
createAddress(entityId, body) {
return http(`/api/entity/${entityId}/address`, { method: 'POST', body });
},
/** PATCH /api/entity/:entityId/address/:id → { address } */
updateAddress(entityId, id, body) {
return http(`/api/entity/${entityId}/address/${id}`, {
method: 'PATCH',
body,
});
},
/** DELETE /api/entity/:entityId/address/:id → 204 */
deleteAddress(entityId, id) {
return http(`/api/entity/${entityId}/address/${id}`, { method: 'DELETE' });
},
/** GET /api/entity/:entityId/contact → { contacts: EntityContact[] } */
listContacts(entityId) {
return http(`/api/entity/${entityId}/contact`);
},
/** POST /api/entity/:entityId/contact → 201 { contact } */
createContact(entityId, body) {
return http(`/api/entity/${entityId}/contact`, { method: 'POST', body });
},
/** PATCH /api/entity/:entityId/contact/:id → { contact } */
updateContact(entityId, id, body) {
return http(`/api/entity/${entityId}/contact/${id}`, {
method: 'PATCH',
body,
});
},
/** DELETE /api/entity/:entityId/contact/:id → 204 */
deleteContact(entityId, id) {
return http(`/api/entity/${entityId}/contact/${id}`, { method: 'DELETE' });
},
};
export const company = {
/** GET /api/company → { companies: [...] } */
list() {
return http('/api/company');
},
/** Uses GET /api/company until a dedicated endpoint exists. */
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 };
},
create({ name, iso639, taxrate, reducedtaxrate }) {
return http('/api/company', {
method: 'POST',
body: { name, iso639, taxrate, reducedtaxrate },
});
},
/**
* PUT /api/company/:companyId
* Body: { name, iso639, taxrate, reducedtaxrate }
*/
update(companyId, { name, iso639, taxrate, reducedtaxrate }) {
return http(`/api/company/${companyId}`, {
method: 'PUT',
body: { name, iso639, taxrate, reducedtaxrate },
});
},
select(companyId) {
return http('/api/auth/selected-company', {
method: 'PUT',
body: { companyId },
});
},
/**
* 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: { phase: string, current: number, total: number, item?: string }) => void} [callbacks.onProgress]
* @param {(stats: object) => void} [callbacks.onDone]
* @param {(error: string) => void} [callbacks.onError]
* @param {object} [options]
* @param {AbortSignal} [options.signal]
*/
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 '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);
});
},
};
export const posterminal = {
/**
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin }] }
* List all terminals for the selected company.
*/
list() {
return http('/api/posterminal');
},
/**
* PUT /api/posterminal
* Body: { name: string }
* Creates a new terminal (auto-generates 6-digit pin).
* → 200 { posterminal: { id, name, token, pairingpin } }
*/
create({ name }) {
return http('/api/posterminal', {
method: 'PUT',
body: { name },
});
},
/**
* PATCH /api/posterminal/:id
* Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string }
* Update posterminal configuration.
* → 200 { posterminal }
*/
update(terminalId, patch) {
return http(`/api/posterminal/${terminalId}`, {
method: 'PATCH',
body: patch,
});
},
/**
* DELETE /api/posterminal/:id → 204
* Deletes a terminal.
*/
delete(terminalId) {
return http(`/api/posterminal/${terminalId}`, {
method: 'DELETE',
});
},
/**
* PUT /api/posterminal/:id/category/:categoryId
* Enable a category on a terminal.
* → 200 { id: number, categoryIds: number[] }
*/
enableCategory(terminalId, categoryId) {
return http(`/api/posterminal/${terminalId}/category/${categoryId}`, {
method: 'PUT',
});
},
/**
* DELETE /api/posterminal/:id/category/:categoryId
* Disable a category on a terminal.
* → 200 { id: number, categoryIds: number[] }
*/
disableCategory(terminalId, categoryId) {
return http(`/api/posterminal/${terminalId}/category/${categoryId}`, {
method: 'DELETE',
});
},
/**
* PUT /api/posterminal/:id/all
* Enable all categories on a terminal.
* → 200 { id: number, categoryIds: number[] }
*/
enableAllCategories(terminalId) {
return http(`/api/posterminal/${terminalId}/all`, {
method: 'PUT',
});
},
/**
* DELETE /api/posterminal/:id/all
* Disable all categories on a terminal.
* → 200 { id: number, categoryIds: number[] }
*/
disableAllCategories(terminalId) {
return http(`/api/posterminal/${terminalId}/all`, {
method: 'DELETE',
});
},
};
export const number = {
/**
* GET /api/number → { numbers: [{ id, type, prefix, padding, next }] }
* Lists the number sequences for the selected company.
*/
list() {
return http('/api/number');
},
/**
* PATCH /api/number/:id
* Body: at least one of { prefix, padding, next } — only present keys are patched.
* → 200 { number: { id, type, prefix, padding, next } }
*/
update(id, body) {
return http(`/api/number/${id}`, { method: 'PATCH', body });
},
};
export const auth = {
async me() {
try {
const data = await http('/api/auth/me');
return {
user: data.user ?? null,
googleClientId: data.googleClientId ?? null,
};
} catch (err) {
if (err.status === 401 || err.status === 403) {
return {
user: null,
googleClientId: err.data?.googleClientId ?? null,
forbidden: err.status === 403,
message: err.message,
};
}
throw err;
}
},
async loginWithGoogle(idToken) {
logApi('loginWithGoogle', {
idTokenLength: idToken?.length ?? 0,
idTokenPrefix: idToken ? `${idToken.slice(0, 12)}…` : null,
});
const data = await http('/api/auth/login', {
method: 'POST',
body: { idToken },
});
// Confirm whether the session cookie actually works (not just login JSON).
const session = await auth.me();
logApi('session-after-login', {
loginUser: data.user?.email ?? data.user?.sub ?? data.user ?? null,
meUser: session.user?.email ?? session.user?.sub ?? session.user ?? null,
meForbidden: session.forbidden ?? false,
meMessage: session.message ?? null,
sessionOk: Boolean(session.user),
});
return { user: data.user, session };
},
async logout() {
try {
await http('/api/auth/logout', { method: 'POST' });
} catch (err) {
if (err.status !== 401) throw err;
}
},
};
/**
* Company-scoped ZPL label templates (selectedCompanyId from session).
* Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb }
*/
export const labeltemplate = {
/** GET /api/labeltemplate → { labeltemplates: LabelTemplate[] } */
list() {
return http('/api/labeltemplate');
},
/** GET /api/labeltemplate/:id → { labeltemplate: LabelTemplate } */
get(id) {
return http(`/api/labeltemplate/${encodeURIComponent(id)}`);
},
/**
* POST /api/labeltemplate
* Body: { name, width, height, dpi, type, content }
* → 201 { labeltemplate: LabelTemplate }
*/
create(body) {
return http('/api/labeltemplate', { method: 'POST', body });
},
/**
* PATCH /api/labeltemplate/:id
* Body: partial { name?, width?, height?, dpi?, type?, content? }
* → 200 { labeltemplate: LabelTemplate }
*/
update(id, body) {
return http(`/api/labeltemplate/${encodeURIComponent(id)}`, { method: 'PATCH', body });
},
/** DELETE /api/labeltemplate/:id → 204 */
delete(id) {
return http(`/api/labeltemplate/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
// thumb is returned inline as a base64 string in list/get responses
};
export const globalHistory = {
/**
* GET /api/history
* Global system/entity history with paging/sorting/filtering
*/
list({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
const params = new URLSearchParams();
if (startRow != null) params.set('startRow', String(startRow));
if (endRow != null) params.set('endRow', String(endRow));
if (offset != null) params.set('offset', String(offset));
if (limit != null) params.set('limit', String(limit));
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
const qs = params.toString();
return http(`/api/history${qs ? `?${qs}` : ''}`);
},
};
export const modelconversation = {
/**
* GET /api/modelconversation
* Lists the user's model conversations in the selected company.
* → 200 { conversations: [{ id, companyuser_id, provider, model, created_at, updated_at, state }] }
*/
list() {
return http('/api/modelconversation');
},
/**
* GET /api/modelconversation/:id
* Fetch conversation details by ID.
* @param {number|string} id
*/
get(id) {
return http(`/api/modelconversation/${encodeURIComponent(id)}`);
},
/**
* POST /api/modelconversation
* Creates a new conversation in state 'idle'.
* Body: { provider, model }
* → 201 { conversation: { id, companyuser_id, provider, model, created_at, updated_at, state } }
*/
create(body) {
return http('/api/modelconversation', { method: 'POST', body });
},
/**
* GET /api/modelconversation/:id/message
* Lists conversation messages, oldest first.
* → 200 { messages: [{ id, conversation_id, created_at, role, message }] }
*/
listMessages(id) {
return http(`/api/modelconversation/${encodeURIComponent(id)}/message`);
},
addMessage(id, body) {
return http(`/api/modelconversation/${encodeURIComponent(id)}/message`, { method: 'POST', body });
},
/**
* DELETE /api/modelconversation/:id
*/
delete(id) {
return http(`/api/modelconversation/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
};
export const vfs = {
/**
* GET /api/vfs
* List files in the virtual filesystem for the selected company.
* → 200 { files: [{ name, size, mime, updated_at, created_at, isText, isImage, isPdf }] }
*/
list() {
return http('/api/vfs');
},
/**
* GET /api/vfs/*path
* Fetch raw content of a file (e.g. text for the editor).
* @param {string} path
*/
async getText(path) {
const clean = String(path || '').replace(/^\/+/, '');
const res = await fetch(`/api/vfs/${clean}?raw=1`, {
credentials: 'include',
});
if (!res.ok) {
throw new Error(`Datei konnte nicht geladen werden (${res.status} ${res.statusText})`);
}
return res.text();
},
/**
* PUT /api/vfs/*path
* Create or update a file.
* @param {string} path
* @param {string} content
* @param {string} [mime]
*/
save(path, content, mime) {
const clean = String(path || '').replace(/^\/+/, '');
return http(`/api/vfs/${clean}`, {
method: 'PUT',
body: { content, mime },
});
},
/**
* DELETE /api/vfs/*path
* Delete a file.
* @param {string} path
*/
delete(path) {
const clean = String(path || '').replace(/^\/+/, '');
return http(`/api/vfs/${clean}`, {
method: 'DELETE',
});
},
/**
* POST /api/vfs/upload
* Upload multiple files or a single file to VFS.
* @param {File[]|FileList|File} files
* @param {string} [targetDir]
*/
async upload(files, targetDir = '') {
const formData = new FormData();
if (targetDir) formData.append('targetDir', targetDir);
const list = Array.isArray(files) ? files : files instanceof FileList ? Array.from(files) : [files];
for (const f of list) {
formData.append('files', f);
}
const res = await fetch('/api/vfs/upload', {
method: 'POST',
headers: { ...clientIdHeaders() },
credentials: 'include',
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Upload fehlgeschlagen (${res.status})`);
}
return res.json();
},
/**
* POST /api/vfs/mkdir
* Create a directory in VFS.
* @param {string} path
*/
mkdir(path) {
const clean = String(path || '').replace(/^\/+/, '');
return http('/api/vfs/mkdir', {
method: 'POST',
body: { path: clean },
});
},
/**
* POST /api/vfs/rename
* Rename or move a file or directory in VFS.
* @param {string} oldPath
* @param {string} newPath
*/
rename(oldPath, newPath) {
const cleanOld = String(oldPath || '').replace(/^\/+/, '');
const cleanNew = String(newPath || '').replace(/^\/+/, '');
return http('/api/vfs/rename', {
method: 'POST',
body: { oldPath: cleanOld, newPath: cleanNew },
});
},
};
export default {
auth,
article,
attributename,
attributevalue,
brand,
category,
company,
customergroup,
delivery,
entity,
labeltemplate,
ledger,
modelconversation,
number,
order,
posterminal,
printer,
propertyname,
quote,
setting,
supplier,
thumb,
unit,
vfs,
warehouse,
stock,
globalHistory,
};