2230 lines
62 KiB
JavaScript
2230 lines
62 KiB
JavaScript
// 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;
|
||
}
|
||
|
||
/**
|
||
* 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({ parentId, name, track_stock }) {
|
||
return http('/api/article', {
|
||
method: 'POST',
|
||
body: {
|
||
parentId: Number(parentId),
|
||
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 }
|
||
*/
|
||
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 },
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 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',
|
||
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,
|
||
};
|
||
|
||
|