Files
dashfront/src/components/article/ArticleStockAdjustDialog.jsx
2026-10-29 17:52:14 +01:00

587 lines
20 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 { 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 AppDialog from '../AppDialog';
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 TextInput from '../TextInput';
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 Switch from '@mui/material/Switch';
import { enqueueSnackbar } from 'notistack';
import { stock } from '../../api';
import { formatStock } from './searchFormat';
import { notifyError } from '../../utils/notify';
import artikelAutomat from '../../automats/artikelAutomat';
import companyAutomat from '../../automats/companyAutomat';
import {
BORDER,
GREEN_BG,
GREEN_PANEL,
RED_BG,
RED_PANEL,
SURFACE_BASE,
SURFACE_RAISED,
} from '../../colors';
export default class ArticleStockAdjustDialog extends PureComponent {
constructor(props) {
super(props);
const initialWhId = this.resolveInitialWarehouseId(props);
const isTrackStock =
props.article?.track_stock !== false && props.article?.track_stock !== 0;
this.state = {
mode: 'diff', // 'diff' | 'target'
warehouseId: initialWhId,
diffValue: '',
targetValue: '',
reason: '',
submitting: false,
error: null,
warehouses: companyAutomat.state.warehouses || [],
trackStock: isTrackStock,
initialTrackStock: isTrackStock,
};
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.handleTrackStockToggle = this.handleTrackStockToggle.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
componentDidMount() {
this.unsubWarehouse = companyAutomat.subscribe((state) => {
const warehouses = state.warehouses || [];
this.setState((prev) => ({
warehouses,
warehouseId: prev.warehouseId || (warehouses.length > 0 ? warehouses[0].id : ''),
}));
}, ['warehouses']);
}
componentWillUnmount() {
this.unsubWarehouse?.();
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
const initialWhId = this.resolveInitialWarehouseId(this.props);
const isTrackStock =
this.props.article?.track_stock !== false && this.props.article?.track_stock !== 0;
this.setState({
mode: 'diff',
warehouseId: initialWhId,
diffValue: '',
targetValue: '',
reason: '',
submitting: false,
error: null,
warehouses: companyAutomat.state.warehouses || [],
trackStock: isTrackStock,
initialTrackStock: isTrackStock,
});
}
}
resolveInitialWarehouseId(props) {
const articleWarehouses = Array.isArray(props.article?.warehouses)
? props.article.warehouses
: [];
if (articleWarehouses.length > 0) {
return articleWarehouses[0].id;
}
const allWarehouses = companyAutomat.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 });
}
handleTrackStockToggle(event) {
this.setState({ trackStock: event.target.checked, error: null });
}
calculateAdjustment() {
const { mode, warehouseId, diffValue, targetValue, trackStock } = this.state;
if (!trackStock) {
return { valid: false, amount: 0, newStock: 0 };
}
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, onArticleUpdated } = this.props;
const { warehouseId, reason, submitting, trackStock, initialTrackStock } = this.state;
if (submitting) return;
const trackStockChanged = trackStock !== initialTrackStock;
const { valid, amount } = this.calculateAdjustment();
const hasAdjustment = trackStock && valid && amount !== 0;
if (!trackStockChanged && !hasAdjustment) {
if (trackStock && (!valid || amount === 0)) {
this.setState({ error: 'Bitte geben Sie eine gültige Bestandsänderung ein (ungleich 0).' });
}
return;
}
if (hasAdjustment && !warehouseId) {
this.setState({ error: 'Bitte wählen Sie ein Lager aus.' });
return;
}
this.setState({ submitting: true, error: null });
try {
let updatedArticle = null;
// 1. Update track_stock if changed
if (trackStockChanged) {
const trackRes = await artikelAutomat.actions.setTrackStock(article.sku, {
track_stock: trackStock,
});
updatedArticle = trackRes?.article || null;
}
// 2. Adjust stock if an adjustment was entered and stock is tracked
let adjustResult = null;
if (hasAdjustment) {
const payload = {
sku: article.sku,
warehouseId: Number(warehouseId),
amount,
text: reason.trim() || undefined,
};
adjustResult = await stock.adjust(payload);
}
// User feedback
if (trackStockChanged && hasAdjustment) {
enqueueSnackbar(
`Bestandsführung ${trackStock ? 'aktiviert' : 'deaktiviert'} und Bestand angepasst.`,
{ variant: 'success' }
);
} else if (trackStockChanged) {
enqueueSnackbar(
`Bestandsführung für „${article.name || article.sku}“ ${trackStock ? 'aktiviert' : 'deaktiviert'}.`,
{ variant: 'success' }
);
} else if (hasAdjustment) {
enqueueSnackbar(
`Bestand für „${article.name || article.sku}“ erfolgreich angepasst (${amount > 0 ? `+${amount}` : amount}).`,
{ variant: 'success' }
);
}
// Reload article in automat cache
void artikelAutomat.actions.reloadArticle(article.sku).catch(() => { });
if (updatedArticle) {
onArticleUpdated?.(updatedArticle);
}
if (adjustResult && onAdjustSuccess) {
onAdjustSuccess(adjustResult);
}
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,
trackStock,
initialTrackStock,
} = this.state;
const trackStockChanged = trackStock !== initialTrackStock;
const currentStock = this.getCurrentStock(warehouseId);
const { valid, amount, newStock, isZeroChange } = this.calculateAdjustment();
const hasAdjustment = trackStock && valid && amount !== 0;
const canSubmit =
!submitting && (trackStockChanged || (hasAdjustment && Boolean(warehouseId)));
const selectedWarehouse = warehouses.find((w) => Number(w.id) === Number(warehouseId));
const unit =
article?.unit?.symbol ||
article?.unit?.name ||
'';
return (
<AppDialog
open={Boolean(open)}
onClose={submitting ? undefined : onClose}
maxWidth="sm"
fullWidth
aria-labelledby="stock-adjust-dialog-title"
slotProps={{
paper: {
sx: {
minHeight: 560,
},
},
}}
>
<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, display: 'flex', flexDirection: 'column' }}>
<Stack spacing={2.5} sx={{ flex: 1 }}>
{/* Article overview */}
<Box
sx={{
p: 1.5,
borderRadius: 1,
backgroundColor: SURFACE_BASE,
border: `1px solid ${BORDER}`,
}}
>
<Typography variant="caption" color="text.secondary" display="block">
Artikel (<Box component="span" sx={{ fontFamily: 'monospace' }}>{article?.sku}</Box>)
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{article?.name || '–'}
</Typography>
</Box>
{/* Track Stock Toggle Card */}
<Box
sx={{
p: 1.5,
borderRadius: 1,
backgroundColor: SURFACE_BASE,
border: `1px solid ${BORDER}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ pr: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Bestand verfolgen
</Typography>
</Box>
<Switch
checked={Boolean(trackStock)}
onChange={this.handleTrackStockToggle}
disabled={submitting}
color="primary"
/>
</Box>
{warehouses.length === 0 ? (
<Alert severity="warning">
Es sind keine Lager vorhanden. Bitte legen Sie zuerst unter „Artikel → Lager“ ein Lager an.
</Alert>
) : (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2.5,
opacity: trackStock ? 1 : 0.45,
pointerEvents: trackStock ? 'auto' : 'none',
transition: 'opacity 0.2s ease',
userSelect: trackStock ? 'auto' : 'none',
}}
>
{/* 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 || !trackStock}
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: SURFACE_RAISED,
border: `1px solid ${BORDER}`,
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 || !trackStock}
>
<ToggleButton value="diff" sx={{ py: 0.75, fontWeight: 500 }}>
<TrendingUpIcon sx={{ fontSize: 18, mr: 0.75 }} />
Zu-/abbuchen (+ / -)
</ToggleButton>
<ToggleButton value="target" sx={{ py: 0.75, fontWeight: 500 }}>
<TuneIcon sx={{ fontSize: 18, mr: 0.75 }} />
Bestand setzen
</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Amount input */}
{mode === 'diff' ? (
<TextInput
fullWidth
label="Menge (+ für Zugang, - für Abgang)"
type="number"
value={diffValue}
onChange={this.handleDiffChange}
disabled={submitting || !trackStock}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
{Number(diffValue) < 0 ? (
<TrendingDownIcon color="error" fontSize="small" />
) : (
<TrendingUpIcon color="success" fontSize="small" />
)}
</InputAdornment>
),
},
}}
/>
) : (
<TextInput
fullWidth
label="Bestand"
type="number"
value={targetValue}
onChange={this.handleTargetChange}
disabled={submitting || !trackStock}
/>
)}
{/* Summary calculation pill */}
{valid && amount !== 0 && (
<Box
sx={{
p: 1.5,
borderRadius: 1,
backgroundColor: amount > 0 ? GREEN_BG : RED_BG,
border: `1px solid ${amount > 0 ? GREEN_PANEL : RED_PANEL}`,
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>
<TextInput
fullWidth
label="Buchungstext"
value={reason}
onChange={this.handleReasonChange}
disabled={submitting || !trackStock}
/>
</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={!canSubmit}
startIcon={
submitting ? (
<CircularProgress size={16} color="inherit" />
) : (
<TuneIcon />
)
}
sx={{ minWidth: 150, fontWeight: 600 }}
>
{submitting
? 'Wird gespeichert...'
: hasAdjustment
? 'Bestand anpassen'
: 'Speichern'}
</Button>
</DialogActions>
</AppDialog>
);
}
}