// 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 `.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 (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 `.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} 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, };