This commit is contained in:
seb
2026-09-26 23:05:48 +02:00
parent 454c5ab6bf
commit 3ab9d904e6
12 changed files with 1505 additions and 65 deletions

View File

@@ -0,0 +1,516 @@
import { PureComponent } from 'react';
import Alert from '@mui/material/Alert';
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 FormControl from '@mui/material/FormControl';
import FormHelperText from '@mui/material/FormHelperText';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Typography from '@mui/material/Typography';
import CloseIcon from '@mui/icons-material/Close';
import WarehouseIcon from '@mui/icons-material/Warehouse';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
import TuneIcon from '@mui/icons-material/Tune';
import { enqueueSnackbar } from 'notistack';
import { stock } from '../../api';
import { formatStock } from '../../pages/artikel/searchFormat';
import { notifyError } from '../../utils/notify';
import artikelAutomat from '../../automats/artikelAutomat';
import warehouseAutomat from '../../automats/warehouseAutomat';
const REASON_PRESETS = [
'Inventurdifferenz',
'Korrektur',
'Bruch / Beschädigung',
'Schwund',
'Eigenbedarf / Test',
];
export default class ArticleStockAdjustDialog extends PureComponent {
constructor(props) {
super(props);
const initialWhId = this.resolveInitialWarehouseId(props);
this.state = {
mode: 'diff', // 'diff' | 'target'
warehouseId: initialWhId,
diffValue: '',
targetValue: '',
reason: '',
submitting: false,
error: null,
warehouses: warehouseAutomat.state.warehouses || [],
};
this.handleWarehouseChange = this.handleWarehouseChange.bind(this);
this.handleModeChange = this.handleModeChange.bind(this);
this.handleDiffChange = this.handleDiffChange.bind(this);
this.handleTargetChange = this.handleTargetChange.bind(this);
this.handleReasonChange = this.handleReasonChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
componentDidMount() {
this.unsubWarehouse = warehouseAutomat.subscribe((state) => {
const warehouses = state.warehouses || [];
this.setState((prev) => ({
warehouses,
warehouseId: prev.warehouseId || (warehouses.length > 0 ? warehouses[0].id : ''),
}));
});
void warehouseAutomat.actions.load().catch(() => {});
}
componentWillUnmount() {
this.unsubWarehouse?.();
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
void warehouseAutomat.actions.load().catch(() => {});
const initialWhId = this.resolveInitialWarehouseId(this.props);
this.setState({
mode: 'diff',
warehouseId: initialWhId,
diffValue: '',
targetValue: '',
reason: '',
submitting: false,
error: null,
warehouses: warehouseAutomat.state.warehouses || [],
});
}
}
resolveInitialWarehouseId(props) {
const articleWarehouses = Array.isArray(props.article?.warehouses)
? props.article.warehouses
: [];
if (articleWarehouses.length > 0) {
return articleWarehouses[0].id;
}
const allWarehouses = warehouseAutomat.state.warehouses || [];
return allWarehouses.length > 0 ? allWarehouses[0].id : '';
}
getCurrentStock(warehouseId) {
const { article } = this.props;
const wh = article?.warehouses?.find((w) => Number(w.id) === Number(warehouseId));
return wh?.stock != null ? Number(wh.stock) : 0;
}
handleWarehouseChange(event) {
const nextWhId = event.target.value ? Number(event.target.value) : '';
this.setState({ warehouseId: nextWhId });
}
handleModeChange(_event, nextMode) {
if (!nextMode) return;
this.setState({ mode: nextMode, error: null });
}
handleDiffChange(event) {
this.setState({ diffValue: event.target.value, error: null });
}
handleTargetChange(event) {
this.setState({ targetValue: event.target.value, error: null });
}
handleReasonChange(event) {
this.setState({ reason: event.target.value });
}
calculateAdjustment() {
const { mode, warehouseId, diffValue, targetValue } = this.state;
const currentStock = this.getCurrentStock(warehouseId);
if (mode === 'diff') {
const parsed = parseFloat(String(diffValue).replace(',', '.'));
if (!Number.isFinite(parsed) || parsed === 0) {
return { valid: false, amount: 0, newStock: currentStock };
}
return {
valid: true,
amount: parsed,
newStock: currentStock + parsed,
};
} else {
const parsed = parseFloat(String(targetValue).replace(',', '.'));
if (!Number.isFinite(parsed)) {
return { valid: false, amount: 0, newStock: currentStock };
}
const amount = parsed - currentStock;
if (amount === 0) {
return { valid: false, amount: 0, newStock: currentStock, isZeroChange: true };
}
return {
valid: true,
amount,
newStock: parsed,
};
}
}
async handleSubmit(event) {
event?.preventDefault();
const { article, onClose, onAdjustSuccess } = this.props;
const { warehouseId, reason, submitting } = this.state;
if (submitting) return;
if (!warehouseId) {
this.setState({ error: 'Bitte wählen Sie ein Lager aus.' });
return;
}
const { valid, amount } = this.calculateAdjustment();
if (!valid || amount === 0) {
this.setState({ error: 'Bitte geben Sie eine gültige Bestandsänderung ein (ungleich 0).' });
return;
}
this.setState({ submitting: true, error: null });
try {
const payload = {
sku: article.sku,
warehouseId: Number(warehouseId),
amount,
text: reason.trim() || undefined,
};
const result = await stock.adjust(payload);
enqueueSnackbar(
`Bestand für „${article.name || article.sku}“ erfolgreich angepasst (${amount > 0 ? `+${amount}` : amount}).`,
{ variant: 'success' }
);
// Reload article to update UI
void artikelAutomat.actions.reloadArticle(article.sku).catch(() => {});
if (onAdjustSuccess) {
onAdjustSuccess(result);
}
if (onClose) {
onClose();
}
} catch (err) {
console.error('[ArticleStockAdjustDialog] Adjust failed:', err);
notifyError(err);
this.setState({
submitting: false,
error: err.message || 'Fehler beim Anpassen des Bestands.',
});
}
}
render() {
const { open, onClose, article } = this.props;
const {
mode,
warehouseId,
diffValue,
targetValue,
reason,
submitting,
error,
warehouses,
} = this.state;
const currentStock = this.getCurrentStock(warehouseId);
const { valid, amount, newStock, isZeroChange } = this.calculateAdjustment();
const selectedWarehouse = warehouses.find((w) => Number(w.id) === Number(warehouseId));
const unit =
article?.unit?.symbol ||
article?.unit?.name ||
'';
return (
<Dialog
open={Boolean(open)}
onClose={submitting ? undefined : onClose}
maxWidth="sm"
fullWidth
aria-labelledby="stock-adjust-dialog-title"
>
<DialogTitle
id="stock-adjust-dialog-title"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pb: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TuneIcon color="primary" />
<Typography variant="h6" component="span" sx={{ fontWeight: 600 }}>
Bestand anpassen
</Typography>
</Box>
<IconButton
size="small"
onClick={onClose}
disabled={submitting}
aria-label="Schließen"
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ pt: 2 }}>
<Stack spacing={2.5}>
{/* Article overview */}
<Box
sx={{
p: 1.5,
borderRadius: 1,
backgroundColor: '#f8fafc',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="caption" color="text.secondary" display="block">
Artikel ({article?.sku})
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{article?.name || '–'}
</Typography>
</Box>
{warehouses.length === 0 ? (
<Alert severity="warning">
Es sind keine Lager vorhanden. Bitte legen Sie zuerst unter „Artikel → Lager“ ein Lager an.
</Alert>
) : (
<>
{/* Warehouse selection & Current stock display */}
<Box
sx={{
display: 'flex',
gap: 2,
alignItems: 'flex-start',
flexDirection: { xs: 'column', sm: 'row' },
}}
>
<FormControl size="small" required sx={{ flex: 1, minWidth: 200 }}>
<InputLabel id="adjust-warehouse-label">Lager *</InputLabel>
<Select
labelId="adjust-warehouse-label"
value={warehouseId ?? ''}
label="Lager *"
onChange={this.handleWarehouseChange}
disabled={submitting}
startAdornment={
<WarehouseIcon sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }} />
}
>
{warehouses.map((wh) => (
<MenuItem key={wh.id} value={wh.id}>
{wh.name}
</MenuItem>
))}
</Select>
<FormHelperText>Lagerort für die Korrektur</FormHelperText>
</FormControl>
<Box
sx={{
p: 1,
px: 2,
borderRadius: 1,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
minWidth: 140,
}}
>
<Typography variant="caption" color="text.secondary" display="block">
Aktueller Bestand
</Typography>
<Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}>
{formatStock(currentStock)} {unit && <span style={{ fontSize: '0.8rem', fontWeight: 500 }}>{unit}</span>}
</Typography>
</Box>
</Box>
{/* Adjustment Mode Toggle */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
Art der Anpassung
</Typography>
<ToggleButtonGroup
value={mode}
exclusive
onChange={this.handleModeChange}
size="small"
fullWidth
disabled={submitting}
>
<ToggleButton value="diff" sx={{ py: 0.75, fontWeight: 500 }}>
<TrendingUpIcon sx={{ fontSize: 18, mr: 0.75 }} />
Menge zu-/abbuchen (+ / -)
</ToggleButton>
<ToggleButton value="target" sx={{ py: 0.75, fontWeight: 500 }}>
<TuneIcon sx={{ fontSize: 18, mr: 0.75 }} />
Neuen Zählbestand setzen
</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Amount input */}
{mode === 'diff' ? (
<TextField
fullWidth
size="small"
label="Menge (+ für Zugang, - für Abgang)"
type="number"
value={diffValue}
onChange={this.handleDiffChange}
disabled={submitting}
placeholder="z.B. +5 oder -2"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
{Number(diffValue) < 0 ? (
<TrendingDownIcon color="error" fontSize="small" />
) : (
<TrendingUpIcon color="success" fontSize="small" />
)}
</InputAdornment>
),
},
}}
helperText={
valid
? `Neuer Bestand: ${formatStock(newStock)}${unit ? ` ${unit}` : ''}`
: 'Geben Sie einen positiven oder negativen Wert ein'
}
/>
) : (
<TextField
fullWidth
size="small"
label="Tatsächlicher Zählbestand (Ist-Bestand)"
type="number"
value={targetValue}
onChange={this.handleTargetChange}
disabled={submitting}
placeholder="z.B. 10"
helperText={
isZeroChange
? 'Der eingegebene Zählbestand entspricht dem aktuellen Bestand (keine Änderung).'
: valid
? `Berechnete Korrektur: ${amount > 0 ? `+${formatStock(amount)}` : formatStock(amount)}${unit ? ` ${unit}` : ''}`
: 'Geben Sie den neu gezählten Bestand ein'
}
/>
)}
{/* Summary calculation pill */}
{valid && amount !== 0 && (
<Box
sx={{
p: 1.5,
borderRadius: 1,
backgroundColor: amount > 0 ? '#ecfdf5' : '#fef2f2',
border: `1px solid ${amount > 0 ? '#a7f3d0' : '#fecaca'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Lager: <strong>{selectedWarehouse?.name || '–'}</strong>
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatStock(currentStock)} → {formatStock(newStock)}
</Typography>
</Box>
<Chip
size="small"
color={amount > 0 ? 'success' : 'error'}
label={`${amount > 0 ? `+${formatStock(amount)}` : formatStock(amount)}${unit ? ` ${unit}` : ''}`}
sx={{ fontWeight: 700 }}
/>
</Box>
)}
{/* Reason / Memo */}
<Box>
<TextField
fullWidth
size="small"
label="Grund / Buchungstext"
value={reason}
onChange={this.handleReasonChange}
disabled={submitting}
placeholder="z.B. Inventur, Bruch, Schwund"
helperText="Wird im Journal / Ledger als Transaktionstext gespeichert"
/>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1 }}>
{REASON_PRESETS.map((preset) => (
<Chip
key={preset}
label={preset}
size="small"
clickable
disabled={submitting}
onClick={() => this.setState({ reason: preset })}
sx={{ fontSize: '0.75rem' }}
/>
))}
</Box>
</Box>
</>
)}
{error && (
<Alert severity="error" sx={{ py: 0.5 }}>
{error}
</Alert>
)}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} disabled={submitting} color="inherit">
Abbrechen
</Button>
<Button
variant="contained"
color="primary"
onClick={this.handleSubmit}
disabled={submitting || !valid || amount === 0 || !warehouseId}
startIcon={
submitting ? (
<CircularProgress size={16} color="inherit" />
) : (
<TuneIcon />
)
}
sx={{ minWidth: 150, fontWeight: 600 }}
>
{submitting ? 'Wird gebucht...' : 'Bestand anpassen'}
</Button>
</DialogActions>
</Dialog>
);
}
}