Files
dashfront/src/api/index.js
2026-09-29 18:26:30 +02:00

1746 lines
48 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;
}
async function http(path, { method = 'GET', body } = {}) {
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
const headers = {};
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
const url = new URL(path, window.location.origin).href;
logApi('request', {
method,
path,
url,
origin: window.location.origin,
credentials: 'include',
// HttpOnly cookies are invisible here; this only shows JS-readable cookies.
documentCookie: document.cookie || '(empty)',
hasBody: Boolean(body),
});
const res = await fetch(path, {
method,
headers,
credentials: 'include',
body: isFormData ? body : (body ? JSON.stringify(body) : undefined),
});
const responseHeaders = readableHeaders(res.headers);
// Browsers hide Set-Cookie from JS; if it's missing here that proves nothing.
logApi('response', {
method,
path,
status: res.status,
statusText: res.statusText,
ok: res.ok,
responseHeaders,
documentCookieAfter: document.cookie || '(empty)',
});
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,
hasUser: Boolean(json?.user),
});
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, ...(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;
}
/**
* Company-scoped article search index (selectedCompanyId from session).
* GET /api/articleIndex → { articles: [...] }
*/
export const articleIndex = {
list() {
return http('/api/articleIndex');
},
};
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)}`);
},
/** POST /api/article → 201 { article } */
create({ parentId, name, track_stock }) {
return http('/api/article', {
method: 'POST',
body: {
parentId,
name,
...(track_stock !== undefined ? { track_stock } : {}),
},
});
},
/**
* PATCH /api/article/:sku
* Body: at least one of { name, unitamount, unitSi, track_stock }
* unitSi must exist for selected company → 404 Unit not found
* → 200 { article } (same detail shape as GET)
*/
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) {
return http(`/api/article/${encodeURIComponent(sku)}`, {
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/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 },
});
},
/**
* 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) {
return http(`/api/article/${encodeURIComponent(sku)}/supplier`, {
method: 'POST',
body,
});
},
/**
* 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' }
);
},
};
/**
* 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' });
},
};
/**
* Content-addressed thumb AVIF by fullHash (SHA-256 hex of full image bytes).
* 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, …
*/
getArticles(categoryId) {
return http(`/api/category/${categoryId}/articles`);
},
/** POST /api/category → 201 { category } */
create({ parentId, name }) {
return http('/api/category', {
method: 'POST',
body: { parentId, name },
});
},
/** DELETE /api/category/:id → 204 */
delete(categoryId) {
return http(`/api/category/${categoryId}`, { 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 }
*/
export const brand = {
/** GET /api/brand → { brands: Brand[] } · 400 No company selected */
list() {
return http('/api/brand');
},
/**
* PUT /api/brand
* Body: { name, gsprpinfo? } — upsert by name for the selected company.
* Same name ⇒ update, otherwise create. Empty name ⇒ 400 Invalid brand.
* → 200 { brand: Brand }
*/
upsert(body) {
return http('/api/brand', { method: 'PUT', 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',
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',
},
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',
},
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 → { orders: OrderSummary[] } */
list() {
return http('/api/order');
},
/** 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 },
});
},
};
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 },
});
},
/**
* 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 default {
auth,
article,
articleIndex,
attributename,
attributevalue,
brand,
category,
company,
customergroup,
delivery,
entity,
labeltemplate,
ledger,
number,
order,
posterminal,
printer,
propertyname,
quote,
supplier,
thumb,
unit,
warehouse,
stock,
};