316 lines
10 KiB
JavaScript
316 lines
10 KiB
JavaScript
import { PureComponent } 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 TextInput from '../TextInput';
|
|
import Typography from '@mui/material/Typography';
|
|
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
|
import { enqueueSnackbar } from 'notistack';
|
|
import BackendError from '../BackendError';
|
|
import { number as numberApi } from '../../api';
|
|
import companyAutomat from '../../automats/companyAutomat';
|
|
import { 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;
|
|
}
|
|
|
|
function buildDrafts(numbers, prevDrafts = {}) {
|
|
return Object.fromEntries(
|
|
(Array.isArray(numbers) ? numbers : []).map((row) => {
|
|
const existing = prevDrafts[row.id];
|
|
return [
|
|
row.id,
|
|
existing?.busy
|
|
? existing
|
|
: {
|
|
prefix: row.prefix ?? '',
|
|
padding: row.padding != null ? String(row.padding) : '',
|
|
next: row.next != null ? String(row.next) : '',
|
|
busy: false,
|
|
saveError: null,
|
|
},
|
|
];
|
|
}),
|
|
);
|
|
}
|
|
|
|
export default class NumberSettingsPanel extends PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
const numbers = Array.isArray(companyAutomat.state.numbers)
|
|
? companyAutomat.state.numbers
|
|
: [];
|
|
const numbersStatus = companyAutomat.state.numbersStatus;
|
|
this.state = {
|
|
isAdmin: companyAutomat.state.current?.role === 'admin',
|
|
status:
|
|
numbersStatus === 'ready'
|
|
? 'ready'
|
|
: numbersStatus === 'failed'
|
|
? 'failed'
|
|
: 'loading',
|
|
error: companyAutomat.state.numbersError
|
|
? backendErrorFrom(companyAutomat.state.numbersError)
|
|
: null,
|
|
numbers,
|
|
// id → { prefix, padding, next, busy, saveError }
|
|
drafts: buildDrafts(numbers),
|
|
};
|
|
this.handleRetry = this.handleRetry.bind(this);
|
|
this.handleSave = this.handleSave.bind(this);
|
|
}
|
|
|
|
componentDidMount() {
|
|
this._unmounted = false;
|
|
this.unsub = companyAutomat.subscribe(this, (state) => {
|
|
const numbers = Array.isArray(state.numbers) ? state.numbers : [];
|
|
return {
|
|
isAdmin: state.current?.role === 'admin',
|
|
status:
|
|
state.numbersStatus === 'ready'
|
|
? 'ready'
|
|
: state.numbersStatus === 'failed'
|
|
? 'failed'
|
|
: 'loading',
|
|
error: state.numbersError ? backendErrorFrom(state.numbersError) : null,
|
|
numbers,
|
|
drafts:
|
|
numbers !== this.state.numbers
|
|
? buildDrafts(numbers, this.state.drafts)
|
|
: this.state.drafts,
|
|
};
|
|
});
|
|
}
|
|
|
|
async load() {
|
|
this.setState({ status: 'loading', error: null });
|
|
try {
|
|
await companyAutomat.actions.reloadNumbers();
|
|
} 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) {
|
|
if (!this.state.isAdmin) return;
|
|
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 numberApi.update(id, body);
|
|
requireObject(data, 'PATCH /api/number');
|
|
requireObject(data.number, 'number');
|
|
const updated = requireDefined(data.number, 'number');
|
|
|
|
const numbers = companyAutomat.actions.replaceNumbers(
|
|
this.state.numbers.map((n) => (n.id === id ? { ...n, ...updated } : n)),
|
|
);
|
|
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;
|
|
this.unsub?.();
|
|
}
|
|
|
|
render() {
|
|
const { isAdmin, 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} />;
|
|
}
|
|
|
|
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>
|
|
<TextInput
|
|
size="small"
|
|
value={draft.prefix}
|
|
disabled={!isAdmin || draft.busy}
|
|
onChange={(e) => this.handleChange(row.id, 'prefix', e.target.value)}
|
|
sx={{ input: { fontFamily: 'monospace' } }}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<TextInput
|
|
size="small"
|
|
type="number"
|
|
value={draft.padding}
|
|
disabled={!isAdmin || draft.busy}
|
|
slotProps={{ htmlInput: { min: 1, step: 1 } }}
|
|
onChange={(e) => this.handleChange(row.id, 'padding', e.target.value)}
|
|
sx={{ input: { fontFamily: 'monospace' } }}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<TextInput
|
|
size="small"
|
|
type="number"
|
|
value={draft.next}
|
|
disabled={!isAdmin || draft.busy}
|
|
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
|
onChange={(e) => this.handleChange(row.id, 'next', e.target.value)}
|
|
sx={{ input: { fontFamily: 'monospace' } }}
|
|
/>
|
|
</TableCell>
|
|
<TableCell align="right">
|
|
<Stack
|
|
direction="row"
|
|
spacing={0}
|
|
sx={{ 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={!isAdmin || draft.busy}
|
|
onClick={() => void this.handleSave(row.id)}
|
|
>
|
|
<SaveOutlinedIcon fontSize="small" />
|
|
</IconButton>
|
|
</Stack>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
);
|
|
}
|
|
} |