317 lines
10 KiB
JavaScript
317 lines
10 KiB
JavaScript
import { Component } from 'react';
|
||
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 { StorageManagerContext } from '../../api/storageManagerContext';
|
||
import { requireObject } from '../../utils/apiShape';
|
||
import { notifyError } from '../../utils/notify';
|
||
import { parsePriceInput } from '../../utils/price';
|
||
import { COMMON_UNITS, DEFAULT_UNIT_SI, unitLabel } from '../../utils/units';
|
||
|
||
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
|
||
);
|
||
}
|
||
|
||
export default class ArticleUnitEditorDialog extends Component {
|
||
static contextType = StorageManagerContext;
|
||
|
||
constructor(props) {
|
||
super(props);
|
||
this.state = {
|
||
busy: false,
|
||
units: [],
|
||
unitsLoading: false,
|
||
form: emptyForm(),
|
||
selectedSi: null,
|
||
};
|
||
}
|
||
|
||
componentDidUpdate(prevProps) {
|
||
if (this.props.open && !prevProps.open) {
|
||
this.setState({ form: emptyForm(), selectedSi: null });
|
||
void this.loadUnits();
|
||
}
|
||
}
|
||
|
||
async loadUnits() {
|
||
this.setState({ unitsLoading: true });
|
||
try {
|
||
const units = await this.context.unitPromise;
|
||
this.setState({ units, unitsLoading: false });
|
||
} catch (err) {
|
||
this.setState({ unitsLoading: false });
|
||
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 data = await this.context.unit.upsert({
|
||
si: String(form.si ?? '').trim(),
|
||
basesi: String(form.basesi ?? '').trim(),
|
||
basesifactor: parseFactor(form.basesifactor),
|
||
symbol: String(form.symbol ?? '').trim(),
|
||
name: String(form.name ?? '').trim(),
|
||
});
|
||
requireObject(data, 'PUT /api/unit');
|
||
requireObject(data.unit, 'unit');
|
||
const units = await this.context.updateUnit();
|
||
this.setState({
|
||
busy: false,
|
||
units,
|
||
form: formFromUnit(data.unit),
|
||
selectedSi: data.unit.si,
|
||
});
|
||
this.props.onUnitUpserted?.(data.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.context.unit.delete(selectedSi);
|
||
const units = await this.context.updateUnit();
|
||
this.setState({ busy: false, units, 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 } = this.props;
|
||
const { busy, form, selectedSi, units, unitsLoading } = this.state;
|
||
const canSave = formValid(form) && !busy;
|
||
const loading = unitsLoading && 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>
|
||
);
|
||
}
|
||
}
|