Add Kundennummer/Nummernkreise settings and settings dialog.
Add number & setting API endpoints, StorageManager caches, and a Settings dialog (tabs for settings keys and number ranges) reachable from the user menu. Show Kundennummer and creation date in the Kunde list, and refactor Neuer Kunde to show an auto-generated number with group select.
This commit is contained in:
286
src/components/settings/NumberSettingsPanel.jsx
Normal file
286
src/components/settings/NumberSettingsPanel.jsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { Component } from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../BackendError';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
|
||||
function parsePadding(value) {
|
||||
const n = Number(value);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
function parseNext(value) {
|
||||
const n = Number(value);
|
||||
return Number.isInteger(n) && n >= 0 ? n : null;
|
||||
}
|
||||
|
||||
export default class NumberSettingsPanel extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
status: 'loading',
|
||||
error: null,
|
||||
numbers: [],
|
||||
// id → { prefix, padding, next, busy, saveError }
|
||||
drafts: {},
|
||||
};
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const numbers = requireArray(
|
||||
await (this.context.numbers ?? this.context.numberPromise),
|
||||
'numbers',
|
||||
);
|
||||
const drafts = Object.fromEntries(
|
||||
numbers.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
prefix: row.prefix ?? '',
|
||||
padding: row.padding != null ? String(row.padding) : '',
|
||||
next: row.next != null ? String(row.next) : '',
|
||||
busy: false,
|
||||
saveError: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
this.setState({ status: 'ready', numbers, drafts });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
handleChange(id, field, value) {
|
||||
this.setState((prev) => ({
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[id]: { ...prev.drafts[id], [field]: value, saveError: null },
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async handleSave(id) {
|
||||
const draft = this.state.drafts[id];
|
||||
if (!draft || draft.busy) return;
|
||||
const row = this.state.numbers.find((r) => r.id === id);
|
||||
if (!row) return;
|
||||
|
||||
const body = {};
|
||||
let invalid = false;
|
||||
let errorMessage = null;
|
||||
|
||||
if (draft.prefix !== (row.prefix ?? '')) {
|
||||
body.prefix = draft.prefix;
|
||||
}
|
||||
if (draft.padding !== (row.padding != null ? String(row.padding) : '')) {
|
||||
const padding = parsePadding(draft.padding);
|
||||
if (padding == null) {
|
||||
invalid = true;
|
||||
errorMessage = 'Stellenzahl muss eine positive ganze Zahl sein.';
|
||||
} else {
|
||||
body.padding = padding;
|
||||
}
|
||||
}
|
||||
if (draft.next !== (row.next != null ? String(row.next) : '')) {
|
||||
const next = parseNext(draft.next);
|
||||
if (next == null) {
|
||||
invalid = true;
|
||||
errorMessage = 'Nächste Nummer muss eine nicht-negative ganze Zahl sein.';
|
||||
} else {
|
||||
body.next = next;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
return;
|
||||
}
|
||||
if (invalid) {
|
||||
this.setState((prev) => ({
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[id]: { ...prev.drafts[id], saveError: errorMessage },
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState((prev) => ({
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[id]: { ...prev.drafts[id], busy: true, saveError: null },
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const data = await this.context.number.update(id, body);
|
||||
requireObject(data, 'PATCH /api/number');
|
||||
requireObject(data.number, 'number');
|
||||
const updated = requireDefined(data.number, 'number');
|
||||
|
||||
const numbers = this.state.numbers.map((n) => (n.id === id ? { ...n, ...body } : n));
|
||||
await this.context.updateNumber();
|
||||
this.setState({
|
||||
numbers,
|
||||
drafts: {
|
||||
...this.state.drafts,
|
||||
[id]: {
|
||||
prefix: updated.prefix ?? '',
|
||||
padding: updated.padding != null ? String(updated.padding) : '',
|
||||
next: updated.next != null ? String(updated.next) : '',
|
||||
busy: false,
|
||||
saveError: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
enqueueSnackbar('Nummernkreis gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._unmounted) return;
|
||||
this.setState((prev) => ({
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[id]: { ...prev.drafts[id], busy: false, saveError: backendErrorFrom(err) },
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, error, numbers, drafts } = this.state;
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||
}
|
||||
if (numbers.length === 0) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Nummernkreise.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Typ</TableCell>
|
||||
<TableCell>Präfix</TableCell>
|
||||
<TableCell>Stellenzahl</TableCell>
|
||||
<TableCell>Nächste Nummer</TableCell>
|
||||
<TableCell align="right" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{numbers.map((row) => {
|
||||
const draft = drafts[row.id];
|
||||
if (!draft) return null;
|
||||
return (
|
||||
<TableRow key={row.id} hover>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{row.type}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
size="small"
|
||||
value={draft.prefix}
|
||||
disabled={draft.busy}
|
||||
onChange={(e) => this.handleChange(row.id, 'prefix', e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={draft.padding}
|
||||
disabled={draft.busy}
|
||||
slotProps={{ htmlInput: { min: 1, step: 1 } }}
|
||||
onChange={(e) => this.handleChange(row.id, 'padding', e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={draft.next}
|
||||
disabled={draft.busy}
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
onChange={(e) => this.handleChange(row.id, 'next', e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0}
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
>
|
||||
{draft.saveError ? (
|
||||
<Box sx={{ maxWidth: 220, mr: 1 }}>
|
||||
<Alert severity="error" sx={{ py: 0 }}>
|
||||
{typeof draft.saveError === 'string'
|
||||
? draft.saveError
|
||||
: draft.saveError.message}
|
||||
</Alert>
|
||||
</Box>
|
||||
) : null}
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Speichern"
|
||||
color="primary"
|
||||
disabled={draft.busy}
|
||||
onClick={() => void this.handleSave(row.id)}
|
||||
>
|
||||
<SaveOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user