587 lines
20 KiB
JavaScript
587 lines
20 KiB
JavaScript
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>
|
||
);
|
||
}
|
||
}
|