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:
317
src/components/article/ArticleUnitEditorDialog.jsx
Normal file
317
src/components/article/ArticleUnitEditorDialog.jsx
Normal 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);
|
||||
Reference in New Issue
Block a user