This commit is contained in:
seb
2026-08-06 00:32:22 +02:00
parent 855710a198
commit d8b48d96e1
7 changed files with 151 additions and 58 deletions

View File

@@ -110,7 +110,8 @@ export class ApiClient {
history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`),
/**
* Load history and refetch on SSE `article-<sku>`.
* `onHistory(history)` — returns unwatch(). Errors via notifyError.
* 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 } */

View File

@@ -176,19 +176,36 @@ export class StorageManager extends ApiClient {
/**
* 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[]) => void} onHistory
* @returns {() => void} unwatch
* @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');
onHistory(requireArray(res.history, 'history'));
all = requireArray(res.history, 'history');
loaded = true;
emit();
});
const run = () =>
@@ -211,17 +228,24 @@ export class StorageManager extends ApiClient {
run();
return () => {
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.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
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.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
}
}
}
},
setSkipAndOffset: (nextSkip, nextOffset) => {
skip = nextSkip;
offset = nextOffset;
emit();
},
};
}

View File

@@ -14,10 +14,14 @@ import SettingsIcon from '@mui/icons-material/Settings';
import {
ARTICLE_TAB,
DEFAULT_ARTICLE_TAB,
artikelDetailPath,
articleShowsCompoundTab,
articleTabFromPathname,
readPreferredArticleTab,
resolveArticleTab,
writePreferredArticleTab,
} from '../../pages/artikel/articleRoute';
import { router } from '../../appRouter';
import { PanelTile } from '../panels';
import { PANEL_TILE_BORDER_RADIUS, responsiveColumnSpan } from '../panels/panelTileLayout';
import ArticleAttributesTab from './tabs/ArticleAttributesTab';
@@ -69,30 +73,70 @@ function visibleTabs(article) {
return tabs;
}
function readRouteTab() {
return articleTabFromPathname(router.state.location.pathname);
}
class ArticleTileTab extends PureComponent {
constructor(props) {
super(props);
this.state = {
preferredTab: readPreferredArticleTab(),
tabParam: readRouteTab(),
};
this.handleTabChange = this.handleTabChange.bind(this);
this.handlePinDefault = this.handlePinDefault.bind(this);
this.syncTabFromRouter = this.syncTabFromRouter.bind(this);
}
componentDidMount() {
this.unsubRouter = router.subscribe(this.syncTabFromRouter);
this.ensureValidTab();
}
componentDidUpdate(prevProps) {
if (prevProps.article !== this.props.article) {
this.ensureValidTab();
}
}
componentWillUnmount() {
this.unsubRouter?.();
this.unsubRouter = null;
}
syncTabFromRouter() {
const tabParam = readRouteTab();
if (tabParam !== this.state.tabParam) {
this.setState({ tabParam }, () => this.ensureValidTab());
}
}
ensureValidTab() {
const { article } = this.props;
const { tabParam } = this.state;
if (!article) return;
const resolved = resolveArticleTab(tabParam, article);
if (tabParam !== resolved) {
void router.navigate(artikelDetailPath(article, resolved), { replace: true });
}
}
handleTabChange(_event, tab) {
this.props.onTabChange?.(tab);
const { article } = this.props;
void router.navigate(artikelDetailPath(article, tab));
}
handlePinDefault() {
const { tab } = this.props;
const tab = resolveArticleTab(this.state.tabParam, this.props.article);
if (!tab) return;
writePreferredArticleTab(tab);
this.setState({ preferredTab: tab });
}
renderPanel() {
renderPanel(tab) {
const { article: articleData, onArticleDeleted } = this.props;
switch (this.props.tab) {
switch (tab) {
case ARTICLE_TAB.history:
return <ArticleHistoryTab sku={articleData.sku} />;
case ARTICLE_TAB.supplier:
@@ -123,9 +167,11 @@ class ArticleTileTab extends PureComponent {
wide = 3,
minHeight = 200,
sx,
tab = DEFAULT_ARTICLE_TAB,
} = this.props;
const { preferredTab } = this.state;
const { preferredTab, tabParam } = this.state;
const tab = resolveArticleTab(tabParam, articleData);
if (tabParam !== tab) return null;
const tabs = visibleTabs(articleData);
const active =
tabs.find((row) => row.id === tab) ??
@@ -270,7 +316,7 @@ class ArticleTileTab extends PureComponent {
...sx,
}}
>
{this.renderPanel()}
{this.renderPanel(tab)}
</PanelTile>
</Box>
);

View File

@@ -1,4 +1,4 @@
import { Component } from 'react';
import { PureComponent } from 'react';
import { PanelTileGrid } from '../panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import withNavigate from '../withNavigate';
@@ -6,10 +6,6 @@ import {
buildCategoryPath,
defaultCategoryPath,
} from '../../pages/artikel/categoryRoute';
import {
artikelDetailPath,
resolveArticleTab,
} from '../../pages/artikel/articleRoute';
import ArticleTileTab from './ArticleTileTab';
import ArticlePhotoTile from './ArticlePhotoTile';
@@ -23,7 +19,7 @@ import ArticleViewLists from './boxes/ArticleViewLists';
/** Stable prop so PureComponent photo tile does not see a new sx each render. */
const PHOTO_TILE_SX = { minHeight: 0 };
class ArticleView extends Component {
class ArticleView extends PureComponent {
static contextType = StorageManagerContext;
constructor(props, context) {
@@ -36,13 +32,11 @@ class ArticleView extends Component {
);
this.state = { article };
this.unwatchArticle = unwatch;
this.handleTabChange = this.handleTabChange.bind(this);
this.handleArticleDeleted = this.handleArticleDeleted.bind(this);
}
componentDidUpdate(prevProps) {
const { sku, tab, navigate } = this.props;
const { article } = this.state;
const { sku } = this.props;
if (prevProps.sku !== sku) {
this.unwatchArticle?.();
@@ -52,13 +46,6 @@ class ArticleView extends Component {
);
this.unwatchArticle = unwatch;
this.setState({ article: next });
return;
}
if (!article) return;
const resolved = resolveArticleTab(tab, article);
if (tab !== resolved) {
navigate(artikelDetailPath(article, resolved), { replace: true });
}
}
@@ -81,14 +68,7 @@ class ArticleView extends Component {
navigate(defaultCategoryPath());
}
handleTabChange(nextTab) {
const { navigate } = this.props;
const { article } = this.state;
navigate(artikelDetailPath(article, nextTab));
}
render() {
const { tab } = this.props;
const { article } = this.state;
const categoryState = this.context.getCategoryState();
const categories = categoryState?.categories ?? null;
@@ -96,9 +76,6 @@ class ArticleView extends Component {
if (!article || categories == null || rootCategoryId == null) return null;
const resolvedTab = resolveArticleTab(tab, article);
if (tab !== resolvedTab) return null;
return (
<PanelTileGrid>
<ArticleViewName article={article} />
@@ -122,8 +99,6 @@ class ArticleView extends Component {
wide={3}
minHeight={200}
article={article}
tab={resolvedTab}
onTabChange={this.handleTabChange}
onArticleDeleted={this.handleArticleDeleted}
/>

View File

@@ -5,6 +5,9 @@ import TableCell from '@mui/material/TableCell';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import ResultsPagination from '../../../pages/artikel/ResultsPagination';
const PAGE_SIZE = 20;
function formatDate(iso) {
return new Date(iso).toLocaleString('de-DE');
@@ -15,25 +18,46 @@ export default class ArticleHistoryTab extends Component {
constructor(props) {
super(props);
this.state = { history: null };
this.state = {
history: null,
total: null,
skip: 0,
};
this.unwatchHistory = null;
this.setSkipAndOffset = null;
this.handlePageChange = this.handlePageChange.bind(this);
}
componentDidMount() {
this.unwatchHistory = this.context.article.historyWatch(
const { unwatch, setSkipAndOffset } = this.context.article.historyWatch(
this.props.sku,
(history) => this.setState({ history }),
(history, total) => this.setState({ history, total }),
);
this.unwatchHistory = unwatch;
this.setSkipAndOffset = setSkipAndOffset;
setSkipAndOffset(0, PAGE_SIZE);
}
componentWillUnmount() {
this.unwatchHistory?.();
this.unwatchHistory = null;
this.setSkipAndOffset = null;
}
handlePageChange(_event, page) {
const skip = (page - 1) * PAGE_SIZE;
this.setSkipAndOffset?.(skip, PAGE_SIZE);
this.setState({ skip });
}
render() {
const { history } = this.state;
if (history == null) return null;
const { history, total, skip } = this.state;
if (history == null || total == null) return null;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.floor(skip / PAGE_SIZE) + 1;
const showPagination = total > PAGE_SIZE;
return (
<Table
size="small"
@@ -49,13 +73,28 @@ export default class ArticleHistoryTab extends Component {
borderBottom: '1px solid rgba(0, 0, 0, 0.35)',
width: '1%',
whiteSpace: 'nowrap',
verticalAlign: 'middle',
}}
>
Datum
</TableCell>
<TableCell
sx={{ borderBottom: '1px solid rgba(0, 0, 0, 0.35)' }}
/>
sx={{
borderBottom: '1px solid rgba(0, 0, 0, 0.35)',
verticalAlign: 'middle',
}}
>
{showPagination ? (
<ResultsPagination
offset={skip}
totalCount={total}
pageSize={PAGE_SIZE}
pageCount={pageCount}
currentPage={currentPage}
onPageChange={this.handlePageChange}
/>
) : null}
</TableCell>
</TableRow>
</TableHead>
<TableBody>

View File

@@ -1,3 +1,5 @@
import { matchPath } from 'react-router-dom';
export const ARTICLE_TAB = {
history: 'history',
supplier: 'supplier',
@@ -84,6 +86,12 @@ export function resolveArticleTab(tabParam, article) {
return preferredArticleTab(article);
}
/** Tab segment from an article-detail pathname, or undefined. */
export function articleTabFromPathname(pathname) {
const match = matchPath('/artikel/:sku/:tab?', pathname);
return match?.params?.tab;
}
/**
* @param {object|string} articleOrSku
* @param {string} [tab] omitted → preferred default from localStorage

View File

@@ -61,9 +61,9 @@ class ArtikelSkuPage extends Component {
}
render() {
const { sku, tab } = this.props;
const { sku } = this.props;
if (!this.state.ready) return null;
return <ArticleView sku={sku} tab={tab} />;
return <ArticleView sku={sku} />;
}
}