Files
dashfront/src/api/ApiClient.js
2026-09-26 23:05:48 +02:00

551 lines
22 KiB
JavaScript

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-<sku>`.
* 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 <img src> (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 <img src> (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;