From 3ab9d904e6df5b8a2bc29f206e7bd032e3680ce3 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 26 Sep 2026 23:05:48 +0200 Subject: [PATCH] u --- src/api/ApiClient.js | 18 + src/api/StorageManager.js | 6 + src/api/index.js | 54 ++ src/automats/index.js | 1 + src/automats/warehouseAutomat.js | 201 +++++++ .../article/ArticleStockAdjustDialog.jsx | 516 ++++++++++++++++++ .../article/boxes/ArticleViewEANs.jsx | 58 +- .../article/boxes/ArticleViewPrice.jsx | 3 +- .../article/boxes/ArticleViewStock.jsx | 223 +++++++- .../manifest/BookDeliveryDialog.jsx | 132 ++++- src/pages/artikel/lager.jsx | 350 +++++++++++- src/utils/deliveryLedger.js | 8 + 12 files changed, 1505 insertions(+), 65 deletions(-) create mode 100644 src/automats/warehouseAutomat.js create mode 100644 src/components/article/ArticleStockAdjustDialog.jsx diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index 51eee6b..514fe8e 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -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 = { /** GET /api/delivery → { deliveries: DeliverySummary[] } */ list: () => this.#json('GET', '/api/delivery'), diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index 15bb62c..4fbbb94 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -8,6 +8,7 @@ import unitAutomat from '../automats/unitAutomat'; import authAutomat from '../automats/authAutomat'; import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat'; 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); }); }); + es.addEventListener('warehouse', () => { + void warehouseAutomat.actions.reload().catch((err) => { + console.error('[storageMgr] warehouse SSE refresh failed', err); + }); + }); for (const sku of new Set([ ...this.#articleWatchers.keys(), ...this.#historyWatchers.keys(), diff --git a/src/api/index.js b/src/api/index.js index 83ac6ab..5b1d970 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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). * DeliverySummary = { id, number, externalNumber, type: 'delivery', supplierId, date } @@ -1264,4 +1316,6 @@ export default { supplier, thumb, unit, + warehouse, + stock, }; diff --git a/src/automats/index.js b/src/automats/index.js index 3ab56e1..377cc41 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -10,6 +10,7 @@ export { default as userSettingsAutomat } from './userSettingsAutomat'; export { setViewerPosition } from './userSettingsAutomat'; export { default as supplierAutomat } from './supplierAutomat'; +export { default as warehouseAutomat } from './warehouseAutomat'; export { default as artikelAutomat } from './artikelAutomat'; export { diff --git a/src/automats/warehouseAutomat.js b/src/automats/warehouseAutomat.js new file mode 100644 index 0000000..488b16b --- /dev/null +++ b/src/automats/warehouseAutomat.js @@ -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} 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>} 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>} 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>} Loaded warehouses. + */ + load() { + return loadWarehouses(); + }, + + /** + * Silently re-fetches the warehouse list while keeping the current state. + * + * @returns {Promise>} 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} 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} 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} + */ + async remove(id) { + await warehouseApi.delete(id); + await warehouseAutomat.actions.reload(); + }, + + /** + * Replaces the cached list without a round-trip. + * + * @param {Array} 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} + */ +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; diff --git a/src/components/article/ArticleStockAdjustDialog.jsx b/src/components/article/ArticleStockAdjustDialog.jsx new file mode 100644 index 0000000..f1a18a0 --- /dev/null +++ b/src/components/article/ArticleStockAdjustDialog.jsx @@ -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 ( + + + + + + Bestand anpassen + + + + + + + + + + {/* Article overview */} + + + Artikel ({article?.sku}) + + + {article?.name || '–'} + + + + {warehouses.length === 0 ? ( + + Es sind keine Lager vorhanden. Bitte legen Sie zuerst unter „Artikel → Lager“ ein Lager an. + + ) : ( + <> + {/* Warehouse selection & Current stock display */} + + + Lager * + + Lagerort für die Korrektur + + + + + Aktueller Bestand + + + {formatStock(currentStock)} {unit && {unit}} + + + + + {/* Adjustment Mode Toggle */} + + + Art der Anpassung + + + + + Menge zu-/abbuchen (+ / -) + + + + Neuen Zählbestand setzen + + + + + {/* Amount input */} + {mode === 'diff' ? ( + + {Number(diffValue) < 0 ? ( + + ) : ( + + )} + + ), + }, + }} + helperText={ + valid + ? `Neuer Bestand: ${formatStock(newStock)}${unit ? ` ${unit}` : ''}` + : 'Geben Sie einen positiven oder negativen Wert ein' + } + /> + ) : ( + 0 ? `+${formatStock(amount)}` : formatStock(amount)}${unit ? ` ${unit}` : ''}` + : 'Geben Sie den neu gezählten Bestand ein' + } + /> + )} + + {/* Summary calculation pill */} + {valid && amount !== 0 && ( + 0 ? '#ecfdf5' : '#fef2f2', + border: `1px solid ${amount > 0 ? '#a7f3d0' : '#fecaca'}`, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }} + > + + + Lager: {selectedWarehouse?.name || '–'} + + + {formatStock(currentStock)} → {formatStock(newStock)} + + + 0 ? 'success' : 'error'} + label={`${amount > 0 ? `+${formatStock(amount)}` : formatStock(amount)}${unit ? ` ${unit}` : ''}`} + sx={{ fontWeight: 700 }} + /> + + )} + + {/* Reason / Memo */} + + + + {REASON_PRESETS.map((preset) => ( + this.setState({ reason: preset })} + sx={{ fontSize: '0.75rem' }} + /> + ))} + + + + )} + + {error && ( + + {error} + + )} + + + + + + + + + + ); + } +} diff --git a/src/components/article/boxes/ArticleViewEANs.jsx b/src/components/article/boxes/ArticleViewEANs.jsx index e0b3aa1..e4a8f4b 100644 --- a/src/components/article/boxes/ArticleViewEANs.jsx +++ b/src/components/article/boxes/ArticleViewEANs.jsx @@ -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 ( - onChange({ eans })} - renderInput={(params) => ( - + {sku} + + onChange({ eans })} + renderInput={(params) => ( + + )} /> - )} - /> + + ); } } @@ -121,7 +126,12 @@ export default class ArticleViewEANs extends PureComponent { canSave={eansDraftValid} display={} edit={({ draft, onChange, inputRef }) => ( - + )} /> ); diff --git a/src/components/article/boxes/ArticleViewPrice.jsx b/src/components/article/boxes/ArticleViewPrice.jsx index bdd92fd..4429ea7 100644 --- a/src/components/article/boxes/ArticleViewPrice.jsx +++ b/src/components/article/boxes/ArticleViewPrice.jsx @@ -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={{ diff --git a/src/components/article/boxes/ArticleViewStock.jsx b/src/components/article/boxes/ArticleViewStock.jsx index 1fcee43..9191d4d 100644 --- a/src/components/article/boxes/ArticleViewStock.jsx +++ b/src/components/article/boxes/ArticleViewStock.jsx @@ -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 ( - - {label ? ( - - {label} - - ) : null} - - {children} - - - ); - } -} +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 ( - - - {articleData.stock != null ? formatStock(articleData.stock) : null} - + <> + + + {/* Top section: Headline and Total Stock */} + + + Bestand + + + + 0 ? 'text.primary' : 'text.secondary', + }} + > + {formatStock(totalStock)} + + {unit ? ( + + {unit} + + ) : null} + + + + {/* Bottom section: Per-warehouse breakdown */} + {hasWarehouses ? ( + + + {warehouses.map((wh) => ( + + + + + {wh.name} + + + + 0 + ? 'text.primary' + : 'text.secondary', + }} + > + {formatStock(wh.stock)} + + + ))} + + + ) : null} + - ); - } + + + + ); +} } diff --git a/src/components/manifest/BookDeliveryDialog.jsx b/src/components/manifest/BookDeliveryDialog.jsx index 4d33304..04b01fb 100644 --- a/src/components/manifest/BookDeliveryDialog.jsx +++ b/src/components/manifest/BookDeliveryDialog.jsx @@ -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 ( )} - + {warehouses.length === 0 && !isFullyBooked && ( + + Es ist noch kein Lager eingerichtet. Bitte legen Sie unter „Artikel → Lager“ zuerst ein Lager an. + + )} + + + + Ziellager * + + {warehouseError ? ( + {warehouseError} + ) : ( + Pflichtfeld: Ziellager für die Buchung + )} + + ) : totalToBook > 0 ? ( - - Es werden {totalToBook} Stück für{' '} - {countLinesToBook} Position{countLinesToBook === 1 ? '' : 'en'} als Lagerzugang (DELIVERY) im Ledger gebucht. + + {selectedWarehouseId ? ( + <> + Es werden {totalToBook} Stück für{' '} + {countLinesToBook} Position{countLinesToBook === 1 ? '' : 'en'} als Lagerzugang (DELIVERY) in das Lager{' '} + „{selectedWarehouse?.name || '–'}“ gebucht. + + ) : ( + <> + Bitte wählen Sie ein Ziellager aus, um die Buchung von{' '} + {totalToBook} Stück durchzuführen. + + )} ) : ( @@ -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 ? ( diff --git a/src/pages/artikel/lager.jsx b/src/pages/artikel/lager.jsx index c4271fd..ce541ce 100644 --- a/src/pages/artikel/lager.jsx +++ b/src/pages/artikel/lager.jsx @@ -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 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 warehouseAutomat from '../../automats/warehouseAutomat'; -export default function ArtikelLagerPage() { - useEffect(() => { - setLastArticleSku(null); - }, []); - - return ( - Lager — Inhalt folgt. +function sortWarehouses(warehouses) { + return [...warehouses].sort((a, b) => + String(a.name).localeCompare(String(b.name), 'de'), ); } + +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 ( + + + + ); + } + + if (status === 'failed') { + return ; + } + + return ( + + + + {returnTo ? ( + this.props.navigate?.(returnTo)} + > + Zurück + + } + > + {newName + ? `Lagername „${newName}“ übernommen.` + : 'Neues Lager anlegen.'} + + ) : null} + + + Lager der ausgewählten Firma. + + + + {warehouses.length === 0 ? ( + + Noch keine Lager angelegt. + + ) : ( + warehouses.map((wh) => ( + + + + 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 }} + /> + void this.handleDelete(wh)} + > + + + + )) + )} + + + + this.setState({ newName: next })} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void this.handleCreate(); + } + }} + sx={{ flex: 1 }} + /> + + + + + + ); + } +} + +export default withNavigate(ArtikelLagerPage); diff --git a/src/utils/deliveryLedger.js b/src/utils/deliveryLedger.js index 32091fc..db75dd0 100644 --- a/src/utils/deliveryLedger.js +++ b/src/utils/deliveryLedger.js @@ -116,6 +116,7 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def * @param {Object} params * @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 {number|string} params.warehouseId - Mandatory warehouse ID * @param {Object} [params.ledgerApi] * @param {string} [params.transactionText] * @returns {Promise>} @@ -123,9 +124,15 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def export async function bookDeliveryLines({ delivery, linesToBook, + warehouseId, ledgerApi = defaultLedgerApi, 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) { return []; } @@ -147,6 +154,7 @@ export async function bookDeliveryLines({ const res = await ledgerApi.add({ manifestarticle_id: manifestarticleId, + warehouse_id: numericWarehouseId, transaction_type: 'DELIVERY', amount, transaction_text: memo,