diff --git a/src/automats/customergroupAutomat.js b/src/automats/customergroupAutomat.js index ecab921..e87e899 100644 --- a/src/automats/customergroupAutomat.js +++ b/src/automats/customergroupAutomat.js @@ -1,16 +1,19 @@ /** * @file customergroupAutomat.js * @description Manages cached customer groups (`GET /api/customergroup`) for - * the active company. Built via `createListAutomat`. + * the active company. Follows the brand/warehouse automat pattern with + * filtering, deduplicated loads, silent reloads, and CRUD actions. * * Upstream Triggers: - * - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with - * a `selectedCompanyId`, and `reset()` on `'anon'`. - * - UI / Routes: Manual calls to `load()` or `reload()`. + * - `authAutomat`: Triggers `actions.load()` on `'authed'` with a `selectedCompanyId`, + * and `actions.reset()` on `'anon'`. + * - UI / Routes: Mutating actions (`create`, `update`, `remove`, `reorder`, `filter`). */ +import { Automat } from 'react-automat'; import { customergroup as customergroupApi } from '../api'; -import { createListAutomat } from './createListAutomat'; +import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; +import authAutomat from './authAutomat'; /** * @typedef {Object} CustomerGroup @@ -20,22 +23,175 @@ import { createListAutomat } from './createListAutomat'; * @property {boolean} editable False for reserved POS / Lieferanten groups. */ +/** + * @typedef {Object} CustomerGroupState + * @property {Array} items Loaded records. + * @property {Array} filteredItems Records matching active search filter. + * @property {string} filter Active search/filter string. + * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. + * @property {Error|string|null} error Last load error. + */ + +/** @type {CustomerGroupState} */ +const initialState = { + items: [], + filteredItems: [], + filter: '', + status: 'idle', + error: null, +}; + +const applyFilter = (groups, filter) => { + const query = String(filter ?? '').trim().toLowerCase(); + return query + ? groups.filter((g) => String(g.name ?? '').toLowerCase().includes(query)) + : groups; +}; + +const sortGroups = (items) => + [...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0)); + +let customergroupAutomat; + +const updateStateWithGroups = (groups, extra = {}) => { + const list = sortGroups(Array.isArray(groups) ? groups : []); + const filteredItems = applyFilter(list, customergroupAutomat.state.filter); + customergroupAutomat.setState({ items: list, filteredItems, ...extra }); + return list; +}; + +async function fetchGroups() { + const data = await customergroupApi.list(); + requireObject(data, 'GET /api/customergroup'); + return requireArray(data.customergroups, 'customergroups'); +} + +let inflightLoad = null; + +function loadGroups() { + if (inflightLoad) return inflightLoad; + inflightLoad = (async () => { + customergroupAutomat.setState({ status: 'loading', error: null }); + try { + const groups = await fetchGroups(); + updateStateWithGroups(groups, { status: 'ready', error: null }); + return groups; + } catch (error) { + customergroupAutomat.setState({ status: 'failed', error }); + throw error; + } finally { + inflightLoad = null; + } + })(); + return inflightLoad; +} + +const actions = { + /** Sets the active search filter query and updates filteredItems. */ + filter(query) { + const filter = String(query ?? ''); + customergroupAutomat.setState({ + filter, + filteredItems: applyFilter(customergroupAutomat.state.items, filter), + }); + }, + + /** Loads the list, deduplicating concurrent callers. */ + load() { + return loadGroups(); + }, + + /** Silently refreshes the customer groups without flashing loading state. */ + async reload() { + try { + const groups = await fetchGroups(); + updateStateWithGroups(groups, { status: 'ready', error: null }); + return groups; + } catch (error) { + customergroupAutomat.setState({ error }); + throw error; + } + }, + + /** Replaces the cached list without an API call. */ + replace(items) { + return updateStateWithGroups(items, { status: 'ready', error: null }); + }, + + /** Returns cached items if ready, otherwise loads. */ + ensureLoaded() { + if (customergroupAutomat.state.status === 'ready') { + return Promise.resolve(customergroupAutomat.state.items); + } + return actions.load(); + }, + + /** Resets state to initial idle values. */ + reset() { + inflightLoad = null; + customergroupAutomat.setState(initialState); + }, + + /** Creates a customer group and refreshes the cache. */ + async create(body) { + const data = await customergroupApi.create(body); + requireObject(data, 'POST /api/customergroup'); + requireObject(data.customergroup, 'customergroup'); + requireDefined(data.customergroup.id, 'customergroup.id'); + await customergroupAutomat.actions.reload(); + return data.customergroup; + }, + + /** Updates a customer group name and refreshes the cache. */ + async update(id, body) { + const data = await customergroupApi.update(id, body); + requireObject(data, 'PATCH /api/customergroup/:id'); + requireObject(data.customergroup, 'customergroup'); + requireDefined(data.customergroup.id, 'customergroup.id'); + await customergroupAutomat.actions.reload(); + return data.customergroup; + }, + + rename(id, body) { + return actions.update(id, body); + }, + + /** Deletes a customer group and refreshes the cache. */ + async remove(id) { + await customergroupApi.delete(id); + await customergroupAutomat.actions.reload(); + }, + + /** Reorders customer groups and refreshes the cache. */ + async reorder(customergroupIds) { + const data = await customergroupApi.order({ customergroupIds }); + requireObject(data, 'PUT /api/customergroup/order'); + await customergroupAutomat.actions.reload(); + }, +}; + /** * Company customer groups (`GET /api/customergroup`). * * Reserved groups (`POS`, `Lieferanten`) are flagged `editable: false` and must * not be renamed or deleted by the UI. * - * @type {import('react-automat').Automat} + * @type {Automat} */ -export const customergroupAutomat = createListAutomat({ +customergroupAutomat = new Automat(initialState, actions, { name: 'customergroup', - fetch: async () => { - const data = await customergroupApi.list(); - return data.customergroups ?? []; - }, - sort: (items) => - [...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0)), +}); + +export { customergroupAutomat }; + +// Load on session/company establishment, reset on logout. +authAutomat.subscribe((auth) => { + const companyId = auth.user?.selectedCompanyId; + if (auth.status === 'authed' && companyId) { + void actions.load().catch(() => {}); + } else if (auth.status === 'anon') { + actions.reset(); + } }); export default customergroupAutomat; diff --git a/src/components/kunde/KundeGruppe.jsx b/src/components/kunde/KundeGruppe.jsx new file mode 100644 index 0000000..30f3efa --- /dev/null +++ b/src/components/kunde/KundeGruppe.jsx @@ -0,0 +1,155 @@ +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 customergroupAutomat from '../../automats/customergroupAutomat'; +import { notifyError } from '../../utils/notify'; + +class KundeGruppe extends PureComponent { + constructor(props) { + super(props); + const item = props.group ?? props.item; + this.state = { + draft: item?.name ?? '', + }; + } + + componentDidUpdate(prevProps) { + const prevItem = prevProps.group ?? prevProps.item; + const item = this.props.group ?? 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.group ?? this.props.item; + this.props.onChange?.(next, item); + }; + + handleBlur = () => { + const item = this.props.group ?? this.props.item; + if (!item) return; + const editable = item.editable !== false; + if (!editable) return; + + const draft = this.state.draft.trim(); + if (!draft) { + this.setState({ draft: item.name ?? '' }); + return; + } + if (draft !== item.name) { + void this.renameGroup(item, draft); + } + }; + + async renameGroup(item, name) { + try { + await customergroupAutomat.actions.update(item.id, { name }); + enqueueSnackbar('Kundengruppe gespeichert.', { variant: 'success' }); + } catch (err) { + this.setState({ draft: item.name ?? '' }); + notifyError(err); + } + } + + handleKeyDown = (event) => { + const item = this.props.group ?? this.props.item; + const editable = item?.editable !== false; + if (!editable) return; + + 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.group ?? this.props.item; + if (!item) return; + const editable = item.editable !== false; + if (!editable) return; + + if (!window.confirm(`Kundengruppe „${item.name}“ wirklich löschen?`)) return; + void (async () => { + try { + await customergroupAutomat.actions.remove(item.id); + enqueueSnackbar('Kundengruppe gelöscht.', { variant: 'success' }); + } catch (err) { + notifyError(err); + } + })(); + }; + + render() { + const { disabled, sx } = this.props; + const item = this.props.group ?? this.props.item; + const { draft } = this.state; + const name = item?.name ?? ''; + const editable = item?.editable !== false; + + return ( + + + + + + + + + + + + ); + } +} + +export default KundeGruppe; diff --git a/src/components/kunde/index.js b/src/components/kunde/index.js new file mode 100644 index 0000000..6a5a50d --- /dev/null +++ b/src/components/kunde/index.js @@ -0,0 +1,2 @@ +export { default as KundeGruppe } from './KundeGruppe'; +export { default as withEntityRoute } from './withEntityRoute'; diff --git a/src/routes/kunde/gruppen.jsx b/src/routes/kunde/gruppen.jsx index cada452..b61c434 100644 --- a/src/routes/kunde/gruppen.jsx +++ b/src/routes/kunde/gruppen.jsx @@ -1,226 +1,270 @@ -import { PureComponent } from 'react'; +import { PureComponent, createRef } 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 GroupsIcon from '@mui/icons-material/Groups'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import Sortable from 'sortablejs'; import { enqueueSnackbar } from 'notistack'; import BackendError from '../../components/BackendError'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; -import { customergroup as customergroupApi } from '../../api'; -import customergroupAutomat from '../../automats/customergroupAutomat'; -import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; +import withNavigate from '../../components/withNavigate'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; -import { - PURPLE_BG, -} from '../../colors'; +import customergroupAutomat from '../../automats/customergroupAutomat'; +import { KundeGruppe } from '../../components/kunde'; +import { PURPLE_BG } from '../../colors'; -function sortGroups(groups) { - return [...groups].sort((a, b) => { - const bySort = a.sort - b.sort; - if (bySort !== 0) return bySort; - const byName = String(a.name).localeCompare(String(b.name), 'de'); - if (byName !== 0) return byName; - return a.id - b.id; - }); +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 }; } -function draftsFrom(groups) { - return Object.fromEntries(groups.map((row) => [row.id, row.name])); -} +class KundeGruppenSearchField extends PureComponent { + constructor(props) { + super(props); + const initial = customergroupAutomat.state.filter || props.prefill || ''; + this.state = { + input: initial, + submitting: false, + }; + if (props.prefill && !customergroupAutomat.state.filter) { + customergroupAutomat.actions.filter(props.prefill); + } + this.handleSubmit = this.handleSubmit.bind(this); + } -function requireGroupListItem(row, path) { - requireObject(row, path); - requireDefined(row.id, `${path}.id`); - requireDefined(row.name, `${path}.name`); - requireDefined(row.sort, `${path}.sort`); - requireDefined(row.editable, `${path}.editable`); + componentDidMount() { + this.unsub = customergroupAutomat.subscribe(this, (state) => ({ + input: state.filter, + })); + } + + componentWillUnmount() { + this.unsub?.(); + } + + onInputChange(input) { + this.setState({ input }); + customergroupAutomat.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 customergroupAutomat.actions.create({ name }); + enqueueSnackbar(`Kundengruppe „${row.name || name}“ angelegt.`, { variant: 'success' }); + this.setState({ input: '', submitting: false }); + customergroupAutomat.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 + ? `Kundengruppenname „${input}“ übernommen.` + : 'Neue Kundengruppe anlegen.'} + + ) : null} + + + this.onInputChange(next)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void this.handleSubmit(); + } + }} + /> + + + + + ); + } } class KundeGruppenPage extends PureComponent { constructor(props) { super(props); + const initialGroups = + customergroupAutomat.state.filteredItems ?? customergroupAutomat.state.items ?? []; this.state = { - status: 'loading', - error: null, - groups: [], - drafts: {}, - newName: '', - busyKey: null, + status: customergroupAutomat.state.status === 'ready' ? 'ready' : 'loading', + error: customergroupAutomat.state.error ? backendErrorFrom(customergroupAutomat.state.error) : null, + groups: initialGroups, + filter: customergroupAutomat.state.filter ?? '', }; + this.searchRef = createRef(); + this.listRef = createRef(); + this._sortable = null; this._cancelled = false; - this.handleCreate = this.handleCreate.bind(this); this.handleRetry = this.handleRetry.bind(this); + this._initSortable = this._initSortable.bind(this); + this._destroySortable = this._destroySortable.bind(this); } componentDidMount() { this._cancelled = false; - void this.load(); + + this.unsub = customergroupAutomat.subscribe(this, (state) => ({ + groups: state.filteredItems ?? state.items, + filter: state.filter ?? '', + status: state.status, + error: state.error ? backendErrorFrom(state.error) : null, + })); + + if (customergroupAutomat.state.status !== 'ready') { + void customergroupAutomat.actions.load().catch((err) => notifyError(err)); + } else { + this._initSortable(); + } + } + + componentDidUpdate(prevProps, prevState) { + if ( + prevState.status !== this.state.status || + prevState.groups !== this.state.groups || + prevState.filter !== this.state.filter + ) { + if (this.state.status === 'ready') { + this._initSortable(); + } + } } componentWillUnmount() { this._cancelled = true; + this.unsub?.(); + this._destroySortable(); } - applyGroups(groups) { - groups.forEach((row, index) => { - requireGroupListItem(row, `customergroups[${index}]`); - }); - const sorted = sortGroups(groups); - this.setState({ - status: 'ready', - error: null, - groups: sorted, - drafts: draftsFrom(sorted), + _initSortable() { + this._destroySortable(); + 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.groups]; + const [moved] = next.splice(oldIndex, 1); + next.splice(newIndex, 0, moved); + return { groups: next }; + }, + () => { + const ids = this.state.groups.map((g) => g.id); + void this._persistOrder(ids); + } + ); + }, }); } - async load() { - this.setState({ status: 'loading', error: null }); + _destroySortable() { + this._sortable?.destroy(); + this._sortable = null; + } + + async _persistOrder(customergroupIds) { try { - const list = await customergroupAutomat.actions.ensureLoaded(); - if (this._cancelled) return; - this.applyGroups(list); + await customergroupAutomat.actions.reorder(customergroupIds); } catch (err) { if (this._cancelled) return; - this.setState({ status: 'failed', error: backendErrorFrom(err) }); + notifyError(err); + void customergroupAutomat.actions.reload(); } } handleRetry() { - void this.load(); - } - - async refreshFromServer() { - const list = await customergroupAutomat.actions.reload(); - if (this._cancelled) return; - this.applyGroups(list); - } - - async handleCreate() { - const name = this.state.newName.trim(); - if (!name || this.state.busyKey != null) return; - - this.setState({ busyKey: 'new' }); - try { - const data = await customergroupApi.create({ name }); - requireObject(data, 'POST /api/customergroup'); - requireObject(data.customergroup, 'customergroup'); - requireDefined(data.customergroup.id, 'customergroup.id'); - requireDefined(data.customergroup.sort, 'customergroup.sort'); - await this.refreshFromServer(); - if (this._cancelled) return; - this.setState({ newName: '', busyKey: null }); - enqueueSnackbar('Kundengruppe angelegt.', { variant: 'success' }); - } catch (err) { - if (this._cancelled) return; - notifyError(err); - this.setState({ busyKey: null }); - } - } - - async handleRename(group) { - if (!group.editable) return; - const draft = String(this.state.drafts[group.id] ?? '').trim(); - if (!draft) { - this.setState((prev) => ({ - drafts: { ...prev.drafts, [group.id]: group.name }, - })); - return; - } - if (draft === group.name || this.state.busyKey != null) return; - - this.setState({ busyKey: group.id }); - try { - const data = await customergroupApi.update(group.id, { - name: draft, - }); - requireObject(data, 'PATCH /api/customergroup/:id'); - requireObject(data.customergroup, 'customergroup'); - requireDefined(data.customergroup.id, 'customergroup.id'); - requireDefined(data.customergroup.sort, 'customergroup.sort'); - await this.refreshFromServer(); - if (this._cancelled) return; - this.setState({ busyKey: null }); - enqueueSnackbar('Kundengruppe gespeichert.', { variant: 'success' }); - } catch (err) { - if (this._cancelled) return; - notifyError(err); - this.setState((prev) => ({ - busyKey: null, - drafts: { ...prev.drafts, [group.id]: group.name }, - })); - } - } - - async handleMove(group, delta) { - const { groups, busyKey } = this.state; - if (busyKey != null) return; - - const index = groups.findIndex((row) => row.id === group.id); - const targetIndex = index + delta; - if (index < 0 || targetIndex < 0 || targetIndex >= groups.length) return; - - const next = [...groups]; - const [row] = next.splice(index, 1); - next.splice(targetIndex, 0, row); - const customergroupIds = next.map((item) => item.id); - - this.setState({ busyKey: group.id }); - try { - const data = await customergroupApi.order({ customergroupIds }); - requireObject(data, 'PUT /api/customergroup/order'); - requireArray(data.customergroupIds, 'customergroupIds'); - await this.refreshFromServer(); - if (this._cancelled) return; - this.setState({ busyKey: null }); - } catch (err) { - if (this._cancelled) return; - notifyError(err); - try { - await this.refreshFromServer(); - } catch (refreshErr) { - notifyError(refreshErr); - } - if (this._cancelled) return; - this.setState({ busyKey: null }); - } - } - - async handleDelete(group) { - if (!group.editable || this.state.busyKey != null) return; - if (!window.confirm(`Kundengruppe „${group.name}“ wirklich löschen?`)) { - return; - } - - this.setState({ busyKey: group.id }); - try { - await customergroupApi.delete(group.id); - await this.refreshFromServer(); - if (this._cancelled) return; - this.setState({ busyKey: null }); - enqueueSnackbar('Kundengruppe gelöscht.', { variant: 'success' }); - } catch (err) { - if (this._cancelled) return; - notifyError(err); - this.setState({ busyKey: null }); - } + void customergroupAutomat.actions.load().catch((err) => notifyError(err)); } render() { - const { status, error, groups, drafts, newName, busyKey } = this.state; - const busy = busyKey != null; + const { status, error, groups, filter } = this.state; + const { prefill, returnTo } = getInitialParams(this.props); - if (status === 'loading') { + if (status === 'loading' || status === 'idle') { return ( @@ -232,117 +276,40 @@ class KundeGruppenPage extends PureComponent { return ; } + const trimmedFilter = filter?.trim() ?? ''; + return ( - - - - + <> + + + + {groups.length === 0 ? ( - Noch keine Kundengruppen. + {trimmedFilter + ? `Keine Treffer für „${trimmedFilter}“.` + : 'Noch keine Kundengruppen angelegt.'} ) : ( - groups.map((group, index) => { - const editable = group.editable === true; - return ( - - - - this.setState((prev) => ({ - drafts: { ...prev.drafts, [group.id]: next }, - })) - } - onBlur={() => void this.handleRename(group)} - onKeyDown={(event) => { - if (!editable) return; - if (event.key === 'Enter') { - event.preventDefault(); - event.target.blur(); - } - if (event.key === 'Escape') { - event.preventDefault(); - this.setState((prev) => ({ - drafts: { - ...prev.drafts, - [group.id]: group.name, - }, - })); - event.target.blur(); - } - }} - sx={{ flex: 1 }} - /> - void this.handleMove(group, -1)} - > - - - = groups.length - 1} - onClick={() => void this.handleMove(group, 1)} - > - - - void this.handleDelete(group)} - > - - - - ); - }) + groups.map((group) => ( + + )) )} - - - this.setState({ newName: next })} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - void this.handleCreate(); - } - }} - sx={{ flex: 1 }} - /> - - - - - + + + ); } } -export default KundeGruppenPage; +export { KundeGruppe, KundeGruppenSearchField }; +export default withNavigate(KundeGruppenPage);