u
This commit is contained in:
516
src/components/article/ArticleStockAdjustDialog.jsx
Normal file
516
src/components/article/ArticleStockAdjustDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -61,31 +61,36 @@ class EansDisplay extends Component {
|
||||
|
||||
class EansEdit extends Component {
|
||||
render() {
|
||||
const { draft, onChange, inputRef } = this.props;
|
||||
const { sku, draft, onChange, inputRef } = this.props;
|
||||
return (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
multiple
|
||||
freeSolo
|
||||
options={[]}
|
||||
value={draft.eans ?? []}
|
||||
onChange={(_event, eans) => onChange({ eans })}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
inputRef={inputRef}
|
||||
label="EANs (Enter zum Hinzufügen)"
|
||||
size="small"
|
||||
slotProps={{
|
||||
...params.slotProps,
|
||||
inputLabel: {
|
||||
...params.slotProps?.inputLabel,
|
||||
shrink: true,
|
||||
},
|
||||
}}
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<PanelValue label="SKU">{sku}</PanelValue>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
multiple
|
||||
freeSolo
|
||||
options={[]}
|
||||
value={draft.eans ?? []}
|
||||
onChange={(_event, eans) => onChange({ eans })}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
inputRef={inputRef}
|
||||
label="EANs (Enter zum Hinzufügen)"
|
||||
size="small"
|
||||
slotProps={{
|
||||
...params.slotProps,
|
||||
inputLabel: {
|
||||
...params.slotProps?.inputLabel,
|
||||
shrink: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -121,7 +126,12 @@ export default class ArticleViewEANs extends PureComponent {
|
||||
canSave={eansDraftValid}
|
||||
display={<EansDisplay sku={articleData.sku} eans={value.eans} />}
|
||||
edit={({ draft, onChange, inputRef }) => (
|
||||
<EansEdit draft={draft} onChange={onChange} inputRef={inputRef} />
|
||||
<EansEdit
|
||||
sku={articleData.sku}
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,8 @@ const articlePriceTextSx = {
|
||||
|
||||
const articlePriceFieldSx = {
|
||||
flexShrink: 0,
|
||||
width: 'auto',
|
||||
mr: 11,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
alignItems: 'baseline',
|
||||
flexShrink: 0,
|
||||
@@ -186,7 +188,6 @@ class PriceAmountField extends Component {
|
||||
onChange?.(event.target.value);
|
||||
}
|
||||
}
|
||||
fullWidth
|
||||
error={error}
|
||||
sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx}
|
||||
slotProps={{
|
||||
|
||||
@@ -1,39 +1,210 @@
|
||||
import { Component, PureComponent } from 'react';
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { formatStock } from '../../../pages/artikel/searchFormat';
|
||||
|
||||
class PanelValue extends Component {
|
||||
render() {
|
||||
const { label, children } = this.props;
|
||||
if (children == null || children === '') return null;
|
||||
return (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
{label ? (
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{label}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="body2" component="div">
|
||||
{children}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
import ArticleStockAdjustDialog from '../ArticleStockAdjustDialog';
|
||||
|
||||
const SECOND_ROW_MIN_HEIGHT = 220;
|
||||
|
||||
export default class ArticleViewStock extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
adjustOpen: false,
|
||||
};
|
||||
this.handleOpenAdjust = this.handleOpenAdjust.bind(this);
|
||||
this.handleCloseAdjust = this.handleCloseAdjust.bind(this);
|
||||
}
|
||||
|
||||
handleOpenAdjust() {
|
||||
this.setState({ adjustOpen: true });
|
||||
}
|
||||
|
||||
handleCloseAdjust() {
|
||||
this.setState({ adjustOpen: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { article: articleData } = this.props;
|
||||
const { adjustOpen } = this.state;
|
||||
const warehouses = Array.isArray(articleData?.warehouses)
|
||||
? articleData.warehouses
|
||||
: [];
|
||||
const hasWarehouses = warehouses.length > 0;
|
||||
|
||||
const totalStock = hasWarehouses
|
||||
? warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0)
|
||||
: articleData?.stock != null
|
||||
? Number(articleData.stock)
|
||||
: 0;
|
||||
|
||||
const rawUnit =
|
||||
articleData?.unit?.symbol ||
|
||||
articleData?.unit?.name ||
|
||||
'';
|
||||
const unit =
|
||||
rawUnit && !['stk', 'stück', 'pc', 'stk.'].includes(rawUnit.trim().toLowerCase())
|
||||
? rawUnit
|
||||
: '';
|
||||
|
||||
return (
|
||||
<PanelTile wide={1} minHeight={SECOND_ROW_MIN_HEIGHT} color="#bbf7d0">
|
||||
<PanelValue>
|
||||
{articleData.stock != null ? formatStock(articleData.stock) : null}
|
||||
</PanelValue>
|
||||
<>
|
||||
<PanelTile
|
||||
wide={1}
|
||||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||
color="#bbf7d0"
|
||||
action={{
|
||||
label: 'Bestand anpassen',
|
||||
icon: EditOutlinedIcon,
|
||||
onClick: this.handleOpenAdjust,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{/* Top section: Headline and Total Stock */}
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
fontWeight: 600,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
Bestand
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 0.75,
|
||||
my: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="div"
|
||||
sx={{
|
||||
fontSize: '2.4rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: '-0.02em',
|
||||
color: totalStock > 0 ? 'text.primary' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{formatStock(totalStock)}
|
||||
</Typography>
|
||||
{unit ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
{unit}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Bottom section: Per-warehouse breakdown */}
|
||||
{hasWarehouses ? (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
pr: 0.5,
|
||||
'&::-webkit-scrollbar': { width: 4 },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.15)',
|
||||
borderRadius: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{warehouses.map((wh) => (
|
||||
<Box
|
||||
key={wh.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
py: 0.35,
|
||||
px: 0.75,
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.55)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
mr: 1,
|
||||
}}
|
||||
>
|
||||
<WarehouseIcon
|
||||
sx={{
|
||||
fontSize: 15,
|
||||
color: 'text.secondary',
|
||||
mr: 0.5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
title={wh.name}
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: '0.82rem',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{wh.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.82rem',
|
||||
flexShrink: 0,
|
||||
color:
|
||||
Number(wh.stock) > 0
|
||||
? 'text.primary'
|
||||
: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{formatStock(wh.stock)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</PanelTile>
|
||||
);
|
||||
}
|
||||
|
||||
<ArticleStockAdjustDialog
|
||||
open={adjustOpen}
|
||||
onClose={this.handleCloseAdjust}
|
||||
article={articleData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,15 @@ import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
@@ -29,6 +35,7 @@ import {
|
||||
fetchDeliveryLedgerSummary,
|
||||
bookDeliveryLines,
|
||||
} from '../../utils/deliveryLedger';
|
||||
import warehouseAutomat from '../../automats/warehouseAutomat';
|
||||
|
||||
export default class BookDeliveryDialog extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
@@ -52,6 +59,8 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
const initialWarehouses = warehouseAutomat.state.warehouses || [];
|
||||
|
||||
this.state = {
|
||||
summary: props.summary ?? null,
|
||||
loading: !props.summary,
|
||||
@@ -61,9 +70,13 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
? `Lieferung ${props.delivery.number}`
|
||||
: 'Lieferung Wareneingang',
|
||||
isBooking: false,
|
||||
warehouses: initialWarehouses,
|
||||
selectedWarehouseId: initialWarehouses.length === 1 ? initialWarehouses[0].id : '',
|
||||
warehouseError: null,
|
||||
};
|
||||
|
||||
this.handleAmountChange = this.handleAmountChange.bind(this);
|
||||
this.handleWarehouseChange = this.handleWarehouseChange.bind(this);
|
||||
this.handleTransactionTextChange = this.handleTransactionTextChange.bind(this);
|
||||
this.handleBookAll = this.handleBookAll.bind(this);
|
||||
this.handleResetAmounts = this.handleResetAmounts.bind(this);
|
||||
@@ -71,24 +84,48 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubWarehouse = warehouseAutomat.subscribe((state) => {
|
||||
const warehouses = state.warehouses || [];
|
||||
this.setState((prev) => {
|
||||
const selectedId =
|
||||
prev.selectedWarehouseId ||
|
||||
(warehouses.length === 1 ? warehouses[0].id : '');
|
||||
return {
|
||||
warehouses,
|
||||
selectedWarehouseId: selectedId,
|
||||
};
|
||||
});
|
||||
});
|
||||
void warehouseAutomat.actions.load().catch(() => {});
|
||||
|
||||
if (this.props.open) {
|
||||
void this.loadSummary();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubWarehouse?.();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.open && !prevProps.open) {
|
||||
const currentWarehouses = this.state.warehouses || warehouseAutomat.state.warehouses || [];
|
||||
this.setState(
|
||||
{
|
||||
summary: this.props.summary ?? null,
|
||||
loading: !this.props.summary,
|
||||
error: null,
|
||||
warehouseError: null,
|
||||
selectedWarehouseId:
|
||||
this.state.selectedWarehouseId ||
|
||||
(currentWarehouses.length === 1 ? currentWarehouses[0].id : ''),
|
||||
transactionText: this.props.delivery?.number
|
||||
? `Lieferung ${this.props.delivery.number}`
|
||||
: 'Lieferung Wareneingang',
|
||||
isBooking: false,
|
||||
},
|
||||
() => {
|
||||
void warehouseAutomat.actions.load().catch(() => {});
|
||||
if (this.props.summary) {
|
||||
this.initAmountsFromSummary(this.props.summary, this.props.prefillLineId);
|
||||
} else {
|
||||
@@ -109,6 +146,14 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleWarehouseChange(event) {
|
||||
const value = event.target.value;
|
||||
this.setState({
|
||||
selectedWarehouseId: value ? Number(value) : '',
|
||||
warehouseError: null,
|
||||
});
|
||||
}
|
||||
|
||||
async loadSummary() {
|
||||
const { delivery, prefillLineId } = this.props;
|
||||
if (!delivery?.id) return;
|
||||
@@ -201,7 +246,14 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isBooking: true, error: null });
|
||||
const { selectedWarehouseId } = this.state;
|
||||
if (!selectedWarehouseId) {
|
||||
this.setState({ warehouseError: 'Bitte wählen Sie ein Ziellager aus.' });
|
||||
enqueueSnackbar('Bitte wählen Sie ein Ziellager aus.', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isBooking: true, error: null, warehouseError: null });
|
||||
try {
|
||||
const ledgerApi = this.context?.ledger;
|
||||
const deliveryApi = this.context?.delivery;
|
||||
@@ -209,6 +261,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
await bookDeliveryLines({
|
||||
delivery: summary.delivery || delivery,
|
||||
linesToBook,
|
||||
warehouseId: selectedWarehouseId,
|
||||
ledgerApi,
|
||||
transactionText,
|
||||
});
|
||||
@@ -443,6 +496,9 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
amountsToBook,
|
||||
transactionText,
|
||||
isBooking,
|
||||
warehouses,
|
||||
selectedWarehouseId,
|
||||
warehouseError,
|
||||
} = this.state;
|
||||
|
||||
const lines = summary?.lines || [];
|
||||
@@ -458,6 +514,8 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
(l) => Number(amountsToBook[l.id]) > 0
|
||||
).length;
|
||||
const isFullyBooked = summary?.status === 'booked';
|
||||
const selectedWarehouse = warehouses.find((w) => w.id === selectedWarehouseId);
|
||||
const isWarehouseMissing = !selectedWarehouseId;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -529,7 +587,59 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
{warehouses.length === 0 && !isFullyBooked && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Es ist noch kein Lager eingerichtet. Bitte legen Sie unter „Artikel → Lager“ zuerst ein Lager an.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
}}
|
||||
>
|
||||
<FormControl
|
||||
size="small"
|
||||
required
|
||||
error={Boolean(warehouseError)}
|
||||
disabled={isBooking || isFullyBooked}
|
||||
sx={{ minWidth: { sm: 240 }, flex: { sm: '0 0 auto' } }}
|
||||
>
|
||||
<InputLabel id="warehouse-select-label">Ziellager *</InputLabel>
|
||||
<Select
|
||||
labelId="warehouse-select-label"
|
||||
id="warehouse-select"
|
||||
value={selectedWarehouseId ?? ''}
|
||||
label="Ziellager *"
|
||||
onChange={this.handleWarehouseChange}
|
||||
startAdornment={
|
||||
<WarehouseIcon
|
||||
sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{warehouses.length === 0 ? (
|
||||
<MenuItem value="" disabled>
|
||||
Keine Lager vorhanden
|
||||
</MenuItem>
|
||||
) : (
|
||||
warehouses.map((wh) => (
|
||||
<MenuItem key={wh.id} value={wh.id}>
|
||||
{wh.name}
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
{warehouseError ? (
|
||||
<FormHelperText>{warehouseError}</FormHelperText>
|
||||
) : (
|
||||
<FormHelperText>Pflichtfeld: Ziellager für die Buchung</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
@@ -546,9 +656,19 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
Alle Artikel dieser Lieferung sind bereits vollständig im Lager verbucht.
|
||||
</Alert>
|
||||
) : totalToBook > 0 ? (
|
||||
<Alert severity="info">
|
||||
Es werden <strong>{totalToBook} Stück</strong> für{' '}
|
||||
<strong>{countLinesToBook} Position{countLinesToBook === 1 ? '' : 'en'}</strong> als Lagerzugang (DELIVERY) im Ledger gebucht.
|
||||
<Alert severity={selectedWarehouseId ? 'info' : 'warning'}>
|
||||
{selectedWarehouseId ? (
|
||||
<>
|
||||
Es werden <strong>{totalToBook} Stück</strong> für{' '}
|
||||
<strong>{countLinesToBook} Position{countLinesToBook === 1 ? '' : 'en'}</strong> als Lagerzugang (DELIVERY) in das Lager{' '}
|
||||
<strong>„{selectedWarehouse?.name || '–'}“</strong> gebucht.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Bitte wählen Sie ein <strong>Ziellager</strong> aus, um die Buchung von{' '}
|
||||
<strong>{totalToBook} Stück</strong> durchzuführen.
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="warning">
|
||||
@@ -569,7 +689,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={this.handleConfirmBook}
|
||||
disabled={isBooking || totalToBook <= 0 || loading}
|
||||
disabled={isBooking || totalToBook <= 0 || loading || isWarehouseMissing}
|
||||
startIcon={
|
||||
isBooking ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
|
||||
Reference in New Issue
Block a user