From 60e6858fd549358f8cf24624334ba7104a97112b Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 5 Aug 2026 09:35:47 +0200 Subject: [PATCH] Show article history in a table via GET /article/{sku}/history. --- src/api/ApiClient.js | 17 ++- src/api/StorageManager.js | 66 +++++++++-- src/components/article/ArticleTileTab.jsx | 107 ++++-------------- .../article/tabs/ArticleAttributesTab.jsx | 12 ++ .../article/tabs/ArticleCompoundTab.jsx | 12 ++ .../article/tabs/ArticleHistoryTab.jsx | 71 ++++++++++++ .../article/tabs/ArticleLabelTab.jsx | 12 ++ src/components/article/tabs/ArticleSeoTab.jsx | 33 ++++++ .../article/tabs/ArticleSettingsTab.jsx | 51 +++++++++ .../article/tabs/ArticleSupplierTab.jsx | 12 ++ 10 files changed, 297 insertions(+), 96 deletions(-) create mode 100644 src/components/article/tabs/ArticleAttributesTab.jsx create mode 100644 src/components/article/tabs/ArticleCompoundTab.jsx create mode 100644 src/components/article/tabs/ArticleHistoryTab.jsx create mode 100644 src/components/article/tabs/ArticleLabelTab.jsx create mode 100644 src/components/article/tabs/ArticleSeoTab.jsx create mode 100644 src/components/article/tabs/ArticleSettingsTab.jsx create mode 100644 src/components/article/tabs/ArticleSupplierTab.jsx diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index eb67460..0e1a33d 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -106,8 +106,21 @@ export class ApiClient { 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 }), + /** GET /api/article/{sku}/history → { history: [{ id, date, text }, ...] } */ + history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`), + /** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi } */ + update: async (sku, body) => { + const nameChange = body != null && Object.hasOwn(body, 'name'); + if (nameChange) { + this.setArticleIndexName(sku, body.name); + } + try { + return await this.#json('PATCH', `/api/article/${enc(sku)}`, { body }); + } catch (err) { + if (nameChange) this.clearArticleIndex(); + throw err; + } + }, /** DELETE /api/article/{sku} */ delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`), /** PUT /api/article/{sku}/prices */ diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index cb64d69..4798c72 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -4,16 +4,25 @@ import { ApiClient } from './ApiClient'; /** * 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) { - let promise = load().catch((err) => { - console.error(`[storageMgr] ${label} failed`, err); - throw err; - }); + /** 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) { @@ -22,11 +31,36 @@ function createCache(label, load) { } } + 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; + }, }; } @@ -36,15 +70,33 @@ function createCache(label, load) { export class StorageManager extends ApiClient { /** @type {EventSource | null} */ #events = null; + /** @type {ReturnType | null} */ + #articleIndexCache = null; constructor(baseUrl = window.location.origin) { super(baseUrl); this.#initCaches(); } + /** 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)), + ); + } + + /** 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 articleIndexCache = createCache('GET /articleIndex', () => this.#loadArticleIndex()); + this.#articleIndexCache = createCache('GET /articleIndex', () => this.#loadArticleIndex()); + const articleIndexCache = this.#articleIndexCache; const brandCache = createCache('GET /brand', () => this.#loadBrands()); const unitCache = createCache('GET /unit', () => this.#loadUnits()); const companyCache = createCache('GET /company', () => this.#loadCompanies()); @@ -87,12 +139,12 @@ export class StorageManager extends ApiClient { const es = new EventSource(this.urlFor('/api/events'), { withCredentials: true }); es.addEventListener('category', () => { void this.updateCategoryTree().catch((err) => { - console.error('[storageMgr] category event refresh failed', err); + console.error('[storageMgr] category SSE refresh failed', err); }); }); es.addEventListener('articles', () => { void this.updateArticleIndex().catch((err) => { - console.error('[storageMgr] articles event refresh failed', err); + console.error('[storageMgr] articles SSE refresh failed', err); }); }); es.onerror = () => { diff --git a/src/components/article/ArticleTileTab.jsx b/src/components/article/ArticleTileTab.jsx index cce1124..e4f8feb 100644 --- a/src/components/article/ArticleTileTab.jsx +++ b/src/components/article/ArticleTileTab.jsx @@ -1,20 +1,16 @@ import { Component } from 'react'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import Tab from '@mui/material/Tab'; import Tabs from '@mui/material/Tabs'; -import Typography from '@mui/material/Typography'; import AccountTreeIcon from '@mui/icons-material/AccountTree'; import CategoryIcon from '@mui/icons-material/Category'; -import DeleteIcon from '@mui/icons-material/Delete'; import HistoryIcon from '@mui/icons-material/History'; import LabelIcon from '@mui/icons-material/Label'; import LocalShippingIcon from '@mui/icons-material/LocalShipping'; import ManageSearchIcon from '@mui/icons-material/ManageSearch'; import PushPinIcon from '@mui/icons-material/PushPin'; import SettingsIcon from '@mui/icons-material/Settings'; -import { StorageManagerContext } from '../../api/storageManagerContext'; import { ARTICLE_TAB, DEFAULT_ARTICLE_TAB, @@ -22,9 +18,15 @@ import { readPreferredArticleTab, writePreferredArticleTab, } from '../../pages/artikel/articleRoute'; -import { notifyError } from '../../utils/notify'; import { PanelTile } from '../panels'; import { PANEL_TILE_BORDER_RADIUS, responsiveColumnSpan } from '../panels/panelTileLayout'; +import ArticleAttributesTab from './tabs/ArticleAttributesTab'; +import ArticleCompoundTab from './tabs/ArticleCompoundTab'; +import ArticleHistoryTab from './tabs/ArticleHistoryTab'; +import ArticleLabelTab from './tabs/ArticleLabelTab'; +import ArticleSeoTab from './tabs/ArticleSeoTab'; +import ArticleSettingsTab from './tabs/ArticleSettingsTab'; +import ArticleSupplierTab from './tabs/ArticleSupplierTab'; const ALWAYS_TABS = [ { id: ARTICLE_TAB.history, label: 'History', color: '#fed7aa', Icon: HistoryIcon }, @@ -67,42 +69,14 @@ function visibleTabs(article) { return tabs; } -function PanelValue({ label, children }) { - if (children == null || children === '') return null; - return ( - - {label ? ( - - {label} - - ) : null} - - {children} - - - ); -} - -function Placeholder({ label }) { - return ( - - {label} - - ); -} - class ArticleTileTab extends Component { - static contextType = StorageManagerContext; - constructor(props) { super(props); this.state = { - busy: false, preferredTab: readPreferredArticleTab(), }; this.handleTabChange = this.handleTabChange.bind(this); this.handlePinDefault = this.handlePinDefault.bind(this); - this.handleDeleteArticle = this.handleDeleteArticle.bind(this); } handleTabChange(_event, tab) { @@ -116,69 +90,28 @@ class ArticleTileTab extends Component { this.setState({ preferredTab: tab }); } - async handleDeleteArticle() { - const { article: articleData, onArticleDeleted } = this.props; - if (this.state.busy) return; - if (!window.confirm(`Artikel „${articleData.name ?? articleData.sku}“ wirklich löschen?`)) { - return; - } - - this.setState({ busy: true }); - try { - await this.context.article.delete(articleData.sku); - onArticleDeleted?.(articleData); - } catch (err) { - notifyError(err); - this.setState({ busy: false }); - } - } - - renderSeo() { - const { article: articleData } = this.props; - return ( - <> - {articleData.slug} - {articleData.seoTitle} - {articleData.seoDescription} - {articleData.description} - - ); - } - - renderSettings() { - const { busy } = this.state; - return ( - - - - ); - } - renderPanel() { + const { article: articleData, onArticleDeleted } = this.props; switch (this.props.tab) { case ARTICLE_TAB.history: - return ; + return ; case ARTICLE_TAB.supplier: - return ; + return ; case ARTICLE_TAB.seo: - return this.renderSeo(); + return ; case ARTICLE_TAB.label: - return ; + return ; case ARTICLE_TAB.attributes: - return ; + return ; case ARTICLE_TAB.compound: - return ; + return ; case ARTICLE_TAB.settings: - return this.renderSettings(); + return ( + + ); default: return null; } diff --git a/src/components/article/tabs/ArticleAttributesTab.jsx b/src/components/article/tabs/ArticleAttributesTab.jsx new file mode 100644 index 0000000..6357848 --- /dev/null +++ b/src/components/article/tabs/ArticleAttributesTab.jsx @@ -0,0 +1,12 @@ +import { Component } from 'react'; +import Typography from '@mui/material/Typography'; + +export default class ArticleAttributesTab extends Component { + render() { + return ( + + Attribute und Merkmale + + ); + } +} diff --git a/src/components/article/tabs/ArticleCompoundTab.jsx b/src/components/article/tabs/ArticleCompoundTab.jsx new file mode 100644 index 0000000..adbc842 --- /dev/null +++ b/src/components/article/tabs/ArticleCompoundTab.jsx @@ -0,0 +1,12 @@ +import { Component } from 'react'; +import Typography from '@mui/material/Typography'; + +export default class ArticleCompoundTab extends Component { + render() { + return ( + + Compound Editor + + ); + } +} diff --git a/src/components/article/tabs/ArticleHistoryTab.jsx b/src/components/article/tabs/ArticleHistoryTab.jsx new file mode 100644 index 0000000..0738d64 --- /dev/null +++ b/src/components/article/tabs/ArticleHistoryTab.jsx @@ -0,0 +1,71 @@ +import { Component } from 'react'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import { StorageManagerContext } from '../../../api/storageManagerContext'; +import { notifyError } from '../../../utils/notify'; + +function formatDate(iso) { + return new Date(iso).toLocaleString('de-DE'); +} + +export default class ArticleHistoryTab extends Component { + static contextType = StorageManagerContext; + + constructor(props) { + super(props); + this.state = { history: null }; + } + + componentDidMount() { + void this.context.article + .history(this.props.sku) + .then((res) => this.setState({ history: res.history })) + .catch(notifyError); + } + + render() { + const { history } = this.state; + if (history == null) return null; + return ( + + + + + Datum + + + + + + {history.map((entry) => ( + + + {formatDate(entry.date)} + + {entry.text} + + ))} + +
+ ); + } +} diff --git a/src/components/article/tabs/ArticleLabelTab.jsx b/src/components/article/tabs/ArticleLabelTab.jsx new file mode 100644 index 0000000..d1da224 --- /dev/null +++ b/src/components/article/tabs/ArticleLabelTab.jsx @@ -0,0 +1,12 @@ +import { Component } from 'react'; +import Typography from '@mui/material/Typography'; + +export default class ArticleLabelTab extends Component { + render() { + return ( + + Label + + ); + } +} diff --git a/src/components/article/tabs/ArticleSeoTab.jsx b/src/components/article/tabs/ArticleSeoTab.jsx new file mode 100644 index 0000000..0a96abf --- /dev/null +++ b/src/components/article/tabs/ArticleSeoTab.jsx @@ -0,0 +1,33 @@ +import { Component } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +function PanelValue({ label, children }) { + if (children == null || children === '') return null; + return ( + + {label ? ( + + {label} + + ) : null} + + {children} + + + ); +} + +export default class ArticleSeoTab extends Component { + render() { + const { article: articleData } = this.props; + return ( + <> + {articleData.slug} + {articleData.seoTitle} + {articleData.seoDescription} + {articleData.description} + + ); + } +} diff --git a/src/components/article/tabs/ArticleSettingsTab.jsx b/src/components/article/tabs/ArticleSettingsTab.jsx new file mode 100644 index 0000000..4af7c5f --- /dev/null +++ b/src/components/article/tabs/ArticleSettingsTab.jsx @@ -0,0 +1,51 @@ +import { Component } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { StorageManagerContext } from '../../../api/storageManagerContext'; +import { notifyError } from '../../../utils/notify'; + +export default class ArticleSettingsTab extends Component { + static contextType = StorageManagerContext; + + constructor(props) { + super(props); + this.state = { busy: false }; + this.handleDeleteArticle = this.handleDeleteArticle.bind(this); + } + + async handleDeleteArticle() { + const { article: articleData, onArticleDeleted } = this.props; + if (this.state.busy) return; + if (!window.confirm(`Artikel „${articleData.name ?? articleData.sku}“ wirklich löschen?`)) { + return; + } + + this.setState({ busy: true }); + try { + await this.context.article.delete(articleData.sku); + onArticleDeleted?.(articleData); + } catch (err) { + notifyError(err); + this.setState({ busy: false }); + } + } + + render() { + const { busy } = this.state; + return ( + + + + ); + } +} diff --git a/src/components/article/tabs/ArticleSupplierTab.jsx b/src/components/article/tabs/ArticleSupplierTab.jsx new file mode 100644 index 0000000..e85fcd0 --- /dev/null +++ b/src/components/article/tabs/ArticleSupplierTab.jsx @@ -0,0 +1,12 @@ +import { Component } from 'react'; +import Typography from '@mui/material/Typography'; + +export default class ArticleSupplierTab extends Component { + render() { + return ( + + Supplier + + ); + } +}