Files
dashfront/src/components/article/ArticleUnitEditorDialog.jsx
seb 80f093649a Add article pack units with Redux-cached company unit list.
Edit amount/unit on the price tile (pc×1 = no pack unit), manage units in a dialog, and keep the default pc unit non-deletable.
2026-08-01 06:37:47 +02:00

318 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Component } from 'react';
import { connect } from 'react-redux';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { notifyError } from '../../utils/notify';
import { parsePriceInput } from '../../utils/price';
import { COMMON_UNITS, DEFAULT_UNIT_SI, unitLabel } from '../../utils/units';
import {
deleteUnit,
loadUnits,
selectUnits,
selectUnitsStatus,
upsertUnit,
} from '../../store/unitSlice';
function emptyForm() {
return {
si: '',
basesi: '',
basesifactor: '1',
symbol: '',
name: '',
};
}
function formFromUnit(row) {
// API may return null symbol/name (e.g. default pc).
return {
si: row.si ?? '',
basesi: row.basesi ?? '',
basesifactor: String(row.basesifactor ?? '1'),
symbol: row.symbol ?? '',
name: row.name ?? '',
};
}
function parseFactor(value) {
return parsePriceInput(value);
}
function formValid(form) {
const si = String(form.si ?? '').trim();
const basesi = String(form.basesi ?? '').trim();
const symbol = String(form.symbol ?? '').trim();
const name = String(form.name ?? '').trim();
const factor = parseFactor(form.basesifactor);
return (
si.length > 0 &&
basesi.length > 0 &&
symbol.length > 0 &&
name.length > 0 &&
factor != null &&
factor > 0
);
}
class ArticleUnitEditorDialog extends Component {
constructor(props) {
super(props);
this.state = {
busy: false,
form: emptyForm(),
selectedSi: null,
};
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
this.setState({ form: emptyForm(), selectedSi: null });
if (this.props.status !== 'succeeded') {
void this.props.loadUnits().catch((err) => notifyError(err));
}
}
}
handleSelectUnit = (row) => {
this.setState({ form: formFromUnit(row), selectedSi: row.si });
};
handlePreset = (preset) => {
this.setState({ form: formFromUnit(preset), selectedSi: preset.si });
};
handleField = (field, value) => {
this.setState((state) => {
const form = { ...state.form, [field]: value };
if (field === 'si' && (!state.form.basesi || state.form.basesi === state.form.si)) {
form.basesi = value;
}
return { form };
});
};
handleSave = async () => {
const { form } = this.state;
if (!formValid(form)) return;
this.setState({ busy: true });
try {
const unit = await this.props.upsertUnit({
si: String(form.si ?? '').trim(),
basesi: String(form.basesi ?? '').trim(),
basesifactor: parseFactor(form.basesifactor),
symbol: String(form.symbol ?? '').trim(),
name: String(form.name ?? '').trim(),
}).unwrap();
this.setState({
busy: false,
form: formFromUnit(unit),
selectedSi: unit.si,
});
this.props.onUnitUpserted?.(unit);
} catch (err) {
this.setState({ busy: false });
notifyError(err);
}
};
handleDelete = async () => {
const { selectedSi } = this.state;
if (!selectedSi || selectedSi === DEFAULT_UNIT_SI) return;
this.setState({ busy: true });
try {
await this.props.deleteUnit(selectedSi).unwrap();
this.setState({ busy: false, form: emptyForm(), selectedSi: null });
this.props.onUnitDeleted?.(selectedSi);
} catch (err) {
this.setState({ busy: false });
notifyError(err);
}
};
handleNew = () => {
this.setState({ form: emptyForm(), selectedSi: null });
};
render() {
const { open, onClose, units, status } = this.props;
const { busy, form, selectedSi } = this.state;
const canSave = formValid(form) && !busy;
const loading = status === 'loading' && units.length === 0;
return (
<Dialog open={open} onClose={busy ? undefined : onClose} maxWidth="md" fullWidth>
<DialogTitle>Einheiten</DialogTitle>
<DialogContent dividers>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Typische Einheiten
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 2 }}>
{COMMON_UNITS.map((preset) => (
<Chip
key={preset.si}
size="small"
label={`${preset.symbol} (${preset.si})`}
onClick={() => this.handlePreset(preset)}
disabled={busy}
/>
))}
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1.2fr' },
gap: 2,
minHeight: 280,
}}
>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Firma
</Typography>
{loading ? (
<CircularProgress size={28} sx={{ mt: 2 }} />
) : (
<List dense disablePadding sx={{ maxHeight: 320, overflow: 'auto' }}>
{units.map((row) => (
<ListItem
key={row.si}
disablePadding
secondaryAction={
selectedSi === row.si && row.si !== DEFAULT_UNIT_SI ? (
<IconButton
edge="end"
aria-label="Löschen"
disabled={busy}
onClick={() => void this.handleDelete()}
>
<DeleteOutlineOutlinedIcon />
</IconButton>
) : null
}
>
<ListItemButton
selected={selectedSi === row.si}
onClick={() => this.handleSelectUnit(row)}
>
<ListItemText
primary={unitLabel(row)}
secondary={`si ${row.si} · ${row.basesifactor} × ${row.basesi}`}
/>
</ListItemButton>
</ListItem>
))}
{units.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 2 }}>
Noch keine Einheiten.
</Typography>
) : null}
</List>
)}
<Button type="button" onClick={this.handleNew} disabled={busy} sx={{ mt: 1 }}>
Neu
</Button>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField
label="Name"
value={form.name}
onChange={(e) => this.handleField('name', e.target.value)}
size="small"
fullWidth
disabled={busy}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="Symbol"
value={form.symbol}
onChange={(e) => this.handleField('symbol', e.target.value)}
size="small"
fullWidth
disabled={busy}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="si"
value={form.si}
onChange={(e) => this.handleField('si', e.target.value)}
size="small"
fullWidth
disabled={busy}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="basesi"
value={form.basesi}
onChange={(e) => this.handleField('basesi', e.target.value)}
size="small"
fullWidth
disabled={busy}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="basesifactor"
value={form.basesifactor}
onChange={(e) => this.handleField('basesifactor', e.target.value)}
size="small"
fullWidth
disabled={busy}
error={
form.basesifactor !== '' &&
(parseFactor(form.basesifactor) == null ||
parseFactor(form.basesifactor) <= 0)
}
slotProps={{
inputLabel: { shrink: true },
htmlInput: { inputMode: 'decimal' },
}}
/>
<Button
type="button"
variant="contained"
onClick={() => void this.handleSave()}
disabled={!canSave}
>
{busy ? <CircularProgress size={22} color="inherit" /> : 'Speichern'}
</Button>
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button type="button" onClick={onClose} disabled={busy}>
Schließen
</Button>
</DialogActions>
</Dialog>
);
}
}
const mapStateToProps = (state) => ({
units: selectUnits(state),
status: selectUnitsStatus(state),
});
const mapDispatchToProps = (dispatch) => ({
loadUnits: () => dispatch(loadUnits()),
upsertUnit: (body) => dispatch(upsertUnit(body)),
deleteUnit: (si) => dispatch(deleteUnit(si)),
});
export default connect(mapStateToProps, mapDispatchToProps)(ArticleUnitEditorDialog);