u
This commit is contained in:
23
index.html
23
index.html
@@ -24,6 +24,29 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ async function http(path, { method = 'GET', body } = {}) {
|
|||||||
logApi('error-body', { method, path, status: res.status, message, data });
|
logApi('error-body', { method, path, status: res.status, message, data });
|
||||||
const err = new Error(message || `Request failed (${res.status})`);
|
const err = new Error(message || `Request failed (${res.status})`);
|
||||||
err.status = res.status;
|
err.status = res.status;
|
||||||
|
err.path = path;
|
||||||
|
err.method = method;
|
||||||
err.data = data;
|
err.data = data;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -92,6 +94,30 @@ export const article = {
|
|||||||
const params = new URLSearchParams({ target });
|
const params = new URLSearchParams({ target });
|
||||||
return http(`/api/article/get?${params}`);
|
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 = {
|
export const company = {
|
||||||
@@ -119,14 +145,8 @@ export const company = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend suggestion (not implemented yet):
|
|
||||||
*
|
|
||||||
* PUT /api/company/:companyId
|
* PUT /api/company/:companyId
|
||||||
* Body: { name, iso639, taxrate, reducedtaxrate }
|
* 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 }) {
|
update(companyId, { name, iso639, taxrate, reducedtaxrate }) {
|
||||||
return http(`/api/company/${companyId}`, {
|
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 { Component } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { bootstrapSession } from '../store/authSlice';
|
import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice';
|
||||||
|
|
||||||
|
let sessionBootstrapped = false;
|
||||||
|
|
||||||
class AuthBootstrap extends Component {
|
class AuthBootstrap extends Component {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
if (sessionBootstrapped) return;
|
||||||
|
sessionBootstrapped = true;
|
||||||
|
|
||||||
this.props
|
this.props
|
||||||
.bootstrapSession()
|
.bootstrapSession()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -24,7 +29,7 @@ class AuthBootstrap extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
bootstrapSession: () => dispatch(bootstrapSession()),
|
bootstrapSession: () => dispatch(bootstrapSessionThunk()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(null, mapDispatchToProps)(AuthBootstrap);
|
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 ToggleButton from '@mui/material/ToggleButton';
|
||||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { company } from '../api';
|
import BackendError from './BackendError';
|
||||||
import { selectAuthUser } from '../store/authSlice';
|
import { selectAuthUser } from '../store/authSlice';
|
||||||
import {
|
import {
|
||||||
ISO639_OPTIONS,
|
loadCurrentCompany,
|
||||||
companyLanguageToIso639,
|
selectCompanyStatus,
|
||||||
parseTaxRate,
|
selectCurrentCompany,
|
||||||
} from '../utils/companyForm';
|
updateCompany as updateCompanyThunk,
|
||||||
|
} from '../store/companySlice';
|
||||||
|
import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
|
||||||
|
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
||||||
|
|
||||||
class CompanySettingsDialog extends Component {
|
class CompanySettingsDialog extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
loading: false,
|
|
||||||
busy: false,
|
busy: false,
|
||||||
error: null,
|
error: null,
|
||||||
name: '',
|
name: '',
|
||||||
@@ -37,39 +39,37 @@ class CompanySettingsDialog extends Component {
|
|||||||
reducedtaxrate: '',
|
reducedtaxrate: '',
|
||||||
};
|
};
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
this.syncFormFromCompany = this.syncFormFromCompany.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
const { open, user } = this.props;
|
const { open, company, companyStatus } = this.props;
|
||||||
const companyId = user?.selectedCompanyId;
|
if (open && !prevProps.open) {
|
||||||
if (open && !prevProps.open && companyId) {
|
this.setState({ error: null });
|
||||||
void this.loadCompany(companyId);
|
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) {
|
syncFormFromCompany(company) {
|
||||||
this.setState({ loading: true, error: null });
|
this.setState({
|
||||||
try {
|
name: company.name ?? '',
|
||||||
const data = await company.getById(companyId);
|
iso639: company.iso639 ?? 'de',
|
||||||
const item = data.company;
|
taxrate: company.taxrate != null ? String(company.taxrate) : '',
|
||||||
this.setState({
|
reducedtaxrate: company.reducedtaxrate != null ? String(company.reducedtaxrate) : '',
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSubmit(event) {
|
async handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const { user, onClose } = this.props;
|
const { onClose } = this.props;
|
||||||
const { name, iso639, taxrate, reducedtaxrate } = this.state;
|
const { name, iso639, taxrate, reducedtaxrate } = this.state;
|
||||||
const companyId = user?.selectedCompanyId;
|
|
||||||
const parsedTaxrate = parseTaxRate(taxrate);
|
const parsedTaxrate = parseTaxRate(taxrate);
|
||||||
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
|
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
|
||||||
const canSave =
|
const canSave =
|
||||||
@@ -78,31 +78,34 @@ class CompanySettingsDialog extends Component {
|
|||||||
parsedTaxrate != null &&
|
parsedTaxrate != null &&
|
||||||
parsedReducedtaxrate != null;
|
parsedReducedtaxrate != null;
|
||||||
|
|
||||||
if (!companyId || !canSave) {
|
if (!canSave) {
|
||||||
this.setState({ error: 'Bitte alle Pflichtfelder ausfüllen.' });
|
this.setState({ error: 'Bitte alle Pflichtfelder ausfüllen.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ busy: true, error: null });
|
this.setState({ busy: true, error: null });
|
||||||
try {
|
try {
|
||||||
await company.update(companyId, {
|
await this.props
|
||||||
name: name.trim(),
|
.updateCompany({
|
||||||
iso639,
|
name: name.trim(),
|
||||||
taxrate: parsedTaxrate,
|
iso639,
|
||||||
reducedtaxrate: parsedReducedtaxrate,
|
taxrate: parsedTaxrate,
|
||||||
});
|
reducedtaxrate: parsedReducedtaxrate,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' });
|
enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' });
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: err.message });
|
this.setState({ error: backendErrorFrom(err) });
|
||||||
} finally {
|
} finally {
|
||||||
this.setState({ busy: false });
|
this.setState({ busy: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { open, onClose } = this.props;
|
const { open, onClose, companyStatus } = this.props;
|
||||||
const { loading, busy, error, name, iso639, taxrate, reducedtaxrate } = this.state;
|
const { busy, error, name, iso639, taxrate, reducedtaxrate } = this.state;
|
||||||
|
const loading = companyStatus === 'loading' && !busy;
|
||||||
const parsedTaxrate = parseTaxRate(taxrate);
|
const parsedTaxrate = parseTaxRate(taxrate);
|
||||||
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
|
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
|
||||||
const canSave =
|
const canSave =
|
||||||
@@ -117,9 +120,13 @@ class CompanySettingsDialog extends Component {
|
|||||||
<Box component="form" onSubmit={this.handleSubmit} noValidate>
|
<Box component="form" onSubmit={this.handleSubmit} noValidate>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
isBackendFailure(error) ? (
|
||||||
{error}
|
<BackendError error={error} sx={{ mb: 2 }} />
|
||||||
</Alert>
|
) : (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{typeof error === 'string' ? error : error.message}
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -212,6 +219,13 @@ class CompanySettingsDialog extends Component {
|
|||||||
|
|
||||||
const mapStateToProps = (state) => ({
|
const mapStateToProps = (state) => ({
|
||||||
user: selectAuthUser(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 ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import BusinessIcon from '@mui/icons-material/Business';
|
import BusinessIcon from '@mui/icons-material/Business';
|
||||||
|
import BackendError from './BackendError';
|
||||||
import { company } from '../api';
|
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 {
|
import {
|
||||||
defaultIso639,
|
defaultIso639,
|
||||||
ISO639_OPTIONS,
|
ISO639_OPTIONS,
|
||||||
parseTaxRate,
|
parseTaxRate,
|
||||||
} from '../utils/companyForm';
|
} from '../utils/companyForm';
|
||||||
|
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
||||||
|
|
||||||
class CompanySetupDialog extends Component {
|
class CompanySetupDialog extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -44,6 +50,7 @@ class CompanySetupDialog extends Component {
|
|||||||
this.handleSelect = this.handleSelect.bind(this);
|
this.handleSelect = this.handleSelect.bind(this);
|
||||||
this.handleCreate = this.handleCreate.bind(this);
|
this.handleCreate = this.handleCreate.bind(this);
|
||||||
this.handleLogout = this.handleLogout.bind(this);
|
this.handleLogout = this.handleLogout.bind(this);
|
||||||
|
this.handleClose = this.handleClose.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -67,7 +74,7 @@ class CompanySetupDialog extends Component {
|
|||||||
try {
|
try {
|
||||||
await this.props.selectCompany(companyId).unwrap();
|
await this.props.selectCompany(companyId).unwrap();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: err.message });
|
this.setState({ error: backendErrorFrom(err) });
|
||||||
} finally {
|
} finally {
|
||||||
this.setState({ busy: false });
|
this.setState({ busy: false });
|
||||||
}
|
}
|
||||||
@@ -100,7 +107,7 @@ class CompanySetupDialog extends Component {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: err.message });
|
this.setState({ error: backendErrorFrom(err) });
|
||||||
} finally {
|
} finally {
|
||||||
this.setState({ busy: false });
|
this.setState({ busy: false });
|
||||||
}
|
}
|
||||||
@@ -110,6 +117,10 @@ class CompanySetupDialog extends Component {
|
|||||||
void this.props.logout();
|
void this.props.logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleClose(_event, reason) {
|
||||||
|
if (reason === 'backdropClick' || reason === 'escapeKeyDown') return;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { companies, loading, busy, error, name, iso639, taxrate, reducedtaxrate } =
|
const { companies, loading, busy, error, name, iso639, taxrate, reducedtaxrate } =
|
||||||
this.state;
|
this.state;
|
||||||
@@ -123,7 +134,7 @@ class CompanySetupDialog extends Component {
|
|||||||
parsedReducedtaxrate != null;
|
parsedReducedtaxrate != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open disableEscapeKeyDown maxWidth="sm" fullWidth>
|
<Dialog open onClose={this.handleClose} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{loading
|
{loading
|
||||||
? 'Unternehmen'
|
? 'Unternehmen'
|
||||||
@@ -141,9 +152,13 @@ class CompanySetupDialog extends Component {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
isBackendFailure(error) ? (
|
||||||
{error}
|
<BackendError error={error} sx={{ mb: 2 }} />
|
||||||
</Alert>
|
) : (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{typeof error === 'string' ? error : error.message}
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -268,9 +283,9 @@ class CompanySetupDialog extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
createCompany: (payload) => dispatch(createCompany(payload)),
|
createCompany: (payload) => dispatch(createCompanyThunk(payload)),
|
||||||
selectCompany: (companyId) => dispatch(selectCompany(companyId)),
|
selectCompany: (companyId) => dispatch(selectCompanyThunk(companyId)),
|
||||||
logout: () => dispatch(logout()),
|
logout: () => dispatch(logoutThunk()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(null, mapDispatchToProps)(CompanySetupDialog);
|
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 { ThemeProvider } from '@mui/material/styles';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { SnackbarProvider } from 'notistack';
|
import { SnackbarProvider } from 'notistack';
|
||||||
import { registerSW } from 'virtual:pwa-register';
|
|
||||||
import AuthBootstrap from './components/AuthBootstrap';
|
import AuthBootstrap from './components/AuthBootstrap';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { store } from './store';
|
import { store } from './store';
|
||||||
import theme from './theme';
|
import theme from './theme';
|
||||||
import './index.css';
|
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(
|
createRoot(document.getElementById('root')).render(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import Alert from '@mui/material/Alert';
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
@@ -11,6 +10,8 @@ import Typography from '@mui/material/Typography';
|
|||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
import { article } from '../../api';
|
import { article } from '../../api';
|
||||||
|
import BackendError from '../../components/BackendError';
|
||||||
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { formatPrice } from '../../utils/price';
|
import { formatPrice } from '../../utils/price';
|
||||||
import {
|
import {
|
||||||
buildSearchUrl,
|
buildSearchUrl,
|
||||||
@@ -273,7 +274,7 @@ class ArtikelSearchPage extends Component {
|
|||||||
categoryFacets: null,
|
categoryFacets: null,
|
||||||
totalCount: null,
|
totalCount: null,
|
||||||
recordset: null,
|
recordset: null,
|
||||||
error: err.message || 'Suche fehlgeschlagen',
|
error: backendErrorFrom(err),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -344,9 +345,11 @@ class ArtikelSearchPage extends Component {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
<BackendError
|
||||||
{error}
|
error={error}
|
||||||
</Alert>
|
sx={{ mb: 2 }}
|
||||||
|
onRetry={this.fetchResults}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{searchActive && (
|
{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 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() {
|
const gridSx = {
|
||||||
return (
|
display: 'grid',
|
||||||
<Typography color="text.secondary">Kategorien — Inhalt folgt.</Typography>
|
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 { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
|
||||||
import Alert from '@mui/material/Alert';
|
import Alert from '@mui/material/Alert';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
@@ -13,6 +13,8 @@ import TableHead from '@mui/material/TableHead';
|
|||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import { article } from '../../api';
|
import { article } from '../../api';
|
||||||
|
import BackendError from '../../components/BackendError';
|
||||||
|
import { backendErrorFrom, isBackendFailure } from '../../utils/backendError';
|
||||||
import { formatPrice, getBrutto } from '../../utils/price';
|
import { formatPrice, getBrutto } from '../../utils/price';
|
||||||
import { formatStock } from './searchFormat';
|
import { formatStock } from './searchFormat';
|
||||||
|
|
||||||
@@ -272,6 +274,11 @@ export default function ArtikelSkuPage() {
|
|||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
setReloadKey((key) => key + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(e) {
|
function onKeyDown(e) {
|
||||||
@@ -306,7 +313,7 @@ export default function ArtikelSkuPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setData(null);
|
setData(null);
|
||||||
setError(err.message || 'Artikel konnte nicht geladen werden');
|
setError(backendErrorFrom(err));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@@ -317,7 +324,7 @@ export default function ArtikelSkuPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [target]);
|
}, [target, reloadKey]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -328,9 +335,17 @@ export default function ArtikelSkuPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
if (isBackendFailure(error)) {
|
||||||
|
return (
|
||||||
|
<BackendError
|
||||||
|
error={error}
|
||||||
|
onRetry={retry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Alert severity="error" sx={{ maxWidth: 640 }}>
|
<Alert severity="error" sx={{ maxWidth: 640 }}>
|
||||||
{error}
|
{typeof error === 'string' ? error : error.message}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,17 @@ export const logout = createAsyncThunk('auth/logout', async () => {
|
|||||||
window.history.replaceState(null, '', '/');
|
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 = {
|
const initialState = {
|
||||||
user: null,
|
user: null,
|
||||||
googleClientId: null,
|
googleClientId: null,
|
||||||
status: 'loading',
|
status: 'loading',
|
||||||
companyReady: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const authSlice = createSlice({
|
const authSlice = createSlice({
|
||||||
@@ -46,40 +52,47 @@ const authSlice = createSlice({
|
|||||||
builder
|
builder
|
||||||
.addCase(bootstrapSession.fulfilled, (state, action) => {
|
.addCase(bootstrapSession.fulfilled, (state, action) => {
|
||||||
const session = action.payload;
|
const session = action.payload;
|
||||||
state.user = session.user ?? null;
|
state.googleClientId = session.googleClientId ?? state.googleClientId;
|
||||||
state.googleClientId = session.googleClientId ?? null;
|
if (!session.user) {
|
||||||
state.status = session.user ? 'authed' : 'anon';
|
if (state.status !== 'authed') {
|
||||||
state.companyReady = session.user?.selectedCompanyId != null;
|
state.user = null;
|
||||||
|
state.status = 'anon';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.user = mergeUser(state, session.user);
|
||||||
|
state.status = 'authed';
|
||||||
})
|
})
|
||||||
.addCase(bootstrapSession.rejected, (state) => {
|
.addCase(bootstrapSession.rejected, (state) => {
|
||||||
state.user = null;
|
if (state.status === 'loading') {
|
||||||
state.status = 'anon';
|
state.user = null;
|
||||||
state.companyReady = false;
|
state.status = 'anon';
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.addCase(loginWithGoogle.fulfilled, (state, action) => {
|
.addCase(loginWithGoogle.fulfilled, (state, action) => {
|
||||||
const session = action.payload;
|
const session = action.payload;
|
||||||
state.user = session.user;
|
state.user = session.user;
|
||||||
state.googleClientId = session.googleClientId ?? state.googleClientId;
|
state.googleClientId = session.googleClientId ?? state.googleClientId;
|
||||||
state.status = 'authed';
|
state.status = 'authed';
|
||||||
state.companyReady = session.user?.selectedCompanyId != null;
|
|
||||||
})
|
})
|
||||||
.addCase(loginWithGoogle.rejected, (state) => {
|
.addCase(loginWithGoogle.rejected, (state) => {
|
||||||
state.user = null;
|
state.user = null;
|
||||||
state.status = 'anon';
|
state.status = 'anon';
|
||||||
state.companyReady = false;
|
|
||||||
})
|
})
|
||||||
.addCase(selectCompany.fulfilled, (state, action) => {
|
.addCase(selectCompany.fulfilled, (state, action) => {
|
||||||
state.user = { ...state.user, ...action.payload };
|
state.user = mergeUser(state, action.payload);
|
||||||
state.companyReady = true;
|
state.status = 'authed';
|
||||||
})
|
})
|
||||||
.addCase(createCompany.fulfilled, (state, action) => {
|
.addCase(createCompany.fulfilled, (state, action) => {
|
||||||
state.user = { ...state.user, ...action.payload };
|
state.user = mergeUser(state, action.payload);
|
||||||
state.companyReady = true;
|
state.status = 'authed';
|
||||||
|
})
|
||||||
|
.addCase(createCompany.rejected, (state, action) => {
|
||||||
|
console.error('[redux] createCompany.rejected', action.error?.message, action.payload);
|
||||||
})
|
})
|
||||||
.addCase(logout.fulfilled, (state) => {
|
.addCase(logout.fulfilled, (state) => {
|
||||||
state.user = null;
|
state.user = null;
|
||||||
state.status = 'anon';
|
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 selectAuthUser = (state) => state.auth.user;
|
||||||
export const selectGoogleClientId = (state) => state.auth.googleClientId;
|
export const selectGoogleClientId = (state) => state.auth.googleClientId;
|
||||||
export const selectNeedsCompanySetup = (state) =>
|
export const selectNeedsCompanySetup = (state) =>
|
||||||
state.auth.status === 'authed' && !state.auth.companyReady;
|
state.auth.status === 'authed' && state.auth.user?.selectedCompanyId == null;
|
||||||
|
|
||||||
export default authSlice.reducer;
|
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 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({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
auth: authReducer,
|
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';
|
if (company?.languageId === 2) return 'en';
|
||||||
return 'de';
|
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: {
|
build: {
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
output: {
|
output: {
|
||||||
@@ -117,42 +117,48 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
...(command === 'build'
|
||||||
registerType: 'autoUpdate',
|
? [
|
||||||
manifest: {
|
VitePWA({
|
||||||
id: '/',
|
registerType: 'autoUpdate',
|
||||||
name: 'Dashfront',
|
injectRegister: 'auto',
|
||||||
short_name: 'Dashfront',
|
manifest: {
|
||||||
start_url: '/',
|
id: '/',
|
||||||
scope: '/',
|
name: 'Dashfront',
|
||||||
display: 'standalone',
|
short_name: 'Dashfront',
|
||||||
background_color: '#dceeea',
|
start_url: '/',
|
||||||
theme_color: '#3b5bdb',
|
scope: '/',
|
||||||
icons: [
|
display: 'standalone',
|
||||||
{
|
background_color: '#dceeea',
|
||||||
src: '/icons/icon-192.png',
|
theme_color: '#3b5bdb',
|
||||||
sizes: '192x192',
|
icons: [
|
||||||
type: 'image/png',
|
{
|
||||||
},
|
src: '/icons/icon-192.png',
|
||||||
{
|
sizes: '192x192',
|
||||||
src: '/icons/icon-512.png',
|
type: 'image/png',
|
||||||
sizes: '512x512',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
},
|
src: '/icons/icon-512.png',
|
||||||
{
|
sizes: '512x512',
|
||||||
src: '/icons/icon-512.png',
|
type: 'image/png',
|
||||||
sizes: '512x512',
|
},
|
||||||
type: 'image/png',
|
{
|
||||||
purpose: 'maskable',
|
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',
|
},
|
||||||
},
|
workbox: {
|
||||||
}),
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'],
|
||||||
|
navigateFallback: 'index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/api/],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
server: sharedServer,
|
server: sharedServer,
|
||||||
preview: sharedServer,
|
preview: sharedServer,
|
||||||
});
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user