u
This commit is contained in:
23
index.html
23
index.html
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
55
src/components/BackendError.jsx
Normal file
55
src/components/BackendError.jsx
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
18
src/components/tiles/ActionTile.jsx
Normal file
18
src/components/tiles/ActionTile.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/components/tiles/ArticleTile.jsx
Normal file
27
src/components/tiles/ArticleTile.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
18
src/components/tiles/BackTile.jsx
Normal file
18
src/components/tiles/BackTile.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
8
src/components/tiles/CategoryTile.jsx
Normal file
8
src/components/tiles/CategoryTile.jsx
Normal 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 }} />;
|
||||
}
|
||||
}
|
||||
81
src/components/tiles/Tile.jsx
Normal file
81
src/components/tiles/Tile.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
5
src/components/tiles/index.js
Normal file
5
src/components/tiles/index.js
Normal 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';
|
||||
9
src/components/withNavigate.jsx
Normal file
9
src/components/withNavigate.jsx
Normal 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;
|
||||
}
|
||||
15
src/main.jsx
15
src/main.jsx
@@ -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}>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
217
src/store/categorySlice.js
Normal 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
103
src/store/companySlice.js
Normal 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;
|
||||
@@ -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
86
src/utils/backendError.js
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user