Files
dashfront/src/api/index.js
seb 78be835388 Add category picker to article view with draft-based editing
- Add setCategories API method for PUT /api/article/:sku/categories
- Create CategoryPickerDialog component for selecting categories
- Add CategoryPills component with add/remove functionality
- Category changes are now draft-based (like name editing)
- Save button commits both name and category changes together
- Delete icons hidden when only one category remains
- Plus button only visible in edit mode

Implements category assignment flow similar to name editing pattern.
2026-08-03 08:57:32 +02:00

617 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Single source of truth for all backend communication.
/** WebView consoles stringify objects as [object Object]; log JSON text. */
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}`);
}
function readableHeaders(headers) {
const out = {};
headers.forEach((value, key) => {
out[key] = value;
});
return out;
}
async function http(path, { method = 'GET', body } = {}) {
const headers = { 'Content-Type': 'application/json' };
const url = new URL(path, window.location.origin).href;
logApi('request', {
method,
path,
url,
origin: window.location.origin,
credentials: 'include',
// HttpOnly cookies are invisible here; this only shows JS-readable cookies.
documentCookie: document.cookie || '(empty)',
hasBody: Boolean(body),
});
const res = await fetch(path, {
method,
headers,
credentials: 'include',
body: body ? JSON.stringify(body) : undefined,
});
const responseHeaders = readableHeaders(res.headers);
// Browsers hide Set-Cookie from JS; if it's missing here that proves nothing.
logApi('response', {
method,
path,
status: res.status,
statusText: res.statusText,
ok: res.ok,
responseHeaders,
documentCookieAfter: document.cookie || '(empty)',
});
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,
hasUser: Boolean(json?.user),
});
return json;
}
async function httpBinary(path, { method = 'POST', body, contentType } = {}) {
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 },
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;
}
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)}`);
},
/** POST /api/article → 201 { article } */
create({ parentId, name }) {
return http('/api/article', {
method: 'POST',
body: { parentId, name },
});
},
/**
* PATCH /api/article/:sku
* Body: at least one of { name, unitamount, unitSi }
* unitSi must exist for selected company → 404 Unit not found
* → 200 { article } (same detail shape as GET)
*/
update(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}`, {
method: 'PATCH',
body,
});
},
/**
* DELETE /api/article/:sku → 204
* Errors: 400 Article has children, 401, 403, 404 Article not found
*/
delete(sku) {
return http(`/api/article/${encodeURIComponent(sku)}`, {
method: 'DELETE',
});
},
/**
* POST /api/article/:sku/image
* Body: raw AVIF bytes (Content-Type: image/avif, max 1000×1000, prepared client-side via WASM)
* → 201 { image: { picId, sort, full, thumb, fullHash, thumbHash } }
*/
uploadImage(sku, file) {
return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, {
method: 'POST',
contentType: 'image/avif',
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/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 },
});
},
/**
* 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 },
});
},
};
/**
* Content-addressed thumb AVIF by fullHash (SHA-256 hex of full image bytes).
* 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)}`;
},
};
/**
* 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' });
},
};
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, …
*/
getArticles(categoryId) {
return http(`/api/category/${categoryId}/articles`);
},
/** POST /api/category → 201 { category } */
create({ parentId, name }) {
return http('/api/category', {
method: 'POST',
body: { parentId, name },
});
},
/** DELETE /api/category/:id → 204 */
delete(categoryId) {
return http(`/api/category/${categoryId}`, { 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 AVIF bytes (Content-Type: image/avif)
* → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } }
*/
uploadImage(categoryId, file) {
return httpBinary(`/api/category/${categoryId}/image`, {
method: 'POST',
contentType: 'image/avif',
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' });
},
};
/**
* Company-scoped brands (selectedCompanyId from session).
* Shape: Brand = { id, name, gsprpinfo | null }
*/
export const brand = {
/** GET /api/brand → { brands: Brand[] } · 400 No company selected */
list() {
return http('/api/brand');
},
/**
* PUT /api/brand
* Body: { name, gsprpinfo? } — upsert by name for the selected company.
* Same name ⇒ update, otherwise create. Empty name ⇒ 400 Invalid brand.
* → 200 { brand: Brand }
*/
upsert(body) {
return http('/api/brand', { method: 'PUT', body });
},
};
export const company = {
async list() {
try {
return await http('/api/company');
} catch {
return { companies: [] };
}
},
/** 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 },
});
},
};
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 },
});
},
/**
* 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',
});
},
};
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;
}
},
};
export default { auth, article, brand, category, company, posterminal, thumb, unit };