u
This commit is contained in:
@@ -1,678 +0,0 @@
|
||||
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.attributename = {
|
||||
/** GET /api/attributename */
|
||||
list: () => this.#json('GET', '/api/attributename'),
|
||||
/** POST /api/attributename */
|
||||
create: (body) => this.#json('POST', '/api/attributename', { body }),
|
||||
/** PATCH /api/attributename/{id} */
|
||||
update: (id, body) => this.#json('PATCH', `/api/attributename/${enc(id)}`, { body }),
|
||||
/** DELETE /api/attributename/{id} */
|
||||
delete: (id) => this.#json('DELETE', `/api/attributename/${enc(id)}`),
|
||||
/** PUT /api/attributename/order */
|
||||
order: (body) => this.#json('PUT', '/api/attributename/order', { body }),
|
||||
};
|
||||
|
||||
this.attributevalue = {
|
||||
/** GET /api/attributename/{attributeId}/value */
|
||||
listByAttributeId: (attributeId) =>
|
||||
this.#json('GET', `/api/attributename/${enc(attributeId)}/value`),
|
||||
/** POST /api/attributename/{attributeId}/value */
|
||||
create: (attributeId, body) =>
|
||||
this.#json('POST', `/api/attributename/${enc(attributeId)}/value`, { body }),
|
||||
/** PATCH /api/attributevalue/{id} */
|
||||
update: (id, body) => this.#json('PATCH', `/api/attributevalue/${enc(id)}`, { body }),
|
||||
/** DELETE /api/attributevalue/{id} */
|
||||
delete: (id) => this.#json('DELETE', `/api/attributevalue/${enc(id)}`),
|
||||
/** PUT /api/attributename/{attributeId}/value/order */
|
||||
order: (attributeId, body) =>
|
||||
this.#json('PUT', `/api/attributename/${enc(attributeId)}/value/order`, { body }),
|
||||
};
|
||||
|
||||
this.propertyname = {
|
||||
/** GET /api/propertyname */
|
||||
list: () => this.#json('GET', '/api/propertyname'),
|
||||
/** POST /api/propertyname */
|
||||
create: (body) => this.#json('POST', '/api/propertyname', { body }),
|
||||
/** PATCH /api/propertyname/{id} */
|
||||
update: (id, body) => this.#json('PATCH', `/api/propertyname/${enc(id)}`, { body }),
|
||||
/** DELETE /api/propertyname/{id} */
|
||||
delete: (id) => this.#json('DELETE', `/api/propertyname/${enc(id)}`),
|
||||
/** PUT /api/propertyname/order */
|
||||
order: (body) => this.#json('PUT', '/api/propertyname/order', { body }),
|
||||
};
|
||||
|
||||
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 }),
|
||||
/** PUT /api/warehouse/order → 200 { warehouseIds: number[] } */
|
||||
order: (body) => this.#json('PUT', '/api/warehouse/order', { 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 image body */
|
||||
addImage: (id, imageBytes) =>
|
||||
this.#json('POST', `/api/category/${enc(id)}/image`, {
|
||||
body: imageBytes,
|
||||
contentType: imageBytes?.type || 'image/webp',
|
||||
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, track_stock } */
|
||||
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 image body */
|
||||
addImage: (sku, imageBytes) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/image`, {
|
||||
body: imageBytes,
|
||||
contentType: imageBytes?.type || 'image/webp',
|
||||
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 }),
|
||||
/** PUT /api/article/{sku}/track_stock */
|
||||
setTrackStock: async (sku, body) => {
|
||||
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
|
||||
const res = await this.#json('PUT', `/api/article/${enc(sku)}/track_stock`, { body: payload });
|
||||
if (res?.article) {
|
||||
this.setArticle(res.article);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
/** 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)}`),
|
||||
/** GET /api/article/{sku}/attributevalue */
|
||||
getAttributeValues: (sku) =>
|
||||
this.#json('GET', `/api/article/${enc(sku)}/attributevalue`),
|
||||
/** PUT /api/article/{sku}/attributevalue */
|
||||
setAttributeValues: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/attributevalue`, { body }),
|
||||
/** GET /api/article/{sku}/property */
|
||||
getProperties: (sku) =>
|
||||
this.#json('GET', `/api/article/${enc(sku)}/property`),
|
||||
/** PUT /api/article/{sku}/property */
|
||||
setProperties: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/property`, { body }),
|
||||
/** DELETE /api/article/{sku}/property/{propertyId} */
|
||||
deleteProperty: (sku, propertyId) =>
|
||||
this.#json('DELETE', `/api/article/${enc(sku)}/property/${enc(propertyId)}`),
|
||||
/** POST /api/article/{sku}/father */
|
||||
toggleFather: (sku, body) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/father`, { body }),
|
||||
/** GET /api/article/{sku}/children */
|
||||
getChildren: (sku) =>
|
||||
this.#json('GET', `/api/article/${enc(sku)}/children`),
|
||||
/** POST /api/article/{sku}/children */
|
||||
addChild: (sku, body) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/children`, { body }),
|
||||
/** DELETE /api/article/{sku}/children/{childSku} */
|
||||
removeChild: (sku, childSku, { deleteArticle = false } = {}) =>
|
||||
this.#json(
|
||||
'DELETE',
|
||||
`/api/article/${enc(sku)}/children/${enc(childSku)}${deleteArticle ? '?deleteArticle=true' : ''}`
|
||||
),
|
||||
/** GET /api/article/{sku}/articlelist */
|
||||
getArticlelist: (sku) =>
|
||||
this.#json('GET', `/api/article/${enc(sku)}/articlelist`),
|
||||
/** PUT /api/article/{sku}/articlelist */
|
||||
setArticlelist: (sku, items) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/articlelist`, {
|
||||
body: Array.isArray(items) ? { items } : items,
|
||||
}),
|
||||
/** GET /api/article/{sku}/optionslist */
|
||||
getOptionslist: (sku) =>
|
||||
this.#json('GET', `/api/article/${enc(sku)}/optionslist`),
|
||||
/** PUT /api/article/{sku}/optionslist */
|
||||
setOptionslist: (sku, options) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/optionslist`, {
|
||||
body: Array.isArray(options) ? { options } : options,
|
||||
}),
|
||||
};
|
||||
|
||||
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.printer = {
|
||||
/** GET /api/printer → { printers: Printer[] } */
|
||||
list: () => this.#json('GET', '/api/printer'),
|
||||
/** POST /api/printer → 201 { printer } */
|
||||
create: (body) => this.#json('POST', '/api/printer', { body }),
|
||||
/** PATCH /api/printer/{id} → { printer } */
|
||||
update: (id, body) => this.#json('PATCH', `/api/printer/${enc(id)}`, { body }),
|
||||
/** DELETE /api/printer/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/printer/${enc(id)}`),
|
||||
/** PUT /api/printer/order body { printerIds } → { printerIds } */
|
||||
order: (body) => this.#json('PUT', '/api/printer/order', { 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)}`),
|
||||
};
|
||||
|
||||
this.modelconversation = {
|
||||
/** GET /api/modelconversation → { conversations: [...] } */
|
||||
list: () => this.#json('GET', '/api/modelconversation'),
|
||||
/** GET /api/modelconversation/{id} → { conversation } */
|
||||
get: (id) => this.#json('GET', `/api/modelconversation/${enc(id)}`),
|
||||
/** POST /api/modelconversation body { provider, model } → { conversation } */
|
||||
create: (body) => this.#json('POST', '/api/modelconversation', { body }),
|
||||
/** GET /api/modelconversation/{id}/message → { messages: [...] } */
|
||||
listMessages: (id) => this.#json('GET', `/api/modelconversation/${enc(id)}/message`),
|
||||
/** POST /api/modelconversation/{id}/message body { message } → { message } */
|
||||
addMessage: (id, body) => this.#json('POST', `/api/modelconversation/${enc(id)}/message`, { body }),
|
||||
/** DELETE /api/modelconversation/{id} */
|
||||
delete: (id) => this.#json('DELETE', `/api/modelconversation/${enc(id)}`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/** 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;
|
||||
@@ -1,819 +0,0 @@
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import { notifyError } from '../utils/notify';
|
||||
import { ApiClient } from './ApiClient';
|
||||
import artikelAutomat from '../automats/artikelAutomat';
|
||||
import companyAutomat from '../automats/companyAutomat';
|
||||
import brandAutomat from '../automats/brandAutomat';
|
||||
import unitAutomat from '../automats/unitAutomat';
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat';
|
||||
import posterminalAutomat from '../automats/posterminalAutomat';
|
||||
import warehouseAutomat from '../automats/warehouseAutomat';
|
||||
import attributeAutomat from '../automats/attributeAutomat';
|
||||
import propertyAutomat from '../automats/propertyAutomat';
|
||||
|
||||
|
||||
/**
|
||||
* Stale-while-revalidate cache: readers keep the current promise; update()
|
||||
* swaps only after a successful reload. Failed updates leave the cache intact.
|
||||
* replace() writes local data without a round-trip.
|
||||
*/
|
||||
function createCache(label, load) {
|
||||
/** Settled value, or `undefined` if not loaded yet / last load failed. */
|
||||
let current;
|
||||
let promise = load()
|
||||
.then((data) => {
|
||||
current = data;
|
||||
return data;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`[storageMgr] ${label} failed`, err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
async function update() {
|
||||
try {
|
||||
const data = await load();
|
||||
current = data;
|
||||
promise = Promise.resolve(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`[storageMgr] update ${label} failed`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function replace(data) {
|
||||
current = data;
|
||||
promise = Promise.resolve(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
current = undefined;
|
||||
promise = load()
|
||||
.then((data) => {
|
||||
current = data;
|
||||
return data;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`[storageMgr] ${label} failed`, err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
update,
|
||||
replace,
|
||||
clear,
|
||||
get promise() {
|
||||
return promise;
|
||||
},
|
||||
/** Loaded value, or `undefined` if the initial load has not settled successfully. */
|
||||
get value() {
|
||||
return current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ApiClient plus stale-while-revalidate caches and GET /api/events invalidation.
|
||||
*/
|
||||
export class StorageManager extends ApiClient {
|
||||
/** @type {EventSource | null} */
|
||||
#events = null;
|
||||
/** @type {ReturnType<StorageManager['#createDeferredCache']> | null} */
|
||||
#articleIndexCache = null;
|
||||
/** @type {Map<string, Set<(article: object) => void>>} sku → article listeners */
|
||||
#articleWatchers = new Map();
|
||||
/** @type {Map<string, Set<() => void>>} sku → history refreshers */
|
||||
#historyWatchers = new Map();
|
||||
/** @type {Map<string, Set<() => void>>} sku → supplier refreshers */
|
||||
#supplierWatchers = new Map();
|
||||
|
||||
constructor(baseUrl = window.location.origin) {
|
||||
super(baseUrl);
|
||||
this.#initCaches();
|
||||
this.article.watch = (sku, onArticle) => this.watchArticle(sku, onArticle);
|
||||
this.article.supplierWatch = (sku, onSuppliers) =>
|
||||
this.watchArticleSuppliers(sku, onSuppliers);
|
||||
}
|
||||
|
||||
/** Cached article detail from the shared artikelAutomat, or null. */
|
||||
getArticle(sku) {
|
||||
return artikelAutomat.state.bySku[String(sku)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the shared article cache in sync after GET/PATCH/PUT responses and
|
||||
* notify any StorageManager-level watchers.
|
||||
*/
|
||||
setArticle(article) {
|
||||
requireObject(article, 'article');
|
||||
requireDefined(article.sku, 'article.sku');
|
||||
const key = String(article.sku);
|
||||
const stored = artikelAutomat.actions.setArticle(article);
|
||||
|
||||
const watchers = this.#articleWatchers.get(key);
|
||||
if (watchers) {
|
||||
for (const onArticle of watchers) onArticle(stored);
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/article/{sku} via the shared cache (deduplicated), notify watchers.
|
||||
* @param {string} sku
|
||||
*/
|
||||
async loadArticle(sku) {
|
||||
const article = await artikelAutomat.actions.loadArticle(sku);
|
||||
const watchers = this.#articleWatchers.get(String(sku));
|
||||
if (watchers) {
|
||||
for (const onArticle of watchers) onArticle(article);
|
||||
}
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to article detail updates (local setArticle + SSE `article-<sku>`).
|
||||
* @param {string} sku
|
||||
* @param {(article: object) => void} onArticle
|
||||
* @returns {{ article: object | null, unwatch: () => void }}
|
||||
*/
|
||||
watchArticle(sku, onArticle) {
|
||||
const key = String(sku);
|
||||
const needListen =
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key);
|
||||
|
||||
let watchers = this.#articleWatchers.get(key);
|
||||
if (!watchers) {
|
||||
watchers = new Set();
|
||||
this.#articleWatchers.set(key, watchers);
|
||||
}
|
||||
watchers.add(onArticle);
|
||||
if (needListen) {
|
||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
|
||||
return {
|
||||
article: this.getArticle(key) ?? null,
|
||||
unwatch: () => {
|
||||
const set = this.#articleWatchers.get(key);
|
||||
if (!set) return;
|
||||
set.delete(onArticle);
|
||||
if (set.size === 0) {
|
||||
this.#articleWatchers.delete(key);
|
||||
if (
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key)
|
||||
) {
|
||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed category map + root id from categoryAutomat, or null if not yet loaded.
|
||||
* @returns {{ categories: Record<string, object>, rootCategoryId: unknown } | null}
|
||||
*/
|
||||
getCategoryState() {
|
||||
const catState = categoryAutomat.state;
|
||||
if (catState.categories == null || catState.treeStatus !== 'succeeded') return null;
|
||||
return {
|
||||
categories: catState.categories,
|
||||
rootCategoryId: catState.rootCategoryId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch article history, then refetch whenever SSE emits `article-<sku>`.
|
||||
* Paginates in the store (full list from HTTP; slice via setSkipAndOffset).
|
||||
* @param {string} sku
|
||||
* @param {(history: unknown[], total: number) => void} onHistory
|
||||
* @returns {{ unwatch: () => void, setSkipAndOffset: (skip: number, offset: number) => void }}
|
||||
*/
|
||||
watchArticleHistory(sku, onHistory) {
|
||||
const key = String(sku);
|
||||
let active = true;
|
||||
let loaded = false;
|
||||
/** @type {unknown[]} */
|
||||
let all = [];
|
||||
let skip = 0;
|
||||
/** Page size (how many rows to emit). */
|
||||
let offset = Number.POSITIVE_INFINITY;
|
||||
|
||||
const emit = () => {
|
||||
if (!active || !loaded) return;
|
||||
const total = all.length;
|
||||
const end =
|
||||
offset === Number.POSITIVE_INFINITY ? total : skip + offset;
|
||||
onHistory(all.slice(skip, end), total);
|
||||
};
|
||||
|
||||
const refresh = () =>
|
||||
this.article.history(key).then((res) => {
|
||||
if (!active) return;
|
||||
requireObject(res, 'GET /api/article/{sku}/history');
|
||||
all = requireArray(res.history, 'history');
|
||||
loaded = true;
|
||||
emit();
|
||||
});
|
||||
|
||||
const run = () =>
|
||||
void refresh().catch((err) => {
|
||||
if (!active) return;
|
||||
notifyError(err);
|
||||
});
|
||||
|
||||
const needListen =
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key);
|
||||
|
||||
let watchers = this.#historyWatchers.get(key);
|
||||
if (!watchers) {
|
||||
watchers = new Set();
|
||||
this.#historyWatchers.set(key, watchers);
|
||||
}
|
||||
watchers.add(run);
|
||||
if (needListen) {
|
||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
return {
|
||||
unwatch: () => {
|
||||
active = false;
|
||||
const set = this.#historyWatchers.get(key);
|
||||
if (!set) return;
|
||||
set.delete(run);
|
||||
if (set.size === 0) {
|
||||
this.#historyWatchers.delete(key);
|
||||
if (
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key)
|
||||
) {
|
||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
}
|
||||
},
|
||||
setSkipAndOffset: (nextSkip, nextOffset) => {
|
||||
skip = nextSkip;
|
||||
offset = nextOffset;
|
||||
emit();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load suppliers for an article and refetch on SSE `article-<sku>`.
|
||||
* @param {string} sku
|
||||
* @param {(suppliers: Array<object>) => void} onSuppliers
|
||||
* @returns {{ unwatch: () => void, refresh: () => Promise<void> }}
|
||||
*/
|
||||
watchArticleSuppliers(sku, onSuppliers) {
|
||||
const key = String(sku);
|
||||
let active = true;
|
||||
|
||||
const refresh = async () => {
|
||||
const res = await this.article.getSuppliers(key);
|
||||
if (!active) return;
|
||||
requireObject(res, 'GET /api/article/{sku}/supplier');
|
||||
const list = requireArray(res.suppliers, 'suppliers');
|
||||
onSuppliers(list);
|
||||
};
|
||||
|
||||
const run = () => {
|
||||
void refresh().catch((err) => {
|
||||
if (!active) return;
|
||||
notifyError(err);
|
||||
});
|
||||
};
|
||||
|
||||
const needListen =
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key);
|
||||
|
||||
let watchers = this.#supplierWatchers.get(key);
|
||||
if (!watchers) {
|
||||
watchers = new Set();
|
||||
this.#supplierWatchers.set(key, watchers);
|
||||
}
|
||||
watchers.add(run);
|
||||
if (needListen) {
|
||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
return {
|
||||
refresh,
|
||||
unwatch: () => {
|
||||
active = false;
|
||||
const set = this.#supplierWatchers.get(key);
|
||||
if (!set) return;
|
||||
set.delete(run);
|
||||
if (set.size === 0) {
|
||||
this.#supplierWatchers.delete(key);
|
||||
if (
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key)
|
||||
) {
|
||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#onArticleSignal = (event) => {
|
||||
const sku = event.type.slice('article-'.length);
|
||||
|
||||
// Refresh the shared cache; silently when nothing is watching this SKU.
|
||||
if (this.#articleWatchers.has(sku)) {
|
||||
void artikelAutomat.actions.reloadArticle(sku).catch((err) => notifyError(err));
|
||||
} else {
|
||||
void artikelAutomat.actions.reloadArticle(sku).catch(() => {});
|
||||
}
|
||||
const historyWatchers = this.#historyWatchers.get(sku);
|
||||
if (historyWatchers) {
|
||||
for (const run of historyWatchers) run();
|
||||
}
|
||||
const supplierWatchers = this.#supplierWatchers.get(sku);
|
||||
if (supplierWatchers) {
|
||||
for (const run of supplierWatchers) run();
|
||||
}
|
||||
};
|
||||
|
||||
/** Patch local articleIndex name if already loaded; no-op otherwise. */
|
||||
setArticleIndexName(sku, name) {
|
||||
const articles = this.#articleIndexCache.value;
|
||||
if (!articles) return;
|
||||
this.#articleIndexCache.replace(
|
||||
articles.map((a) => (String(a.sku) === String(sku) ? { ...a, name } : a)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Patch cached article detail name if already cached; no-op otherwise. */
|
||||
setArticleName(sku, name) {
|
||||
const current = this.getArticle(sku);
|
||||
if (!current) return;
|
||||
this.setArticle({ ...current, name });
|
||||
}
|
||||
|
||||
/** Drop local articleIndex if loaded (forces reload on next read). */
|
||||
clearArticleIndex() {
|
||||
if (!this.#articleIndexCache.value) return;
|
||||
this.#articleIndexCache.clear();
|
||||
}
|
||||
|
||||
#initCaches() {
|
||||
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
|
||||
const deliveryCache = this.#createDeferredCache('GET /delivery', () =>
|
||||
this.#loadDeliveries(),
|
||||
);
|
||||
const customergroupCache = this.#createDeferredCache('GET /customergroup', () =>
|
||||
this.#loadCustomergroups(),
|
||||
);
|
||||
const entityCache = this.#createDeferredCache('GET /entity', () =>
|
||||
this.#loadEntities(),
|
||||
);
|
||||
const numberCache = this.#createDeferredCache('GET /number', () =>
|
||||
this.#loadNumbers(),
|
||||
);
|
||||
const quoteCache = this.#createDeferredCache('GET /quote', () =>
|
||||
this.#loadQuotes(),
|
||||
);
|
||||
const orderCache = this.#createDeferredCache('GET /order', () =>
|
||||
this.#loadOrders(),
|
||||
);
|
||||
|
||||
// articleIndex is auth-gated — create a deferred cache that only loads after auth
|
||||
this.#articleIndexCache = this.#createDeferredCache(
|
||||
'GET /articleIndex',
|
||||
() => this.#loadArticleIndex(),
|
||||
);
|
||||
const articleIndexCache = this.#articleIndexCache;
|
||||
|
||||
Object.defineProperties(this, {
|
||||
authMePromise: { get: () => authMeCache.promise },
|
||||
updateAuthMe: { value: authMeCache.update },
|
||||
/** Settled GET /auth/me body, or undefined. */
|
||||
authMe: { get: () => authMeCache.value },
|
||||
|
||||
articleIndexPromise: { get: () => articleIndexCache.promise },
|
||||
updateArticleIndex: { value: articleIndexCache.update },
|
||||
|
||||
// ── Automat bridges (brand, unit, company, posterminal) ──────────────────
|
||||
/** Brand list from brandAutomat (may be empty until authed). */
|
||||
brands: { get: () => brandAutomat.state.brands },
|
||||
/** Promise that resolves when brands are first loaded. */
|
||||
brandPromise: {
|
||||
get: () => brandAutomat.state.status === 'succeeded'
|
||||
? Promise.resolve(brandAutomat.state.brands)
|
||||
: new Promise((resolve) => {
|
||||
const unsub = brandAutomat.subscribe((s) => {
|
||||
if (s.status === 'succeeded') { unsub(); resolve(s.brands); }
|
||||
});
|
||||
}),
|
||||
},
|
||||
/** Reload brands from API. */
|
||||
updateBrand: { value: () => brandAutomat.actions.load() },
|
||||
|
||||
/** Unit list from unitAutomat. */
|
||||
units: { get: () => unitAutomat.state.units },
|
||||
/** Promise that resolves when units are first loaded. */
|
||||
unitPromise: {
|
||||
get: () => unitAutomat.state.status === 'succeeded'
|
||||
? Promise.resolve(unitAutomat.state.units)
|
||||
: new Promise((resolve) => {
|
||||
const unsub = unitAutomat.subscribe((s) => {
|
||||
if (s.status === 'succeeded') { unsub(); resolve(s.units); }
|
||||
});
|
||||
}),
|
||||
},
|
||||
/** Reload units from API. */
|
||||
updateUnit: { value: () => unitAutomat.actions.load() },
|
||||
|
||||
/** Posterminal list from posterminalAutomat. */
|
||||
posterminals: { get: () => posterminalAutomat.state.terminals },
|
||||
/** Promise that resolves when posterminals are first loaded. */
|
||||
posterminalPromise: {
|
||||
get: () => posterminalAutomat.state.status === 'succeeded'
|
||||
? Promise.resolve(posterminalAutomat.state.terminals)
|
||||
: new Promise((resolve, reject) => {
|
||||
const unsub = posterminalAutomat.subscribe((s) => {
|
||||
if (s.status === 'succeeded') { unsub(); resolve(s.terminals); }
|
||||
else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); }
|
||||
});
|
||||
}),
|
||||
},
|
||||
/** Reload posterminals from API. */
|
||||
updatePosterminal: { value: () => posterminalAutomat.actions.load() },
|
||||
|
||||
/**
|
||||
* Current company object from companyAutomat, or null.
|
||||
* Previously: companies array from GET /api/company.
|
||||
* Consumers that used companies.find(c => c.id === companyId) can use .company directly.
|
||||
*/
|
||||
company: { get: () => companyAutomat.state.current ?? null },
|
||||
/** Promise that resolves when company data is loaded. */
|
||||
companyPromise: {
|
||||
get: () => companyAutomat.state.status === 'succeeded'
|
||||
? Promise.resolve(companyAutomat.state.current)
|
||||
: new Promise((resolve, reject) => {
|
||||
const unsub = companyAutomat.subscribe((s) => {
|
||||
if (s.status === 'succeeded') { unsub(); resolve(s.current); }
|
||||
else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); }
|
||||
});
|
||||
}),
|
||||
},
|
||||
/** Reload company data from API. */
|
||||
updateCompany: { value: () => companyAutomat.actions.load() },
|
||||
|
||||
// Legacy compat: some components call store.companies.find(c => c.id === companyId).
|
||||
// Return a single-element array so .find() still works.
|
||||
companies: { get: () => companyAutomat.state.current ? [companyAutomat.state.current] : [] },
|
||||
|
||||
// ── Category tree bridge (delegated to categoryAutomat) ──────────────────
|
||||
/** Category tree promise resolving when categoryAutomat tree is loaded. */
|
||||
categoryTreePromise: {
|
||||
get: () => categoryAutomat.state.treeStatus === 'succeeded'
|
||||
? Promise.resolve(categoryAutomat.state.categories)
|
||||
: new Promise((resolve, reject) => {
|
||||
const unsub = categoryAutomat.subscribe((s) => {
|
||||
if (s.treeStatus === 'succeeded') { unsub(); resolve(s.categories); }
|
||||
else if (s.treeStatus === 'failed') { unsub(); reject(new Error(s.error)); }
|
||||
});
|
||||
}),
|
||||
},
|
||||
/** Reload category tree via categoryAutomat. */
|
||||
updateCategoryTree: { value: () => categoryAutomat.actions.loadTree() },
|
||||
/** Current categories map from categoryAutomat, or null. */
|
||||
categoryTree: { get: () => categoryAutomat.state.categories },
|
||||
|
||||
// ── Deferred caches (auth-gated) ─────────────────────────────────────────
|
||||
deliveryPromise: { get: () => deliveryCache.promise },
|
||||
updateDelivery: { value: deliveryCache.update },
|
||||
/** Settled GET /delivery list, or undefined. */
|
||||
deliveries: { get: () => deliveryCache.value },
|
||||
|
||||
customergroupPromise: { get: () => customergroupCache.promise },
|
||||
updateCustomergroup: { value: customergroupCache.update },
|
||||
/** Settled GET /customergroup list, or undefined. */
|
||||
customergroups: { get: () => customergroupCache.value },
|
||||
|
||||
entityPromise: { get: () => entityCache.promise },
|
||||
updateEntity: { value: entityCache.update },
|
||||
/** Settled GET /entity list, or undefined. */
|
||||
entities: { get: () => entityCache.value },
|
||||
|
||||
numberPromise: { get: () => numberCache.promise },
|
||||
updateNumber: { value: numberCache.update },
|
||||
/** Settled GET /number list, or undefined. */
|
||||
numbers: { get: () => numberCache.value },
|
||||
|
||||
quotePromise: { get: () => quoteCache.promise },
|
||||
updateQuote: { value: quoteCache.update },
|
||||
/** Settled GET /quote list, or undefined. */
|
||||
quotes: { get: () => quoteCache.value },
|
||||
|
||||
orderPromise: { get: () => orderCache.promise },
|
||||
updateOrder: { value: orderCache.update },
|
||||
/** Settled GET /order list, or undefined. */
|
||||
orders: { get: () => orderCache.value },
|
||||
|
||||
/** Close and reopen GET /api/events (required after selected-company change). */
|
||||
reconnectEvents: { value: () => this.#reconnectEvents() },
|
||||
});
|
||||
|
||||
let lastCompanyId = null;
|
||||
|
||||
const syncWithAuth = (auth) => {
|
||||
const companyId =
|
||||
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
||||
? String(auth.user.selectedCompanyId)
|
||||
: null;
|
||||
|
||||
if (companyId === lastCompanyId) return;
|
||||
const prevCompanyId = lastCompanyId;
|
||||
lastCompanyId = companyId;
|
||||
|
||||
if (!companyId) {
|
||||
// Unauthenticated or logged out
|
||||
this.#disconnectEvents();
|
||||
articleIndexCache.clear();
|
||||
deliveryCache.clear();
|
||||
customergroupCache.clear();
|
||||
entityCache.clear();
|
||||
numberCache.clear();
|
||||
quoteCache.clear();
|
||||
orderCache.clear();
|
||||
artikelAutomat.actions.clear();
|
||||
} else {
|
||||
// Company established or switched
|
||||
if (prevCompanyId) {
|
||||
articleIndexCache.clear();
|
||||
deliveryCache.clear();
|
||||
customergroupCache.clear();
|
||||
entityCache.clear();
|
||||
numberCache.clear();
|
||||
quoteCache.clear();
|
||||
orderCache.clear();
|
||||
artikelAutomat.actions.clear();
|
||||
}
|
||||
this.#reconnectEvents();
|
||||
void articleIndexCache.trigger().catch(() => {});
|
||||
void deliveryCache.trigger().catch(() => {});
|
||||
void customergroupCache.trigger().catch(() => {});
|
||||
void entityCache.trigger().catch(() => {});
|
||||
void numberCache.trigger().catch(() => {});
|
||||
void quoteCache.trigger().catch(() => {});
|
||||
void orderCache.trigger().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
authAutomat.subscribe(syncWithAuth);
|
||||
syncWithAuth(authAutomat.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cache that does NOT load immediately but waits for an explicit trigger
|
||||
* or loads on-demand once authenticated.
|
||||
*/
|
||||
#createDeferredCache(label, load) {
|
||||
let current;
|
||||
let loadPromise = null;
|
||||
|
||||
const trigger = () => {
|
||||
if (loadPromise) return loadPromise;
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
const data = await load();
|
||||
current = data;
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`[storageMgr] ${label} failed`, err);
|
||||
loadPromise = null;
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
return loadPromise;
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
try {
|
||||
const data = await load();
|
||||
current = data;
|
||||
loadPromise = Promise.resolve(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`[storageMgr] update ${label} failed`, err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const replace = (data) => {
|
||||
current = data;
|
||||
loadPromise = Promise.resolve(data);
|
||||
return data;
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
current = undefined;
|
||||
loadPromise = null;
|
||||
};
|
||||
|
||||
return {
|
||||
update,
|
||||
replace,
|
||||
clear,
|
||||
trigger,
|
||||
get promise() {
|
||||
if (!loadPromise) {
|
||||
if (authAutomat.state.status === 'authed' && authAutomat.state.user?.selectedCompanyId) {
|
||||
return trigger();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const unsub = authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
|
||||
unsub();
|
||||
trigger().then(resolve, reject);
|
||||
} else if (auth.status === 'anon') {
|
||||
unsub();
|
||||
reject(new Error(`Cannot load ${label}: unauthenticated`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return loadPromise;
|
||||
},
|
||||
get value() {
|
||||
return current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* GET /api/events — SSE signals for the selected company.
|
||||
* Stream is bound at connect time; reconnect after company switch.
|
||||
*/
|
||||
#reconnectEvents() {
|
||||
this.#disconnectEvents();
|
||||
const es = new EventSource(this.urlFor('/api/events'), { withCredentials: true });
|
||||
es.addEventListener('category', () => {
|
||||
void categoryAutomat.actions.loadTree().catch((err) => {
|
||||
console.error('[storageMgr] category tree SSE refresh failed', err);
|
||||
});
|
||||
invalidateAllCategoryViews();
|
||||
});
|
||||
es.addEventListener('articles', () => {
|
||||
invalidateAllCategoryViews();
|
||||
void this.updateArticleIndex().catch((err) => {
|
||||
console.error('[storageMgr] articles SSE refresh failed', err);
|
||||
});
|
||||
});
|
||||
es.addEventListener('warehouse', () => {
|
||||
void warehouseAutomat.actions.reload().catch((err) => {
|
||||
console.error('[storageMgr] warehouse SSE refresh failed', err);
|
||||
});
|
||||
});
|
||||
es.addEventListener('attributename', () => {
|
||||
void attributeAutomat.actions.load().catch((err) => {
|
||||
console.error('[storageMgr] attributename SSE refresh failed', err);
|
||||
});
|
||||
});
|
||||
es.addEventListener('propertyname', () => {
|
||||
void propertyAutomat.actions.load().catch((err) => {
|
||||
console.error('[storageMgr] propertyname SSE refresh failed', err);
|
||||
});
|
||||
});
|
||||
for (const sku of new Set([
|
||||
...this.#articleWatchers.keys(),
|
||||
...this.#historyWatchers.keys(),
|
||||
...this.#supplierWatchers.keys(),
|
||||
])) {
|
||||
es.addEventListener(`article-${sku}`, this.#onArticleSignal);
|
||||
}
|
||||
es.onerror = () => {
|
||||
console.error('[storageMgr] events stream error');
|
||||
};
|
||||
this.#events = es;
|
||||
}
|
||||
|
||||
#disconnectEvents() {
|
||||
if (!this.#events) return;
|
||||
this.#events.close();
|
||||
this.#events = null;
|
||||
}
|
||||
|
||||
#syncEvents(user) {
|
||||
if (user?.selectedCompanyId != null) {
|
||||
this.#reconnectEvents();
|
||||
} else {
|
||||
this.#disconnectEvents();
|
||||
}
|
||||
}
|
||||
|
||||
async #loadAuthMe() {
|
||||
try {
|
||||
const data = await this.auth.me();
|
||||
requireObject(data, 'GET /api/auth/me');
|
||||
const session = {
|
||||
user: data.user,
|
||||
googleClientId: data.googleClientId,
|
||||
};
|
||||
this.#syncEvents(session.user);
|
||||
return session;
|
||||
} catch (err) {
|
||||
if (err.status === 401 || err.status === 403) {
|
||||
this.#syncEvents(null);
|
||||
return {
|
||||
user: null,
|
||||
googleClientId: err.data?.googleClientId,
|
||||
forbidden: err.status === 403,
|
||||
message: err.message,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async #loadArticleIndex() {
|
||||
const data = await this.article.index();
|
||||
requireObject(data, 'GET /api/articleIndex');
|
||||
const articles = requireArray(data.articles, 'articleIndex.articles');
|
||||
// Map backend rows to the recordset shape used by the result list.
|
||||
// `categories` is a comma-joined string (formatCategoryList splits on ',').
|
||||
return articles.map((a) => ({
|
||||
id: a.id,
|
||||
sku: a.sku,
|
||||
name: a.name,
|
||||
eans: a.eans ?? [],
|
||||
categories: Array.isArray(a.categoryIds) ? a.categoryIds.join(',') : null,
|
||||
isFather: Boolean(a.isFather ?? a.is_father),
|
||||
}));
|
||||
}
|
||||
|
||||
async #loadDeliveries() {
|
||||
const data = await this.delivery.list();
|
||||
requireObject(data, 'GET /api/delivery');
|
||||
return requireArray(data.deliveries, 'deliveries');
|
||||
}
|
||||
|
||||
async #loadCustomergroups() {
|
||||
const data = await this.customergroup.list();
|
||||
requireObject(data, 'GET /api/customergroup');
|
||||
return requireArray(data.customergroups, 'customergroups');
|
||||
}
|
||||
|
||||
async #loadEntities() {
|
||||
const data = await this.entity.list();
|
||||
requireObject(data, 'GET /api/entity');
|
||||
return requireArray(data.entities, 'entities');
|
||||
}
|
||||
|
||||
async #loadNumbers() {
|
||||
const data = await this.number.list();
|
||||
requireObject(data, 'GET /api/number');
|
||||
return requireArray(data.numbers, 'numbers');
|
||||
}
|
||||
|
||||
async #loadQuotes() {
|
||||
const data = await this.quote.list();
|
||||
requireObject(data, 'GET /api/quote');
|
||||
return requireArray(data.quotes, 'quotes');
|
||||
}
|
||||
|
||||
async #loadOrders() {
|
||||
const data = await this.order.list();
|
||||
requireObject(data, 'GET /api/order');
|
||||
return requireArray(data.orders, 'orders');
|
||||
}
|
||||
|
||||
async doSearch({ target, onUpdate }) {
|
||||
const articles = await this.articleIndexPromise;
|
||||
const query = target.trim().toLowerCase();
|
||||
const matches = articles.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
p.sku.toLowerCase().includes(query) ||
|
||||
(p.eans ?? []).some((ean) => String(ean).includes(query)),
|
||||
);
|
||||
onUpdate({
|
||||
categoryFacets: {},
|
||||
totalCount: matches.length,
|
||||
recordset: matches,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default StorageManager;
|
||||
@@ -1,5 +1,7 @@
|
||||
// Single source of truth for all backend communication.
|
||||
|
||||
import { requireArray, requireObject } from '../utils/apiShape';
|
||||
|
||||
/** WebView consoles stringify objects as [object Object]; log JSON text. */
|
||||
function logApi(tag, details) {
|
||||
let text;
|
||||
@@ -195,6 +197,29 @@ export const articleIndex = {
|
||||
list() {
|
||||
return http('/api/articleIndex');
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches the company article index and maps backend rows to the recordset
|
||||
* shape consumed by the search result list.
|
||||
*
|
||||
* `categories` becomes a comma-joined string because `formatCategoryList`
|
||||
* splits on ','.
|
||||
*
|
||||
* @returns {Promise<Array<ArticleIndexRecord>>}
|
||||
*/
|
||||
async listRecords() {
|
||||
const data = await http('/api/articleIndex');
|
||||
requireObject(data, 'GET /api/articleIndex');
|
||||
const articles = requireArray(data.articles, 'articleIndex.articles');
|
||||
return articles.map((a) => ({
|
||||
id: a.id,
|
||||
sku: a.sku,
|
||||
name: a.name,
|
||||
eans: a.eans ?? [],
|
||||
categories: Array.isArray(a.categoryIds) ? a.categoryIds.join(',') : null,
|
||||
isFather: Boolean(a.isFather ?? a.is_father),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
export const article = {
|
||||
@@ -337,6 +362,17 @@ export const article = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -624,6 +660,46 @@ export const printer = {
|
||||
delete(id) {
|
||||
return http(`/api/printer/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/printer/order
|
||||
* Body: { printerIds: number[] }
|
||||
* → 200 { printerIds: number[] }
|
||||
*/
|
||||
order({ printerIds }) {
|
||||
return http('/api/printer/order', { method: 'PUT', body: { printerIds } });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Company-scoped key/value settings (selectedCompanyId from session).
|
||||
* Shape: Setting = { key, value, frontend }
|
||||
*/
|
||||
export const setting = {
|
||||
/**
|
||||
* GET /api/company/setting
|
||||
* Optional query: { frontendOnly } → 200 { settings: Setting[] }
|
||||
*/
|
||||
list({ frontendOnly } = {}) {
|
||||
const qs = frontendOnly ? '?frontendOnly=1' : '';
|
||||
return http(`/api/company/setting${qs}`);
|
||||
},
|
||||
|
||||
/** GET /api/company/setting/:key → { setting: Setting } */
|
||||
get(key) {
|
||||
return http(`/api/company/setting/${encodeURIComponent(key)}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/company/setting/:key
|
||||
* Body: { value, frontend } → 200 { setting: Setting }
|
||||
*/
|
||||
upsert(key, body) {
|
||||
return http(`/api/company/setting/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1912,6 +1988,7 @@ export default {
|
||||
printer,
|
||||
propertyname,
|
||||
quote,
|
||||
setting,
|
||||
supplier,
|
||||
thumb,
|
||||
unit,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createContext, createElement, useContext, useRef } from 'react';
|
||||
import { StorageManager } from './StorageManager';
|
||||
|
||||
let defaultStorageManager = null;
|
||||
|
||||
export function getStorageManager() {
|
||||
if (!defaultStorageManager) {
|
||||
defaultStorageManager = new StorageManager();
|
||||
}
|
||||
return defaultStorageManager;
|
||||
}
|
||||
|
||||
export const StorageManagerContext = createContext(getStorageManager());
|
||||
|
||||
/**
|
||||
* Provider — mount once near the top of the tree.
|
||||
*/
|
||||
export function StorageManagerProvider({ children }) {
|
||||
const mgrRef = useRef(null);
|
||||
if (!mgrRef.current) {
|
||||
mgrRef.current = getStorageManager();
|
||||
}
|
||||
|
||||
return createElement(StorageManagerContext.Provider, { value: mgrRef.current }, children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for function components that need the store manager.
|
||||
*/
|
||||
export function useStorageManager() {
|
||||
const ctx = useContext(StorageManagerContext) || getStorageManager();
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user