This commit is contained in:
seb
2026-07-31 08:34:51 +02:00
parent a95b7f4b1e
commit 797ea59bed
24 changed files with 1151 additions and 130 deletions

View File

@@ -1,5 +1,4 @@
import { Component } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
@@ -11,6 +10,8 @@ import Typography from '@mui/material/Typography';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import { article } from '../../api';
import BackendError from '../../components/BackendError';
import { backendErrorFrom } from '../../utils/backendError';
import { formatPrice } from '../../utils/price';
import {
buildSearchUrl,
@@ -273,7 +274,7 @@ class ArtikelSearchPage extends Component {
categoryFacets: null,
totalCount: null,
recordset: null,
error: err.message || 'Suche fehlgeschlagen',
error: backendErrorFrom(err),
});
}
};
@@ -344,9 +345,11 @@ class ArtikelSearchPage extends Component {
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
<BackendError
error={error}
sx={{ mb: 2 }}
onRetry={this.fetchResults}
/>
)}
{searchActive && (

View File

@@ -1,7 +1,227 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
import BackendError from '../../components/BackendError';
import {
ActionTile,
ArticleTile,
BackTile,
CategoryTile,
TILE_SIZE,
} from '../../components/tiles';
import withNavigate from '../../components/withNavigate';
import { selectCompanyId } from '../../store/companySlice';
import {
categoryDisplayName,
loadCategoryArticles,
loadCategoryTree,
navigateBack,
navigateToCategory,
selectCanNavigateBack,
selectCategoryArticles,
selectCategoryArticlesStatus,
selectCategoryError,
selectCategoryTreeStatus,
selectChildCategories,
selectCurrentCategory,
} from '../../store/categorySlice';
export default function ArtikelKategorienPage() {
return (
<Typography color="text.secondary">Kategorien — Inhalt folgt.</Typography>
);
const gridSx = {
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${TILE_SIZE}px, 1fr))`,
gap: 2,
};
class ArtikelKategorienPage extends Component {
componentDidMount() {
this.ensureTreeLoaded();
this.ensureArticlesLoaded();
}
componentDidUpdate(prevProps) {
if (this.props.companyId !== prevProps.companyId) {
this.ensureTreeLoaded();
}
if (this.props.currentCategoryId !== prevProps.currentCategoryId) {
this.ensureArticlesLoaded();
}
if (
this.props.treeStatus === 'succeeded' &&
prevProps.treeStatus !== 'succeeded'
) {
this.ensureArticlesLoaded();
}
}
ensureTreeLoaded = () => {
const { companyId, treeStatus } = this.props;
if (!companyId || treeStatus !== 'idle') return;
this.props.loadCategoryTree(companyId);
};
ensureArticlesLoaded = () => {
const { currentCategoryId, articlesStatus, rootCategoryId } = this.props;
if (!currentCategoryId || articlesStatus !== 'idle') return;
// Root articles come from tree response; only fetch for subcategories.
if (String(currentCategoryId) === String(rootCategoryId)) return;
this.props.loadCategoryArticles({ categoryId: currentCategoryId });
};
handleRetryTree = () => {
const { companyId } = this.props;
if (companyId) this.props.loadCategoryTree(companyId);
};
handleRetryArticles = () => {
const { currentCategoryId } = this.props;
if (currentCategoryId) {
this.props.loadCategoryArticles({ categoryId: currentCategoryId });
}
};
handleCategoryClick = (categoryId) => {
this.props.navigateToCategory(categoryId);
};
handleArticleClick = (sku) => {
this.props.navigate(`/artikel/${encodeURIComponent(sku)}`);
};
render() {
const {
companyId,
rootCategoryId,
treeStatus,
articlesStatus,
error,
canGoBack,
currentCategory,
currentCategoryId,
childCategories,
articles,
} = this.props;
if (!companyId) {
return (
<Typography color="text.secondary">
Kein Unternehmen ausgewählt.
</Typography>
);
}
if (treeStatus === 'loading' || treeStatus === 'idle') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (treeStatus === 'failed') {
return (
<BackendError
error={error}
onRetry={this.handleRetryTree}
/>
);
}
if (!rootCategoryId) {
return (
<Typography color="text.secondary">
Kategoriebaum ist leer.
</Typography>
);
}
const categoryName = categoryDisplayName(
currentCategory?.name,
currentCategoryId,
rootCategoryId,
);
const showArticlesLoading = articlesStatus === 'loading';
return (
<>
{categoryName ? (
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600 }}>
{categoryName}
</Typography>
) : null}
{error && articlesStatus === 'failed' && (
<BackendError
error={error}
sx={{ mb: 2 }}
onRetry={this.handleRetryArticles}
/>
)}
<Box sx={gridSx}>
{canGoBack && (
<BackTile onClick={this.props.navigateBack} />
)}
{childCategories.map((item) => (
<CategoryTile
key={item.id}
label={categoryDisplayName(item.name, item.id, rootCategoryId)}
onClick={() => this.handleCategoryClick(item.id)}
/>
))}
{articles.map((item) => (
<ArticleTile
key={item.id ?? item.sku}
label={item.name?.trim() || undefined}
article={item}
onClick={() => this.handleArticleClick(item.sku)}
/>
))}
<ActionTile
label="Neue Kategorie"
disabled
onClick={() => {}}
/>
<ActionTile
label="Neuer Artikel"
disabled
onClick={() => {}}
/>
</Box>
{showArticlesLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
)}
</>
);
}
}
const mapStateToProps = (state) => ({
companyId: selectCompanyId(state),
rootCategoryId: state.category.rootCategoryId,
treeStatus: selectCategoryTreeStatus(state),
articlesStatus: selectCategoryArticlesStatus(state),
error: selectCategoryError(state),
currentCategoryId: state.category.currentCategoryId,
currentCategory: selectCurrentCategory(state),
childCategories: selectChildCategories(state),
articles: selectCategoryArticles(state),
canGoBack: selectCanNavigateBack(state),
});
const mapDispatchToProps = {
loadCategoryTree,
loadCategoryArticles,
navigateToCategory,
navigateBack,
};
export default withNavigate(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
@@ -13,6 +13,8 @@ import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import { article } from '../../api';
import BackendError from '../../components/BackendError';
import { backendErrorFrom, isBackendFailure } from '../../utils/backendError';
import { formatPrice, getBrutto } from '../../utils/price';
import { formatStock } from './searchFormat';
@@ -272,6 +274,11 @@ export default function ArtikelSkuPage() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
const retry = useCallback(() => {
setReloadKey((key) => key + 1);
}, []);
useEffect(() => {
function onKeyDown(e) {
@@ -306,7 +313,7 @@ export default function ArtikelSkuPage() {
} catch (err) {
if (!cancelled) {
setData(null);
setError(err.message || 'Artikel konnte nicht geladen werden');
setError(backendErrorFrom(err));
}
} finally {
if (!cancelled) setLoading(false);
@@ -317,7 +324,7 @@ export default function ArtikelSkuPage() {
return () => {
cancelled = true;
};
}, [target]);
}, [target, reloadKey]);
if (loading) {
return (
@@ -328,9 +335,17 @@ export default function ArtikelSkuPage() {
}
if (error) {
if (isBackendFailure(error)) {
return (
<BackendError
error={error}
onRetry={retry}
/>
);
}
return (
<Alert severity="error" sx={{ maxWidth: 640 }}>
{error}
{typeof error === 'string' ? error : error.message}
</Alert>
);
}