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

@@ -24,6 +24,29 @@
</head>
<body>
<div id="root"></div>
<script>
(function () {
var host = location.hostname;
if (host !== 'localhost' && host !== '127.0.0.1') return;
if (!navigator.serviceWorker) return;
var hadSw = false;
navigator.serviceWorker.getRegistrations().then(function (regs) {
hadSw = regs.length > 0;
return Promise.all(regs.map(function (r) { return r.unregister(); }));
}).then(function () {
if (!window.caches) return hadSw;
return caches.keys().then(function (keys) {
return Promise.all(keys.map(function (k) { return caches.delete(k); }))
.then(function () { return hadSw; });
});
}).then(function (reload) {
if (reload && !sessionStorage.getItem('dashfront-sw-cleared')) {
sessionStorage.setItem('dashfront-sw-cleared', '1');
location.reload();
}
});
})();
</script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -65,6 +65,8 @@ async function http(path, { method = 'GET', body } = {}) {
logApi('error-body', { method, path, status: res.status, message, data });
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
err.path = path;
err.method = method;
err.data = data;
throw err;
}
@@ -92,6 +94,30 @@ export const article = {
const params = new URLSearchParams({ target });
return http(`/api/article/get?${params}`);
},
/**
* GET /api/article/byCategory?categoryId&offset&maxCount
* Articles assigned to a category (for tile view).
*/
byCategory({ categoryId, offset, maxCount } = {}) {
const params = new URLSearchParams({ categoryId: String(categoryId) });
if (offset) params.set('offset', String(offset));
if (maxCount != null) params.set('maxCount', String(maxCount));
return http(`/api/article/byCategory?${params}`);
},
};
export const category = {
/**
* GET /api/category/tree?companyId=
* Flat map of categories keyed by id; rootCategoryId resolved server-side.
*/
getTree({ companyId } = {}) {
const params = new URLSearchParams();
if (companyId != null) params.set('companyId', String(companyId));
const qs = params.toString();
return http(`/api/category/tree${qs ? `?${qs}` : ''}`);
},
};
export const company = {
@@ -119,14 +145,8 @@ export const company = {
},
/**
* Backend suggestion (not implemented yet):
*
* PUT /api/company/:companyId
* Body: { name, iso639, taxrate, reducedtaxrate }
* Response: { company: { id, name, iso639, taxrate, reducedtaxrate, ... } }
* Auth: session cookie; user must be allowed to edit that company.
*
* Optional: add iso639 to GET /api/company list items (today only languageId).
*/
update(companyId, { name, iso639, taxrate, reducedtaxrate }) {
return http(`/api/company/${companyId}`, {
@@ -194,4 +214,4 @@ export const auth = {
},
};
export default { auth, article, company };
export default { auth, article, category, company };

View File

@@ -1,10 +1,15 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import { enqueueSnackbar } from 'notistack';
import { bootstrapSession } from '../store/authSlice';
import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice';
let sessionBootstrapped = false;
class AuthBootstrap extends Component {
componentDidMount() {
if (sessionBootstrapped) return;
sessionBootstrapped = true;
this.props
.bootstrapSession()
.unwrap()
@@ -24,7 +29,7 @@ class AuthBootstrap extends Component {
}
const mapDispatchToProps = (dispatch) => ({
bootstrapSession: () => dispatch(bootstrapSession()),
bootstrapSession: () => dispatch(bootstrapSessionThunk()),
});
export default connect(null, mapDispatchToProps)(AuthBootstrap);

View File

@@ -0,0 +1,55 @@
import { Component } from 'react';
import Alert from '@mui/material/Alert';
import AlertTitle from '@mui/material/AlertTitle';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { formatBackendError } from '../utils/backendError';
class BackendError extends Component {
render() {
const { error, title, onRetry, sx, action } = this.props;
if (!error) return null;
const formatted = formatBackendError(error);
const heading = title || formatted.title;
return (
<Alert
severity="error"
sx={{ maxWidth: 720, ...sx }}
action={action}
>
<AlertTitle>{heading}</AlertTitle>
{formatted.detail ? (
<Typography variant="body2" component="div" sx={{ mb: formatted.request ? 1 : 0 }}>
{formatted.detail}
</Typography>
) : null}
{formatted.request ? (
<Box
component="code"
sx={{
display: 'block',
fontSize: '0.75rem',
color: 'text.secondary',
fontFamily: 'monospace',
}}
>
{formatted.request}
{formatted.status != null ? ` → ${formatted.status}` : ''}
</Box>
) : null}
{onRetry ? (
<Box sx={{ mt: 1.5 }}>
<Button size="small" variant="outlined" color="inherit" onClick={onRetry}>
Erneut versuchen
</Button>
</Box>
) : null}
</Alert>
);
}
}
export default BackendError;

View File

@@ -16,19 +16,21 @@ import TextField from '@mui/material/TextField';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { enqueueSnackbar } from 'notistack';
import { company } from '../api';
import BackendError from './BackendError';
import { selectAuthUser } from '../store/authSlice';
import {
ISO639_OPTIONS,
companyLanguageToIso639,
parseTaxRate,
} from '../utils/companyForm';
loadCurrentCompany,
selectCompanyStatus,
selectCurrentCompany,
updateCompany as updateCompanyThunk,
} from '../store/companySlice';
import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
class CompanySettingsDialog extends Component {
constructor(props) {
super(props);
this.state = {
loading: false,
busy: false,
error: null,
name: '',
@@ -37,39 +39,37 @@ class CompanySettingsDialog extends Component {
reducedtaxrate: '',
};
this.handleSubmit = this.handleSubmit.bind(this);
this.syncFormFromCompany = this.syncFormFromCompany.bind(this);
}
componentDidUpdate(prevProps) {
const { open, user } = this.props;
const companyId = user?.selectedCompanyId;
if (open && !prevProps.open && companyId) {
void this.loadCompany(companyId);
const { open, company, companyStatus } = this.props;
if (open && !prevProps.open) {
this.setState({ error: null });
if (company) {
this.syncFormFromCompany(company);
} else if (companyStatus !== 'loading') {
void this.props.loadCurrentCompany();
}
}
if (open && company && company !== prevProps.company) {
this.syncFormFromCompany(company);
}
}
async loadCompany(companyId) {
this.setState({ loading: true, error: null });
try {
const data = await company.getById(companyId);
const item = data.company;
this.setState({
name: item.name ?? '',
iso639: companyLanguageToIso639(item),
taxrate: item.taxrate != null ? String(item.taxrate) : '',
reducedtaxrate: item.reducedtaxrate != null ? String(item.reducedtaxrate) : '',
});
} catch (err) {
this.setState({ error: err.message });
} finally {
this.setState({ loading: false });
}
syncFormFromCompany(company) {
this.setState({
name: company.name ?? '',
iso639: company.iso639 ?? 'de',
taxrate: company.taxrate != null ? String(company.taxrate) : '',
reducedtaxrate: company.reducedtaxrate != null ? String(company.reducedtaxrate) : '',
});
}
async handleSubmit(event) {
event.preventDefault();
const { user, onClose } = this.props;
const { onClose } = this.props;
const { name, iso639, taxrate, reducedtaxrate } = this.state;
const companyId = user?.selectedCompanyId;
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canSave =
@@ -78,31 +78,34 @@ class CompanySettingsDialog extends Component {
parsedTaxrate != null &&
parsedReducedtaxrate != null;
if (!companyId || !canSave) {
if (!canSave) {
this.setState({ error: 'Bitte alle Pflichtfelder ausfüllen.' });
return;
}
this.setState({ busy: true, error: null });
try {
await company.update(companyId, {
name: name.trim(),
iso639,
taxrate: parsedTaxrate,
reducedtaxrate: parsedReducedtaxrate,
});
await this.props
.updateCompany({
name: name.trim(),
iso639,
taxrate: parsedTaxrate,
reducedtaxrate: parsedReducedtaxrate,
})
.unwrap();
enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' });
onClose();
} catch (err) {
this.setState({ error: err.message });
this.setState({ error: backendErrorFrom(err) });
} finally {
this.setState({ busy: false });
}
}
render() {
const { open, onClose } = this.props;
const { loading, busy, error, name, iso639, taxrate, reducedtaxrate } = this.state;
const { open, onClose, companyStatus } = this.props;
const { busy, error, name, iso639, taxrate, reducedtaxrate } = this.state;
const loading = companyStatus === 'loading' && !busy;
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canSave =
@@ -117,9 +120,13 @@ class CompanySettingsDialog extends Component {
<Box component="form" onSubmit={this.handleSubmit} noValidate>
<DialogContent>
{error ? (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
isBackendFailure(error) ? (
<BackendError error={error} sx={{ mb: 2 }} />
) : (
<Alert severity="error" sx={{ mb: 2 }}>
{typeof error === 'string' ? error : error.message}
</Alert>
)
) : null}
{loading ? (
@@ -212,6 +219,13 @@ class CompanySettingsDialog extends Component {
const mapStateToProps = (state) => ({
user: selectAuthUser(state),
company: selectCurrentCompany(state),
companyStatus: selectCompanyStatus(state),
});
export default connect(mapStateToProps)(CompanySettingsDialog);
const mapDispatchToProps = (dispatch) => ({
loadCurrentCompany: () => dispatch(loadCurrentCompany()),
updateCompany: (payload) => dispatch(updateCompanyThunk(payload)),
});
export default connect(mapStateToProps, mapDispatchToProps)(CompanySettingsDialog);

View File

@@ -20,13 +20,19 @@ import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Typography from '@mui/material/Typography';
import BusinessIcon from '@mui/icons-material/Business';
import BackendError from './BackendError';
import { company } from '../api';
import { createCompany, logout, selectCompany } from '../store/authSlice';
import {
createCompany as createCompanyThunk,
logout as logoutThunk,
selectCompany as selectCompanyThunk,
} from '../store/authSlice';
import {
defaultIso639,
ISO639_OPTIONS,
parseTaxRate,
} from '../utils/companyForm';
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
class CompanySetupDialog extends Component {
constructor(props) {
@@ -44,6 +50,7 @@ class CompanySetupDialog extends Component {
this.handleSelect = this.handleSelect.bind(this);
this.handleCreate = this.handleCreate.bind(this);
this.handleLogout = this.handleLogout.bind(this);
this.handleClose = this.handleClose.bind(this);
}
componentDidMount() {
@@ -67,7 +74,7 @@ class CompanySetupDialog extends Component {
try {
await this.props.selectCompany(companyId).unwrap();
} catch (err) {
this.setState({ error: err.message });
this.setState({ error: backendErrorFrom(err) });
} finally {
this.setState({ busy: false });
}
@@ -100,7 +107,7 @@ class CompanySetupDialog extends Component {
})
.unwrap();
} catch (err) {
this.setState({ error: err.message });
this.setState({ error: backendErrorFrom(err) });
} finally {
this.setState({ busy: false });
}
@@ -110,6 +117,10 @@ class CompanySetupDialog extends Component {
void this.props.logout();
}
handleClose(_event, reason) {
if (reason === 'backdropClick' || reason === 'escapeKeyDown') return;
}
render() {
const { companies, loading, busy, error, name, iso639, taxrate, reducedtaxrate } =
this.state;
@@ -123,7 +134,7 @@ class CompanySetupDialog extends Component {
parsedReducedtaxrate != null;
return (
<Dialog open disableEscapeKeyDown maxWidth="sm" fullWidth>
<Dialog open onClose={this.handleClose} maxWidth="sm" fullWidth>
<DialogTitle>
{loading
? 'Unternehmen'
@@ -141,9 +152,13 @@ class CompanySetupDialog extends Component {
) : null}
{error ? (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
isBackendFailure(error) ? (
<BackendError error={error} sx={{ mb: 2 }} />
) : (
<Alert severity="error" sx={{ mb: 2 }}>
{typeof error === 'string' ? error : error.message}
</Alert>
)
) : null}
{loading ? (
@@ -268,9 +283,9 @@ class CompanySetupDialog extends Component {
}
const mapDispatchToProps = (dispatch) => ({
createCompany: (payload) => dispatch(createCompany(payload)),
selectCompany: (companyId) => dispatch(selectCompany(companyId)),
logout: () => dispatch(logout()),
createCompany: (payload) => dispatch(createCompanyThunk(payload)),
selectCompany: (companyId) => dispatch(selectCompanyThunk(companyId)),
logout: () => dispatch(logoutThunk()),
});
export default connect(null, mapDispatchToProps)(CompanySetupDialog);

View File

@@ -0,0 +1,18 @@
import AddIcon from '@mui/icons-material/Add';
import Typography from '@mui/material/Typography';
import { Tile } from './Tile';
export class ActionTile extends Tile {
renderIcon() {
return <AddIcon color="primary" sx={{ fontSize: 40 }} />;
}
renderLabel() {
const { label = 'Aktion' } = this.props;
return (
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>
{label}
</Typography>
);
}
}

View File

@@ -0,0 +1,27 @@
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import Typography from '@mui/material/Typography';
import { Tile } from './Tile';
import { formatPrice } from '../../utils/price';
export class ArticleTile extends Tile {
renderIcon() {
return <Inventory2OutlinedIcon color="action" sx={{ fontSize: 36 }} />;
}
renderSecondary() {
const { article } = this.props;
if (!article) return null;
return (
<>
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}>
{article.sku}
</Typography>
{article.price != null && (
<Typography variant="caption" fontWeight={600}>
{formatPrice(article.price)}
</Typography>
)}
</>
);
}
}

View File

@@ -0,0 +1,18 @@
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import Typography from '@mui/material/Typography';
import { Tile } from './Tile';
export class BackTile extends Tile {
renderIcon() {
return <ArrowBackIcon color="action" sx={{ fontSize: 36 }} />;
}
renderLabel() {
const { label = 'Zurück' } = this.props;
return (
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>
{label}
</Typography>
);
}
}

View File

@@ -0,0 +1,8 @@
import FolderIcon from '@mui/icons-material/Folder';
import { Tile } from './Tile';
export class CategoryTile extends Tile {
renderIcon() {
return <FolderIcon color="primary" sx={{ fontSize: 40 }} />;
}
}

View File

@@ -0,0 +1,81 @@
import { Component } from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
export const TILE_SIZE = 140;
export const tileBaseSx = {
aspectRatio: '1 / 1',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
p: 1.5,
cursor: 'pointer',
userSelect: 'none',
textAlign: 'center',
transition: 'border-color 0.15s, background-color 0.15s',
'&:hover': {
bgcolor: 'action.hover',
borderColor: 'primary.main',
},
};
export class Tile extends Component {
handleClick = () => {
this.props.onClick?.();
};
renderIcon() {
return null;
}
renderLabel() {
const { label } = this.props;
if (!label) return null;
return (
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>
{label}
</Typography>
);
}
renderSecondary() {
const { secondary } = this.props;
if (!secondary) return null;
return (
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.2 }}>
{secondary}
</Typography>
);
}
render() {
const { sx, disabled } = this.props;
return (
<Paper
variant="outlined"
component="button"
type="button"
disabled={disabled}
onClick={this.handleClick}
sx={{
...tileBaseSx,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
...(disabled
? { opacity: 0.5, pointerEvents: 'none' }
: {}),
...sx,
}}
>
{this.renderIcon()}
{this.renderLabel()}
{this.renderSecondary()}
</Paper>
);
}
}

View File

@@ -0,0 +1,5 @@
export { TILE_SIZE, Tile, tileBaseSx } from './Tile';
export { BackTile } from './BackTile';
export { CategoryTile } from './CategoryTile';
export { ArticleTile } from './ArticleTile';
export { ActionTile } from './ActionTile';

View File

@@ -0,0 +1,9 @@
import { useNavigate } from 'react-router-dom';
export default function withNavigate(Wrapped) {
function NavigateWrapper(props) {
const navigate = useNavigate();
return <Wrapped {...props} navigate={navigate} />;
}
return NavigateWrapper;
}

View File

@@ -3,14 +3,25 @@ import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { Provider } from 'react-redux';
import { SnackbarProvider } from 'notistack';
import { registerSW } from 'virtual:pwa-register';
import AuthBootstrap from './components/AuthBootstrap';
import App from './App';
import { store } from './store';
import theme from './theme';
import './index.css';
registerSW({ immediate: true });
async function clearDevServiceWorker() {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return;
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
}
if (import.meta.env.DEV) {
void clearDevServiceWorker();
}
createRoot(document.getElementById('root')).render(
<Provider store={store}>

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>
);
}

View File

@@ -31,11 +31,17 @@ export const logout = createAsyncThunk('auth/logout', async () => {
window.history.replaceState(null, '', '/');
});
function mergeUser(state, incoming) {
if (!incoming) return state.user;
const selectedCompanyId =
incoming.selectedCompanyId ?? state.user?.selectedCompanyId ?? null;
return { ...state.user, ...incoming, selectedCompanyId };
}
const initialState = {
user: null,
googleClientId: null,
status: 'loading',
companyReady: false,
};
const authSlice = createSlice({
@@ -46,40 +52,47 @@ const authSlice = createSlice({
builder
.addCase(bootstrapSession.fulfilled, (state, action) => {
const session = action.payload;
state.user = session.user ?? null;
state.googleClientId = session.googleClientId ?? null;
state.status = session.user ? 'authed' : 'anon';
state.companyReady = session.user?.selectedCompanyId != null;
state.googleClientId = session.googleClientId ?? state.googleClientId;
if (!session.user) {
if (state.status !== 'authed') {
state.user = null;
state.status = 'anon';
}
return;
}
state.user = mergeUser(state, session.user);
state.status = 'authed';
})
.addCase(bootstrapSession.rejected, (state) => {
state.user = null;
state.status = 'anon';
state.companyReady = false;
if (state.status === 'loading') {
state.user = null;
state.status = 'anon';
}
})
.addCase(loginWithGoogle.fulfilled, (state, action) => {
const session = action.payload;
state.user = session.user;
state.googleClientId = session.googleClientId ?? state.googleClientId;
state.status = 'authed';
state.companyReady = session.user?.selectedCompanyId != null;
})
.addCase(loginWithGoogle.rejected, (state) => {
state.user = null;
state.status = 'anon';
state.companyReady = false;
})
.addCase(selectCompany.fulfilled, (state, action) => {
state.user = { ...state.user, ...action.payload };
state.companyReady = true;
state.user = mergeUser(state, action.payload);
state.status = 'authed';
})
.addCase(createCompany.fulfilled, (state, action) => {
state.user = { ...state.user, ...action.payload };
state.companyReady = true;
state.user = mergeUser(state, action.payload);
state.status = 'authed';
})
.addCase(createCompany.rejected, (state, action) => {
console.error('[redux] createCompany.rejected', action.error?.message, action.payload);
})
.addCase(logout.fulfilled, (state) => {
state.user = null;
state.status = 'anon';
state.companyReady = false;
});
},
});
@@ -88,6 +101,6 @@ export const selectAuthStatus = (state) => state.auth.status;
export const selectAuthUser = (state) => state.auth.user;
export const selectGoogleClientId = (state) => state.auth.googleClientId;
export const selectNeedsCompanySetup = (state) =>
state.auth.status === 'authed' && !state.auth.companyReady;
state.auth.status === 'authed' && state.auth.user?.selectedCompanyId == null;
export default authSlice.reducer;

217
src/store/categorySlice.js Normal file
View File

@@ -0,0 +1,217 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { article, category } from '../api';
import { catchBackendError, resolveBackendError } from '../utils/backendError';
import { logout } from './authSlice';
import { loadCurrentCompany } from './companySlice';
export const ROOT_CATEGORY_NAME = 'Home';
export function normalizeCategoryNode(item) {
return {
...item,
id: item.id,
parentId: item.parentId ?? 0,
name: item.name?.trim() || null,
};
}
/** Backend: { tree: { tree: [...], articles: [] } } */
export function parseCategoryTreeResponse(data, companyRootCategoryId = null) {
const payload = data?.tree ?? data;
const rawNodes = payload?.tree ?? payload?.categories ?? data?.categories ?? [];
const list = Array.isArray(rawNodes) ? rawNodes : Object.values(rawNodes);
const categories = Object.fromEntries(
list.map((item) => [String(item.id), normalizeCategoryNode(item)]),
);
const rootCategoryId = data?.rootCategoryId ?? companyRootCategoryId ?? null;
const rootArticles = Array.isArray(payload?.articles) ? payload.articles : [];
return { rootCategoryId, categories, rootArticles };
}
export function categoryDisplayName(name, categoryId, rootCategoryId) {
if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
return name?.trim() || '';
}
export function normalizeCategoryMap(data) {
return parseCategoryTreeResponse(data).categories;
}
export function getCategoryChildren(categories, parentId) {
if (!categories) return [];
const parentKey = String(parentId);
return Object.values(categories)
.filter((item) => String(item.parentId ?? 0) === parentKey)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id);
}
export const loadCategoryTree = createAsyncThunk(
'category/loadTree',
async (companyId, { rejectWithValue, getState }) => {
try {
const data = await category.getTree({ companyId });
const fallbackRootId = getState().company.current?.rootCategoryId ?? null;
const { rootCategoryId, categories, rootArticles } = parseCategoryTreeResponse(
data,
fallbackRootId,
);
return {
companyId,
rootCategoryId,
categories,
rootArticles,
};
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const loadCategoryArticles = createAsyncThunk(
'category/loadArticles',
async ({ categoryId, offset = 0, maxCount = 200 } = {}, { rejectWithValue }) => {
try {
const data = await article.byCategory({ categoryId, offset, maxCount });
return {
categoryId,
recordset: data.recordset ?? [],
length: data.length ?? data.recordset?.length ?? 0,
};
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = {
companyId: null,
categories: null,
rootCategoryId: null,
rootArticles: [],
currentCategoryId: null,
articles: [],
articlesTotal: 0,
treeStatus: 'idle',
articlesStatus: 'idle',
error: null,
};
const categorySlice = createSlice({
name: 'category',
initialState,
reducers: {
navigateToCategory(state, action) {
state.currentCategoryId = action.payload;
state.articles = [];
state.articlesTotal = 0;
state.articlesStatus = 'idle';
state.error = null;
},
navigateBack(state) {
const current = state.categories?.[String(state.currentCategoryId)];
const parentId = current?.parentId;
if (parentId == null || parentId === 0) return;
if (String(parentId) === String(state.rootCategoryId) || state.categories?.[String(parentId)]) {
state.currentCategoryId = parentId;
state.error = null;
if (String(parentId) === String(state.rootCategoryId)) {
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
} else {
state.articles = [];
state.articlesTotal = 0;
state.articlesStatus = 'idle';
}
}
},
resetCategoryState() {
return initialState;
},
},
extraReducers: (builder) => {
builder
.addCase(loadCategoryTree.pending, (state) => {
state.treeStatus = 'loading';
state.error = null;
})
.addCase(loadCategoryTree.fulfilled, (state, action) => {
state.treeStatus = 'succeeded';
state.companyId = action.payload.companyId;
state.categories = action.payload.categories;
state.rootCategoryId = action.payload.rootCategoryId;
state.currentCategoryId = action.payload.rootCategoryId;
state.rootArticles = action.payload.rootArticles ?? [];
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
})
.addCase(loadCategoryTree.rejected, (state, action) => {
state.treeStatus = 'failed';
state.error = resolveBackendError(action);
})
.addCase(loadCategoryArticles.pending, (state) => {
state.articlesStatus = 'loading';
state.error = null;
})
.addCase(loadCategoryArticles.fulfilled, (state, action) => {
state.articlesStatus = 'succeeded';
state.articles = action.payload.recordset;
state.articlesTotal = action.payload.length;
})
.addCase(loadCategoryArticles.rejected, (state, action) => {
state.articlesStatus = 'failed';
state.articles = [];
state.articlesTotal = 0;
state.error = resolveBackendError(action);
})
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
return {
...initialState,
companyId: nextCompanyId,
};
}
})
.addCase(logout.fulfilled, () => initialState);
},
});
export const {
navigateToCategory,
navigateBack,
resetCategoryState,
} = categorySlice.actions;
export const selectCategoryMap = (state) => state.category.categories;
export const selectCurrentCategoryId = (state) => state.category.currentCategoryId;
export const selectCategoryRootId = (state) => state.category.rootCategoryId;
export const selectCategoryTreeStatus = (state) => state.category.treeStatus;
export const selectCategoryArticles = (state) => state.category.articles;
export const selectCategoryArticlesStatus = (state) => state.category.articlesStatus;
export const selectCategoryError = (state) => state.category.error;
export const selectCurrentCategory = (state) => {
const id = state.category.currentCategoryId;
if (id == null) return null;
return state.category.categories?.[String(id)] ?? null;
};
export const selectChildCategories = (state) =>
getCategoryChildren(state.category.categories, state.category.currentCategoryId);
export const selectCanNavigateBack = (state) => {
const { currentCategoryId, rootCategoryId, categories } = state.category;
if (currentCategoryId == null || rootCategoryId == null) return false;
if (String(currentCategoryId) === String(rootCategoryId)) return false;
const parentId = categories?.[String(currentCategoryId)]?.parentId;
return parentId != null && parentId !== 0;
};
export default categorySlice.reducer;

103
src/store/companySlice.js Normal file
View File

@@ -0,0 +1,103 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { company } from '../api';
import { normalizeCompany } from '../utils/companyForm';
import {
bootstrapSession,
createCompany,
loginWithGoogle,
logout,
selectCompany,
} from './authSlice';
export const loadCurrentCompany = createAsyncThunk(
'company/loadCurrent',
async (_, { getState }) => {
const companyId = getState().auth.user?.selectedCompanyId;
if (!companyId) return null;
const data = await company.getById(companyId);
return normalizeCompany(data.company);
},
);
export const updateCompany = createAsyncThunk(
'company/update',
async (payload, { getState }) => {
const companyId = getState().auth.user?.selectedCompanyId;
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
await company.update(companyId, payload);
const data = await company.getById(companyId);
return normalizeCompany(data.company, payload);
},
);
const initialState = {
current: null,
status: 'idle',
error: null,
};
const companySlice = createSlice({
name: 'company',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(loadCurrentCompany.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
state.status = 'succeeded';
state.current = action.payload;
})
.addCase(loadCurrentCompany.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(updateCompany.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(updateCompany.fulfilled, (state, action) => {
state.status = 'succeeded';
state.current = action.payload;
})
.addCase(updateCompany.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(logout.fulfilled, (state) => {
state.current = null;
state.status = 'idle';
state.error = null;
});
},
});
export const selectCurrentCompany = (state) => state.company.current;
export const selectCompanyId = (state) => state.company.current?.id ?? null;
export const selectCompanyStatus = (state) => state.company.status;
export const selectCompanyError = (state) => state.company.error;
export const selectCompanyName = (state) => state.company.current?.name ?? '';
export const selectCompanyIso639 = (state) => state.company.current?.iso639 ?? 'de';
export const selectCompanyTaxrate = (state) => state.company.current?.taxrate ?? null;
export const selectCompanyReducedTaxrate = (state) => state.company.current?.reducedtaxrate ?? null;
export const selectCompanyRootCategoryId = (state) => state.company.current?.rootCategoryId ?? null;
export function companyLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
bootstrapSession.fulfilled.match(action) ||
loginWithGoogle.fulfilled.match(action) ||
selectCompany.fulfilled.match(action) ||
createCompany.fulfilled.match(action),
effect: async (_action, api) => {
const companyId = api.getState().auth.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadCurrentCompany());
}
},
});
}
export default companySlice.reducer;

View File

@@ -1,8 +1,45 @@
import { configureStore } from '@reduxjs/toolkit';
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import authReducer from './authSlice';
import categoryReducer from './categorySlice';
import companyReducer, { companyLoadListener } from './companySlice';
const listenerMiddleware = createListenerMiddleware();
companyLoadListener(listenerMiddleware);
function reduxDevLogger() {
return (storeApi) => (next) => (action) => {
if (
!import.meta.env.DEV ||
(!action.type?.startsWith('auth/') &&
!action.type?.startsWith('company/') &&
!action.type?.startsWith('category/'))
) {
return next(action);
}
const before = storeApi.getState();
const result = next(action);
const after = storeApi.getState();
console.log('[redux]', action.type, {
payload: action.payload,
auth: {
selectedCompanyId: after.auth.user?.selectedCompanyId ?? null,
needsCompanySetup:
after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null,
},
company: after.company.current,
});
return result;
};
}
export const store = configureStore({
reducer: {
auth: authReducer,
company: companyReducer,
category: categoryReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(listenerMiddleware.middleware)
.concat(import.meta.env.DEV ? reduxDevLogger() : []),
});

86
src/utils/backendError.js Normal file
View File

@@ -0,0 +1,86 @@
const STATUS_TITLES = {
400: 'Ungültige Anfrage',
401: 'Nicht angemeldet',
403: 'Zugriff verweigert',
404: 'Nicht gefunden',
409: 'Konflikt',
422: 'Ungültige Daten',
500: 'Serverfehler',
502: 'Gateway-Fehler',
503: 'Dienst nicht erreichbar',
};
const GENERIC_HTTP_TITLES = new Set([
'not found',
'unauthorized',
'forbidden',
'internal server error',
'bad request',
'service unavailable',
]);
/** Normalize any thrown value into a stable backend error object. */
export function backendErrorFrom(err) {
if (!err) {
return { message: 'Unbekannter Fehler', status: null, path: null, method: null };
}
if (typeof err === 'string') {
return { message: err, status: null, path: null, method: null };
}
if (err.message != null && (err.status != null || err.path != null || err.method != null)) {
return {
message: err.message,
status: err.status ?? null,
path: err.path ?? null,
method: err.method ?? null,
};
}
if (err.message != null) {
return {
message: err.message,
status: err.status ?? null,
path: err.path ?? null,
method: err.method ?? null,
};
}
return { message: String(err), status: null, path: null, method: null };
}
/** Read structured error from a rejected RTK async thunk action. */
export function resolveBackendError(action) {
if (action.payload) return backendErrorFrom(action.payload);
return backendErrorFrom(action.error);
}
export function formatBackendError(error) {
const normalized = backendErrorFrom(error);
const { message, status, path, method } = normalized;
const statusTitle = status != null ? STATUS_TITLES[status] : null;
const genericTitle =
message && GENERIC_HTTP_TITLES.has(message.trim().toLowerCase())
? statusTitle
: null;
const title = statusTitle || genericTitle || (status != null ? `HTTP ${status}` : 'Backend-Fehler');
const detail =
message &&
message.trim().toLowerCase() !== title?.trim().toLowerCase() &&
!GENERIC_HTTP_TITLES.has(message.trim().toLowerCase())
? message
: null;
const request = path ? `${method || 'GET'} ${path}` : null;
return { title, detail, status, request };
}
export function catchBackendError(rejectWithValue) {
return (err) => rejectWithValue(backendErrorFrom(err));
}
export function isBackendFailure(error) {
const normalized = backendErrorFrom(error);
return normalized.status != null || normalized.path != null;
}

View File

@@ -16,3 +16,15 @@ export function companyLanguageToIso639(company) {
if (company?.languageId === 2) return 'en';
return 'de';
}
export function normalizeCompany(raw, overrides = {}) {
if (!raw) return null;
return {
id: raw.id,
name: overrides.name ?? raw.name ?? '',
iso639: overrides.iso639 ?? companyLanguageToIso639(raw),
taxrate: overrides.taxrate ?? raw.taxrate ?? null,
reducedtaxrate: overrides.reducedtaxrate ?? raw.reducedtaxrate ?? null,
rootCategoryId: overrides.rootCategoryId ?? raw.rootCategoryId ?? null,
};
}

View File

@@ -96,7 +96,7 @@ const sharedServer = {
},
};
export default defineConfig({
export default defineConfig(({ command }) => ({
build: {
rolldownOptions: {
output: {
@@ -117,42 +117,48 @@ export default defineConfig({
},
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
id: '/',
name: 'Dashfront',
short_name: 'Dashfront',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#dceeea',
theme_color: '#3b5bdb',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'],
navigateFallback: 'index.html',
},
}),
...(command === 'build'
? [
VitePWA({
registerType: 'autoUpdate',
injectRegister: 'auto',
manifest: {
id: '/',
name: 'Dashfront',
short_name: 'Dashfront',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#dceeea',
theme_color: '#3b5bdb',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api/],
},
}),
]
: []),
],
server: sharedServer,
preview: sharedServer,
});
}));