u
This commit is contained in:
@@ -94,6 +94,24 @@ export class ApiClient {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.warehouse = {
|
||||||
|
/** GET /api/warehouse → { warehouses: Warehouse[] } */
|
||||||
|
list: () => this.#json('GET', '/api/warehouse'),
|
||||||
|
/** GET /api/warehouse/{id} → { warehouse: Warehouse } */
|
||||||
|
getById: (id) => this.#json('GET', `/api/warehouse/${enc(id)}`),
|
||||||
|
/** POST /api/warehouse → 201 { warehouse: Warehouse } */
|
||||||
|
create: (body) => this.#json('POST', '/api/warehouse', { body }),
|
||||||
|
/** PATCH /api/warehouse/{id} → 200 { warehouse: Warehouse } */
|
||||||
|
update: (id, body) => this.#json('PATCH', `/api/warehouse/${enc(id)}`, { body }),
|
||||||
|
/** DELETE /api/warehouse/{id} → 204 */
|
||||||
|
delete: (id) => this.#empty('DELETE', `/api/warehouse/${enc(id)}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.stock = {
|
||||||
|
/** POST /api/stock/adjust → 201 { manifest, lines, ledger } */
|
||||||
|
adjust: (body) => this.#json('POST', '/api/stock/adjust', { body }),
|
||||||
|
};
|
||||||
|
|
||||||
this.delivery = {
|
this.delivery = {
|
||||||
/** GET /api/delivery → { deliveries: DeliverySummary[] } */
|
/** GET /api/delivery → { deliveries: DeliverySummary[] } */
|
||||||
list: () => this.#json('GET', '/api/delivery'),
|
list: () => this.#json('GET', '/api/delivery'),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import unitAutomat from '../automats/unitAutomat';
|
|||||||
import authAutomat from '../automats/authAutomat';
|
import authAutomat from '../automats/authAutomat';
|
||||||
import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat';
|
import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat';
|
||||||
import posterminalAutomat from '../automats/posterminalAutomat';
|
import posterminalAutomat from '../automats/posterminalAutomat';
|
||||||
|
import warehouseAutomat from '../automats/warehouseAutomat';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -677,6 +678,11 @@ export class StorageManager extends ApiClient {
|
|||||||
console.error('[storageMgr] articles SSE refresh failed', err);
|
console.error('[storageMgr] articles SSE refresh failed', err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
es.addEventListener('warehouse', () => {
|
||||||
|
void warehouseAutomat.actions.reload().catch((err) => {
|
||||||
|
console.error('[storageMgr] warehouse SSE refresh failed', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
for (const sku of new Set([
|
for (const sku of new Set([
|
||||||
...this.#articleWatchers.keys(),
|
...this.#articleWatchers.keys(),
|
||||||
...this.#historyWatchers.keys(),
|
...this.#historyWatchers.keys(),
|
||||||
|
|||||||
@@ -633,6 +633,58 @@ export const supplier = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company-scoped warehouses / Lager (selectedCompanyId from session).
|
||||||
|
* Shape: Warehouse = { id, name }
|
||||||
|
*/
|
||||||
|
export const warehouse = {
|
||||||
|
/** GET /api/warehouse → { warehouses: Warehouse[] } */
|
||||||
|
list() {
|
||||||
|
return http('/api/warehouse');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** GET /api/warehouse/:id → { warehouse: Warehouse } */
|
||||||
|
getById(id) {
|
||||||
|
return http(`/api/warehouse/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/warehouse
|
||||||
|
* Body: { name } — empty name ⇒ 400 Name required.
|
||||||
|
* → 201 { warehouse: Warehouse }
|
||||||
|
*/
|
||||||
|
create(body) {
|
||||||
|
return http('/api/warehouse', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/warehouse/:id
|
||||||
|
* Body: { name } — empty name ⇒ 400 Name required.
|
||||||
|
* → 200 { warehouse: Warehouse }
|
||||||
|
*/
|
||||||
|
update(id, body) {
|
||||||
|
return http(`/api/warehouse/${id}`, { method: 'PATCH', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** DELETE /api/warehouse/:id → 204 */
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/warehouse/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stock operations (manual stock adjustment).
|
||||||
|
*/
|
||||||
|
export const stock = {
|
||||||
|
/**
|
||||||
|
* POST /api/stock/adjust
|
||||||
|
* Body: { sku, warehouseId, amount, text?, date?, externalNumber?, lines? }
|
||||||
|
*/
|
||||||
|
adjust(body) {
|
||||||
|
return http('/api/stock/adjust', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Company-scoped goods receipts (manifest type = delivery).
|
* Company-scoped goods receipts (manifest type = delivery).
|
||||||
* DeliverySummary = { id, number, externalNumber, type: 'delivery', supplierId, date }
|
* DeliverySummary = { id, number, externalNumber, type: 'delivery', supplierId, date }
|
||||||
@@ -1264,4 +1316,6 @@ export default {
|
|||||||
supplier,
|
supplier,
|
||||||
thumb,
|
thumb,
|
||||||
unit,
|
unit,
|
||||||
|
warehouse,
|
||||||
|
stock,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export { default as userSettingsAutomat } from './userSettingsAutomat';
|
|||||||
export { setViewerPosition } from './userSettingsAutomat';
|
export { setViewerPosition } from './userSettingsAutomat';
|
||||||
|
|
||||||
export { default as supplierAutomat } from './supplierAutomat';
|
export { default as supplierAutomat } from './supplierAutomat';
|
||||||
|
export { default as warehouseAutomat } from './warehouseAutomat';
|
||||||
|
|
||||||
export { default as artikelAutomat } from './artikelAutomat';
|
export { default as artikelAutomat } from './artikelAutomat';
|
||||||
export {
|
export {
|
||||||
|
|||||||
201
src/automats/warehouseAutomat.js
Normal file
201
src/automats/warehouseAutomat.js
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import { warehouse as warehouseApi } from '../api';
|
||||||
|
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||||
|
import authAutomat from './authAutomat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Warehouse
|
||||||
|
* @property {string|number} id Warehouse identifier.
|
||||||
|
* @property {string} name Warehouse name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} WarehouseState
|
||||||
|
* @property {Array<Warehouse>} warehouses Company-scoped warehouses.
|
||||||
|
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||||
|
* @property {Error|null} error Last load error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {WarehouseState} */
|
||||||
|
const initialState = {
|
||||||
|
warehouses: [],
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let warehouseAutomat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the company-scoped warehouse list.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<Warehouse>>} Warehouse list.
|
||||||
|
*/
|
||||||
|
async function fetchWarehouses() {
|
||||||
|
const data = await warehouseApi.list();
|
||||||
|
requireObject(data, 'GET /api/warehouse');
|
||||||
|
return requireArray(data.warehouses, 'warehouses');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-flight `load()` promise so concurrent callers share one request. */
|
||||||
|
let inflightLoad = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the warehouse list once, deduplicating concurrent callers.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<Warehouse>>} Loaded warehouses.
|
||||||
|
*/
|
||||||
|
function loadWarehouses() {
|
||||||
|
if (inflightLoad) return inflightLoad;
|
||||||
|
inflightLoad = (async () => {
|
||||||
|
warehouseAutomat.setState({ status: 'loading', error: null });
|
||||||
|
try {
|
||||||
|
const warehouses = await fetchWarehouses();
|
||||||
|
warehouseAutomat.setState({ warehouses, status: 'ready', error: null });
|
||||||
|
return warehouses;
|
||||||
|
} catch (error) {
|
||||||
|
warehouseAutomat.setState({ status: 'failed', error });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
inflightLoad = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflightLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
/**
|
||||||
|
* Loads the warehouse list, exposing a loading/failed lifecycle.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<Warehouse>>} Loaded warehouses.
|
||||||
|
*/
|
||||||
|
load() {
|
||||||
|
return loadWarehouses();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silently re-fetches the warehouse list while keeping the current state.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<Warehouse>>} Fresh warehouses.
|
||||||
|
*/
|
||||||
|
async reload() {
|
||||||
|
try {
|
||||||
|
const warehouses = await fetchWarehouses();
|
||||||
|
warehouseAutomat.setState({ warehouses, status: 'ready', error: null });
|
||||||
|
return warehouses;
|
||||||
|
} catch (error) {
|
||||||
|
warehouseAutomat.setState({ error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a warehouse and refreshes the cached list.
|
||||||
|
*
|
||||||
|
* @param {{name: string}} body Creation payload.
|
||||||
|
* @returns {Promise<Warehouse>} Created warehouse.
|
||||||
|
*/
|
||||||
|
async create(body) {
|
||||||
|
const data = await warehouseApi.create(body);
|
||||||
|
requireObject(data, 'POST /api/warehouse');
|
||||||
|
requireObject(data.warehouse, 'warehouse');
|
||||||
|
requireDefined(data.warehouse.id, 'warehouse.id');
|
||||||
|
await warehouseAutomat.actions.reload();
|
||||||
|
return data.warehouse;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames a warehouse and refreshes the cached list.
|
||||||
|
*
|
||||||
|
* @param {string|number} id Warehouse identifier.
|
||||||
|
* @param {{name: string}} body Update payload.
|
||||||
|
* @returns {Promise<Warehouse>} Updated warehouse.
|
||||||
|
*/
|
||||||
|
async update(id, body) {
|
||||||
|
const data = await warehouseApi.update(id, body);
|
||||||
|
requireObject(data, 'PATCH /api/warehouse/:id');
|
||||||
|
requireObject(data.warehouse, 'warehouse');
|
||||||
|
requireDefined(data.warehouse.id, 'warehouse.id');
|
||||||
|
await warehouseAutomat.actions.reload();
|
||||||
|
return data.warehouse;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a warehouse and refreshes the cached list.
|
||||||
|
*
|
||||||
|
* @param {string|number} id Warehouse identifier.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async remove(id) {
|
||||||
|
await warehouseApi.delete(id);
|
||||||
|
await warehouseAutomat.actions.reload();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the cached list without a round-trip.
|
||||||
|
*
|
||||||
|
* @param {Array<Warehouse>} warehouses Warehouses to cache.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
setWarehouses(warehouses) {
|
||||||
|
warehouseAutomat.setState({
|
||||||
|
warehouses: Array.isArray(warehouses) ? warehouses : [],
|
||||||
|
status: 'ready',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forgets the cached list and returns to the initial state.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
warehouseAutomat.setState({ ...initialState });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company-scoped warehouse cache. Loads automatically once an authenticated
|
||||||
|
* session with a selected company exists and is cleared when auth is gone.
|
||||||
|
*
|
||||||
|
* @type {Automat<WarehouseState>}
|
||||||
|
*/
|
||||||
|
warehouseAutomat = new Automat(initialState, actions, { name: 'warehouse' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the cache key from the auth state: the selected company while
|
||||||
|
* authenticated, otherwise `null`.
|
||||||
|
*
|
||||||
|
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||||
|
* @returns {string|null} Cache key.
|
||||||
|
*/
|
||||||
|
function authKey(authState) {
|
||||||
|
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||||
|
const companyId = authState.user.selectedCompanyId;
|
||||||
|
return companyId == null ? null : String(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastAuthKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the warehouse list when auth/company becomes available and forgets it
|
||||||
|
* when the session ends.
|
||||||
|
*
|
||||||
|
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function syncWithAuth(authState) {
|
||||||
|
const key = authKey(authState);
|
||||||
|
if (key === lastAuthKey) return;
|
||||||
|
lastAuthKey = key;
|
||||||
|
if (key == null) {
|
||||||
|
warehouseAutomat.actions.clear();
|
||||||
|
} else {
|
||||||
|
void warehouseAutomat.actions.load().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authAutomat.subscribe(syncWithAuth);
|
||||||
|
syncWithAuth(authAutomat.state);
|
||||||
|
|
||||||
|
export default warehouseAutomat;
|
||||||
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,8 +61,11 @@ class EansDisplay extends Component {
|
|||||||
|
|
||||||
class EansEdit extends Component {
|
class EansEdit extends Component {
|
||||||
render() {
|
render() {
|
||||||
const { draft, onChange, inputRef } = this.props;
|
const { sku, draft, onChange, inputRef } = this.props;
|
||||||
return (
|
return (
|
||||||
|
<Box sx={{ width: '100%' }}>
|
||||||
|
<PanelValue label="SKU">{sku}</PanelValue>
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
fullWidth
|
fullWidth
|
||||||
multiple
|
multiple
|
||||||
@@ -86,6 +89,8 @@ class EansEdit extends Component {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +126,12 @@ export default class ArticleViewEANs extends PureComponent {
|
|||||||
canSave={eansDraftValid}
|
canSave={eansDraftValid}
|
||||||
display={<EansDisplay sku={articleData.sku} eans={value.eans} />}
|
display={<EansDisplay sku={articleData.sku} eans={value.eans} />}
|
||||||
edit={({ draft, onChange, inputRef }) => (
|
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 = {
|
const articlePriceFieldSx = {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
width: 'auto',
|
||||||
|
mr: 11,
|
||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
alignItems: 'baseline',
|
alignItems: 'baseline',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -186,7 +188,6 @@ class PriceAmountField extends Component {
|
|||||||
onChange?.(event.target.value);
|
onChange?.(event.target.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fullWidth
|
|
||||||
error={error}
|
error={error}
|
||||||
sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx}
|
sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
|
|||||||
@@ -1,39 +1,210 @@
|
|||||||
import { Component, PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Typography from '@mui/material/Typography';
|
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 { PanelTile } from 'dashgui/panels';
|
||||||
import { formatStock } from '../../../pages/artikel/searchFormat';
|
import { formatStock } from '../../../pages/artikel/searchFormat';
|
||||||
|
import ArticleStockAdjustDialog from '../ArticleStockAdjustDialog';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECOND_ROW_MIN_HEIGHT = 220;
|
const SECOND_ROW_MIN_HEIGHT = 220;
|
||||||
|
|
||||||
export default class ArticleViewStock extends PureComponent {
|
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() {
|
render() {
|
||||||
const { article: articleData } = this.props;
|
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 (
|
return (
|
||||||
<PanelTile wide={1} minHeight={SECOND_ROW_MIN_HEIGHT} color="#bbf7d0">
|
<>
|
||||||
<PanelValue>
|
<PanelTile
|
||||||
{articleData.stock != null ? formatStock(articleData.stock) : null}
|
wide={1}
|
||||||
</PanelValue>
|
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>
|
</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 Chip from '@mui/material/Chip';
|
||||||
import Alert from '@mui/material/Alert';
|
import Alert from '@mui/material/Alert';
|
||||||
import Paper from '@mui/material/Paper';
|
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 CloseIcon from '@mui/icons-material/Close';
|
||||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||||
|
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
@@ -29,6 +35,7 @@ import {
|
|||||||
fetchDeliveryLedgerSummary,
|
fetchDeliveryLedgerSummary,
|
||||||
bookDeliveryLines,
|
bookDeliveryLines,
|
||||||
} from '../../utils/deliveryLedger';
|
} from '../../utils/deliveryLedger';
|
||||||
|
import warehouseAutomat from '../../automats/warehouseAutomat';
|
||||||
|
|
||||||
export default class BookDeliveryDialog extends PureComponent {
|
export default class BookDeliveryDialog extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
static contextType = StorageManagerContext;
|
||||||
@@ -52,6 +59,8 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initialWarehouses = warehouseAutomat.state.warehouses || [];
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
summary: props.summary ?? null,
|
summary: props.summary ?? null,
|
||||||
loading: !props.summary,
|
loading: !props.summary,
|
||||||
@@ -61,9 +70,13 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
? `Lieferung ${props.delivery.number}`
|
? `Lieferung ${props.delivery.number}`
|
||||||
: 'Lieferung Wareneingang',
|
: 'Lieferung Wareneingang',
|
||||||
isBooking: false,
|
isBooking: false,
|
||||||
|
warehouses: initialWarehouses,
|
||||||
|
selectedWarehouseId: initialWarehouses.length === 1 ? initialWarehouses[0].id : '',
|
||||||
|
warehouseError: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.handleAmountChange = this.handleAmountChange.bind(this);
|
this.handleAmountChange = this.handleAmountChange.bind(this);
|
||||||
|
this.handleWarehouseChange = this.handleWarehouseChange.bind(this);
|
||||||
this.handleTransactionTextChange = this.handleTransactionTextChange.bind(this);
|
this.handleTransactionTextChange = this.handleTransactionTextChange.bind(this);
|
||||||
this.handleBookAll = this.handleBookAll.bind(this);
|
this.handleBookAll = this.handleBookAll.bind(this);
|
||||||
this.handleResetAmounts = this.handleResetAmounts.bind(this);
|
this.handleResetAmounts = this.handleResetAmounts.bind(this);
|
||||||
@@ -71,24 +84,48 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
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) {
|
if (this.props.open) {
|
||||||
void this.loadSummary();
|
void this.loadSummary();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubWarehouse?.();
|
||||||
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (this.props.open && !prevProps.open) {
|
if (this.props.open && !prevProps.open) {
|
||||||
|
const currentWarehouses = this.state.warehouses || warehouseAutomat.state.warehouses || [];
|
||||||
this.setState(
|
this.setState(
|
||||||
{
|
{
|
||||||
summary: this.props.summary ?? null,
|
summary: this.props.summary ?? null,
|
||||||
loading: !this.props.summary,
|
loading: !this.props.summary,
|
||||||
error: null,
|
error: null,
|
||||||
|
warehouseError: null,
|
||||||
|
selectedWarehouseId:
|
||||||
|
this.state.selectedWarehouseId ||
|
||||||
|
(currentWarehouses.length === 1 ? currentWarehouses[0].id : ''),
|
||||||
transactionText: this.props.delivery?.number
|
transactionText: this.props.delivery?.number
|
||||||
? `Lieferung ${this.props.delivery.number}`
|
? `Lieferung ${this.props.delivery.number}`
|
||||||
: 'Lieferung Wareneingang',
|
: 'Lieferung Wareneingang',
|
||||||
isBooking: false,
|
isBooking: false,
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
|
void warehouseAutomat.actions.load().catch(() => {});
|
||||||
if (this.props.summary) {
|
if (this.props.summary) {
|
||||||
this.initAmountsFromSummary(this.props.summary, this.props.prefillLineId);
|
this.initAmountsFromSummary(this.props.summary, this.props.prefillLineId);
|
||||||
} else {
|
} 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() {
|
async loadSummary() {
|
||||||
const { delivery, prefillLineId } = this.props;
|
const { delivery, prefillLineId } = this.props;
|
||||||
if (!delivery?.id) return;
|
if (!delivery?.id) return;
|
||||||
@@ -201,7 +246,14 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
return;
|
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 {
|
try {
|
||||||
const ledgerApi = this.context?.ledger;
|
const ledgerApi = this.context?.ledger;
|
||||||
const deliveryApi = this.context?.delivery;
|
const deliveryApi = this.context?.delivery;
|
||||||
@@ -209,6 +261,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
await bookDeliveryLines({
|
await bookDeliveryLines({
|
||||||
delivery: summary.delivery || delivery,
|
delivery: summary.delivery || delivery,
|
||||||
linesToBook,
|
linesToBook,
|
||||||
|
warehouseId: selectedWarehouseId,
|
||||||
ledgerApi,
|
ledgerApi,
|
||||||
transactionText,
|
transactionText,
|
||||||
});
|
});
|
||||||
@@ -443,6 +496,9 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
amountsToBook,
|
amountsToBook,
|
||||||
transactionText,
|
transactionText,
|
||||||
isBooking,
|
isBooking,
|
||||||
|
warehouses,
|
||||||
|
selectedWarehouseId,
|
||||||
|
warehouseError,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
const lines = summary?.lines || [];
|
const lines = summary?.lines || [];
|
||||||
@@ -458,6 +514,8 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
(l) => Number(amountsToBook[l.id]) > 0
|
(l) => Number(amountsToBook[l.id]) > 0
|
||||||
).length;
|
).length;
|
||||||
const isFullyBooked = summary?.status === 'booked';
|
const isFullyBooked = summary?.status === 'booked';
|
||||||
|
const selectedWarehouse = warehouses.find((w) => w.id === selectedWarehouseId);
|
||||||
|
const isWarehouseMissing = !selectedWarehouseId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -529,7 +587,59 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
</Box>
|
</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
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
@@ -546,9 +656,19 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
Alle Artikel dieser Lieferung sind bereits vollständig im Lager verbucht.
|
Alle Artikel dieser Lieferung sind bereits vollständig im Lager verbucht.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : totalToBook > 0 ? (
|
) : totalToBook > 0 ? (
|
||||||
<Alert severity="info">
|
<Alert severity={selectedWarehouseId ? 'info' : 'warning'}>
|
||||||
|
{selectedWarehouseId ? (
|
||||||
|
<>
|
||||||
Es werden <strong>{totalToBook} Stück</strong> für{' '}
|
Es werden <strong>{totalToBook} Stück</strong> für{' '}
|
||||||
<strong>{countLinesToBook} Position{countLinesToBook === 1 ? '' : 'en'}</strong> als Lagerzugang (DELIVERY) im Ledger gebucht.
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<Alert severity="warning">
|
<Alert severity="warning">
|
||||||
@@ -569,7 +689,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={this.handleConfirmBook}
|
onClick={this.handleConfirmBook}
|
||||||
disabled={isBooking || totalToBook <= 0 || loading}
|
disabled={isBooking || totalToBook <= 0 || loading || isWarehouseMissing}
|
||||||
startIcon={
|
startIcon={
|
||||||
isBooking ? (
|
isBooking ? (
|
||||||
<CircularProgress size={16} color="inherit" />
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
|||||||
@@ -1,13 +1,347 @@
|
|||||||
import { useEffect } from 'react';
|
import { PureComponent } from 'react';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
|
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import BackendError from '../../components/BackendError';
|
||||||
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
|
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||||
|
import withNavigate from '../../components/withNavigate';
|
||||||
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
import { setLastArticleSku } from '../../automats';
|
import { setLastArticleSku } from '../../automats';
|
||||||
|
import warehouseAutomat from '../../automats/warehouseAutomat';
|
||||||
|
|
||||||
export default function ArtikelLagerPage() {
|
function sortWarehouses(warehouses) {
|
||||||
useEffect(() => {
|
return [...warehouses].sort((a, b) =>
|
||||||
setLastArticleSku(null);
|
String(a.name).localeCompare(String(b.name), 'de'),
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Typography color="text.secondary">Lager — Inhalt folgt.</Typography>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function draftsFrom(warehouses) {
|
||||||
|
return Object.fromEntries(warehouses.map((row) => [row.id, row.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialParams(props) {
|
||||||
|
const searchParams =
|
||||||
|
typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||||||
|
const prefill =
|
||||||
|
props.location?.state?.name ??
|
||||||
|
props.location?.state?.newName ??
|
||||||
|
searchParams?.get('name') ??
|
||||||
|
searchParams?.get('newName') ??
|
||||||
|
'';
|
||||||
|
const returnTo =
|
||||||
|
props.location?.state?.returnTo ??
|
||||||
|
searchParams?.get('returnTo') ??
|
||||||
|
null;
|
||||||
|
return { prefill: prefill.trim(), returnTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
class ArtikelLagerPage extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const { prefill } = getInitialParams(props);
|
||||||
|
const initialWarehouses = sortWarehouses(warehouseAutomat.state.warehouses || []);
|
||||||
|
this.state = {
|
||||||
|
status: warehouseAutomat.state.status === 'ready' ? 'ready' : 'loading',
|
||||||
|
error: warehouseAutomat.state.error ? backendErrorFrom(warehouseAutomat.state.error) : null,
|
||||||
|
warehouses: initialWarehouses,
|
||||||
|
drafts: draftsFrom(initialWarehouses),
|
||||||
|
newName: prefill,
|
||||||
|
busyKey: null,
|
||||||
|
};
|
||||||
|
this._cancelled = false;
|
||||||
|
this.handleCreate = this.handleCreate.bind(this);
|
||||||
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
|
this.loadWarehouses = this.loadWarehouses.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
setLastArticleSku(null);
|
||||||
|
const { prefill } = getInitialParams(this.props);
|
||||||
|
if (prefill && !this.state.newName) {
|
||||||
|
this.setState({ newName: prefill });
|
||||||
|
}
|
||||||
|
this._cancelled = false;
|
||||||
|
|
||||||
|
this.unsub = warehouseAutomat.subscribe((state) => {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
if (state.status === 'ready') {
|
||||||
|
this.applyWarehouses(state.warehouses);
|
||||||
|
} else if (state.status === 'failed') {
|
||||||
|
this.setState({ status: 'failed', error: backendErrorFrom(state.error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void this.loadWarehouses();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.location !== this.props.location) {
|
||||||
|
const { prefill } = getInitialParams(this.props);
|
||||||
|
if (prefill && prefill !== this.state.newName) {
|
||||||
|
this.setState({ newName: prefill });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this._cancelled = true;
|
||||||
|
this.unsub?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyWarehouses(warehouses) {
|
||||||
|
const sorted = sortWarehouses(warehouses);
|
||||||
|
this.setState((prev) => {
|
||||||
|
const currentDrafts = prev.drafts || {};
|
||||||
|
const nextDrafts = { ...draftsFrom(sorted) };
|
||||||
|
// Preserve unsaved in-progress edits for still-existing items if not busy
|
||||||
|
for (const wh of sorted) {
|
||||||
|
if (prev.busyKey !== wh.id && currentDrafts[wh.id] !== undefined) {
|
||||||
|
nextDrafts[wh.id] = currentDrafts[wh.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'ready',
|
||||||
|
error: null,
|
||||||
|
warehouses: sorted,
|
||||||
|
drafts: nextDrafts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadWarehouses() {
|
||||||
|
if (warehouseAutomat.state.status !== 'ready') {
|
||||||
|
this.setState({ status: 'loading', error: null });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const list = await warehouseAutomat.actions.load();
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.applyWarehouses(list);
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry() {
|
||||||
|
void this.loadWarehouses();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshFromServer() {
|
||||||
|
const list = await warehouseAutomat.actions.reload();
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.applyWarehouses(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleCreate() {
|
||||||
|
const name = this.state.newName.trim();
|
||||||
|
if (!name || this.state.busyKey != null) return;
|
||||||
|
|
||||||
|
const { returnTo } = getInitialParams(this.props);
|
||||||
|
|
||||||
|
this.setState({ busyKey: 'new' });
|
||||||
|
try {
|
||||||
|
const newWarehouse = await warehouseAutomat.actions.create({ name });
|
||||||
|
await this.refreshFromServer();
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState({ newName: '', busyKey: null });
|
||||||
|
enqueueSnackbar(`Lager „${newWarehouse.name || name}“ angelegt.`, { variant: 'success' });
|
||||||
|
|
||||||
|
if (returnTo && this.props.navigate) {
|
||||||
|
this.props.navigate(returnTo);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
notifyError(err);
|
||||||
|
this.setState({ busyKey: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRename(wh) {
|
||||||
|
const draft = String(this.state.drafts[wh.id] ?? '').trim();
|
||||||
|
if (!draft) {
|
||||||
|
this.setState((prev) => ({
|
||||||
|
drafts: { ...prev.drafts, [wh.id]: wh.name },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft === wh.name || this.state.busyKey != null) return;
|
||||||
|
|
||||||
|
this.setState({ busyKey: wh.id });
|
||||||
|
try {
|
||||||
|
await warehouseAutomat.actions.update(wh.id, { name: draft });
|
||||||
|
await this.refreshFromServer();
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState({ busyKey: null });
|
||||||
|
enqueueSnackbar('Lager gespeichert.', { variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
notifyError(err);
|
||||||
|
this.setState((prev) => ({
|
||||||
|
busyKey: null,
|
||||||
|
drafts: { ...prev.drafts, [wh.id]: wh.name },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDelete(wh) {
|
||||||
|
if (this.state.busyKey != null) return;
|
||||||
|
if (!window.confirm(`Lager „${wh.name}“ wirklich löschen?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ busyKey: wh.id });
|
||||||
|
try {
|
||||||
|
await warehouseAutomat.actions.remove(wh.id);
|
||||||
|
await this.refreshFromServer();
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState({ busyKey: null });
|
||||||
|
enqueueSnackbar('Lager gelöscht.', { variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
notifyError(err);
|
||||||
|
this.setState({ busyKey: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { status, error, warehouses, drafts, newName, busyKey } = this.state;
|
||||||
|
const busy = busyKey != null;
|
||||||
|
|
||||||
|
const { returnTo } = getInitialParams(this.props);
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'failed') {
|
||||||
|
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelTileGrid>
|
||||||
|
<PanelTile wide={2} minHeight={280} color="#bbf7d0">
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{returnTo ? (
|
||||||
|
<Alert
|
||||||
|
severity="info"
|
||||||
|
sx={{ py: 0.5, alignItems: 'center' }}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
onClick={() => this.props.navigate?.(returnTo)}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{newName
|
||||||
|
? `Lagername „${newName}“ übernommen.`
|
||||||
|
: 'Neues Lager anlegen.'}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Lager der ausgewählten Firma.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<List dense disablePadding>
|
||||||
|
{warehouses.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||||
|
Noch keine Lager angelegt.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
warehouses.map((wh) => (
|
||||||
|
<ListItem
|
||||||
|
key={wh.id}
|
||||||
|
disableGutters
|
||||||
|
sx={{ gap: 1, py: 0.75, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<WarehouseIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
|
||||||
|
<SingleLineTextEdit
|
||||||
|
size="small"
|
||||||
|
label=""
|
||||||
|
value={drafts[wh.id] ?? ''}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(next) =>
|
||||||
|
this.setState((prev) => ({
|
||||||
|
drafts: { ...prev.drafts, [wh.id]: next },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onBlur={() => void this.handleRename(wh)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.target.blur();
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.setState((prev) => ({
|
||||||
|
drafts: { ...prev.drafts, [wh.id]: wh.name },
|
||||||
|
}));
|
||||||
|
event.target.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
size="small"
|
||||||
|
aria-label={`„${wh.name}“ löschen`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void this.handleDelete(wh)}
|
||||||
|
>
|
||||||
|
<DeleteOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<SingleLineTextEdit
|
||||||
|
size="small"
|
||||||
|
label="Neues Lager"
|
||||||
|
value={newName}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(next) => this.setState({ newName: next })}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void this.handleCreate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={busy || !newName.trim()}
|
||||||
|
onClick={() => void this.handleCreate()}
|
||||||
|
>
|
||||||
|
{busyKey === 'new' ? '…' : 'Hinzufügen'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</PanelTile>
|
||||||
|
</PanelTileGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withNavigate(ArtikelLagerPage);
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
|
|||||||
* @param {Object} params
|
* @param {Object} params
|
||||||
* @param {Object} params.delivery - The delivery object (used for transaction memo and fallback)
|
* @param {Object} params.delivery - The delivery object (used for transaction memo and fallback)
|
||||||
* @param {Array<{ manifestarticleId?: number, id?: number, amount: number, sku?: string }>} params.linesToBook
|
* @param {Array<{ manifestarticleId?: number, id?: number, amount: number, sku?: string }>} params.linesToBook
|
||||||
|
* @param {number|string} params.warehouseId - Mandatory warehouse ID
|
||||||
* @param {Object} [params.ledgerApi]
|
* @param {Object} [params.ledgerApi]
|
||||||
* @param {string} [params.transactionText]
|
* @param {string} [params.transactionText]
|
||||||
* @returns {Promise<Array<Object>>}
|
* @returns {Promise<Array<Object>>}
|
||||||
@@ -123,9 +124,15 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
|
|||||||
export async function bookDeliveryLines({
|
export async function bookDeliveryLines({
|
||||||
delivery,
|
delivery,
|
||||||
linesToBook,
|
linesToBook,
|
||||||
|
warehouseId,
|
||||||
ledgerApi = defaultLedgerApi,
|
ledgerApi = defaultLedgerApi,
|
||||||
transactionText,
|
transactionText,
|
||||||
}) {
|
}) {
|
||||||
|
const numericWarehouseId = Number(warehouseId);
|
||||||
|
if (!numericWarehouseId || numericWarehouseId <= 0) {
|
||||||
|
throw new Error('Bitte wählen Sie ein Lager aus.');
|
||||||
|
}
|
||||||
|
|
||||||
if (!Array.isArray(linesToBook) || linesToBook.length === 0) {
|
if (!Array.isArray(linesToBook) || linesToBook.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -147,6 +154,7 @@ export async function bookDeliveryLines({
|
|||||||
|
|
||||||
const res = await ledgerApi.add({
|
const res = await ledgerApi.add({
|
||||||
manifestarticle_id: manifestarticleId,
|
manifestarticle_id: manifestarticleId,
|
||||||
|
warehouse_id: numericWarehouseId,
|
||||||
transaction_type: 'DELIVERY',
|
transaction_type: 'DELIVERY',
|
||||||
amount,
|
amount,
|
||||||
transaction_text: memo,
|
transaction_text: memo,
|
||||||
|
|||||||
Reference in New Issue
Block a user