From ff54fe3a182d31c9d680d9e1ccdc93923bd799e4 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 10 Oct 2026 00:39:23 +0200 Subject: [PATCH] u --- src/api/article.js | 413 +++++++ src/api/attributename.js | 28 + src/api/attributevalue.js | 34 + src/api/auth.js | 52 + src/api/brand.js | 71 ++ src/api/category.js | 118 ++ src/api/company.js | 168 +++ src/api/customergroup.js | 48 + src/api/delivery.js | 151 +++ src/api/entity.js | 98 ++ src/api/globalHistory.js | 19 + src/api/http.js | 185 +++ src/api/index.js | 2256 ---------------------------------- src/api/labeltemplate.js | 42 + src/api/ledger.js | 53 + src/api/modelconversation.js | 51 + src/api/number.js | 20 + src/api/order.js | 49 + src/api/posterminal.js | 91 ++ src/api/printer.js | 49 + src/api/propertyname.js | 28 + src/api/quote.js | 42 + src/api/serper.js | 142 +++ src/api/setting.js | 32 + src/api/stock.js | 14 + src/api/supplier.js | 51 + src/api/thumb.js | 13 + src/api/unit.js | 30 + src/api/vfs.js | 109 ++ src/api/warehouse.js | 49 + 30 files changed, 2250 insertions(+), 2256 deletions(-) create mode 100644 src/api/article.js create mode 100644 src/api/attributename.js create mode 100644 src/api/attributevalue.js create mode 100644 src/api/auth.js create mode 100644 src/api/brand.js create mode 100644 src/api/category.js create mode 100644 src/api/company.js create mode 100644 src/api/customergroup.js create mode 100644 src/api/delivery.js create mode 100644 src/api/entity.js create mode 100644 src/api/globalHistory.js create mode 100644 src/api/http.js delete mode 100644 src/api/index.js create mode 100644 src/api/labeltemplate.js create mode 100644 src/api/ledger.js create mode 100644 src/api/modelconversation.js create mode 100644 src/api/number.js create mode 100644 src/api/order.js create mode 100644 src/api/posterminal.js create mode 100644 src/api/printer.js create mode 100644 src/api/propertyname.js create mode 100644 src/api/quote.js create mode 100644 src/api/serper.js create mode 100644 src/api/setting.js create mode 100644 src/api/stock.js create mode 100644 src/api/supplier.js create mode 100644 src/api/thumb.js create mode 100644 src/api/unit.js create mode 100644 src/api/vfs.js create mode 100644 src/api/warehouse.js diff --git a/src/api/article.js b/src/api/article.js new file mode 100644 index 0000000..378799c --- /dev/null +++ b/src/api/article.js @@ -0,0 +1,413 @@ +import { http, httpBinary, httpGetBinary } from './http.js'; + +/** + * NOTE: there is no `articleIndex` namespace anymore. + * + * The whole-company search index (`GET /api/articleIndex`) was abandoned. Article + * lists are fetched per category via `category.getArticles(categoryId)` + * (`GET /api/category/:id/articles`); `categoryAutomat.loadTree()` warms every + * category view right after the tree loads, and `articleIndexAutomat` derives the + * client-side search index from those views. + */ + +export const article = { + search({ target, filter, offset, maxCount } = {}) { + const params = new URLSearchParams({ target }); + if (filter) params.set('filter', filter); + if (offset) params.set('offset', String(offset)); + if (maxCount != null) params.set('maxCount', String(maxCount)); + return http(`/api/article/search?${params}`); + }, + + /** + * GET /api/article/:sku → { article } + * Detail: prices[], photos[], eans[], brand, unit, categoryIds, … + * (Flat net/taxRate/fullHash/thumb* only on category tree / getCategoryArticles.) + */ + getBySku(sku) { + return http(`/api/article/${encodeURIComponent(sku)}`); + }, + + /** + * GET /api/article/history + * History of all articles with paging/sorting/filtering + */ + getHistory({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) { + const params = new URLSearchParams(); + if (startRow != null) params.set('startRow', String(startRow)); + if (endRow != null) params.set('endRow', String(endRow)); + if (offset != null) params.set('offset', String(offset)); + if (limit != null) params.set('limit', String(limit)); + if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel)); + if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel)); + const qs = params.toString(); + return http(`/api/article/history${qs ? `?${qs}` : ''}`); + }, + + /** + * GET /api/article/:sku/history + */ + getBySkuHistory(sku, { limit } = {}) { + const params = new URLSearchParams(); + if (limit != null) params.set('limit', String(limit)); + const qs = params.toString(); + return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`); + }, + + /** POST /api/article → 201 { article } */ + create({ categoryId, name, track_stock }) { + return http('/api/article', { + method: 'POST', + body: { + categoryId: Number(categoryId), + name, + ...(track_stock !== undefined ? { track_stock: Boolean(track_stock) } : {}), + }, + }); + }, + + /** + * PATCH /api/article/:sku + * Body: at least one of { name, unitamount, unitSi, track_stock, desc, seo } + * unitSi must exist for selected company → 404 Unit not found + * → 200 { article } (same detail shape as GET: includes desc, seo) + */ + update(sku, body) { + return http(`/api/article/${encodeURIComponent(sku)}`, { + method: 'PATCH', + body, + }); + }, + + /** + * PUT /api/article/:sku/track_stock + * Body: { track_stock: boolean } + * → 200 { article } + */ + setTrackStock(sku, body) { + const payload = typeof body === 'boolean' ? { track_stock: body } : body; + return http(`/api/article/${encodeURIComponent(sku)}/track_stock`, { + method: 'PUT', + body: payload, + }); + }, + + /** + * DELETE /api/article/:sku → 204 + * Errors: 400 Article has children, 401, 403, 404 Article not found + */ + delete(sku, { cascade = false } = {}) { + const qs = cascade ? '?cascade=true' : ''; + return http(`/api/article/${encodeURIComponent(sku)}${qs}`, { + method: 'DELETE', + }); + }, + + /** + * POST /api/article/:sku/image + * Body: raw image bytes (Content-Type: image/webp, max 1000×1000) + * → 201 { image: { picId, sort, full, thumb, fullHash, thumbHash } } + */ + uploadImage(sku, file) { + return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, { + method: 'POST', + contentType: file?.type || 'image/webp', + body: file, + }); + }, + + /** + * DELETE /api/article/:sku/image/:fullHash → 204 + */ + deleteImage(sku, fullHash) { + return http( + `/api/article/${encodeURIComponent(sku)}/image/${encodeURIComponent(fullHash)}`, + { method: 'DELETE' }, + ); + }, + + /** + * PUT /api/article/:sku/images/order + * Body: { fullHashes: string[] } (every image exactly once) + * → 200 { fullHashes: string[] } + */ + orderImages(sku, fullHashes) { + return http(`/api/article/${encodeURIComponent(sku)}/images/order`, { + method: 'PUT', + body: { fullHashes }, + }); + }, + + /** + * PUT /api/article/:sku/prices + * Body: { prices: [{ price (net), tax (rate), minmultiplier? }] } + * Replace-all POS customer group prices. Empty prices clears all POS prices. + * → 200 { article } (same shape as GET by SKU) + */ + setPrices(sku, prices) { + return http(`/api/article/${encodeURIComponent(sku)}/prices`, { + method: 'PUT', + body: { prices }, + }); + }, + + /** + * PUT /api/article/:sku/weight + * Body: { weight } → 200 { article } + */ + setWeight(sku, body) { + return http(`/api/article/${encodeURIComponent(sku)}/weight`, { + method: 'PUT', + body, + }); + }, + + /** + * PUT /api/article/:sku/brand + * Body: { brandId: number | null } — null clears the brand. + * brandId must belong to the article's company → 404 Article or brand not found. + * → 200 { article } (same shape as GET by SKU) + */ + setBrandIdBySku(sku, brandId) { + return http(`/api/article/${encodeURIComponent(sku)}/brand`, { + method: 'PUT', + body: { brandId: brandId != null ? Number(brandId) : null }, + }); + }, + + /** + * PUT /api/article/:sku/eans + * Body: { eans: string[] } — replace-all. Empty array clears all. + * EANs must be non-empty unique strings → 400 Invalid eans. + * → 200 { article } (same shape as GET by SKU) + */ + setEansBySku(sku, eans) { + return http(`/api/article/${encodeURIComponent(sku)}/eans`, { + method: 'PUT', + body: { eans }, + }); + }, + + /** + * PUT /api/article/:sku/categories + * Body: { categoryIds: number[] } — replace-all. Empty array clears all. + * Categories must belong to the article's company; ids must be unique. + * → 200 { article } (same shape as GET by SKU) + */ + setCategories(sku, categoryIds) { + return http(`/api/article/${encodeURIComponent(sku)}/categories`, { + method: 'PUT', + body: { categoryIds }, + }); + }, + + /** + * GET /api/article/:sku/supplier → { suppliers: ArticleSupplier[] } + */ + getSuppliers(sku) { + return http(`/api/article/${encodeURIComponent(sku)}/supplier`); + }, + + /** + * POST /api/article/:sku/supplier + * Body: { entityId, sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? } + * → 201 { supplier: ArticleSupplier } + */ + addSupplier(sku, body) { + const payload = { + ...body, + entityId: Number(body?.entityId), + }; + return http(`/api/article/${encodeURIComponent(sku)}/supplier`, { + method: 'POST', + body: payload, + }); + }, + + /** + * PATCH /api/article/:sku/supplier/:id + * Body: { sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? } + * → 200 { supplier: ArticleSupplier } + */ + updateSupplier(sku, id, body) { + return http( + `/api/article/${encodeURIComponent(sku)}/supplier/${encodeURIComponent(id)}`, + { + method: 'PATCH', + body, + }, + ); + }, + + /** + * PUT /api/article/:sku/supplier/order + * Body: { ids: number[] } + * → 200 { ids: number[] } + */ + orderSuppliers(sku, ids) { + return http(`/api/article/${encodeURIComponent(sku)}/supplier/order`, { + method: 'PUT', + body: { ids }, + }); + }, + + /** + * DELETE /api/article/:sku/supplier/:id → 204 + */ + deleteSupplier(sku, id) { + return http( + `/api/article/${encodeURIComponent(sku)}/supplier/${encodeURIComponent(id)}`, + { + method: 'DELETE', + }, + ); + }, + + /** + * GET /api/article/export.zip → application/zip (binary download) + * `artikel.zip` = artikel.csv (semicolon-separated, Windows-1252) + + * one `.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, + }); + }, +}; diff --git a/src/api/attributename.js b/src/api/attributename.js new file mode 100644 index 0000000..9489169 --- /dev/null +++ b/src/api/attributename.js @@ -0,0 +1,28 @@ +import { http } from './http.js'; + +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 } }); + }, +}; diff --git a/src/api/attributevalue.js b/src/api/attributevalue.js new file mode 100644 index 0000000..d93bc2d --- /dev/null +++ b/src/api/attributevalue.js @@ -0,0 +1,34 @@ +import { http } from './http.js'; + +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 }, + }); + }, +}; diff --git a/src/api/auth.js b/src/api/auth.js new file mode 100644 index 0000000..187e4ef --- /dev/null +++ b/src/api/auth.js @@ -0,0 +1,52 @@ +import { http, logApi } from './http.js'; + +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; + } + }, +}; diff --git a/src/api/brand.js b/src/api/brand.js new file mode 100644 index 0000000..29e7ead --- /dev/null +++ b/src/api/brand.js @@ -0,0 +1,71 @@ +import { http, httpBinary } from './http.js'; + +/** + * Company-scoped brands (selectedCompanyId from session). + * Shape: Brand = { id, name, gsprpinfo | null, pics: BrandPic[] } + */ +export const brand = { + /** + * GET /api/brand → { brands: Brand[], total, offset, limit } + * @param {{ offset?: number, limit?: number, includeSmallThumbs?: boolean }} [params] + */ + list(params) { + const offset = Number(params?.offset ?? 0); + const limit = Number(params?.limit ?? 0); + const includeSmallThumbs = params?.includeSmallThumbs; + const query = new URLSearchParams(); + if (Number.isFinite(offset) && offset > 0) query.set('offset', String(offset)); + if (Number.isFinite(limit) && limit > 0) query.set('limit', String(limit)); + if (includeSmallThumbs != null) query.set('includeSmallThumbs', includeSmallThumbs ? 'true' : 'false'); + const qs = query.toString(); + return http(qs ? `/api/brand?${qs}` : '/api/brand'); + }, + + /** + * POST /api/brand + * Body: { name, gsprpinfo? } — insert for the selected company. + * Empty name ⇒ 400 Invalid brand. + * → 201 { brand: Brand } · 409 Brand already exists + */ + create(body) { + return http('/api/brand', { method: 'POST', body }); + }, + + /** + * PATCH /api/brand/:id + * Body: { name, gsprpinfo? } — rename / update info. + * → 200 { id, name, gsprpinfo, touchedArticleCount } · 404 Brand not found + */ + rename(id, body) { + return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'PATCH', body }); + }, + + /** + * DELETE /api/brand/:id + * → 204 No Content · 404 Brand not found + */ + delete(id) { + return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'DELETE' }); + }, + + /** + * POST /api/brand/:id/image + * Accepts full-size image body (Content-Type: image/webp). + * → 201 { brand: { id, fullHash, thumbW, thumbH, thumbHash, pics } } + */ + addImage(brandId, file) { + return httpBinary(`/api/brand/${brandId}/image`, { + method: 'POST', + contentType: file?.type || 'image/webp', + body: file, + }); + }, + + /** + * DELETE /api/brand/:id/image + * → 200 { brand: { id, thumbW: null, thumbH: null, thumbHash: null, pics: [] } } + */ + deleteImage(brandId) { + return http(`/api/brand/${brandId}/image`, { method: 'DELETE' }); + }, +}; diff --git a/src/api/category.js b/src/api/category.js new file mode 100644 index 0000000..1ad7db5 --- /dev/null +++ b/src/api/category.js @@ -0,0 +1,118 @@ +import { http, httpBinary, httpGetBinary } from './http.js'; + +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'); + }, +}; diff --git a/src/api/company.js b/src/api/company.js new file mode 100644 index 0000000..85318e9 --- /dev/null +++ b/src/api/company.js @@ -0,0 +1,168 @@ +import { http } from './http.js'; + +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); + }); + }, +}; diff --git a/src/api/customergroup.js b/src/api/customergroup.js new file mode 100644 index 0000000..0eb8008 --- /dev/null +++ b/src/api/customergroup.js @@ -0,0 +1,48 @@ +import { http } from './http.js'; + +/** + * 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' }); + }, +}; diff --git a/src/api/delivery.js b/src/api/delivery.js new file mode 100644 index 0000000..a117fa6 --- /dev/null +++ b/src/api/delivery.js @@ -0,0 +1,151 @@ +import { http, clientIdHeaders } from './http.js'; + +/** + * Company-scoped goods receipts (manifest type = delivery). + * DeliverySummary = { id, number, externalNumber, type: 'delivery', supplierId, date } + * - number: server-allocated, read-only manifest number (from nextNumber('delivery')). + * - externalNumber: user-entered reference (nullable). + * DeliveryLine = { id, articleId, sku, amount, net, transactedAmount, rebate, tax, comment } + * Delivery = DeliverySummary & { lines: DeliveryLine[] } + * DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? } + */ +export const delivery = { + /** GET /api/delivery → { deliveries: DeliverySummary[] } */ + list() { + return http('/api/delivery'); + }, + + /** GET /api/delivery/:id → { delivery: Delivery } */ + get(id) { + return http(`/api/delivery/${id}`); + }, + + /** + * POST /api/delivery + * Body: { externalNumber?, date, supplierId?, lines: DeliveryLineInput[] } + * number is NOT sent — the server allocates it automatically. The + * caller-supplied reference goes in externalNumber (nullable). + * Line requires sku, amount, net, tax. rebate→0, transactedAmount→amount. + * → 201 { delivery: Delivery } + */ + create(body) { + return http('/api/delivery', { method: 'POST', body }); + }, + + /** + * PATCH /api/delivery/:id + * Body: partial { externalNumber?, date?, supplierId?, lines? }. + * number is read-only and cannot be edited. externalNumber is editable and + * can be cleared by sending null or an empty string. + * If lines present, replaces all lines; if omitted, lines unchanged. + * → 200 { delivery: Delivery } + */ + update(id, body) { + return http(`/api/delivery/${id}`, { method: 'PATCH', body }); + }, + + /** DELETE /api/delivery/:id → 204 */ + delete(id) { + return http(`/api/delivery/${id}`, { method: 'DELETE' }); + }, + + /** + * GET /api/delivery/:id/file/:fileId → Blob + * Full binary content of attached file (PDF, text, etc.) + */ + async getFile(id, fileId, { download = false } = {}) { + const query = download ? '?download=true' : ''; + const res = await fetch(`/api/delivery/${encodeURIComponent(id)}/file/${encodeURIComponent(fileId)}${query}`, { + method: 'GET', + credentials: 'include', + }); + if (!res.ok) { + let message = res.statusText; + try { + const data = await res.json(); + message = data.error || data.message || message; + } catch { /* ignore */ } + const err = new Error(message || `Request failed (${res.status})`); + err.status = res.status; + throw err; + } + return res.blob(); + }, + + /** + * POST /api/delivery/parse — multipart/form-data, field "file" + * Accepts PDF, TXT or CSV document (max 50 MB) + * Streams SSE events: status, llm_call, tool_call, usage, progress, result, error + */ + async *parseSse(file, { signal } = {}) { + const formData = new FormData(); + formData.append('file', file, file.name); + const res = await fetch('/api/delivery/parse', { + method: 'POST', + headers: { ...clientIdHeaders() }, + credentials: 'include', + body: formData, + signal, + }); + if (!res.ok) { + let message = res.statusText; + try { + const data = await res.json(); + message = data.error || data.message || message; + } catch { /* ignore */ } + const err = new Error(message || `Request failed (${res.status})`); + err.status = res.status; + throw err; + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + let currentEvent = 'message'; + + const onAbort = () => { + try { + reader.cancel(); + } catch { /* ignore */ } + }; + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop(); + for (const line of lines) { + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const raw = line.slice(5).trim(); + let data; + try { data = JSON.parse(raw); } catch { data = raw; } + yield { event: currentEvent, data }; + currentEvent = 'message'; + } + } + } + } catch (err) { + if (signal?.aborted || err.name === 'AbortError') { + return; + } + throw err; + } finally { + if (signal) { + signal.removeEventListener('abort', onAbort); + } + try { + reader.releaseLock(); + } catch { /* ignore */ } + } + }, +}; diff --git a/src/api/entity.js b/src/api/entity.js new file mode 100644 index 0000000..4e0f11f --- /dev/null +++ b/src/api/entity.js @@ -0,0 +1,98 @@ +import { http } from './http.js'; + +/** + * 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' }); + }, +}; diff --git a/src/api/globalHistory.js b/src/api/globalHistory.js new file mode 100644 index 0000000..b648b21 --- /dev/null +++ b/src/api/globalHistory.js @@ -0,0 +1,19 @@ +import { http } from './http.js'; + +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}` : ''}`); + }, +}; diff --git a/src/api/http.js b/src/api/http.js new file mode 100644 index 0000000..f48eaca --- /dev/null +++ b/src/api/http.js @@ -0,0 +1,185 @@ +// Shared HTTP helpers for all backend communication. +// Extracted from src/api/index.js — single source of truth for fetch plumbing. + +/** WebView consoles stringify objects as [object Object]; log JSON text. */ +export 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}`); +} + +export function readableHeaders(headers) { + const out = {}; + headers.forEach((value, key) => { + out[key] = value; + }); + return out; +} + +export const SSE_CLIENT_ID_KEY = 'sse.cid'; +export const CLIENT_ID_HEADER = 'X-Client-Id'; + +export function getStoredClientId() { + try { + if (typeof window === 'undefined') return null; + return window.sessionStorage.getItem(SSE_CLIENT_ID_KEY); + } catch { + return null; + } +} + +export function clientIdHeaders() { + const cid = getStoredClientId(); + return cid ? { [CLIENT_ID_HEADER]: cid } : {}; +} + +function isMutatingMethod(method) { + const upper = String(method || 'GET').toUpperCase(); + return upper !== 'GET' && upper !== 'HEAD'; +} + +export async function http(path, { method = 'GET', body } = {}) { + const isFormData = typeof FormData !== 'undefined' && body instanceof FormData; + const headers = {}; + if (!isFormData) { + headers['Content-Type'] = 'application/json'; + } + if (isMutatingMethod(method)) { + Object.assign(headers, clientIdHeaders()); + } + const url = new URL(path, window.location.origin).href; + + const res = await fetch(path, { + method, + headers, + credentials: 'include', + body: isFormData ? body : (body ? JSON.stringify(body) : undefined), + }); + + if (!res.ok) { + let message = res.statusText; + let data; + try { + data = await res.json(); + message = data.error || data.message || message; + } catch { + message = (await res.text().catch(() => message)) || message; + } + logApi('error-body', { method, path, status: res.status, message, data }); + const err = new Error(message || `Request failed (${res.status})`); + err.status = res.status; + err.path = path; + err.method = method; + err.data = data; + throw err; + } + if (res.status === 204) return null; + const json = await res.json(); + return json; +} + +export async function httpBinary(path, { method = 'POST', body, contentType, headers: extraHeaders } = {}) { + const url = new URL(path, window.location.origin).href; + + logApi('request', { + method, + path, + url, + origin: window.location.origin, + credentials: 'include', + contentType, + bodyBytes: body?.byteLength ?? body?.size ?? null, + }); + + const res = await fetch(path, { + method, + headers: { 'Content-Type': contentType, ...clientIdHeaders(), ...(extraHeaders || {}) }, + credentials: 'include', + body, + }); + + const responseHeaders = readableHeaders(res.headers); + logApi('response', { + method, + path, + status: res.status, + statusText: res.statusText, + ok: res.ok, + responseHeaders, + }); + + if (!res.ok) { + let message = res.statusText; + let data; + try { + data = await res.json(); + message = data.error || data.message || message; + } catch { + message = (await res.text().catch(() => message)) || message; + } + logApi('error-body', { method, path, status: res.status, message, data }); + const err = new Error(message || `Request failed (${res.status})`); + err.status = res.status; + err.path = path; + err.method = method; + err.data = data; + throw err; + } + if (res.status === 204) return null; + const json = await res.json(); + logApi('ok-body', { + method, + path, + keys: json && typeof json === 'object' ? Object.keys(json) : typeof json, + hasArticle: Boolean(json?.article), + }); + return json; +} + +/** GET a file (e.g. a ZIP download). Returns a Blob on success; JSON errors on failure. */ +export 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; +} diff --git a/src/api/index.js b/src/api/index.js deleted file mode 100644 index 8aea2fc..0000000 --- a/src/api/index.js +++ /dev/null @@ -1,2256 +0,0 @@ -// Single source of truth for all backend communication. - - - -/** WebView consoles stringify objects as [object Object]; log JSON text. */ -function logApi(tag, details) { - let text; - try { - text = JSON.stringify(details, null, 2); - } catch { - text = String(details); - } - console[tag === 'error-body' ? 'error' : 'log'](`[api] ${tag} ${text}`); -} - -function readableHeaders(headers) { - const out = {}; - headers.forEach((value, key) => { - out[key] = value; - }); - return out; -} - -const SSE_CLIENT_ID_KEY = 'sse.cid'; -const CLIENT_ID_HEADER = 'X-Client-Id'; - -function getStoredClientId() { - try { - if (typeof window === 'undefined') return null; - return window.sessionStorage.getItem(SSE_CLIENT_ID_KEY); - } catch { - return null; - } -} - -function clientIdHeaders() { - const cid = getStoredClientId(); - return cid ? { [CLIENT_ID_HEADER]: cid } : {}; -} - -function isMutatingMethod(method) { - const upper = String(method || 'GET').toUpperCase(); - return upper !== 'GET' && upper !== 'HEAD'; -} - -async function http(path, { method = 'GET', body } = {}) { - const isFormData = typeof FormData !== 'undefined' && body instanceof FormData; - const headers = {}; - if (!isFormData) { - headers['Content-Type'] = 'application/json'; - } - if (isMutatingMethod(method)) { - Object.assign(headers, clientIdHeaders()); - } - const url = new URL(path, window.location.origin).href; - - const res = await fetch(path, { - method, - headers, - credentials: 'include', - body: isFormData ? body : (body ? JSON.stringify(body) : undefined), - }); - - if (!res.ok) { - let message = res.statusText; - let data; - try { - data = await res.json(); - message = data.error || data.message || message; - } catch { - message = (await res.text().catch(() => message)) || message; - } - logApi('error-body', { method, path, status: res.status, message, data }); - const err = new Error(message || `Request failed (${res.status})`); - err.status = res.status; - err.path = path; - err.method = method; - err.data = data; - throw err; - } - if (res.status === 204) return null; - const json = await res.json(); - return json; -} - -async function httpBinary(path, { method = 'POST', body, contentType, headers: extraHeaders } = {}) { - const url = new URL(path, window.location.origin).href; - - logApi('request', { - method, - path, - url, - origin: window.location.origin, - credentials: 'include', - contentType, - bodyBytes: body?.byteLength ?? body?.size ?? null, - }); - - const res = await fetch(path, { - method, - headers: { 'Content-Type': contentType, ...clientIdHeaders(), ...(extraHeaders || {}) }, - credentials: 'include', - body, - }); - - const responseHeaders = readableHeaders(res.headers); - logApi('response', { - method, - path, - status: res.status, - statusText: res.statusText, - ok: res.ok, - responseHeaders, - }); - - if (!res.ok) { - let message = res.statusText; - let data; - try { - data = await res.json(); - message = data.error || data.message || message; - } catch { - message = (await res.text().catch(() => message)) || message; - } - logApi('error-body', { method, path, status: res.status, message, data }); - const err = new Error(message || `Request failed (${res.status})`); - err.status = res.status; - err.path = path; - err.method = method; - err.data = data; - throw err; - } - if (res.status === 204) return null; - const json = await res.json(); - logApi('ok-body', { - method, - path, - keys: json && typeof json === 'object' ? Object.keys(json) : typeof json, - hasArticle: Boolean(json?.article), - }); - return json; -} - -/** GET a file (e.g. a ZIP download). Returns a Blob on success; JSON errors on failure. */ -async function httpGetBinary(path) { - logApi('request', { - method: 'GET', - path, - url: new URL(path, window.location.origin).href, - origin: window.location.origin, - credentials: 'include', - }); - - const res = await fetch(path, { method: 'GET', credentials: 'include' }); - - logApi('response', { - method: 'GET', - path, - status: res.status, - statusText: res.statusText, - ok: res.ok, - contentType: res.headers.get('content-type'), - responseHeaders: readableHeaders(res.headers), - }); - - if (!res.ok) { - let message = res.statusText; - let data; - try { - data = await res.json(); - message = data.error || data.message || message; - } catch { - message = (await res.text().catch(() => message)) || message; - } - logApi('error-body', { method: 'GET', path, status: res.status, message, data }); - const err = new Error(message || `Request failed (${res.status})`); - err.status = res.status; - err.path = path; - err.method = 'GET'; - err.data = data; - throw err; - } - - const blob = await res.blob(); - return blob; -} - -/** - * NOTE: there is no `articleIndex` namespace anymore. - * - * The whole-company search index (`GET /api/articleIndex`) was abandoned. Article - * lists are fetched per category via `category.getArticles(categoryId)` - * (`GET /api/category/:id/articles`); `categoryAutomat.loadTree()` warms every - * category view right after the tree loads, and `articleIndexAutomat` derives the - * client-side search index from those views. - */ - -export const article = { - search({ target, filter, offset, maxCount } = {}) { - const params = new URLSearchParams({ target }); - if (filter) params.set('filter', filter); - if (offset) params.set('offset', String(offset)); - if (maxCount != null) params.set('maxCount', String(maxCount)); - return http(`/api/article/search?${params}`); - }, - - /** - * GET /api/article/:sku → { article } - * Detail: prices[], photos[], eans[], brand, unit, categoryIds, … - * (Flat net/taxRate/fullHash/thumb* only on category tree / getCategoryArticles.) - */ - getBySku(sku) { - return http(`/api/article/${encodeURIComponent(sku)}`); - }, - - /** - * GET /api/article/history - * History of all articles with paging/sorting/filtering - */ - getHistory({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) { - const params = new URLSearchParams(); - if (startRow != null) params.set('startRow', String(startRow)); - if (endRow != null) params.set('endRow', String(endRow)); - if (offset != null) params.set('offset', String(offset)); - if (limit != null) params.set('limit', String(limit)); - if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel)); - if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel)); - const qs = params.toString(); - return http(`/api/article/history${qs ? `?${qs}` : ''}`); - }, - - /** - * GET /api/article/:sku/history - */ - getBySkuHistory(sku, { limit } = {}) { - const params = new URLSearchParams(); - if (limit != null) params.set('limit', String(limit)); - const qs = params.toString(); - return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`); - }, - - /** POST /api/article → 201 { article } */ - create({ categoryId, name, track_stock }) { - return http('/api/article', { - method: 'POST', - body: { - categoryId: Number(categoryId), - name, - ...(track_stock !== undefined ? { track_stock: Boolean(track_stock) } : {}), - }, - }); - }, - - /** - * PATCH /api/article/:sku - * Body: at least one of { name, unitamount, unitSi, track_stock, desc, seo } - * unitSi must exist for selected company → 404 Unit not found - * → 200 { article } (same detail shape as GET: includes desc, seo) - */ - update(sku, body) { - return http(`/api/article/${encodeURIComponent(sku)}`, { - method: 'PATCH', - body, - }); - }, - - /** - * PUT /api/article/:sku/track_stock - * Body: { track_stock: boolean } - * → 200 { article } - */ - setTrackStock(sku, body) { - const payload = typeof body === 'boolean' ? { track_stock: body } : body; - return http(`/api/article/${encodeURIComponent(sku)}/track_stock`, { - method: 'PUT', - body: payload, - }); - }, - - /** - * DELETE /api/article/:sku → 204 - * Errors: 400 Article has children, 401, 403, 404 Article not found - */ - delete(sku, { cascade = false } = {}) { - const qs = cascade ? '?cascade=true' : ''; - return http(`/api/article/${encodeURIComponent(sku)}${qs}`, { - method: 'DELETE', - }); - }, - - /** - * POST /api/article/:sku/image - * Body: raw image bytes (Content-Type: image/webp, max 1000×1000) - * → 201 { image: { picId, sort, full, thumb, fullHash, thumbHash } } - */ - uploadImage(sku, file) { - return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, { - method: 'POST', - contentType: file?.type || 'image/webp', - body: file, - }); - }, - - /** - * DELETE /api/article/:sku/image/:fullHash → 204 - */ - deleteImage(sku, fullHash) { - return http( - `/api/article/${encodeURIComponent(sku)}/image/${encodeURIComponent(fullHash)}`, - { method: 'DELETE' }, - ); - }, - - /** - * PUT /api/article/:sku/images/order - * Body: { fullHashes: string[] } (every image exactly once) - * → 200 { fullHashes: string[] } - */ - orderImages(sku, fullHashes) { - return http(`/api/article/${encodeURIComponent(sku)}/images/order`, { - method: 'PUT', - body: { fullHashes }, - }); - }, - - /** - * PUT /api/article/:sku/prices - * Body: { prices: [{ price (net), tax (rate), minmultiplier? }] } - * Replace-all POS customer group prices. Empty prices clears all POS prices. - * → 200 { article } (same shape as GET by SKU) - */ - setPrices(sku, prices) { - return http(`/api/article/${encodeURIComponent(sku)}/prices`, { - method: 'PUT', - body: { prices }, - }); - }, - - /** - * PUT /api/article/:sku/weight - * Body: { weight } → 200 { article } - */ - setWeight(sku, body) { - return http(`/api/article/${encodeURIComponent(sku)}/weight`, { - method: 'PUT', - body, - }); - }, - - /** - * PUT /api/article/:sku/brand - * Body: { brandId: number | null } — null clears the brand. - * brandId must belong to the article's company → 404 Article or brand not found. - * → 200 { article } (same shape as GET by SKU) - */ - setBrandIdBySku(sku, brandId) { - return http(`/api/article/${encodeURIComponent(sku)}/brand`, { - method: 'PUT', - body: { brandId: brandId != null ? Number(brandId) : null }, - }); - }, - - /** - * PUT /api/article/:sku/eans - * Body: { eans: string[] } — replace-all. Empty array clears all. - * EANs must be non-empty unique strings → 400 Invalid eans. - * → 200 { article } (same shape as GET by SKU) - */ - setEansBySku(sku, eans) { - return http(`/api/article/${encodeURIComponent(sku)}/eans`, { - method: 'PUT', - body: { eans }, - }); - }, - - /** - * PUT /api/article/:sku/categories - * Body: { categoryIds: number[] } — replace-all. Empty array clears all. - * Categories must belong to the article's company; ids must be unique. - * → 200 { article } (same shape as GET by SKU) - */ - setCategories(sku, categoryIds) { - return http(`/api/article/${encodeURIComponent(sku)}/categories`, { - method: 'PUT', - body: { categoryIds }, - }); - }, - - /** - * GET /api/article/:sku/supplier → { suppliers: ArticleSupplier[] } - */ - getSuppliers(sku) { - return http(`/api/article/${encodeURIComponent(sku)}/supplier`); - }, - - /** - * POST /api/article/:sku/supplier - * Body: { entityId, sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? } - * → 201 { supplier: ArticleSupplier } - */ - addSupplier(sku, body) { - const payload = { - ...body, - entityId: Number(body?.entityId), - }; - return http(`/api/article/${encodeURIComponent(sku)}/supplier`, { - method: 'POST', - body: payload, - }); - }, - - /** - * PATCH /api/article/:sku/supplier/:id - * Body: { sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? } - * → 200 { supplier: ArticleSupplier } - */ - updateSupplier(sku, id, body) { - return http( - `/api/article/${encodeURIComponent(sku)}/supplier/${encodeURIComponent(id)}`, - { - method: 'PATCH', - body, - }, - ); - }, - - /** - * PUT /api/article/:sku/supplier/order - * Body: { ids: number[] } - * → 200 { ids: number[] } - */ - orderSuppliers(sku, ids) { - return http(`/api/article/${encodeURIComponent(sku)}/supplier/order`, { - method: 'PUT', - body: { ids }, - }); - }, - - /** - * DELETE /api/article/:sku/supplier/:id → 204 - */ - deleteSupplier(sku, id) { - return http( - `/api/article/${encodeURIComponent(sku)}/supplier/${encodeURIComponent(id)}`, - { - method: 'DELETE', - }, - ); - }, - - /** - * GET /api/article/export.zip → application/zip (binary download) - * `artikel.zip` = artikel.csv (semicolon-separated, Windows-1252) + - * one `.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, pics: BrandPic[] } - */ -export const brand = { - /** - * GET /api/brand → { brands: Brand[], total, offset, limit } - * @param {{ offset?: number, limit?: number, includeSmallThumbs?: boolean }} [params] - */ - list(params) { - const offset = Number(params?.offset ?? 0); - const limit = Number(params?.limit ?? 0); - const includeSmallThumbs = params?.includeSmallThumbs; - const query = new URLSearchParams(); - if (Number.isFinite(offset) && offset > 0) query.set('offset', String(offset)); - if (Number.isFinite(limit) && limit > 0) query.set('limit', String(limit)); - if (includeSmallThumbs != null) query.set('includeSmallThumbs', includeSmallThumbs ? 'true' : 'false'); - const qs = query.toString(); - return http(qs ? `/api/brand?${qs}` : '/api/brand'); - }, - - /** - * POST /api/brand - * Body: { name, gsprpinfo? } — insert for the selected company. - * Empty name ⇒ 400 Invalid brand. - * → 201 { brand: Brand } · 409 Brand already exists - */ - create(body) { - return http('/api/brand', { method: 'POST', body }); - }, - - /** - * PATCH /api/brand/:id - * Body: { name, gsprpinfo? } — rename / update info. - * → 200 { id, name, gsprpinfo, touchedArticleCount } · 404 Brand not found - */ - rename(id, body) { - return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'PATCH', body }); - }, - - /** - * DELETE /api/brand/:id - * → 204 No Content · 404 Brand not found - */ - delete(id) { - return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'DELETE' }); - }, - - /** - * POST /api/brand/:id/image - * Accepts full-size image body (Content-Type: image/webp). - * → 201 { brand: { id, fullHash, thumbW, thumbH, thumbHash, pics } } - */ - addImage(brandId, file) { - return httpBinary(`/api/brand/${brandId}/image`, { - method: 'POST', - contentType: file?.type || 'image/webp', - body: file, - }); - }, - - /** - * DELETE /api/brand/:id/image - * → 200 { brand: { id, thumbW: null, thumbH: null, thumbHash: null, pics: [] } } - */ - deleteImage(brandId) { - return http(`/api/brand/${brandId}/image`, { method: 'DELETE' }); - }, -}; - -export const attributename = { - /** GET /api/attributename → { attributenames: Attributename[] } */ - list() { - return http('/api/attributename'); - }, - - /** POST /api/attributename → 201 { attributename } */ - create(body) { - return http('/api/attributename', { method: 'POST', body }); - }, - - /** PATCH /api/attributename/:id → 200 { attributename } */ - update(id, body) { - return http(`/api/attributename/${encodeURIComponent(id)}`, { method: 'PATCH', body }); - }, - - /** DELETE /api/attributename/:id → 200 */ - delete(id) { - return http(`/api/attributename/${encodeURIComponent(id)}`, { method: 'DELETE' }); - }, - - /** PUT /api/attributename/order → 200 */ - order(attributenameIds) { - return http('/api/attributename/order', { method: 'PUT', body: { attributenameIds } }); - }, -}; - -export const attributevalue = { - /** GET /api/attributename/:attributeId/value → { values: AttributeValue[] } */ - listByAttributeId(attributeId) { - return http(`/api/attributename/${encodeURIComponent(attributeId)}/value`); - }, - - /** POST /api/attributename/:attributeId/value → 201 { attributevalue } */ - create(attributeId, body) { - return http(`/api/attributename/${encodeURIComponent(attributeId)}/value`, { - method: 'POST', - body, - }); - }, - - /** PATCH /api/attributevalue/:id → 200 { attributevalue } */ - update(id, body) { - return http(`/api/attributevalue/${encodeURIComponent(id)}`, { method: 'PATCH', body }); - }, - - /** DELETE /api/attributevalue/:id → 200 */ - delete(id) { - return http(`/api/attributevalue/${encodeURIComponent(id)}`, { method: 'DELETE' }); - }, - - /** PUT /api/attributename/:attributeId/value/order → 200 */ - order(attributeId, attributevalueIds) { - return http(`/api/attributename/${encodeURIComponent(attributeId)}/value/order`, { - method: 'PUT', - body: { attributevalueIds }, - }); - }, -}; - -export const propertyname = { - /** GET /api/propertyname → { propertynames: Propertyname[] } */ - list() { - return http('/api/propertyname'); - }, - - /** POST /api/propertyname → 201 { propertyname } */ - create(body) { - return http('/api/propertyname', { method: 'POST', body }); - }, - - /** PATCH /api/propertyname/:id → 200 { propertyname } */ - update(id, body) { - return http(`/api/propertyname/${encodeURIComponent(id)}`, { method: 'PATCH', body }); - }, - - /** DELETE /api/propertyname/:id → 200 */ - delete(id) { - return http(`/api/propertyname/${encodeURIComponent(id)}`, { method: 'DELETE' }); - }, - - /** PUT /api/propertyname/order → 200 */ - order(propertynameIds) { - return http('/api/propertyname/order', { method: 'PUT', body: { propertynameIds } }); - }, -}; - -/** - * Company-scoped suppliers / Lieferanten (selectedCompanyId from session). - * Shape: Supplier = { id, name, createdAt, updatedAt } - */ -export const supplier = { - /** GET /api/supplier → { suppliers: Supplier[] } */ - list() { - return http('/api/supplier'); - }, - - /** - * POST /api/supplier - * Body: { name } — empty name ⇒ 400 Name required. - * → 201 { supplier: Supplier } - */ - create(body) { - return http('/api/supplier', { method: 'POST', body }); - }, - - /** - * PATCH /api/supplier/:id - * Body: { name } — empty name ⇒ 400 Name required. - * → 200 { supplier: Supplier } - */ - update(id, body) { - return http(`/api/supplier/${id}`, { method: 'PATCH', body }); - }, - - /** DELETE /api/supplier/:id → 204 */ - delete(id) { - return http(`/api/supplier/${id}`, { method: 'DELETE' }); - }, - - /** - * GET /api/supplier/:id/articles?skus=... - * → 200 { articles: SupplierArticle[] } - */ - getArticles(id, skus) { - let query = ''; - if (skus != null) { - const list = Array.isArray(skus) ? skus : [skus]; - const filtered = list.map((s) => String(s).trim()).filter(Boolean); - if (filtered.length > 0) { - query = `?skus=${encodeURIComponent(filtered.join(','))}`; - } - } - return http(`/api/supplier/${encodeURIComponent(id)}/articles${query}`); - }, -}; - -/** - * Company-scoped warehouses / Lager (selectedCompanyId from session). - * Shape: Warehouse = { id, name } - */ -export const warehouse = { - /** GET /api/warehouse → { warehouses: Warehouse[] } */ - list() { - return http('/api/warehouse'); - }, - - /** GET /api/warehouse/:id → { warehouse: Warehouse } */ - getById(id) { - return http(`/api/warehouse/${id}`); - }, - - /** - * POST /api/warehouse - * Body: { name, sort? } — empty name ⇒ 400 Name required. - * → 201 { warehouse: Warehouse } - */ - create(body) { - return http('/api/warehouse', { method: 'POST', body }); - }, - - /** - * PATCH /api/warehouse/:id - * Body: { name?, sort? } - * → 200 { warehouse: Warehouse } - */ - update(id, body) { - return http(`/api/warehouse/${id}`, { method: 'PATCH', body }); - }, - - /** - * PUT /api/warehouse/order - * Body: { warehouseIds: number[] } - * → 200 { warehouseIds: number[] } - */ - order(body) { - return http('/api/warehouse/order', { method: 'PUT', body }); - }, - - /** DELETE /api/warehouse/:id → 204 */ - delete(id) { - return http(`/api/warehouse/${id}`, { method: 'DELETE' }); - }, -}; - -/** - * Stock operations (manual stock adjustment). - */ -export const stock = { - /** - * POST /api/stock/adjust - * Body: { sku, warehouseId, amount, text?, date?, externalNumber?, lines? } - */ - adjust(body) { - return http('/api/stock/adjust', { method: 'POST', body }); - }, -}; - -/** - * Company-scoped goods receipts (manifest type = delivery). - * DeliverySummary = { id, number, externalNumber, type: 'delivery', supplierId, date } - * - number: server-allocated, read-only manifest number (from nextNumber('delivery')). - * - externalNumber: user-entered reference (nullable). - * DeliveryLine = { id, articleId, sku, amount, net, transactedAmount, rebate, tax, comment } - * Delivery = DeliverySummary & { lines: DeliveryLine[] } - * DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? } - */ -export const delivery = { - /** GET /api/delivery → { deliveries: DeliverySummary[] } */ - list() { - return http('/api/delivery'); - }, - - /** GET /api/delivery/:id → { delivery: Delivery } */ - get(id) { - return http(`/api/delivery/${id}`); - }, - - /** - * POST /api/delivery - * Body: { externalNumber?, date, supplierId?, lines: DeliveryLineInput[] } - * number is NOT sent — the server allocates it automatically. The - * caller-supplied reference goes in externalNumber (nullable). - * Line requires sku, amount, net, tax. rebate→0, transactedAmount→amount. - * → 201 { delivery: Delivery } - */ - create(body) { - return http('/api/delivery', { method: 'POST', body }); - }, - - /** - * PATCH /api/delivery/:id - * Body: partial { externalNumber?, date?, supplierId?, lines? }. - * number is read-only and cannot be edited. externalNumber is editable and - * can be cleared by sending null or an empty string. - * If lines present, replaces all lines; if omitted, lines unchanged. - * → 200 { delivery: Delivery } - */ - update(id, body) { - return http(`/api/delivery/${id}`, { method: 'PATCH', body }); - }, - - /** DELETE /api/delivery/:id → 204 */ - delete(id) { - return http(`/api/delivery/${id}`, { method: 'DELETE' }); - }, - - /** - * GET /api/delivery/:id/file/:fileId → Blob - * Full binary content of attached file (PDF, text, etc.) - */ - async getFile(id, fileId, { download = false } = {}) { - const query = download ? '?download=true' : ''; - const res = await fetch(`/api/delivery/${encodeURIComponent(id)}/file/${encodeURIComponent(fileId)}${query}`, { - method: 'GET', - credentials: 'include', - }); - if (!res.ok) { - let message = res.statusText; - try { - const data = await res.json(); - message = data.error || data.message || message; - } catch { /* ignore */ } - const err = new Error(message || `Request failed (${res.status})`); - err.status = res.status; - throw err; - } - return res.blob(); - }, - - /** - * POST /api/delivery/parse — multipart/form-data, field "file" - * Accepts PDF, TXT or CSV document (max 50 MB) - * Streams SSE events: status, llm_call, tool_call, usage, progress, result, error - */ - async *parseSse(file, { signal } = {}) { - const formData = new FormData(); - formData.append('file', file, file.name); - const res = await fetch('/api/delivery/parse', { - method: 'POST', - headers: { ...clientIdHeaders() }, - credentials: 'include', - body: formData, - signal, - }); - if (!res.ok) { - let message = res.statusText; - try { - const data = await res.json(); - message = data.error || data.message || message; - } catch { /* ignore */ } - const err = new Error(message || `Request failed (${res.status})`); - err.status = res.status; - throw err; - } - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buf = ''; - let currentEvent = 'message'; - - const onAbort = () => { - try { - reader.cancel(); - } catch { /* ignore */ } - }; - if (signal) { - if (signal.aborted) { - onAbort(); - return; - } - signal.addEventListener('abort', onAbort, { once: true }); - } - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - const lines = buf.split('\n'); - buf = lines.pop(); - for (const line of lines) { - if (line.startsWith('event:')) { - currentEvent = line.slice(6).trim(); - } else if (line.startsWith('data:')) { - const raw = line.slice(5).trim(); - let data; - try { data = JSON.parse(raw); } catch { data = raw; } - yield { event: currentEvent, data }; - currentEvent = 'message'; - } - } - } - } catch (err) { - if (signal?.aborted || err.name === 'AbortError') { - return; - } - throw err; - } finally { - if (signal) { - signal.removeEventListener('abort', onAbort); - } - try { - reader.releaseLock(); - } catch { /* ignore */ } - } - }, -}; - -/** - * Serper Google Images search and WebP thumbnail streaming. - */ -export const serper = { - /** - * POST /api/serper/images - * Body: { q, gl, hl, num, preferSource } - * Streams SSE events: images, thumb, done, error - * - * @param {object} params - * @param {string} params.q - Search query - * @param {string} [params.gl='de'] - Country code - * @param {string} [params.hl='de'] - Language code - * @param {number} [params.num=100] - Result count (1-100) - * @param {boolean} [params.preferSource=false] - Prefer high-res source image - * @param {object} [options] - * @param {AbortSignal} [options.signal] - Abort controller signal - * @returns {AsyncGenerator<{event: string, data: any}>} - */ - async *searchImages( - { q, gl = 'de', hl = 'de', num = 100, preferSource = false } = {}, - { signal } = {}, - ) { - const res = await fetch('/api/serper/images', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...clientIdHeaders(), - }, - credentials: 'include', - body: JSON.stringify({ q, gl, hl, num, preferSource }), - signal, - }); - if (!res.ok) { - let message = res.statusText; - try { - const data = await res.json(); - message = data.error || data.message || message; - } catch { - /* ignore */ - } - const err = new Error(message || `Request failed (${res.status})`); - err.status = res.status; - throw err; - } - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buf = ''; - let currentEvent = 'message'; - - const onAbort = () => { - try { - reader.cancel(); - } catch { - /* ignore */ - } - }; - if (signal) { - if (signal.aborted) { - onAbort(); - return; - } - signal.addEventListener('abort', onAbort, { once: true }); - } - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - const lines = buf.split('\n'); - buf = lines.pop(); - for (const line of lines) { - if (line.startsWith('event:')) { - currentEvent = line.slice(6).trim(); - } else if (line.startsWith('data:')) { - const raw = line.slice(5).trim(); - let data; - try { - data = JSON.parse(raw); - } catch { - data = raw; - } - yield { event: currentEvent, data }; - currentEvent = 'message'; - } - } - } - } catch (err) { - if (signal?.aborted || err.name === 'AbortError') { - return; - } - throw err; - } finally { - if (signal) { - signal.removeEventListener('abort', onAbort); - } - try { - reader.releaseLock(); - } catch { - /* ignore */ - } - } - }, - - /** - * POST /api/serper/image - * Crawl image from URL, crop white/transparency margins, resize to max 1000px, return AVIF Blob. - * - * @param {string} url - External image URL to crawl and process - * @param {object} [options] - * @param {AbortSignal} [options.signal] - Abort controller signal - * @returns {Promise} AVIF image blob - */ - async fetchImage(url, { signal } = {}) { - const res = await fetch('/api/serper/image', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...clientIdHeaders(), - }, - credentials: 'include', - body: JSON.stringify({ url }), - signal, - }); - if (!res.ok) { - let message = res.statusText; - try { - const data = await res.json(); - message = data.error || data.message || message; - } catch { - /* ignore */ - } - const err = new Error(message || `Request failed (${res.status})`); - err.status = res.status; - throw err; - } - return await res.blob(); - }, -}; - -/** - * Append-only stock and payment ledger. - * Entry = { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at } - * Input = { manifestarticle_id, transaction_type, amount, transaction_text } - */ -export const ledger = { - /** - * POST /api/ledger - * Body: { manifestarticle_id, transaction_type, amount, transaction_text } - * → 201 { ledger: LedgerEntry } - */ - add(body) { - return http('/api/ledger', { method: 'POST', body }); - }, - - /** - * GET /api/ledger/manifestarticle/:manifestarticleId - * Query: ?transaction_type=... - * → 200 { ledger: LedgerEntry[] } - */ - listByManifestarticleId(manifestarticleId, transactionType) { - const query = transactionType ? `?transaction_type=${encodeURIComponent(transactionType)}` : ''; - return http(`/api/ledger/manifestarticle/${encodeURIComponent(manifestarticleId)}${query}`); - }, - - /** - * GET /api/ledger/sku/:sku - * Query: ?transaction_type=... - * → 200 { ledger: LedgerEntry[] } - */ - listBySku(sku, transactionType) { - const query = transactionType ? `?transaction_type=${encodeURIComponent(transactionType)}` : ''; - return http(`/api/ledger/sku/${encodeURIComponent(sku)}${query}`); - }, - - /** - * GET /api/ledger - * Query: { manifestarticle_id?, sku?, transaction_type? } - * → 200 { ledger: LedgerEntry[] } - */ - list(query) { - const params = new URLSearchParams(); - if (query?.manifestarticle_id != null) params.set('manifestarticle_id', query.manifestarticle_id); - if (query?.manifestarticleId != null) params.set('manifestarticle_id', query.manifestarticleId); - if (query?.sku != null) params.set('sku', query.sku); - if (query?.transaction_type != null) params.set('transaction_type', query.transaction_type); - if (query?.transactionType != null) params.set('transaction_type', query.transactionType); - const qs = params.toString(); - return http(`/api/ledger${qs ? `?${qs}` : ''}`); - }, -}; - -/** - * Sales quotes (manifest type = quote). - * Summary = { id, number, type: 'quote', entityId, date } - * Line = { id, articleId, sku, amount, net, rebate, tax, comment } — no transactedAmount - * LineInput = { sku, amount, net, tax, rebate?, comment? } - */ -export const quote = { - /** GET /api/quote → { quotes: QuoteSummary[] } */ - list() { - return http('/api/quote'); - }, - - /** GET /api/quote/:id → { quote: Quote } */ - get(id) { - return http(`/api/quote/${id}`); - }, - - /** - * POST /api/quote - * Body: { number, date, entityId?, lines } - * → 201 { quote: Quote } - */ - create(body) { - return http('/api/quote', { method: 'POST', body }); - }, - - /** - * PATCH /api/quote/:id - * Body: partial { number?, date?, entityId?, lines? } - * → 200 { quote: Quote } - */ - update(id, body) { - return http(`/api/quote/${id}`, { method: 'PATCH', body }); - }, - - /** DELETE /api/quote/:id → 204 */ - delete(id) { - return http(`/api/quote/${id}`, { method: 'DELETE' }); - }, -}; - -/** - * Sales orders (manifest type = order). - * Summary = { id, number, type: 'order', entityId, date } - * Line shape same as quote (no transactedAmount). - */ -export const order = { - /** - * GET /api/order?offset=0&limit=20 - * @param {{ offset?: number, limit?: number }} [params] - * → { orders: Order[], total: number, offset: number, limit: number } - */ - list({ offset = 0, limit = 20 } = {}) { - const params = new URLSearchParams(); - if (offset != null) params.set('offset', String(offset)); - if (limit != null) params.set('limit', String(limit)); - const qs = params.toString(); - return http(`/api/order${qs ? `?${qs}` : ''}`); - }, - - /** GET /api/order/:id → { order: Order } */ - get(id) { - return http(`/api/order/${id}`); - }, - - /** - * POST /api/order - * Body: { number, date, entityId?, lines } - * → 201 { order: Order } - */ - create(body) { - return http('/api/order', { method: 'POST', body }); - }, - - /** - * PATCH /api/order/:id - * Body: partial { number?, date?, entityId?, lines? } - * → 200 { order: Order } - */ - update(id, body) { - return http(`/api/order/${id}`, { method: 'PATCH', body }); - }, - - /** DELETE /api/order/:id → 204 */ - delete(id) { - return http(`/api/order/${id}`, { method: 'DELETE' }); - }, -}; - -/** - * Company-scoped customer groups. - * Shape: CustomerGroup = { id, name, sort } - * List item: CustomerGroup & { editable } — editable false for POS / Lieferanten - * (name locked). Reorder via PUT /api/customergroup/order. - */ -export const customergroup = { - /** GET /api/customergroup → { customergroups: (CustomerGroup & { editable })[] } ordered by sort */ - list() { - return http('/api/customergroup'); - }, - - /** - * POST /api/customergroup - * Body: { name } — empty name ⇒ 400 Name required; - * reserved POS / Lieferanten ⇒ 400 Name not allowed; sort appends next index. - * → 201 { customergroup: CustomerGroup } - */ - create(body) { - return http('/api/customergroup', { method: 'POST', body }); - }, - - /** - * PATCH /api/customergroup/:id - * Body: { name } — locked groups cannot be renamed (Name cannot be changed). - * → 200 { customergroup: CustomerGroup } - */ - update(id, body) { - return http(`/api/customergroup/${id}`, { method: 'PATCH', body }); - }, - - /** - * PUT /api/customergroup/order - * Body: { customergroupIds: number[] } — must be exactly the company set (same ids, no dups) - * else 400 Invalid customer group order. Sets sort to each id’s index 0…n-1. - * → 200 { customergroupIds: number[] } - */ - order(body) { - return http('/api/customergroup/order', { method: 'PUT', body }); - }, - - /** DELETE /api/customergroup/:id → 204 · 409 Customer group in use */ - delete(id) { - return http(`/api/customergroup/${id}`, { method: 'DELETE' }); - }, -}; - -/** - * Company-scoped entities (customers / contacts in a customergroup). - * Shape: Entity = { id, customergroupId, firstName, name, createdAt, updatedAt } - */ -export const entity = { - /** - * GET /api/entity → { entities: Entity[] } - * Optional query: customergroupId - */ - list({ customergroupId } = {}) { - const qs = - customergroupId != null - ? `?customergroupId=${encodeURIComponent(customergroupId)}` - : ''; - return http(`/api/entity${qs}`); - }, - - /** GET /api/entity/:id → { entity: Entity } */ - get(id) { - return http(`/api/entity/${id}`); - }, - - /** - * POST /api/entity - * Body: { customergroupId, name, firstName? } - * → 201 { entity: Entity } - */ - create(body) { - return http('/api/entity', { method: 'POST', body }); - }, - - /** - * PATCH /api/entity/:id - * Body: { name?, firstName? } - * → 200 { entity: Entity } - */ - update(id, body) { - return http(`/api/entity/${id}`, { method: 'PATCH', body }); - }, - - /** DELETE /api/entity/:id → 204 */ - delete(id) { - return http(`/api/entity/${id}`, { method: 'DELETE' }); - }, - - /** GET /api/entity/next-number → { number: string } */ - nextNumber() { - return http('/api/entity/next-number'); - }, - - /** GET /api/entity/:entityId/address → { addresses: EntityAddress[] } */ - listAddresses(entityId) { - return http(`/api/entity/${entityId}/address`); - }, - - /** POST /api/entity/:entityId/address → 201 { address } */ - createAddress(entityId, body) { - return http(`/api/entity/${entityId}/address`, { method: 'POST', body }); - }, - - /** PATCH /api/entity/:entityId/address/:id → { address } */ - updateAddress(entityId, id, body) { - return http(`/api/entity/${entityId}/address/${id}`, { - method: 'PATCH', - body, - }); - }, - - /** DELETE /api/entity/:entityId/address/:id → 204 */ - deleteAddress(entityId, id) { - return http(`/api/entity/${entityId}/address/${id}`, { method: 'DELETE' }); - }, - - /** GET /api/entity/:entityId/contact → { contacts: EntityContact[] } */ - listContacts(entityId) { - return http(`/api/entity/${entityId}/contact`); - }, - - /** POST /api/entity/:entityId/contact → 201 { contact } */ - createContact(entityId, body) { - return http(`/api/entity/${entityId}/contact`, { method: 'POST', body }); - }, - - /** PATCH /api/entity/:entityId/contact/:id → { contact } */ - updateContact(entityId, id, body) { - return http(`/api/entity/${entityId}/contact/${id}`, { - method: 'PATCH', - body, - }); - }, - - /** DELETE /api/entity/:entityId/contact/:id → 204 */ - deleteContact(entityId, id) { - return http(`/api/entity/${entityId}/contact/${id}`, { method: 'DELETE' }); - }, -}; - -export const company = { - /** GET /api/company → { companies: [...] } */ - list() { - return http('/api/company'); - }, - - /** Uses GET /api/company until a dedicated endpoint exists. */ - async getById(companyId) { - const data = await http('/api/company'); - const found = data.companies?.find((item) => item.id === companyId); - if (!found) throw new Error('Unternehmen nicht gefunden.'); - return { company: found }; - }, - - create({ name, iso639, taxrate, reducedtaxrate }) { - return http('/api/company', { - method: 'POST', - body: { name, iso639, taxrate, reducedtaxrate }, - }); - }, - - /** - * PUT /api/company/:companyId - * Body: { name, iso639, taxrate, reducedtaxrate } - */ - update(companyId, { name, iso639, taxrate, reducedtaxrate }) { - return http(`/api/company/${companyId}`, { - method: 'PUT', - body: { name, iso639, taxrate, reducedtaxrate }, - }); - }, - - select(companyId) { - return http('/api/auth/selected-company', { - method: 'PUT', - body: { companyId }, - }); - }, - - /** - * POST /api/company/import - * Uploads and streams catalog import (.zip) archive via Server-Sent Events. - * Tracks client upload progress (bytes/percentage) and processes incoming SSE events in real-time. - * - * @param {File|Blob|null} file - * @param {object} [callbacks] - * @param {(progress: { loaded: number, total: number, percent: number }) => void} [callbacks.onUploadProgress] - * @param {(message: string) => void} [callbacks.onStatus] - * @param {(progress: { phase: string, current: number, total: number, item?: string }) => void} [callbacks.onProgress] - * @param {(stats: object) => void} [callbacks.onDone] - * @param {(error: string) => void} [callbacks.onError] - * @param {object} [options] - * @param {AbortSignal} [options.signal] - */ - importCatalog(file, callbacks = {}, { signal } = {}) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/company/import'); - xhr.withCredentials = true; - xhr.setRequestHeader('Accept', 'text/event-stream'); - - if (signal) { - if (signal.aborted) { - return reject(new DOMException('Aborted', 'AbortError')); - } - signal.addEventListener('abort', () => { - xhr.abort(); - reject(new DOMException('Aborted', 'AbortError')); - }); - } - - // Track request body upload progress - if (file && xhr.upload) { - xhr.upload.onprogress = (event) => { - if (event.lengthComputable && event.total > 0) { - const percent = Math.min(100, Math.round((event.loaded / event.total) * 100)); - callbacks.onUploadProgress?.({ - loaded: event.loaded, - total: event.total, - percent, - }); - } - }; - } - - // Stream and parse incoming SSE chunks as the server processes the archive - let processedIndex = 0; - let buffer = ''; - - const processChunks = () => { - const text = xhr.responseText || ''; - if (text.length <= processedIndex) return; - const newChunk = text.slice(processedIndex); - processedIndex = text.length; - - buffer += newChunk; - const messages = buffer.split('\n\n'); - buffer = messages.pop() || ''; - - for (const msg of messages) { - if (!msg.trim()) continue; - - const eventMatch = msg.match(/^event:\s*(\w+)/m); - const dataMatch = msg.match(/^data:\s*(.+)$/m); - - const eventType = eventMatch ? eventMatch[1] : 'message'; - let eventData = null; - if (dataMatch) { - try { - eventData = JSON.parse(dataMatch[1]); - } catch { - eventData = dataMatch[1]; - } - } - - switch (eventType) { - case 'status': - callbacks.onStatus?.(eventData?.message); - break; - case 'progress': - callbacks.onProgress?.(eventData); - break; - case 'done': - callbacks.onDone?.(eventData?.stats ?? eventData); - break; - case 'error': - callbacks.onError?.(eventData?.message || 'Fehler beim Importieren'); - break; - } - } - }; - - xhr.onprogress = processChunks; - - xhr.onload = () => { - processChunks(); - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - let errMsg = `Upload fehlgeschlagen (HTTP ${xhr.status})`; - try { - const parsed = JSON.parse(xhr.responseText); - errMsg = parsed.error || parsed.message || errMsg; - } catch { - // ignore - } - reject(new Error(errMsg)); - } - }; - - xhr.onerror = () => { - reject(new Error('Netzwerkfehler beim Upload der Datei')); - }; - - xhr.onabort = () => { - reject(new DOMException('Aborted', 'AbortError')); - }; - - const formData = new FormData(); - if (file) { - formData.append('file', file); - } - xhr.send(formData); - }); - }, -}; - -export const posterminal = { - /** - * GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin }] } - * List all terminals for the selected company. - */ - list() { - return http('/api/posterminal'); - }, - - /** - * PUT /api/posterminal - * Body: { name: string } - * Creates a new terminal (auto-generates 6-digit pin). - * → 200 { posterminal: { id, name, token, pairingpin } } - */ - create({ name }) { - return http('/api/posterminal', { - method: 'PUT', - body: { name }, - }); - }, - - /** - * PATCH /api/posterminal/:id - * Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string } - * Update posterminal configuration. - * → 200 { posterminal } - */ - update(terminalId, patch) { - return http(`/api/posterminal/${terminalId}`, { - method: 'PATCH', - body: patch, - }); - }, - - /** - * DELETE /api/posterminal/:id → 204 - * Deletes a terminal. - */ - delete(terminalId) { - return http(`/api/posterminal/${terminalId}`, { - method: 'DELETE', - }); - }, - - /** - * PUT /api/posterminal/:id/category/:categoryId - * Enable a category on a terminal. - * → 200 { id: number, categoryIds: number[] } - */ - enableCategory(terminalId, categoryId) { - return http(`/api/posterminal/${terminalId}/category/${categoryId}`, { - method: 'PUT', - }); - }, - - /** - * DELETE /api/posterminal/:id/category/:categoryId - * Disable a category on a terminal. - * → 200 { id: number, categoryIds: number[] } - */ - disableCategory(terminalId, categoryId) { - return http(`/api/posterminal/${terminalId}/category/${categoryId}`, { - method: 'DELETE', - }); - }, - - /** - * PUT /api/posterminal/:id/all - * Enable all categories on a terminal. - * → 200 { id: number, categoryIds: number[] } - */ - enableAllCategories(terminalId) { - return http(`/api/posterminal/${terminalId}/all`, { - method: 'PUT', - }); - }, - - /** - * DELETE /api/posterminal/:id/all - * Disable all categories on a terminal. - * → 200 { id: number, categoryIds: number[] } - */ - disableAllCategories(terminalId) { - return http(`/api/posterminal/${terminalId}/all`, { - method: 'DELETE', - }); - }, -}; - -export const number = { - /** - * GET /api/number → { numbers: [{ id, type, prefix, padding, next }] } - * Lists the number sequences for the selected company. - */ - list() { - return http('/api/number'); - }, - - /** - * PATCH /api/number/:id - * Body: at least one of { prefix, padding, next } — only present keys are patched. - * → 200 { number: { id, type, prefix, padding, next } } - */ - update(id, body) { - return http(`/api/number/${id}`, { method: 'PATCH', body }); - }, -}; - -export const auth = { - async me() { - try { - const data = await http('/api/auth/me'); - return { - user: data.user ?? null, - googleClientId: data.googleClientId ?? null, - }; - } catch (err) { - if (err.status === 401 || err.status === 403) { - return { - user: null, - googleClientId: err.data?.googleClientId ?? null, - forbidden: err.status === 403, - message: err.message, - }; - } - throw err; - } - }, - - async loginWithGoogle(idToken) { - logApi('loginWithGoogle', { - idTokenLength: idToken?.length ?? 0, - idTokenPrefix: idToken ? `${idToken.slice(0, 12)}…` : null, - }); - const data = await http('/api/auth/login', { - method: 'POST', - body: { idToken }, - }); - // Confirm whether the session cookie actually works (not just login JSON). - const session = await auth.me(); - logApi('session-after-login', { - loginUser: data.user?.email ?? data.user?.sub ?? data.user ?? null, - meUser: session.user?.email ?? session.user?.sub ?? session.user ?? null, - meForbidden: session.forbidden ?? false, - meMessage: session.message ?? null, - sessionOk: Boolean(session.user), - }); - return { user: data.user, session }; - }, - - async logout() { - try { - await http('/api/auth/logout', { method: 'POST' }); - } catch (err) { - if (err.status !== 401) throw err; - } - }, -}; - -/** - * Company-scoped ZPL label templates (selectedCompanyId from session). - * Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb } - */ -export const labeltemplate = { - /** GET /api/labeltemplate → { labeltemplates: LabelTemplate[] } */ - list() { - return http('/api/labeltemplate'); - }, - - /** GET /api/labeltemplate/:id → { labeltemplate: LabelTemplate } */ - get(id) { - return http(`/api/labeltemplate/${encodeURIComponent(id)}`); - }, - - /** - * POST /api/labeltemplate - * Body: { name, width, height, dpi, type, content } - * → 201 { labeltemplate: LabelTemplate } - */ - create(body) { - return http('/api/labeltemplate', { method: 'POST', body }); - }, - - /** - * PATCH /api/labeltemplate/:id - * Body: partial { name?, width?, height?, dpi?, type?, content? } - * → 200 { labeltemplate: LabelTemplate } - */ - update(id, body) { - return http(`/api/labeltemplate/${encodeURIComponent(id)}`, { method: 'PATCH', body }); - }, - - /** DELETE /api/labeltemplate/:id → 204 */ - delete(id) { - return http(`/api/labeltemplate/${encodeURIComponent(id)}`, { method: 'DELETE' }); - }, - - // thumb is returned inline as a base64 string in list/get responses -}; - -export const globalHistory = { - /** - * GET /api/history - * Global system/entity history with paging/sorting/filtering - */ - list({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) { - const params = new URLSearchParams(); - if (startRow != null) params.set('startRow', String(startRow)); - if (endRow != null) params.set('endRow', String(endRow)); - if (offset != null) params.set('offset', String(offset)); - if (limit != null) params.set('limit', String(limit)); - if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel)); - if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel)); - const qs = params.toString(); - return http(`/api/history${qs ? `?${qs}` : ''}`); - }, -}; - -export const modelconversation = { - /** - * GET /api/modelconversation - * Lists the user's model conversations in the selected company. - * → 200 { conversations: [{ id, companyuser_id, provider, model, created_at, updated_at, state }] } - */ - list() { - return http('/api/modelconversation'); - }, - - /** - * GET /api/modelconversation/:id - * Fetch conversation details by ID. - * @param {number|string} id - */ - get(id) { - return http(`/api/modelconversation/${encodeURIComponent(id)}`); - }, - - /** - * POST /api/modelconversation - * Creates a new conversation in state 'idle'. - * Body: { provider, model } - * → 201 { conversation: { id, companyuser_id, provider, model, created_at, updated_at, state } } - */ - create(body) { - return http('/api/modelconversation', { method: 'POST', body }); - }, - - /** - * GET /api/modelconversation/:id/message - * Lists conversation messages, oldest first. - * → 200 { messages: [{ id, conversation_id, created_at, role, message }] } - */ - listMessages(id) { - return http(`/api/modelconversation/${encodeURIComponent(id)}/message`); - }, - - addMessage(id, body) { - return http(`/api/modelconversation/${encodeURIComponent(id)}/message`, { method: 'POST', body }); - }, - - /** - * DELETE /api/modelconversation/:id - */ - delete(id) { - return http(`/api/modelconversation/${encodeURIComponent(id)}`, { method: 'DELETE' }); - }, -}; - -export const vfs = { - /** - * GET /api/vfs - * List files in the virtual filesystem for the selected company. - * → 200 { files: [{ name, size, mime, updated_at, created_at, isText, isImage, isPdf }] } - */ - list() { - return http('/api/vfs'); - }, - - /** - * GET /api/vfs/*path - * Fetch raw content of a file (e.g. text for the editor). - * @param {string} path - */ - async getText(path) { - const clean = String(path || '').replace(/^\/+/, ''); - const res = await fetch(`/api/vfs/${clean}?raw=1`, { - credentials: 'include', - }); - if (!res.ok) { - throw new Error(`Datei konnte nicht geladen werden (${res.status} ${res.statusText})`); - } - return res.text(); - }, - - /** - * PUT /api/vfs/*path - * Create or update a file. - * @param {string} path - * @param {string} content - * @param {string} [mime] - */ - save(path, content, mime) { - const clean = String(path || '').replace(/^\/+/, ''); - return http(`/api/vfs/${clean}`, { - method: 'PUT', - body: { content, mime }, - }); - }, - - /** - * DELETE /api/vfs/*path - * Delete a file. - * @param {string} path - */ - delete(path) { - const clean = String(path || '').replace(/^\/+/, ''); - return http(`/api/vfs/${clean}`, { - method: 'DELETE', - }); - }, - - /** - * POST /api/vfs/upload - * Upload multiple files or a single file to VFS. - * @param {File[]|FileList|File} files - * @param {string} [targetDir] - */ - async upload(files, targetDir = '') { - const formData = new FormData(); - if (targetDir) formData.append('targetDir', targetDir); - const list = Array.isArray(files) ? files : files instanceof FileList ? Array.from(files) : [files]; - for (const f of list) { - formData.append('files', f); - } - const res = await fetch('/api/vfs/upload', { - method: 'POST', - headers: { ...clientIdHeaders() }, - credentials: 'include', - body: formData, - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || `Upload fehlgeschlagen (${res.status})`); - } - return res.json(); - }, - - /** - * POST /api/vfs/mkdir - * Create a directory in VFS. - * @param {string} path - */ - mkdir(path) { - const clean = String(path || '').replace(/^\/+/, ''); - return http('/api/vfs/mkdir', { - method: 'POST', - body: { path: clean }, - }); - }, - - /** - * POST /api/vfs/rename - * Rename or move a file or directory in VFS. - * @param {string} oldPath - * @param {string} newPath - */ - rename(oldPath, newPath) { - const cleanOld = String(oldPath || '').replace(/^\/+/, ''); - const cleanNew = String(newPath || '').replace(/^\/+/, ''); - return http('/api/vfs/rename', { - method: 'POST', - body: { oldPath: cleanOld, newPath: cleanNew }, - }); - }, -}; - -export default { - auth, - article, - attributename, - attributevalue, - brand, - category, - company, - customergroup, - delivery, - entity, - labeltemplate, - ledger, - modelconversation, - number, - order, - posterminal, - printer, - propertyname, - quote, - setting, - supplier, - thumb, - unit, - vfs, - warehouse, - stock, - globalHistory, -}; - diff --git a/src/api/labeltemplate.js b/src/api/labeltemplate.js new file mode 100644 index 0000000..bb1fcfe --- /dev/null +++ b/src/api/labeltemplate.js @@ -0,0 +1,42 @@ +import { http } from './http.js'; + +/** + * 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 +}; diff --git a/src/api/ledger.js b/src/api/ledger.js new file mode 100644 index 0000000..ffce3da --- /dev/null +++ b/src/api/ledger.js @@ -0,0 +1,53 @@ +import { http } from './http.js'; + +/** + * 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}` : ''}`); + }, +}; diff --git a/src/api/modelconversation.js b/src/api/modelconversation.js new file mode 100644 index 0000000..f3c21f4 --- /dev/null +++ b/src/api/modelconversation.js @@ -0,0 +1,51 @@ +import { http } from './http.js'; + +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' }); + }, +}; diff --git a/src/api/number.js b/src/api/number.js new file mode 100644 index 0000000..0c7d555 --- /dev/null +++ b/src/api/number.js @@ -0,0 +1,20 @@ +import { http } from './http.js'; + +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 }); + }, +}; diff --git a/src/api/order.js b/src/api/order.js new file mode 100644 index 0000000..97a4f1c --- /dev/null +++ b/src/api/order.js @@ -0,0 +1,49 @@ +import { http } from './http.js'; + +/** + * Sales orders (manifest type = order). + * Summary = { id, number, type: 'order', entityId, date } + * Line shape same as quote (no transactedAmount). + */ +export const order = { + /** + * GET /api/order?offset=0&limit=20 + * @param {{ offset?: number, limit?: number }} [params] + * → { orders: Order[], total: number, offset: number, limit: number } + */ + list({ offset = 0, limit = 20 } = {}) { + const params = new URLSearchParams(); + if (offset != null) params.set('offset', String(offset)); + if (limit != null) params.set('limit', String(limit)); + const qs = params.toString(); + return http(`/api/order${qs ? `?${qs}` : ''}`); + }, + + /** GET /api/order/:id → { order: Order } */ + get(id) { + return http(`/api/order/${id}`); + }, + + /** + * POST /api/order + * Body: { number, date, entityId?, lines } + * → 201 { order: Order } + */ + create(body) { + return http('/api/order', { method: 'POST', body }); + }, + + /** + * PATCH /api/order/:id + * Body: partial { number?, date?, entityId?, lines? } + * → 200 { order: Order } + */ + update(id, body) { + return http(`/api/order/${id}`, { method: 'PATCH', body }); + }, + + /** DELETE /api/order/:id → 204 */ + delete(id) { + return http(`/api/order/${id}`, { method: 'DELETE' }); + }, +}; diff --git a/src/api/posterminal.js b/src/api/posterminal.js new file mode 100644 index 0000000..c289701 --- /dev/null +++ b/src/api/posterminal.js @@ -0,0 +1,91 @@ +import { http } from './http.js'; + +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', + }); + }, +}; diff --git a/src/api/printer.js b/src/api/printer.js new file mode 100644 index 0000000..1fbf20d --- /dev/null +++ b/src/api/printer.js @@ -0,0 +1,49 @@ +import { http } from './http.js'; + +/** + * 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 } }); + }, +}; diff --git a/src/api/propertyname.js b/src/api/propertyname.js new file mode 100644 index 0000000..8c6d947 --- /dev/null +++ b/src/api/propertyname.js @@ -0,0 +1,28 @@ +import { http } from './http.js'; + +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 } }); + }, +}; diff --git a/src/api/quote.js b/src/api/quote.js new file mode 100644 index 0000000..ec9c4cb --- /dev/null +++ b/src/api/quote.js @@ -0,0 +1,42 @@ +import { http } from './http.js'; + +/** + * 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' }); + }, +}; diff --git a/src/api/serper.js b/src/api/serper.js new file mode 100644 index 0000000..292c1e5 --- /dev/null +++ b/src/api/serper.js @@ -0,0 +1,142 @@ +import { http, clientIdHeaders } from './http.js'; + +/** + * Serper Google Images search and WebP thumbnail streaming. + */ +export const serper = { + /** + * POST /api/serper/images + * Body: { q, gl, hl, num, preferSource } + * Streams SSE events: images, thumb, done, error + * + * @param {object} params + * @param {string} params.q - Search query + * @param {string} [params.gl='de'] - Country code + * @param {string} [params.hl='de'] - Language code + * @param {number} [params.num=100] - Result count (1-100) + * @param {boolean} [params.preferSource=false] - Prefer high-res source image + * @param {object} [options] + * @param {AbortSignal} [options.signal] - Abort controller signal + * @returns {AsyncGenerator<{event: string, data: any}>} + */ + async *searchImages( + { q, gl = 'de', hl = 'de', num = 100, preferSource = false } = {}, + { signal } = {}, + ) { + const res = await fetch('/api/serper/images', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...clientIdHeaders(), + }, + credentials: 'include', + body: JSON.stringify({ q, gl, hl, num, preferSource }), + signal, + }); + if (!res.ok) { + let message = res.statusText; + try { + const data = await res.json(); + message = data.error || data.message || message; + } catch { + /* ignore */ + } + const err = new Error(message || `Request failed (${res.status})`); + err.status = res.status; + throw err; + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + let currentEvent = 'message'; + + const onAbort = () => { + try { + reader.cancel(); + } catch { + /* ignore */ + } + }; + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop(); + for (const line of lines) { + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const raw = line.slice(5).trim(); + let data; + try { + data = JSON.parse(raw); + } catch { + data = raw; + } + yield { event: currentEvent, data }; + currentEvent = 'message'; + } + } + } + } catch (err) { + if (signal?.aborted || err.name === 'AbortError') { + return; + } + throw err; + } finally { + if (signal) { + signal.removeEventListener('abort', onAbort); + } + try { + reader.releaseLock(); + } catch { + /* ignore */ + } + } + }, + + /** + * POST /api/serper/image + * Crawl image from URL, crop white/transparency margins, resize to max 1000px, return AVIF Blob. + * + * @param {string} url - External image URL to crawl and process + * @param {object} [options] + * @param {AbortSignal} [options.signal] - Abort controller signal + * @returns {Promise} AVIF image blob + */ + async fetchImage(url, { signal } = {}) { + const res = await fetch('/api/serper/image', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...clientIdHeaders(), + }, + credentials: 'include', + body: JSON.stringify({ url }), + signal, + }); + if (!res.ok) { + let message = res.statusText; + try { + const data = await res.json(); + message = data.error || data.message || message; + } catch { + /* ignore */ + } + const err = new Error(message || `Request failed (${res.status})`); + err.status = res.status; + throw err; + } + return await res.blob(); + }, +}; diff --git a/src/api/setting.js b/src/api/setting.js new file mode 100644 index 0000000..3d9a54c --- /dev/null +++ b/src/api/setting.js @@ -0,0 +1,32 @@ +import { http } from './http.js'; + +/** + * 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, + }); + }, +}; diff --git a/src/api/stock.js b/src/api/stock.js new file mode 100644 index 0000000..8c0df6b --- /dev/null +++ b/src/api/stock.js @@ -0,0 +1,14 @@ +import { http } from './http.js'; + +/** + * 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 }); + }, +}; diff --git a/src/api/supplier.js b/src/api/supplier.js new file mode 100644 index 0000000..717b094 --- /dev/null +++ b/src/api/supplier.js @@ -0,0 +1,51 @@ +import { http } from './http.js'; + +/** + * 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}`); + }, +}; diff --git a/src/api/thumb.js b/src/api/thumb.js new file mode 100644 index 0000000..2f5d68b --- /dev/null +++ b/src/api/thumb.js @@ -0,0 +1,13 @@ +/** + * 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)}`; + }, +}; diff --git a/src/api/unit.js b/src/api/unit.js new file mode 100644 index 0000000..5fa2df2 --- /dev/null +++ b/src/api/unit.js @@ -0,0 +1,30 @@ +import { http } from './http.js'; + +/** + * 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' }); + }, +}; diff --git a/src/api/vfs.js b/src/api/vfs.js new file mode 100644 index 0000000..9defd26 --- /dev/null +++ b/src/api/vfs.js @@ -0,0 +1,109 @@ +import { http, clientIdHeaders } from './http.js'; + +export const vfs = { + /** + * GET /api/vfs + * List files in the virtual filesystem for the selected company. + * → 200 { files: [{ name, size, mime, updated_at, created_at, isText, isImage, isPdf }] } + */ + list() { + return http('/api/vfs'); + }, + + /** + * GET /api/vfs/*path + * Fetch raw content of a file (e.g. text for the editor). + * @param {string} path + */ + async getText(path) { + const clean = String(path || '').replace(/^\/+/, ''); + const res = await fetch(`/api/vfs/${clean}?raw=1`, { + credentials: 'include', + }); + if (!res.ok) { + throw new Error(`Datei konnte nicht geladen werden (${res.status} ${res.statusText})`); + } + return res.text(); + }, + + /** + * PUT /api/vfs/*path + * Create or update a file. + * @param {string} path + * @param {string} content + * @param {string} [mime] + */ + save(path, content, mime) { + const clean = String(path || '').replace(/^\/+/, ''); + return http(`/api/vfs/${clean}`, { + method: 'PUT', + body: { content, mime }, + }); + }, + + /** + * DELETE /api/vfs/*path + * Delete a file. + * @param {string} path + */ + delete(path) { + const clean = String(path || '').replace(/^\/+/, ''); + return http(`/api/vfs/${clean}`, { + method: 'DELETE', + }); + }, + + /** + * POST /api/vfs/upload + * Upload multiple files or a single file to VFS. + * @param {File[]|FileList|File} files + * @param {string} [targetDir] + */ + async upload(files, targetDir = '') { + const formData = new FormData(); + if (targetDir) formData.append('targetDir', targetDir); + const list = Array.isArray(files) ? files : files instanceof FileList ? Array.from(files) : [files]; + for (const f of list) { + formData.append('files', f); + } + const res = await fetch('/api/vfs/upload', { + method: 'POST', + headers: { ...clientIdHeaders() }, + credentials: 'include', + body: formData, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Upload fehlgeschlagen (${res.status})`); + } + return res.json(); + }, + + /** + * POST /api/vfs/mkdir + * Create a directory in VFS. + * @param {string} path + */ + mkdir(path) { + const clean = String(path || '').replace(/^\/+/, ''); + return http('/api/vfs/mkdir', { + method: 'POST', + body: { path: clean }, + }); + }, + + /** + * POST /api/vfs/rename + * Rename or move a file or directory in VFS. + * @param {string} oldPath + * @param {string} newPath + */ + rename(oldPath, newPath) { + const cleanOld = String(oldPath || '').replace(/^\/+/, ''); + const cleanNew = String(newPath || '').replace(/^\/+/, ''); + return http('/api/vfs/rename', { + method: 'POST', + body: { oldPath: cleanOld, newPath: cleanNew }, + }); + }, +}; diff --git a/src/api/warehouse.js b/src/api/warehouse.js new file mode 100644 index 0000000..25181b5 --- /dev/null +++ b/src/api/warehouse.js @@ -0,0 +1,49 @@ +import { http } from './http.js'; + +/** + * 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' }); + }, +};