This commit is contained in:
seb
2026-08-05 17:01:42 +02:00
parent 7cecb3dd1f
commit 198e800fd3
14 changed files with 1250 additions and 929 deletions

View File

@@ -1,124 +1,185 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Component } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { useStorageManager } from '../../api/storageManagerContext';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { ArticleView } from '../../components/article';
import BackendError from '../../components/BackendError';
import { requireObject } from '../../utils/apiShape';
import { requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom, isBackendFailure } from '../../utils/backendError';
import { parseCategoryTreeResponse } from '../../store/categorySlice';
import {
artikelDetailPath,
resolveArticleTab,
} from './articleRoute';
import withSkuRoute from './withSkuRoute';
export default function ArtikelSkuPage() {
const { sku: skuParam, tab: tabParam } = useParams();
const navigate = useNavigate();
const store = useStorageManager();
const sku = skuParam ? decodeURIComponent(skuParam) : '';
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
class ArtikelSkuPage extends Component {
static contextType = StorageManagerContext;
const retry = useCallback(() => {
setReloadKey((key) => key + 1);
}, []);
useEffect(() => {
function onKeyDown(e) {
if (e.key !== 'Escape') return;
e.preventDefault();
navigate(-1);
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [navigate]);
useEffect(() => {
let cancelled = false;
async function load() {
if (!sku) {
setError('Keine Artikelnr. angegeben');
setData(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const res = await store.article.get(sku);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
if (!cancelled) {
setData(res.article);
}
} catch (err) {
if (!cancelled) {
setData(null);
setError(backendErrorFrom(err));
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
constructor(props) {
super(props);
this.state = {
data: null,
categories: null,
rootCategoryId: null,
error: null,
loading: true,
reloadKey: 0,
};
}, [sku, reloadKey, store]);
this._cancelled = false;
}
componentDidMount() {
this._cancelled = false;
this.installEscape();
void this.load();
}
componentDidUpdate(prevProps, prevState) {
const { sku, tab: tabParam, navigate } = this.props;
const { data, reloadKey } = this.state;
if (prevProps.sku !== sku || prevState.reloadKey !== reloadKey) {
void this.load();
return;
}
useEffect(() => {
if (!data) return;
const resolved = resolveArticleTab(tabParam, data);
if (tabParam !== resolved) {
navigate(artikelDetailPath(data, resolved), { replace: true });
}
}, [data, tabParam, navigate]);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (error) {
if (isBackendFailure(error)) {
componentWillUnmount() {
this._cancelled = true;
this.removeEscape();
}
installEscape() {
this._onKeyDown = (e) => {
if (e.key !== 'Escape') return;
e.preventDefault();
this.props.navigate(-1);
};
document.addEventListener('keydown', this._onKeyDown);
}
removeEscape() {
if (this._onKeyDown) {
document.removeEventListener('keydown', this._onKeyDown);
this._onKeyDown = null;
}
}
retry = () => {
this.setState((s) => ({ reloadKey: s.reloadKey + 1 }));
};
async load() {
const { sku } = this.props;
const store = this.context;
if (!sku) {
this.setState({
error: 'Keine Artikelnr. angegeben',
data: null,
categories: null,
rootCategoryId: null,
loading: false,
});
return;
}
this._cancelled = false;
this.setState({ loading: true, error: null });
try {
const [res, treeData, companies, session] = await Promise.all([
store.article.get(sku),
store.categoryTreePromise,
store.companyPromise,
store.authMePromise,
]);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
const company = requireDefined(
companies.find((c) => c.id === companyId),
'company',
);
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
if (!this._cancelled) {
this.setState({
data: res.article,
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
error: null,
loading: false,
});
}
} catch (err) {
if (!this._cancelled) {
this.setState({
data: null,
categories: null,
rootCategoryId: null,
error: backendErrorFrom(err),
loading: false,
});
}
}
}
handleArticleUpdated = (next) => {
this.setState({ data: next });
};
handleTabChange = (nextTab) => {
const { data } = this.state;
this.props.navigate(artikelDetailPath(data, nextTab));
};
render() {
const { tab: tabParam } = this.props;
const { data, categories, rootCategoryId, error, loading } = this.state;
if (loading) {
return (
<BackendError
error={error}
onRetry={retry}
/>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (error) {
if (isBackendFailure(error)) {
return <BackendError error={error} onRetry={this.retry} />;
}
return (
<Alert severity="error" sx={{ maxWidth: 640 }}>
{typeof error === 'string' ? error : error.message}
</Alert>
);
}
if (!data || categories == null || rootCategoryId == null) return null;
const tab = resolveArticleTab(tabParam, data);
if (tabParam !== tab) return null;
return (
<Alert severity="error" sx={{ maxWidth: 640 }}>
{typeof error === 'string' ? error : error.message}
</Alert>
<ArticleView
article={data}
categories={categories}
rootCategoryId={rootCategoryId}
tab={tab}
onTabChange={this.handleTabChange}
onArticleUpdated={this.handleArticleUpdated}
/>
);
}
if (!data) return null;
const tab = resolveArticleTab(tabParam, data);
if (tabParam !== tab) return null;
return (
<ArticleView
article={data}
tab={tab}
onTabChange={(nextTab) => {
navigate(artikelDetailPath(data, nextTab));
}}
onArticleUpdated={setData}
/>
);
}
export default withSkuRoute(ArtikelSkuPage);

View File

@@ -0,0 +1,13 @@
import { useNavigate, useParams } from 'react-router-dom';
/** Injects `sku`, `tab`, and `navigate` from the article detail route. */
export default function withSkuRoute(Wrapped) {
function SkuRouteWrapper(props) {
const navigate = useNavigate();
const { sku: skuParam, tab } = useParams();
const sku = skuParam ? decodeURIComponent(skuParam) : '';
return <Wrapped {...props} sku={sku} tab={tab} navigate={navigate} />;
}
return SkuRouteWrapper;
}