u
This commit is contained in:
@@ -1,9 +1,27 @@
|
||||
import { createContext, useContext, useRef } from 'react';
|
||||
import { articleIndex, brand, category, company, posterminal, unit } from './index';
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
|
||||
export const StorageManagerContext = createContext(null);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale-while-revalidate cache: readers keep the current promise; update()
|
||||
* swaps only after a successful reload. Failed updates leave the cache intact.
|
||||
@@ -33,64 +51,258 @@ function createCache(label, load) {
|
||||
};
|
||||
}
|
||||
|
||||
async function loadArticleIndex() {
|
||||
const data = await articleIndex.list();
|
||||
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,
|
||||
}));
|
||||
}
|
||||
class StorageManager {
|
||||
#baseUrl;
|
||||
|
||||
async function loadBrands() {
|
||||
const data = await brand.list();
|
||||
requireObject(data, 'GET /api/brand');
|
||||
return requireArray(data.brands, 'brands');
|
||||
}
|
||||
constructor(baseUrl = window.location.origin) {
|
||||
this.#baseUrl = baseUrl;
|
||||
|
||||
async function loadUnits() {
|
||||
const data = await unit.list();
|
||||
requireObject(data, 'GET /api/unit');
|
||||
return requireArray(data.units, 'units');
|
||||
}
|
||||
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 }),
|
||||
};
|
||||
|
||||
async function loadCompanies() {
|
||||
const data = await company.list();
|
||||
requireObject(data, 'GET /api/company');
|
||||
return requireArray(data.companies, 'companies');
|
||||
}
|
||||
this.health = {
|
||||
/** GET /api/health */
|
||||
get: () => this.#json('GET', '/api/health'),
|
||||
};
|
||||
|
||||
async function loadCategoryTree() {
|
||||
const data = await category.getTree();
|
||||
requireObject(data, 'GET /api/category/tree');
|
||||
requireArray(data.tree, 'category/tree.tree');
|
||||
requireArray(data.articles, 'category/tree.articles');
|
||||
requireDefined(data.languageId, 'category/tree.languageId');
|
||||
return data;
|
||||
}
|
||||
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)}`),
|
||||
};
|
||||
|
||||
async function loadPosterminals() {
|
||||
const data = await posterminal.list();
|
||||
requireObject(data, 'GET /api/posterminal');
|
||||
return requireArray(data.posterminals, 'posterminals');
|
||||
}
|
||||
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 } }),
|
||||
};
|
||||
|
||||
function createStorageManager() {
|
||||
const articleIndexCache = createCache('GET /articleIndex', loadArticleIndex);
|
||||
const brandCache = createCache('GET /brand', loadBrands);
|
||||
const unitCache = createCache('GET /unit', loadUnits);
|
||||
const companyCache = createCache('GET /company', loadCompanies);
|
||||
const categoryTreeCache = createCache('GET /category/tree', loadCategoryTree);
|
||||
const posterminalCache = createCache('GET /posterminal', loadPosterminals);
|
||||
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)}`),
|
||||
};
|
||||
|
||||
async function doSearch({ target, filter, offset, maxCount, onUpdate }) {
|
||||
const articles = await articleIndexCache.promise;
|
||||
this.category = {
|
||||
/** GET /api/category/tree */
|
||||
tree: () => this.#json('GET', '/api/category/tree'),
|
||||
/** GET /api/category/export.zip */
|
||||
exportZip: () => this.#blob('GET', '/api/category/export.zip'),
|
||||
/** POST /api/category */
|
||||
create: (body) => this.#json('POST', '/api/category', { body }),
|
||||
/** GET /api/category/{id}/articles */
|
||||
articles: (id) => this.#json('GET', `/api/category/${enc(id)}/articles`),
|
||||
/** PATCH /api/category/{id} */
|
||||
update: (id, body) => this.#json('PATCH', `/api/category/${enc(id)}`, { body }),
|
||||
/** DELETE /api/category/{id} */
|
||||
delete: (id) => this.#empty('DELETE', `/api/category/${enc(id)}`),
|
||||
/** PUT /api/category/order */
|
||||
order: (body) => this.#json('PUT', '/api/category/order', { body }),
|
||||
/** POST /api/category/{id}/image — raw AVIF body */
|
||||
addImage: (id, avifBytes) =>
|
||||
this.#json('POST', `/api/category/${enc(id)}/image`, {
|
||||
body: avifBytes,
|
||||
contentType: 'image/avif',
|
||||
raw: true,
|
||||
}),
|
||||
/** DELETE /api/category/{id}/image */
|
||||
deleteImage: (id) => this.#json('DELETE', `/api/category/${enc(id)}/image`),
|
||||
};
|
||||
|
||||
this.article = {
|
||||
/** GET /api/articleIndex */
|
||||
index: () => this.#json('GET', '/api/articleIndex'),
|
||||
/** POST /api/article */
|
||||
create: (body) => this.#json('POST', '/api/article', { body }),
|
||||
/** GET /api/article/export.zip */
|
||||
exportZip: (query = {}) => this.#blob('GET', '/api/article/export.zip', { query }),
|
||||
/** GET /api/article/{sku} */
|
||||
get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`),
|
||||
/** PATCH /api/article/{sku} */
|
||||
update: (sku, body) => this.#json('PATCH', `/api/article/${enc(sku)}`, { body }),
|
||||
/** DELETE /api/article/{sku} */
|
||||
delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`),
|
||||
/** PUT /api/article/{sku}/prices */
|
||||
setPrices: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/prices`, { body }),
|
||||
/** POST /api/article/{sku}/image — raw AVIF body */
|
||||
addImage: (sku, avifBytes) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/image`, {
|
||||
body: avifBytes,
|
||||
contentType: 'image/avif',
|
||||
raw: true,
|
||||
}),
|
||||
/** DELETE /api/article/{sku}/image/{fullHash} */
|
||||
deleteImage: (sku, fullHash) =>
|
||||
this.#empty('DELETE', `/api/article/${enc(sku)}/image/${enc(fullHash)}`),
|
||||
/** PUT /api/article/{sku}/images/order */
|
||||
orderImages: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/images/order`, { body }),
|
||||
/** PUT /api/article/{sku}/eans */
|
||||
setEans: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/eans`, { body }),
|
||||
/** PUT /api/article/{sku}/brand */
|
||||
setBrand: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/brand`, { body }),
|
||||
/** PUT /api/article/{sku}/weight */
|
||||
setWeight: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/weight`, { body }),
|
||||
/** PUT /api/article/{sku}/categories */
|
||||
setCategories: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/categories`, { body }),
|
||||
};
|
||||
|
||||
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.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.#initCaches();
|
||||
}
|
||||
|
||||
#initCaches() {
|
||||
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
|
||||
const articleIndexCache = createCache('GET /articleIndex', () => this.#loadArticleIndex());
|
||||
const brandCache = createCache('GET /brand', () => this.#loadBrands());
|
||||
const unitCache = createCache('GET /unit', () => this.#loadUnits());
|
||||
const companyCache = createCache('GET /company', () => this.#loadCompanies());
|
||||
const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree());
|
||||
const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals());
|
||||
|
||||
Object.defineProperties(this, {
|
||||
authMePromise: { get: () => authMeCache.promise },
|
||||
updateAuthMe: { value: authMeCache.update },
|
||||
articleIndexPromise: { get: () => articleIndexCache.promise },
|
||||
updateArticleIndex: { value: articleIndexCache.update },
|
||||
/** @deprecated use updateArticleIndex */
|
||||
update: { value: articleIndexCache.update },
|
||||
brandPromise: { get: () => brandCache.promise },
|
||||
updateBrand: { value: brandCache.update },
|
||||
unitPromise: { get: () => unitCache.promise },
|
||||
updateUnit: { value: unitCache.update },
|
||||
companyPromise: { get: () => companyCache.promise },
|
||||
updateCompany: { value: companyCache.update },
|
||||
categoryTreePromise: { get: () => categoryTreeCache.promise },
|
||||
updateCategoryTree: { value: categoryTreeCache.update },
|
||||
posterminalPromise: { get: () => posterminalCache.promise },
|
||||
updatePosterminal: { value: posterminalCache.update },
|
||||
});
|
||||
}
|
||||
|
||||
async #loadAuthMe() {
|
||||
try {
|
||||
const data = await this.auth.me();
|
||||
requireObject(data, 'GET /api/auth/me');
|
||||
return {
|
||||
user: data.user,
|
||||
googleClientId: data.googleClientId,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.status === 401 || err.status === 403) {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
async #loadBrands() {
|
||||
const data = await this.brand.list();
|
||||
requireObject(data, 'GET /api/brand');
|
||||
return requireArray(data.brands, 'brands');
|
||||
}
|
||||
|
||||
async #loadUnits() {
|
||||
const data = await this.unit.list();
|
||||
requireObject(data, 'GET /api/unit');
|
||||
return requireArray(data.units, 'units');
|
||||
}
|
||||
|
||||
async #loadCompanies() {
|
||||
const data = await this.company.list();
|
||||
requireObject(data, 'GET /api/company');
|
||||
return requireArray(data.companies, 'companies');
|
||||
}
|
||||
|
||||
async #loadCategoryTree() {
|
||||
const data = await this.category.tree();
|
||||
requireObject(data, 'GET /api/category/tree');
|
||||
requireArray(data.tree, 'category/tree.tree');
|
||||
requireArray(data.articles, 'category/tree.articles');
|
||||
requireDefined(data.languageId, 'category/tree.languageId');
|
||||
return data;
|
||||
}
|
||||
|
||||
async #loadPosterminals() {
|
||||
const data = await this.posterminal.list();
|
||||
requireObject(data, 'GET /api/posterminal');
|
||||
return requireArray(data.posterminals, 'posterminals');
|
||||
}
|
||||
|
||||
async doSearch({ target, filter, offset, maxCount, onUpdate }) {
|
||||
const articles = await this.articleIndexPromise;
|
||||
const query = target.trim().toLowerCase();
|
||||
const matches = articles.filter(
|
||||
(p) =>
|
||||
@@ -106,41 +318,77 @@ function createStorageManager() {
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
doSearch,
|
||||
async #json(method, path, { body, query, contentType, raw } = {}) {
|
||||
const url = buildPath(path, query);
|
||||
const headers = {};
|
||||
let payload;
|
||||
if (body !== undefined) {
|
||||
if (raw) {
|
||||
headers['Content-Type'] = contentType;
|
||||
payload = body;
|
||||
} else {
|
||||
headers['Content-Type'] = contentType || 'application/json';
|
||||
payload = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
|
||||
get articleIndexPromise() {
|
||||
return articleIndexCache.promise;
|
||||
},
|
||||
updateArticleIndex: articleIndexCache.update,
|
||||
/** @deprecated use updateArticleIndex */
|
||||
update: articleIndexCache.update,
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: payload,
|
||||
});
|
||||
|
||||
get brandPromise() {
|
||||
return brandCache.promise;
|
||||
},
|
||||
updateBrand: brandCache.update,
|
||||
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();
|
||||
}
|
||||
|
||||
get unitPromise() {
|
||||
return unitCache.promise;
|
||||
},
|
||||
updateUnit: unitCache.update,
|
||||
async #empty(method, path, { query } = {}) {
|
||||
await this.#json(method, path, { query });
|
||||
return null;
|
||||
}
|
||||
|
||||
get companyPromise() {
|
||||
return companyCache.promise;
|
||||
},
|
||||
updateCompany: companyCache.update,
|
||||
async #blob(method, path, { query } = {}) {
|
||||
const url = buildPath(path, query);
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
get categoryTreePromise() {
|
||||
return categoryTreeCache.promise;
|
||||
},
|
||||
updateCategoryTree: categoryTreeCache.update,
|
||||
|
||||
get posterminalPromise() {
|
||||
return posterminalCache.promise;
|
||||
},
|
||||
updatePosterminal: posterminalCache.update,
|
||||
};
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +397,7 @@ function createStorageManager() {
|
||||
export function StorageManagerProvider({ children }) {
|
||||
const mgrRef = useRef(null);
|
||||
if (!mgrRef.current) {
|
||||
mgrRef.current = createStorageManager();
|
||||
mgrRef.current = new StorageManager();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user