function enc(value) { return encodeURIComponent(String(value)); } function buildUrl(baseUrl, path) { return new URL(path, baseUrl || window.location.origin).href; } function buildPath(path, query) { if (!query) return path; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (value == null) continue; params.set(key, String(value)); } const qs = params.toString(); return qs ? `${path}?${qs}` : path; } /** * Thin HTTP client for the dashback API (cookie auth, JSON / blob / empty). */ export class ApiClient { #baseUrl; constructor(baseUrl = window.location.origin) { this.#baseUrl = baseUrl; this.auth = { /** POST /api/auth/login */ login: (body) => this.#json('POST', '/api/auth/login', { body }), /** POST /api/auth/logout */ logout: () => this.#json('POST', '/api/auth/logout'), /** GET /api/auth/me */ me: () => this.#json('GET', '/api/auth/me'), /** PUT /api/auth/selected-company */ selectedCompany: (body) => this.#json('PUT', '/api/auth/selected-company', { body }), }; this.health = { /** GET /api/health */ get: () => this.#json('GET', '/api/health'), }; this.company = { /** GET /api/company */ list: () => this.#json('GET', '/api/company'), /** POST /api/company */ create: (body) => this.#json('POST', '/api/company', { body }), /** PUT /api/company/{id} */ update: (id, body) => this.#json('PUT', `/api/company/${enc(id)}`, { body }), /** DELETE /api/company/{id} */ delete: (id) => this.#empty('DELETE', `/api/company/${enc(id)}`), }; this.unit = { /** GET /api/unit */ list: () => this.#json('GET', '/api/unit'), /** PUT /api/unit */ upsert: (body) => this.#json('PUT', '/api/unit', { body }), /** DELETE /api/unit?si=… */ delete: (si) => this.#empty('DELETE', '/api/unit', { query: { si } }), }; this.brand = { /** GET /api/brand */ list: () => this.#json('GET', '/api/brand'), /** PUT /api/brand */ upsert: (body) => this.#json('PUT', '/api/brand', { body }), /** DELETE /api/brand/{id} */ delete: (id) => this.#empty('DELETE', `/api/brand/${enc(id)}`), }; this.supplier = { /** GET /api/supplier → { suppliers: Supplier[] } */ list: () => this.#json('GET', '/api/supplier'), /** POST /api/supplier → 201 { supplier } */ create: (body) => this.#json('POST', '/api/supplier', { body }), /** PATCH /api/supplier/{id} → { supplier } */ update: (id, body) => this.#json('PATCH', `/api/supplier/${enc(id)}`, { body }), /** DELETE /api/supplier/{id} → 204 */ delete: (id) => this.#empty('DELETE', `/api/supplier/${enc(id)}`), /** GET /api/supplier/{id}/articles?skus=... → { 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 this.#json('GET', `/api/supplier/${enc(id)}/articles${query}`); }, }; this.warehouse = { /** GET /api/warehouse → { warehouses: Warehouse[] } */ list: () => this.#json('GET', '/api/warehouse'), /** GET /api/warehouse/{id} → { warehouse: Warehouse } */ getById: (id) => this.#json('GET', `/api/warehouse/${enc(id)}`), /** POST /api/warehouse → 201 { warehouse: Warehouse } */ create: (body) => this.#json('POST', '/api/warehouse', { body }), /** PATCH /api/warehouse/{id} → 200 { warehouse: Warehouse } */ update: (id, body) => this.#json('PATCH', `/api/warehouse/${enc(id)}`, { body }), /** DELETE /api/warehouse/{id} → 204 */ delete: (id) => this.#empty('DELETE', `/api/warehouse/${enc(id)}`), }; this.stock = { /** POST /api/stock/adjust → 201 { manifest, lines, ledger } */ adjust: (body) => this.#json('POST', '/api/stock/adjust', { body }), }; this.delivery = { /** GET /api/delivery → { deliveries: DeliverySummary[] } */ list: () => this.#json('GET', '/api/delivery'), /** GET /api/delivery/{id} → { delivery: Delivery } */ get: (id) => this.#json('GET', `/api/delivery/${enc(id)}`), /** POST /api/delivery → 201 { delivery: Delivery } */ create: (body) => this.#json('POST', '/api/delivery', { body }), /** PATCH /api/delivery/{id} → { delivery: Delivery } */ update: (id, body) => this.#json('PATCH', `/api/delivery/${enc(id)}`, { body }), /** DELETE /api/delivery/{id} → 204 */ delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`), /** GET /api/delivery/{id}/file/{fileId} → Blob (full binary content) */ getFile: (id, fileId, { download = false } = {}) => this.#blob('GET', `/api/delivery/${enc(id)}/file/${enc(fileId)}`, { query: download ? { download: 'true' } : undefined, }), /** * POST /api/delivery/parse — multipart/form-data, field "file" * Accepts PDF or text files (max 50 MB), detection via mimetype/filename * Returns an async generator that yields SSE event objects: * { event: 'status'|'llm_call'|'tool_call'|'usage'|'progress'|'result'|'error', data: object } */ parseSse: async function* (file, { signal } = {}) { const formData = new FormData(); formData.append('file', file, file.name); const res = await fetch('/api/delivery/parse', { method: 'POST', credentials: 'include', body: formData, signal, }); if (!res.ok) { let message = res.statusText; try { const data = await res.json(); message = data.error || data.message || message; } catch { /* ignore */ } const err = new Error(message || `Request failed (${res.status})`); err.status = res.status; throw err; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ''; let currentEvent = 'message'; const onAbort = () => { try { reader.cancel(); } catch { /* ignore */ } }; if (signal) { if (signal.aborted) { onAbort(); return; } signal.addEventListener('abort', onAbort, { once: true }); } try { while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop(); // keep incomplete last line 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'; } // blank line or comment: reset } } } catch (err) { if (signal?.aborted || err.name === 'AbortError') { return; } throw err; } finally { if (signal) { signal.removeEventListener('abort', onAbort); } try { reader.releaseLock(); } catch { /* ignore */ } } }, parsePdfSse(file, opts) { return this.parseSse(file, opts); }, }; this.ledger = { /** POST /api/ledger → 201 { ledger: LedgerEntry } */ add: (body) => this.#json('POST', '/api/ledger', { body }), /** GET /api/ledger/manifestarticle/{id} → { ledger: LedgerEntry[] } */ listByManifestarticleId: (manifestarticleId, transactionType) => this.#json('GET', `/api/ledger/manifestarticle/${enc(manifestarticleId)}`, { query: transactionType ? { transaction_type: transactionType } : undefined, }), /** GET /api/ledger/sku/{sku} → { ledger: LedgerEntry[] } */ listBySku: (sku, transactionType) => this.#json('GET', `/api/ledger/sku/${enc(sku)}`, { query: transactionType ? { transaction_type: transactionType } : undefined, }), /** GET /api/ledger → { ledger: LedgerEntry[] } */ list: (query) => this.#json('GET', '/api/ledger', { query }), }; this.quote = { /** GET /api/quote → { quotes } */ list: () => this.#json('GET', '/api/quote'), /** GET /api/quote/{id} → { quote } */ get: (id) => this.#json('GET', `/api/quote/${enc(id)}`), /** POST /api/quote → 201 { quote } */ create: (body) => this.#json('POST', '/api/quote', { body }), /** PATCH /api/quote/{id} → { quote } */ update: (id, body) => this.#json('PATCH', `/api/quote/${enc(id)}`, { body }), /** DELETE /api/quote/{id} → 204 */ delete: (id) => this.#empty('DELETE', `/api/quote/${enc(id)}`), }; this.order = { /** GET /api/order → { orders } */ list: () => this.#json('GET', '/api/order'), /** GET /api/order/{id} → { order } */ get: (id) => this.#json('GET', `/api/order/${enc(id)}`), /** POST /api/order → 201 { order } */ create: (body) => this.#json('POST', '/api/order', { body }), /** PATCH /api/order/{id} → { order } */ update: (id, body) => this.#json('PATCH', `/api/order/${enc(id)}`, { body }), /** DELETE /api/order/{id} → 204 */ delete: (id) => this.#empty('DELETE', `/api/order/${enc(id)}`), }; this.customergroup = { /** GET /api/customergroup → { customergroups: { id, name, sort, editable }[] } */ list: () => this.#json('GET', '/api/customergroup'), /** POST /api/customergroup body { name } → 201 { customergroup: { id, name, sort } } */ create: (body) => this.#json('POST', '/api/customergroup', { body }), /** PATCH /api/customergroup/{id} body { name } → { customergroup: { id, name, sort } } */ update: (id, body) => this.#json('PATCH', `/api/customergroup/${enc(id)}`, { body }), /** PUT /api/customergroup/order body { customergroupIds } → { customergroupIds } */ order: (body) => this.#json('PUT', '/api/customergroup/order', { body }), /** DELETE /api/customergroup/{id} → 204 */ delete: (id) => this.#empty('DELETE', `/api/customergroup/${enc(id)}`), }; this.entity = { /** GET /api/entity → { entities } · optional query customergroupId */ list: (query) => this.#json('GET', '/api/entity', { query }), /** GET /api/entity/{id} → { entity } */ get: (id) => this.#json('GET', `/api/entity/${enc(id)}`), /** POST /api/entity → 201 { entity } */ create: (body) => this.#json('POST', '/api/entity', { body }), /** PATCH /api/entity/{id} → { entity } */ update: (id, body) => this.#json('PATCH', `/api/entity/${enc(id)}`, { body }), /** DELETE /api/entity/{id} → 204 */ delete: (id) => this.#empty('DELETE', `/api/entity/${enc(id)}`), /** GET /api/entity/next-number → { number: string } */ nextNumber: () => this.#json('GET', '/api/entity/next-number'), /** GET /api/entity/{entityId}/address → { addresses } */ listAddresses: (entityId) => this.#json('GET', `/api/entity/${enc(entityId)}/address`), /** POST /api/entity/{entityId}/address → 201 { address } */ createAddress: (entityId, body) => this.#json('POST', `/api/entity/${enc(entityId)}/address`, { body }), /** PATCH /api/entity/{entityId}/address/{id} → { address } */ updateAddress: (entityId, id, body) => this.#json('PATCH', `/api/entity/${enc(entityId)}/address/${enc(id)}`, { body, }), /** DELETE /api/entity/{entityId}/address/{id} → 204 */ deleteAddress: (entityId, id) => this.#empty('DELETE', `/api/entity/${enc(entityId)}/address/${enc(id)}`), /** GET /api/entity/{entityId}/contact → { contacts } */ listContacts: (entityId) => this.#json('GET', `/api/entity/${enc(entityId)}/contact`), /** POST /api/entity/{entityId}/contact → 201 { contact } */ createContact: (entityId, body) => this.#json('POST', `/api/entity/${enc(entityId)}/contact`, { body }), /** PATCH /api/entity/{entityId}/contact/{id} → { contact } */ updateContact: (entityId, id, body) => this.#json('PATCH', `/api/entity/${enc(entityId)}/contact/${enc(id)}`, { body, }), /** DELETE /api/entity/{entityId}/contact/{id} → 204 */ deleteContact: (entityId, id) => this.#empty('DELETE', `/api/entity/${enc(entityId)}/contact/${enc(id)}`), }; this.category = { /** GET /api/category/tree */ tree: () => this.#json('GET', '/api/category/tree'), /** GET /api/category/export.zip */ exportZip: () => this.#blob('GET', '/api/category/export.zip'), /** POST /api/category */ create: (body) => this.#json('POST', '/api/category', { body }), /** GET /api/category/{id}/articles */ articles: (id) => this.#json('GET', `/api/category/${enc(id)}/articles`), /** PATCH /api/category/{id} */ update: (id, body) => this.#json('PATCH', `/api/category/${enc(id)}`, { body }), /** DELETE /api/category/{id} */ delete: (id) => this.#empty('DELETE', `/api/category/${enc(id)}`), /** PUT /api/category/order */ order: (body) => this.#json('PUT', '/api/category/order', { body }), /** POST /api/category/{id}/image — raw AVIF body */ addImage: (id, avifBytes) => this.#json('POST', `/api/category/${enc(id)}/image`, { body: avifBytes, contentType: 'image/avif', raw: true, }), /** DELETE /api/category/{id}/image */ deleteImage: (id) => this.#json('DELETE', `/api/category/${enc(id)}/image`), }; this.article = { /** GET /api/articleIndex */ index: () => this.#json('GET', '/api/articleIndex'), /** POST /api/article */ create: (body) => this.#json('POST', '/api/article', { body }), /** GET /api/article/export.zip */ exportZip: (query = {}) => this.#blob('GET', '/api/article/export.zip', { query }), /** GET /api/article/{sku} */ get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`), /** GET /api/article/{sku}/history → { history: [{ id, date, text }, ...] } */ history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`), /** * Load history and refetch on SSE `article-`. * Paginates in StorageManager until the HTTP API supports skip/offset. * `onHistory(history, total)` — returns `{ unwatch, setSkipAndOffset }`. */ historyWatch: (sku, onHistory) => this.watchArticleHistory(sku, onHistory), /** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi } */ update: async (sku, body) => { const nameChange = body != null && Object.hasOwn(body, 'name'); if (nameChange) { this.setArticleIndexName(sku, body.name); this.setArticleName(sku, body.name); } try { const res = await this.#json('PATCH', `/api/article/${enc(sku)}`, { body }); this.setArticle(res.article); return res; } catch (err) { if (nameChange) { this.clearArticleIndex(); if (this.getArticle(sku)) { void this.loadArticle(sku).catch(() => {}); } } throw err; } }, /** DELETE /api/article/{sku} */ delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`), /** PUT /api/article/{sku}/prices */ setPrices: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/prices`, { body }), /** POST /api/article/{sku}/image — raw AVIF body */ addImage: (sku, avifBytes) => this.#json('POST', `/api/article/${enc(sku)}/image`, { body: avifBytes, contentType: 'image/avif', raw: true, }), /** DELETE /api/article/{sku}/image/{fullHash} */ deleteImage: (sku, fullHash) => this.#empty('DELETE', `/api/article/${enc(sku)}/image/${enc(fullHash)}`), /** PUT /api/article/{sku}/images/order */ orderImages: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/images/order`, { body }), /** PUT /api/article/{sku}/eans */ setEans: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/eans`, { body }), /** PUT /api/article/{sku}/brand */ setBrand: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/brand`, { body }), /** PUT /api/article/{sku}/weight */ setWeight: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/weight`, { body }), /** PUT /api/article/{sku}/categories */ setCategories: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/categories`, { body }), /** GET /api/article/{sku}/supplier */ getSuppliers: (sku) => this.#json('GET', `/api/article/${enc(sku)}/supplier`), /** POST /api/article/{sku}/supplier */ addSupplier: (sku, body) => this.#json('POST', `/api/article/${enc(sku)}/supplier`, { body }), /** PATCH /api/article/{sku}/supplier/{id} */ updateSupplier: (sku, id, body) => this.#json('PATCH', `/api/article/${enc(sku)}/supplier/${enc(id)}`, { body }), /** PUT /api/article/{sku}/supplier/order */ orderSuppliers: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/supplier/order`, { body }), /** DELETE /api/article/{sku}/supplier/{id} */ deleteSupplier: (sku, id) => this.#empty('DELETE', `/api/article/${enc(sku)}/supplier/${enc(id)}`), }; this.posterminal = { /** GET /api/posterminal */ list: () => this.#json('GET', '/api/posterminal'), /** PUT /api/posterminal */ upsert: (body) => this.#json('PUT', '/api/posterminal', { body }), /** DELETE /api/posterminal/{id} */ delete: (id) => this.#empty('DELETE', `/api/posterminal/${enc(id)}`), /** PUT /api/posterminal/{id}/category/{categoryId} */ setOn: (id, categoryId) => this.#json('PUT', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`), /** DELETE /api/posterminal/{id}/category/{categoryId} */ setOff: (id, categoryId) => this.#json('DELETE', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`), /** PUT /api/posterminal/{id}/all */ setOnAll: (id) => this.#json('PUT', `/api/posterminal/${enc(id)}/all`), /** DELETE /api/posterminal/{id}/all */ setOffAll: (id) => this.#json('DELETE', `/api/posterminal/${enc(id)}/all`), }; this.number = { /** GET /api/number → { numbers: [{ id, type, prefix, padding, next }] } */ list: () => this.#json('GET', '/api/number'), /** PATCH /api/number/{id} — body: { prefix?, padding?, next? } (only present keys patched) */ update: (id, body) => this.#json('PATCH', `/api/number/${enc(id)}`, { body }), }; this.setting = { /** GET /api/company/setting?frontendOnly=1 → { settings: [{ key, value, frontend }] } */ list: (query) => this.#json('GET', '/api/company/setting', { query }), /** GET /api/company/setting/{key} → { setting: { key, value, frontend } } */ get: (key) => this.#json('GET', `/api/company/setting/${enc(key)}`), /** PUT /api/company/setting/{key} body { value, frontend } → { setting: { key, value, frontend } } */ upsert: (key, body) => this.#json('PUT', `/api/company/setting/${enc(key)}`, { body }), }; this.image = { /** GET /api/image/{fullHash} → AVIF blob */ get: (fullHash) => this.#blob('GET', `/api/image/${enc(fullHash)}`), /** Absolute URL for (same-origin / cookie auth). */ url: (fullHash) => buildUrl(this.#baseUrl, `/api/image/${enc(fullHash)}`), }; this.thumb = { /** GET /api/thumb/{fullHash} → AVIF blob */ get: (fullHash) => this.#blob('GET', `/api/thumb/${enc(fullHash)}`), /** Absolute URL for (same-origin / cookie auth). */ url: (fullHash) => buildUrl(this.#baseUrl, `/api/thumb/${enc(fullHash)}`), }; } /** Absolute URL for a same-origin API path (e.g. SSE). */ urlFor(path) { return buildUrl(this.#baseUrl, path); } async #json(method, path, { body, query, contentType, raw, headers: extraHeaders } = {}) { const url = buildPath(path, query); const headers = { ...extraHeaders }; let payload; if (body !== undefined) { if (typeof FormData !== 'undefined' && body instanceof FormData) { payload = body; } else if (raw) { if (contentType) headers['Content-Type'] = contentType; payload = body; } else { headers['Content-Type'] = contentType || 'application/json'; payload = JSON.stringify(body); } } const res = await fetch(url, { method, headers, credentials: 'include', body: payload, }); 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; } const err = new Error(message || `Request failed (${res.status})`); err.status = res.status; err.path = url; err.method = method; err.data = data; throw err; } if (res.status === 204) return null; return res.json(); } async #empty(method, path, { query } = {}) { await this.#json(method, path, { query }); return null; } async #blob(method, path, { query } = {}) { const url = buildPath(path, query); const res = await fetch(url, { method, credentials: 'include', }); 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; } const err = new Error(message || `Request failed (${res.status})`); err.status = res.status; err.path = url; err.method = method; err.data = data; throw err; } return res.blob(); } } export default ApiClient;