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`), history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`),
/** /**
* Load history and refetch on SSE `article-<sku>`. * 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), historyWatch: (sku, onHistory) => this.watchArticleHistory(sku, onHistory),
/** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi } */ /** 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>`. * 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 {string} sku
* @param {(history: unknown[]) => void} onHistory * @param {(history: unknown[], total: number) => void} onHistory
* @returns {() => void} unwatch * @returns {{ unwatch: () => void, setSkipAndOffset: (skip: number, offset: number) => void }}
*/ */
watchArticleHistory(sku, onHistory) { watchArticleHistory(sku, onHistory) {
const key = String(sku); const key = String(sku);
let active = true; 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 = () => const refresh = () =>
this.article.history(key).then((res) => { this.article.history(key).then((res) => {
if (!active) return; if (!active) return;
requireObject(res, 'GET /api/article/{sku}/history'); requireObject(res, 'GET /api/article/{sku}/history');
onHistory(requireArray(res.history, 'history')); all = requireArray(res.history, 'history');
loaded = true;
emit();
}); });
const run = () => const run = () =>
@@ -211,17 +228,24 @@ export class StorageManager extends ApiClient {
run(); run();
return () => { return {
active = false; unwatch: () => {
const set = this.#historyWatchers.get(key); active = false;
if (!set) return; const set = this.#historyWatchers.get(key);
set.delete(run); if (!set) return;
if (set.size === 0) { set.delete(run);
this.#historyWatchers.delete(key); if (set.size === 0) {
if (!this.#articleWatchers.has(key)) { this.#historyWatchers.delete(key);
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal); 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 { import {
ARTICLE_TAB, ARTICLE_TAB,
DEFAULT_ARTICLE_TAB, DEFAULT_ARTICLE_TAB,
artikelDetailPath,
articleShowsCompoundTab, articleShowsCompoundTab,
articleTabFromPathname,
readPreferredArticleTab, readPreferredArticleTab,
resolveArticleTab,
writePreferredArticleTab, writePreferredArticleTab,
} from '../../pages/artikel/articleRoute'; } from '../../pages/artikel/articleRoute';
import { router } from '../../appRouter';
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 ArticleAttributesTab from './tabs/ArticleAttributesTab';
@@ -69,30 +73,70 @@ function visibleTabs(article) {
return tabs; return tabs;
} }
function readRouteTab() {
return articleTabFromPathname(router.state.location.pathname);
}
class ArticleTileTab extends PureComponent { class ArticleTileTab extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
preferredTab: readPreferredArticleTab(), preferredTab: readPreferredArticleTab(),
tabParam: readRouteTab(),
}; };
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.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) { handleTabChange(_event, tab) {
this.props.onTabChange?.(tab); const { article } = this.props;
void router.navigate(artikelDetailPath(article, tab));
} }
handlePinDefault() { handlePinDefault() {
const { tab } = this.props; const tab = resolveArticleTab(this.state.tabParam, this.props.article);
if (!tab) return; if (!tab) return;
writePreferredArticleTab(tab); writePreferredArticleTab(tab);
this.setState({ preferredTab: tab }); this.setState({ preferredTab: tab });
} }
renderPanel() { renderPanel(tab) {
const { article: articleData, onArticleDeleted } = this.props; const { article: articleData, onArticleDeleted } = this.props;
switch (this.props.tab) { switch (tab) {
case ARTICLE_TAB.history: case ARTICLE_TAB.history:
return <ArticleHistoryTab sku={articleData.sku} />; return <ArticleHistoryTab sku={articleData.sku} />;
case ARTICLE_TAB.supplier: case ARTICLE_TAB.supplier:
@@ -123,9 +167,11 @@ class ArticleTileTab extends PureComponent {
wide = 3, wide = 3,
minHeight = 200, minHeight = 200,
sx, sx,
tab = DEFAULT_ARTICLE_TAB,
} = this.props; } = 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 tabs = visibleTabs(articleData);
const active = const active =
tabs.find((row) => row.id === tab) ?? tabs.find((row) => row.id === tab) ??
@@ -270,7 +316,7 @@ class ArticleTileTab extends PureComponent {
...sx, ...sx,
}} }}
> >
{this.renderPanel()} {this.renderPanel(tab)}
</PanelTile> </PanelTile>
</Box> </Box>
); );

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import { matchPath } from 'react-router-dom';
export const ARTICLE_TAB = { export const ARTICLE_TAB = {
history: 'history', history: 'history',
supplier: 'supplier', supplier: 'supplier',
@@ -84,6 +86,12 @@ export function resolveArticleTab(tabParam, article) {
return preferredArticleTab(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 {object|string} articleOrSku
* @param {string} [tab] omitted → preferred default from localStorage * @param {string} [tab] omitted → preferred default from localStorage

View File

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