From 45cc69730b44930be23422fe9621cd0b14f4f046 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 11 Oct 2026 03:27:02 +0200 Subject: [PATCH] u --- src/automats/warehouseAutomat.js | 41 +- src/components/warehouse/ArtikelLager.jsx | 145 +++++++ src/components/warehouse/index.js | 1 + src/routes/artikel/lager.jsx | 477 +++++++++------------- 4 files changed, 370 insertions(+), 294 deletions(-) create mode 100644 src/components/warehouse/ArtikelLager.jsx create mode 100644 src/components/warehouse/index.js diff --git a/src/automats/warehouseAutomat.js b/src/automats/warehouseAutomat.js index 165c1a5..67be642 100644 --- a/src/automats/warehouseAutomat.js +++ b/src/automats/warehouseAutomat.js @@ -23,6 +23,8 @@ import authAutomat from './authAutomat'; /** * @typedef {Object} WarehouseState * @property {Array} warehouses Company-scoped warehouses. + * @property {Array} filteredWarehouses Filtered warehouses according to active filter query. + * @property {string} filter Active search/filter string. * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. * @property {Error|null} error Last load error. */ @@ -30,12 +32,26 @@ import authAutomat from './authAutomat'; /** @type {WarehouseState} */ const initialState = { warehouses: [], + filteredWarehouses: [], + filter: '', status: 'idle', error: null, }; let warehouseAutomat; +const applyFilter = (warehouses, filter) => { + const query = String(filter ?? '').trim().toLowerCase(); + return query ? warehouses.filter((w) => String(w.name ?? '').toLowerCase().includes(query)) : warehouses; +}; + +const updateStateWithWarehouses = (warehouses, extra = {}) => { + const list = Array.isArray(warehouses) ? warehouses : []; + const filteredWarehouses = applyFilter(list, warehouseAutomat.state.filter); + warehouseAutomat.setState({ warehouses: list, filteredWarehouses, ...extra }); + return list; +}; + /** * Fetches the company-scoped warehouse list. * @@ -61,7 +77,7 @@ function loadWarehouses() { warehouseAutomat.setState({ status: 'loading', error: null }); try { const warehouses = await fetchWarehouses(); - warehouseAutomat.setState({ warehouses, status: 'ready', error: null }); + updateStateWithWarehouses(warehouses, { status: 'ready', error: null }); return warehouses; } catch (error) { warehouseAutomat.setState({ status: 'failed', error }); @@ -74,6 +90,15 @@ function loadWarehouses() { } const actions = { + /** Sets the active warehouse search filter and updates filteredWarehouses. */ + filter(query) { + const filter = String(query ?? ''); + warehouseAutomat.setState({ + filter, + filteredWarehouses: applyFilter(warehouseAutomat.state.warehouses, filter), + }); + }, + /** * Loads the warehouse list, exposing a loading/failed lifecycle. * @@ -91,7 +116,7 @@ const actions = { async reload() { try { const warehouses = await fetchWarehouses(); - warehouseAutomat.setState({ warehouses, status: 'ready', error: null }); + updateStateWithWarehouses(warehouses, { status: 'ready', error: null }); return warehouses; } catch (error) { warehouseAutomat.setState({ error }); @@ -130,6 +155,10 @@ const actions = { return data.warehouse; }, + rename(id, body) { + return actions.update(id, body); + }, + /** * Deletes a warehouse and refreshes the cached list. * @@ -148,6 +177,11 @@ const actions = { * @returns {Promise} */ async reorder(warehouseIds) { + const byId = new Map(warehouseAutomat.state.warehouses.map((w) => [w.id, w])); + const reordered = warehouseIds.map((id) => byId.get(id)).filter(Boolean); + if (reordered.length === warehouseAutomat.state.warehouses.length) { + updateStateWithWarehouses(reordered); + } await warehouseApi.order({ warehouseIds }); await warehouseAutomat.actions.reload(); }, @@ -159,8 +193,7 @@ const actions = { * @returns {void} */ setWarehouses(warehouses) { - warehouseAutomat.setState({ - warehouses: Array.isArray(warehouses) ? warehouses : [], + updateStateWithWarehouses(warehouses, { status: 'ready', error: null, }); diff --git a/src/components/warehouse/ArtikelLager.jsx b/src/components/warehouse/ArtikelLager.jsx new file mode 100644 index 0000000..1317c03 --- /dev/null +++ b/src/components/warehouse/ArtikelLager.jsx @@ -0,0 +1,145 @@ +import { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import ListItem from '@mui/material/ListItem'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; +import { enqueueSnackbar } from 'notistack'; +import warehouseAutomat from '../../automats/warehouseAutomat'; +import { notifyError } from '../../utils/notify'; + +class ArtikelLager extends PureComponent { + constructor(props) { + super(props); + const item = props.warehouse ?? props.item; + this.state = { + draft: item?.name ?? '', + }; + } + + componentDidUpdate(prevProps) { + const prevItem = prevProps.warehouse ?? prevProps.item; + const item = this.props.warehouse ?? this.props.item; + if (prevItem?.name !== item?.name) { + this.setState({ draft: item?.name ?? '' }); + } else if (prevProps.disabled && !this.props.disabled) { + if (this.state.draft !== item?.name) { + this.setState({ draft: item?.name ?? '' }); + } + } + } + + handleChange = (next) => { + this.setState({ draft: next }); + const item = this.props.warehouse ?? this.props.item; + this.props.onChange?.(next, item); + }; + + handleBlur = () => { + const item = this.props.warehouse ?? this.props.item; + if (!item) return; + const draft = this.state.draft.trim(); + if (!draft) { + this.setState({ draft: item.name ?? '' }); + return; + } + if (draft !== item.name) { + void this.renameWarehouse(item, draft); + } + }; + + async renameWarehouse(item, name) { + try { + await warehouseAutomat.actions.update(item.id, { name }); + enqueueSnackbar('Lager gespeichert.', { variant: 'success' }); + } catch (err) { + this.setState({ draft: item.name ?? '' }); + notifyError(err); + } + } + + handleKeyDown = (event) => { + const item = this.props.warehouse ?? this.props.item; + if (event.key === 'Enter') { + event.preventDefault(); + event.target.blur(); + } + if (event.key === 'Escape') { + event.preventDefault(); + this.setState({ draft: item?.name ?? '' }); + event.target.blur(); + } + }; + + handleDelete = () => { + const item = this.props.warehouse ?? this.props.item; + if (!item) return; + if (!window.confirm(`Lager „${item.name}" wirklich löschen?`)) return; + void (async () => { + try { + await warehouseAutomat.actions.remove(item.id); + enqueueSnackbar('Lager gelöscht.', { variant: 'success' }); + } catch (err) { + notifyError(err); + } + })(); + }; + + render() { + const { disabled, sx } = this.props; + const item = this.props.warehouse ?? this.props.item; + const { draft } = this.state; + const name = item?.name ?? ''; + + return ( + + + + + + + + + + + + ); + } +} + +export default ArtikelLager; diff --git a/src/components/warehouse/index.js b/src/components/warehouse/index.js new file mode 100644 index 0000000..0bb7add --- /dev/null +++ b/src/components/warehouse/index.js @@ -0,0 +1 @@ +export { default as ArtikelLager } from './ArtikelLager'; diff --git a/src/routes/artikel/lager.jsx b/src/routes/artikel/lager.jsx index ead2923..12e1177 100644 --- a/src/routes/artikel/lager.jsx +++ b/src/routes/artikel/lager.jsx @@ -3,14 +3,8 @@ 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 DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import WarehouseIcon from '@mui/icons-material/Warehouse'; import Sortable from 'sortablejs'; import { enqueueSnackbar } from 'notistack'; import BackendError from '../../components/BackendError'; @@ -21,13 +15,8 @@ import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; import { setLastArticleSku } from '../../automats'; import warehouseAutomat from '../../automats/warehouseAutomat'; -import { - GREEN_PANEL, -} from '../../colors'; - -function draftsFrom(warehouses) { - return Object.fromEntries(warehouses.map((row) => [row.id, row.name])); -} +import { ArtikelLager } from '../../components/warehouse'; +import { GREEN_PANEL } from '../../colors'; function getInitialParams(props) { const searchParams = @@ -45,62 +34,176 @@ function getInitialParams(props) { return { prefill: prefill.trim(), returnTo }; } +class ArtikelLagerSearchField extends PureComponent { + constructor(props) { + super(props); + const initial = warehouseAutomat.state.filter || props.prefill || ''; + this.state = { + input: initial, + submitting: false, + }; + if (props.prefill && !warehouseAutomat.state.filter) { + warehouseAutomat.actions.filter(props.prefill); + } + this.handleSubmit = this.handleSubmit.bind(this); + } + + componentDidMount() { + this.unsub = warehouseAutomat.subscribe(this, (state) => ({ + input: state.filter, + })); + } + + componentWillUnmount() { + this.unsub?.(); + } + + onInputChange(input) { + this.setState({ input }); + warehouseAutomat.actions.filter(input); + } + + async handleSubmit() { + const name = this.state.input.trim(); + if (!name || this.state.submitting) return; + + this.setState({ submitting: true }); + try { + const row = await warehouseAutomat.actions.create({ name }); + enqueueSnackbar(`Lager „${row.name || name}“ angelegt.`, { variant: 'success' }); + this.setState({ input: '', submitting: false }); + warehouseAutomat.actions.filter(''); + if (this.props.returnTo && this.props.navigate) { + this.props.navigate(this.props.returnTo); + } + } catch (err) { + this.setState({ submitting: false }); + notifyError(err); + } + } + + render() { + const { inputRef, returnTo, navigate } = this.props; + const { input, submitting } = this.state; + const canSubmit = Boolean(input.trim()) && !submitting; + + return ( + + + {returnTo ? ( + navigate?.(returnTo)} + > + Zurück + + } + > + {input + ? `Lagername „${input}“ übernommen.` + : 'Neues Lager anlegen.'} + + ) : null} + + + this.onInputChange(next)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void this.handleSubmit(); + } + }} + /> + + + + + ); + } +} + class ArtikelLagerPage extends PureComponent { constructor(props) { super(props); - const { prefill } = getInitialParams(props); - const initialWarehouses = warehouseAutomat.state.warehouses || []; + const initialWarehouses = + warehouseAutomat.state.filteredWarehouses ?? 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, + filter: warehouseAutomat.state.filter ?? '', }; - this._cancelled = false; + this.searchRef = createRef(); this.listRef = createRef(); this._sortable = null; - - this.handleCreate = this.handleCreate.bind(this); + this._cancelled = false; this.handleRetry = this.handleRetry.bind(this); - this.loadWarehouses = this.loadWarehouses.bind(this); this._initSortable = this._initSortable.bind(this); this._destroySortable = this._destroySortable.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) => { + this.unsub = warehouseAutomat.subscribe(this, (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) }); - } + return { + warehouses: state.filteredWarehouses, + filter: state.filter, + status: state.status, + error: state.error ? backendErrorFrom(state.error) : null, + }; }); - void this.loadWarehouses(); + if (warehouseAutomat.state.status !== 'ready') { + void warehouseAutomat.actions.load().catch((err) => notifyError(err)); + } else { + this._initSortable(); + } } componentDidUpdate(prevProps, prevState) { - if (prevProps.location !== this.props.location) { - const { prefill } = getInitialParams(this.props); - if (prefill && prefill !== this.state.newName) { - this.setState({ newName: prefill }); + if ( + prevState.status !== this.state.status || + prevState.warehouses !== this.state.warehouses || + prevState.filter !== this.state.filter + ) { + if (this.state.status === 'ready') { + this._initSortable(); } } - - // Re-initialise Sortable when warehouses change or list mounts - if (prevState.warehouses !== this.state.warehouses && this.listRef.current) { - this._initSortable(); - } } componentWillUnmount() { @@ -114,24 +217,30 @@ class ArtikelLagerPage extends PureComponent { const el = this.listRef.current; if (!el) return; + const isFiltered = Boolean(this.state.filter?.trim()); + this._sortable = Sortable.create(el, { handle: '.drag-handle', animation: 150, ghostClass: 'sortable-ghost', chosenClass: 'sortable-chosen', + disabled: isFiltered, onEnd: (evt) => { const { oldIndex, newIndex } = evt; if (oldIndex === newIndex) return; - this.setState((prev) => { - const next = [...prev.warehouses]; - const [moved] = next.splice(oldIndex, 1); - next.splice(newIndex, 0, moved); - return { warehouses: next }; - }, () => { - const ids = this.state.warehouses.map((w) => w.id); - void this._persistOrder(ids); - }); + this.setState( + (prev) => { + const next = [...prev.warehouses]; + const [moved] = next.splice(oldIndex, 1); + next.splice(newIndex, 0, moved); + return { warehouses: next }; + }, + () => { + const ids = this.state.warehouses.map((w) => w.id); + void this._persistOrder(ids); + } + ); }, }); } @@ -147,133 +256,19 @@ class ArtikelLagerPage extends PureComponent { } catch (err) { if (this._cancelled) return; notifyError(err); - // Roll back optimistic reorder - void this.refreshFromServer(); - } - } - - applyWarehouses(warehouses) { - // Preserve order returned by the server (already sorted by sort/name from backend) - this.setState((prev) => { - const currentDrafts = prev.drafts || {}; - const nextDrafts = { ...draftsFrom(warehouses) }; - // Preserve unsaved in-progress edits for still-existing items if not busy - for (const wh of warehouses) { - if (prev.busyKey !== wh.id && currentDrafts[wh.id] !== undefined) { - nextDrafts[wh.id] = currentDrafts[wh.id]; - } - } - return { - status: 'ready', - error: null, - warehouses, - 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) }); + void warehouseAutomat.actions.reload(); } } 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 }); - } + void warehouseAutomat.actions.load().catch((err) => notifyError(err)); } render() { - const { status, error, warehouses, drafts, newName, busyKey } = this.state; - const busy = busyKey != null; + const { status, error, warehouses } = this.state; + const { prefill, returnTo } = getInitialParams(this.props); - const { returnTo } = getInitialParams(this.props); - - if (status === 'loading') { + if (status === 'loading' || status === 'idle') { return ( @@ -287,127 +282,28 @@ class ArtikelLagerPage extends PureComponent { return ( <> - {/* SortableJS ghost / chosen styling */} - + - - - {returnTo ? ( - this.props.navigate?.(returnTo)} - > - Zurück - - } - > - {newName - ? `Lagername „${newName}" übernommen.` - : 'Neues Lager anlegen.'} - - ) : null} - - - Reihenfolge per Drag & Drop anpassen. - - - - {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 }} - /> - - - + + + {warehouses.length === 0 ? ( + + Noch keine Lager angelegt. + + ) : ( + warehouses.map((wh) => ( + + )) + )} + @@ -415,4 +311,5 @@ class ArtikelLagerPage extends PureComponent { } } +export { ArtikelLager, ArtikelLagerSearchField }; export default withNavigate(ArtikelLagerPage);