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.
This commit is contained in:
seb
2026-08-01 06:37:47 +02:00
parent 1b8652ceaf
commit 80f093649a
7 changed files with 841 additions and 25 deletions

View File

@@ -0,0 +1,317 @@
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);