u
This commit is contained in:
@@ -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 } */
|
||||||
|
|||||||
@@ -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,7 +228,8 @@ export class StorageManager extends ApiClient {
|
|||||||
|
|
||||||
run();
|
run();
|
||||||
|
|
||||||
return () => {
|
return {
|
||||||
|
unwatch: () => {
|
||||||
active = false;
|
active = false;
|
||||||
const set = this.#historyWatchers.get(key);
|
const set = this.#historyWatchers.get(key);
|
||||||
if (!set) return;
|
if (!set) return;
|
||||||
@@ -222,6 +240,12 @@ export class StorageManager extends ApiClient {
|
|||||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
setSkipAndOffset: (nextSkip, nextOffset) => {
|
||||||
|
skip = nextSkip;
|
||||||
|
offset = nextOffset;
|
||||||
|
emit();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user