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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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