Show article history in a table via GET /article/{sku}/history.

This commit is contained in:
seb
2026-08-05 09:35:47 +02:00
parent debd246012
commit 60e6858fd5
10 changed files with 297 additions and 96 deletions

View File

@@ -106,8 +106,21 @@ export class ApiClient {
exportZip: (query = {}) => this.#blob('GET', '/api/article/export.zip', { query }), exportZip: (query = {}) => this.#blob('GET', '/api/article/export.zip', { query }),
/** GET /api/article/{sku} */ /** GET /api/article/{sku} */
get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`), get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`),
/** PATCH /api/article/{sku} */ /** GET /api/article/{sku}/history → { history: [{ id, date, text }, ...] } */
update: (sku, body) => this.#json('PATCH', `/api/article/${enc(sku)}`, { body }), 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 /api/article/{sku} */
delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`), delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`),
/** PUT /api/article/{sku}/prices */ /** PUT /api/article/{sku}/prices */

View File

@@ -4,9 +4,17 @@ import { ApiClient } from './ApiClient';
/** /**
* Stale-while-revalidate cache: readers keep the current promise; update() * Stale-while-revalidate cache: readers keep the current promise; update()
* swaps only after a successful reload. Failed updates leave the cache intact. * swaps only after a successful reload. Failed updates leave the cache intact.
* replace() writes local data without a round-trip.
*/ */
function createCache(label, load) { function createCache(label, load) {
let promise = load().catch((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); console.error(`[storageMgr] ${label} failed`, err);
throw err; throw err;
}); });
@@ -14,6 +22,7 @@ function createCache(label, load) {
async function update() { async function update() {
try { try {
const data = await load(); const data = await load();
current = data;
promise = Promise.resolve(data); promise = Promise.resolve(data);
return data; return data;
} catch (err) { } 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 { return {
update, update,
replace,
clear,
get promise() { get promise() {
return 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 { export class StorageManager extends ApiClient {
/** @type {EventSource | null} */ /** @type {EventSource | null} */
#events = null; #events = null;
/** @type {ReturnType<typeof createCache> | null} */
#articleIndexCache = null;
constructor(baseUrl = window.location.origin) { constructor(baseUrl = window.location.origin) {
super(baseUrl); super(baseUrl);
this.#initCaches(); 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() { #initCaches() {
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe()); 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 brandCache = createCache('GET /brand', () => this.#loadBrands());
const unitCache = createCache('GET /unit', () => this.#loadUnits()); const unitCache = createCache('GET /unit', () => this.#loadUnits());
const companyCache = createCache('GET /company', () => this.#loadCompanies()); 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 }); const es = new EventSource(this.urlFor('/api/events'), { withCredentials: true });
es.addEventListener('category', () => { es.addEventListener('category', () => {
void this.updateCategoryTree().catch((err) => { void this.updateCategoryTree().catch((err) => {
console.error('[storageMgr] category event refresh failed', err); console.error('[storageMgr] category SSE refresh failed', err);
}); });
}); });
es.addEventListener('articles', () => { es.addEventListener('articles', () => {
void this.updateArticleIndex().catch((err) => { void this.updateArticleIndex().catch((err) => {
console.error('[storageMgr] articles event refresh failed', err); console.error('[storageMgr] articles SSE refresh failed', err);
}); });
}); });
es.onerror = () => { es.onerror = () => {

View File

@@ -1,20 +1,16 @@
import { Component } from 'react'; import { Component } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Tab from '@mui/material/Tab'; import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs'; import Tabs from '@mui/material/Tabs';
import Typography from '@mui/material/Typography';
import AccountTreeIcon from '@mui/icons-material/AccountTree'; import AccountTreeIcon from '@mui/icons-material/AccountTree';
import CategoryIcon from '@mui/icons-material/Category'; import CategoryIcon from '@mui/icons-material/Category';
import DeleteIcon from '@mui/icons-material/Delete';
import HistoryIcon from '@mui/icons-material/History'; import HistoryIcon from '@mui/icons-material/History';
import LabelIcon from '@mui/icons-material/Label'; import LabelIcon from '@mui/icons-material/Label';
import LocalShippingIcon from '@mui/icons-material/LocalShipping'; import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import ManageSearchIcon from '@mui/icons-material/ManageSearch'; import ManageSearchIcon from '@mui/icons-material/ManageSearch';
import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinIcon from '@mui/icons-material/PushPin';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { import {
ARTICLE_TAB, ARTICLE_TAB,
DEFAULT_ARTICLE_TAB, DEFAULT_ARTICLE_TAB,
@@ -22,9 +18,15 @@ import {
readPreferredArticleTab, readPreferredArticleTab,
writePreferredArticleTab, writePreferredArticleTab,
} from '../../pages/artikel/articleRoute'; } from '../../pages/artikel/articleRoute';
import { notifyError } from '../../utils/notify';
import { PanelTile } from '../panels'; import { PanelTile } from '../panels';
import { PANEL_TILE_BORDER_RADIUS, responsiveColumnSpan } from '../panels/panelTileLayout'; 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 = [ const ALWAYS_TABS = [
{ id: ARTICLE_TAB.history, label: 'History', color: '#fed7aa', Icon: HistoryIcon }, { id: ARTICLE_TAB.history, label: 'History', color: '#fed7aa', Icon: HistoryIcon },
@@ -67,42 +69,14 @@ function visibleTabs(article) {
return tabs; return tabs;
} }
function PanelValue({ label, children }) {
if (children == null || children === '') return null;
return (
<Box sx={{ mb: 1 }}>
{label ? (
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
) : null}
<Typography variant="body2" component="div">
{children}
</Typography>
</Box>
);
}
function Placeholder({ label }) {
return (
<Typography variant="body2" color="text.secondary">
{label}
</Typography>
);
}
class ArticleTileTab extends Component { class ArticleTileTab extends Component {
static contextType = StorageManagerContext;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
busy: false,
preferredTab: readPreferredArticleTab(), preferredTab: readPreferredArticleTab(),
}; };
this.handleTabChange = this.handleTabChange.bind(this); this.handleTabChange = this.handleTabChange.bind(this);
this.handlePinDefault = this.handlePinDefault.bind(this); this.handlePinDefault = this.handlePinDefault.bind(this);
this.handleDeleteArticle = this.handleDeleteArticle.bind(this);
} }
handleTabChange(_event, tab) { handleTabChange(_event, tab) {
@@ -116,69 +90,28 @@ class ArticleTileTab extends Component {
this.setState({ preferredTab: tab }); 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 (
<>
<PanelValue label="Slug">{articleData.slug}</PanelValue>
<PanelValue label="SEO-Titel">{articleData.seoTitle}</PanelValue>
<PanelValue label="SEO-Beschreibung">{articleData.seoDescription}</PanelValue>
<PanelValue label="Beschreibung">{articleData.description}</PanelValue>
</>
);
}
renderSettings() {
const { busy } = this.state;
return (
<Box>
<Button
type="button"
color="error"
variant="contained"
startIcon={<DeleteIcon />}
disabled={busy}
onClick={() => void this.handleDeleteArticle()}
>
Artikel löschen
</Button>
</Box>
);
}
renderPanel() { renderPanel() {
const { article: articleData, onArticleDeleted } = this.props;
switch (this.props.tab) { switch (this.props.tab) {
case ARTICLE_TAB.history: case ARTICLE_TAB.history:
return <Placeholder label="History" />; return <ArticleHistoryTab sku={articleData.sku} />;
case ARTICLE_TAB.supplier: case ARTICLE_TAB.supplier:
return <Placeholder label="Supplier" />; return <ArticleSupplierTab />;
case ARTICLE_TAB.seo: case ARTICLE_TAB.seo:
return this.renderSeo(); return <ArticleSeoTab article={articleData} />;
case ARTICLE_TAB.label: case ARTICLE_TAB.label:
return <Placeholder label="Label" />; return <ArticleLabelTab />;
case ARTICLE_TAB.attributes: case ARTICLE_TAB.attributes:
return <Placeholder label="Attribute und Merkmale" />; return <ArticleAttributesTab />;
case ARTICLE_TAB.compound: case ARTICLE_TAB.compound:
return <Placeholder label="Compound Editor" />; return <ArticleCompoundTab />;
case ARTICLE_TAB.settings: case ARTICLE_TAB.settings:
return this.renderSettings(); return (
<ArticleSettingsTab
article={articleData}
onArticleDeleted={onArticleDeleted}
/>
);
default: default:
return null; return null;
} }

View File

@@ -0,0 +1,12 @@
import { Component } from 'react';
import Typography from '@mui/material/Typography';
export default class ArticleAttributesTab extends Component {
render() {
return (
<Typography variant="body2" color="text.secondary">
Attribute und Merkmale
</Typography>
);
}
}

View File

@@ -0,0 +1,12 @@
import { Component } from 'react';
import Typography from '@mui/material/Typography';
export default class ArticleCompoundTab extends Component {
render() {
return (
<Typography variant="body2" color="text.secondary">
Compound Editor
</Typography>
);
}
}

View File

@@ -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 (
<Table
size="small"
sx={{
'& td': { borderBottom: 'none', bgcolor: 'transparent' },
'& th': { bgcolor: 'transparent' },
}}
>
<TableHead>
<TableRow>
<TableCell
sx={{
borderBottom: '1px solid rgba(0, 0, 0, 0.35)',
width: '1%',
whiteSpace: 'nowrap',
}}
>
Datum
</TableCell>
<TableCell
sx={{ borderBottom: '1px solid rgba(0, 0, 0, 0.35)' }}
/>
</TableRow>
</TableHead>
<TableBody>
{history.map((entry) => (
<TableRow key={entry.id}>
<TableCell
sx={{ verticalAlign: 'top', whiteSpace: 'nowrap', width: '1%' }}
>
{formatDate(entry.date)}
</TableCell>
<TableCell sx={{ whiteSpace: 'pre-wrap' }}>{entry.text}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
}

View File

@@ -0,0 +1,12 @@
import { Component } from 'react';
import Typography from '@mui/material/Typography';
export default class ArticleLabelTab extends Component {
render() {
return (
<Typography variant="body2" color="text.secondary">
Label
</Typography>
);
}
}

View File

@@ -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 (
<Box sx={{ mb: 1 }}>
{label ? (
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
) : null}
<Typography variant="body2" component="div">
{children}
</Typography>
</Box>
);
}
export default class ArticleSeoTab extends Component {
render() {
const { article: articleData } = this.props;
return (
<>
<PanelValue label="Slug">{articleData.slug}</PanelValue>
<PanelValue label="SEO-Titel">{articleData.seoTitle}</PanelValue>
<PanelValue label="SEO-Beschreibung">{articleData.seoDescription}</PanelValue>
<PanelValue label="Beschreibung">{articleData.description}</PanelValue>
</>
);
}
}

View File

@@ -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 (
<Box>
<Button
type="button"
color="error"
variant="contained"
startIcon={<DeleteIcon />}
disabled={busy}
onClick={() => void this.handleDeleteArticle()}
>
Artikel löschen
</Button>
</Box>
);
}
}

View File

@@ -0,0 +1,12 @@
import { Component } from 'react';
import Typography from '@mui/material/Typography';
export default class ArticleSupplierTab extends Component {
render() {
return (
<Typography variant="body2" color="text.secondary">
Supplier
</Typography>
);
}
}