Files
dashfront/src/components/article/ArticleStockAdjustDialog.jsx
2026-10-05 14:54:43 +02:00

631 lines
22 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 warehouseAutomat from '../../automats/warehouseAutomat';
import {
BORDER,
GREEN_BG,
GREEN_PANEL,
RED_BG,
RED_PANEL,
SURFACE_BASE,
SURFACE_RAISED,
} from '../../colors';
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);
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: warehouseAutomat.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 = 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);
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: warehouseAutomat.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 = 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 });
}
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 ({article?.sku})
</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>
<Typography variant="caption" color="text.secondary">
{trackStock
? 'Lagerbestand wird für diesen Artikel geführt und bei Buchungen aktualisiert.'
: 'Keine Bestandsführung. Artikel hat unbegrenzte Verfügbarkeit.'}
</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 }} />
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' ? (
<TextInput
fullWidth
label="Menge (+ für Zugang, - für Abgang)"
type="number"
value={diffValue}
onChange={this.handleDiffChange}
disabled={submitting || !trackStock}
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'
}
/>
) : (
<TextInput
fullWidth
label="Tatsächlicher Zählbestand (Ist-Bestand)"
type="number"
value={targetValue}
onChange={this.handleTargetChange}
disabled={submitting || !trackStock}
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 ? 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="Grund / Buchungstext"
value={reason}
onChange={this.handleReasonChange}
disabled={submitting || !trackStock}
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 || !trackStock}
onClick={() => this.setState({ reason: preset })}
sx={{ fontSize: '0.75rem' }}
/>
))}
</Box>
</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>
);
}
}