u
This commit is contained in:
413
src/api/article.js
Normal file
413
src/api/article.js
Normal file
@@ -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 `<fullHash>.avif` per article image (content-addressed, deduplicated).
|
||||
* Optional `categoryId` restricts the export to that category and below.
|
||||
* Errors: 400 No company selected, 401, 403, 404 (JSON { error }).
|
||||
*/
|
||||
exportZip(categoryId) {
|
||||
const params = new URLSearchParams();
|
||||
if (categoryId != null) params.set('categoryId', String(categoryId));
|
||||
const qs = params.toString();
|
||||
return httpGetBinary(`/api/article/export.zip${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
/**
|
||||
* POST /api/article/:sku/print
|
||||
* Body: { printerId, templateId, quantity }
|
||||
* Prints the given template to the specified printer for this article.
|
||||
*/
|
||||
printLabel(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/print`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* GET /api/article/:sku/attributevalue → { attributevalues: ArticleAttributeValue[] }
|
||||
*/
|
||||
getAttributeValues(sku) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/attributevalue`);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/attributevalue
|
||||
* Body: { attributevalues: number[] | { attributeValueId: number, sort?: number }[] }
|
||||
* → 200 { attributevalues: ArticleAttributeValue[] }
|
||||
*/
|
||||
setAttributeValues(sku, attributevalues) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/attributevalue`, {
|
||||
method: 'PUT',
|
||||
body: { attributevalues },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku/property → { properties: ArticleProperty[] }
|
||||
*/
|
||||
getProperties(sku) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/property`);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/property
|
||||
* Body: { properties: { propertyId: number, value: string, sort?: number }[] }
|
||||
* → 200 { properties: ArticleProperty[] }
|
||||
*/
|
||||
setProperties(sku, properties) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/property`, {
|
||||
method: 'PUT',
|
||||
body: { properties },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/article/:sku/property/:propertyId → 200 { ok: true }
|
||||
*/
|
||||
deleteProperty(sku, propertyId) {
|
||||
return http(
|
||||
`/api/article/${encodeURIComponent(sku)}/property/${encodeURIComponent(propertyId)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/article/:sku/parent
|
||||
* Body: { isParent: boolean, attributeNameId?: number }
|
||||
*/
|
||||
toggleParent(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/parent`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku/children → { children: [...] }
|
||||
*/
|
||||
getChildren(sku) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/children`);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/article/:sku/children
|
||||
* Body: { attributeValueId?: number, value?: string, name?: string }
|
||||
*/
|
||||
addChild(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/children`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/article/:sku/children/:childSku
|
||||
*/
|
||||
removeChild(sku, childSku, options = {}) {
|
||||
const qs = options?.deleteArticle ? '?deleteArticle=true' : '';
|
||||
return http(
|
||||
`/api/article/${encodeURIComponent(sku)}/children/${encodeURIComponent(childSku)}${qs}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku/articlelist → { items: [...] }
|
||||
*/
|
||||
getArticlelist(sku) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/articlelist`);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/articlelist
|
||||
* Body: { items: [{ articleId, sku, count, sort }] }
|
||||
*/
|
||||
setArticlelist(sku, items) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/articlelist`, {
|
||||
method: 'PUT',
|
||||
body: Array.isArray(items) ? { items } : items,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku/optionslist → { options: [...] }
|
||||
*/
|
||||
getOptionslist(sku) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/optionslist`);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/optionslist
|
||||
* Body: { options: [{ articleId, sku, count, sort, mandated }] }
|
||||
*/
|
||||
setOptionslist(sku, options) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/optionslist`, {
|
||||
method: 'PUT',
|
||||
body: Array.isArray(options) ? { options } : options,
|
||||
});
|
||||
},
|
||||
};
|
||||
28
src/api/attributename.js
Normal file
28
src/api/attributename.js
Normal file
@@ -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 } });
|
||||
},
|
||||
};
|
||||
34
src/api/attributevalue.js
Normal file
34
src/api/attributevalue.js
Normal file
@@ -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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
52
src/api/auth.js
Normal file
52
src/api/auth.js
Normal file
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
71
src/api/brand.js
Normal file
71
src/api/brand.js
Normal file
@@ -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' });
|
||||
},
|
||||
};
|
||||
118
src/api/category.js
Normal file
118
src/api/category.js
Normal file
@@ -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 `<fullHash>.avif`
|
||||
* entry per category image (content-addressed, deduplicated).
|
||||
* Errors: 400 No company selected, 401, 403, 404 (JSON { error }).
|
||||
*/
|
||||
exportZip() {
|
||||
return httpGetBinary('/api/category/export.zip');
|
||||
},
|
||||
};
|
||||
168
src/api/company.js
Normal file
168
src/api/company.js
Normal file
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
48
src/api/customergroup.js
Normal file
48
src/api/customergroup.js
Normal file
@@ -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' });
|
||||
},
|
||||
};
|
||||
151
src/api/delivery.js
Normal file
151
src/api/delivery.js
Normal file
@@ -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 */ }
|
||||
}
|
||||
},
|
||||
};
|
||||
98
src/api/entity.js
Normal file
98
src/api/entity.js
Normal file
@@ -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' });
|
||||
},
|
||||
};
|
||||
19
src/api/globalHistory.js
Normal file
19
src/api/globalHistory.js
Normal file
@@ -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}` : ''}`);
|
||||
},
|
||||
};
|
||||
185
src/api/http.js
Normal file
185
src/api/http.js
Normal file
@@ -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;
|
||||
}
|
||||
2256
src/api/index.js
2256
src/api/index.js
File diff suppressed because it is too large
Load Diff
42
src/api/labeltemplate.js
Normal file
42
src/api/labeltemplate.js
Normal file
@@ -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
|
||||
};
|
||||
53
src/api/ledger.js
Normal file
53
src/api/ledger.js
Normal file
@@ -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}` : ''}`);
|
||||
},
|
||||
};
|
||||
51
src/api/modelconversation.js
Normal file
51
src/api/modelconversation.js
Normal file
@@ -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' });
|
||||
},
|
||||
};
|
||||
20
src/api/number.js
Normal file
20
src/api/number.js
Normal file
@@ -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 });
|
||||
},
|
||||
};
|
||||
49
src/api/order.js
Normal file
49
src/api/order.js
Normal file
@@ -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' });
|
||||
},
|
||||
};
|
||||
91
src/api/posterminal.js
Normal file
91
src/api/posterminal.js
Normal file
@@ -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',
|
||||
});
|
||||
},
|
||||
};
|
||||
49
src/api/printer.js
Normal file
49
src/api/printer.js
Normal file
@@ -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 } });
|
||||
},
|
||||
};
|
||||
28
src/api/propertyname.js
Normal file
28
src/api/propertyname.js
Normal file
@@ -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 } });
|
||||
},
|
||||
};
|
||||
42
src/api/quote.js
Normal file
42
src/api/quote.js
Normal file
@@ -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' });
|
||||
},
|
||||
};
|
||||
142
src/api/serper.js
Normal file
142
src/api/serper.js
Normal file
@@ -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<Blob>} 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();
|
||||
},
|
||||
};
|
||||
32
src/api/setting.js
Normal file
32
src/api/setting.js
Normal file
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
14
src/api/stock.js
Normal file
14
src/api/stock.js
Normal file
@@ -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 });
|
||||
},
|
||||
};
|
||||
51
src/api/supplier.js
Normal file
51
src/api/supplier.js
Normal file
@@ -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}`);
|
||||
},
|
||||
};
|
||||
13
src/api/thumb.js
Normal file
13
src/api/thumb.js
Normal file
@@ -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 <img src={thumb.url(fullHash)} /> (same-origin; cookies sent).
|
||||
*/
|
||||
url(fullHash) {
|
||||
return `/api/thumb/${encodeURIComponent(fullHash)}`;
|
||||
},
|
||||
};
|
||||
30
src/api/unit.js
Normal file
30
src/api/unit.js
Normal file
@@ -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' });
|
||||
},
|
||||
};
|
||||
109
src/api/vfs.js
Normal file
109
src/api/vfs.js
Normal file
@@ -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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
49
src/api/warehouse.js
Normal file
49
src/api/warehouse.js
Normal file
@@ -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' });
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user