diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index a79742c..8ab5273 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -122,13 +122,15 @@ export class ApiClient { }; this.customergroup = { - /** GET /api/customergroup → { customergroups } */ + /** GET /api/customergroup → { customergroups: { id, name, sort, editable }[] } */ list: () => this.#json('GET', '/api/customergroup'), - /** POST /api/customergroup → 201 { customergroup } */ + /** POST /api/customergroup body { name } → 201 { customergroup: { id, name, sort } } */ create: (body) => this.#json('POST', '/api/customergroup', { body }), - /** PATCH /api/customergroup/{id} → { customergroup } */ + /** PATCH /api/customergroup/{id} body { name } → { customergroup: { id, name, sort } } */ update: (id, body) => this.#json('PATCH', `/api/customergroup/${enc(id)}`, { body }), + /** PUT /api/customergroup/order body { customergroupIds } → { customergroupIds } */ + order: (body) => this.#json('PUT', '/api/customergroup/order', { body }), /** DELETE /api/customergroup/{id} → 204 */ delete: (id) => this.#empty('DELETE', `/api/customergroup/${enc(id)}`), }; diff --git a/src/api/index.js b/src/api/index.js index 19c8d63..3facb18 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -682,17 +682,20 @@ export const order = { /** * Company-scoped customer groups. - * Shape: CustomerGroup = { id, name } + * Shape: CustomerGroup = { id, name, sort } + * List item: CustomerGroup & { editable } — editable false for POS / Lieferanten + * (name locked). Reorder via PUT /api/customergroup/order. */ export const customergroup = { - /** GET /api/customergroup → { customergroups: CustomerGroup[] } */ + /** GET /api/customergroup → { customergroups: (CustomerGroup & { editable })[] } ordered by sort */ list() { return http('/api/customergroup'); }, /** * POST /api/customergroup - * Body: { name } — empty name ⇒ 400 Name required. + * Body: { name } — empty name ⇒ 400 Name required; + * reserved POS / Lieferanten ⇒ 400 Name not allowed; sort appends next index. * → 201 { customergroup: CustomerGroup } */ create(body) { @@ -701,13 +704,23 @@ export const customergroup = { /** * PATCH /api/customergroup/:id - * Body: { name } + * Body: { name } — locked groups cannot be renamed (Name cannot be changed). * → 200 { customergroup: CustomerGroup } */ update(id, body) { return http(`/api/customergroup/${id}`, { method: 'PATCH', body }); }, + /** + * PUT /api/customergroup/order + * Body: { customergroupIds: number[] } — must be exactly the company set (same ids, no dups) + * else 400 Invalid customer group order. Sets sort to each id’s index 0…n-1. + * → 200 { customergroupIds: number[] } + */ + order(body) { + return http('/api/customergroup/order', { method: 'PUT', body }); + }, + /** DELETE /api/customergroup/:id → 204 · 409 Customer group in use */ delete(id) { return http(`/api/customergroup/${id}`, { method: 'DELETE' }); diff --git a/src/pages/kunde/gruppen.jsx b/src/pages/kunde/gruppen.jsx index 9ac29d6..0df0ae4 100644 --- a/src/pages/kunde/gruppen.jsx +++ b/src/pages/kunde/gruppen.jsx @@ -9,25 +9,39 @@ 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 { enqueueSnackbar } from 'notistack'; import BackendError from '../../components/BackendError'; import SingleLineTextEdit from '../../components/SingleLineTextEdit'; import { PanelTile, PanelTileGrid } from '../../components/panels'; import { StorageManagerContext } from '../../api/storageManagerContext'; -import { requireDefined, requireObject } from '../../utils/apiShape'; +import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; function sortGroups(groups) { - return [...groups].sort((a, b) => - String(a.name).localeCompare(String(b.name), 'de'), - ); + 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 draftsFrom(groups) { return Object.fromEntries(groups.map((row) => [row.id, row.name])); } +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`); +} + class KundeGruppenPage extends PureComponent { static contextType = StorageManagerContext; @@ -56,6 +70,9 @@ class KundeGruppenPage extends PureComponent { } applyGroups(groups) { + groups.forEach((row, index) => { + requireGroupListItem(row, `customergroups[${index}]`); + }); const sorted = sortGroups(groups); this.setState({ status: 'ready', @@ -99,6 +116,7 @@ class KundeGruppenPage extends PureComponent { 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 }); @@ -111,6 +129,7 @@ class KundeGruppenPage extends PureComponent { } async handleRename(group) { + if (!group.editable) return; const draft = String(this.state.drafts[group.id] ?? '').trim(); if (!draft) { this.setState((prev) => ({ @@ -128,6 +147,7 @@ class KundeGruppenPage extends PureComponent { 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 }); @@ -142,8 +162,42 @@ class KundeGruppenPage extends PureComponent { } } + 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 this.context.customergroup.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 (this.state.busyKey != null) return; + if (!group.editable || this.state.busyKey != null) return; if (!window.confirm(`Kundengruppe „${group.name}“ wirklich löschen?`)) { return; } @@ -182,65 +236,81 @@ class KundeGruppenPage extends PureComponent { - - Kundengruppen der ausgewählten Firma. - - {groups.length === 0 ? ( Noch keine Kundengruppen. ) : ( - groups.map((group) => ( - - - - this.setState((prev) => ({ - drafts: { ...prev.drafts, [group.id]: next }, - })) - } - onBlur={() => void this.handleRename(group)} - onKeyDown={(event) => { - 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.handleDelete(group)} + 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)} + > + + + + ); + }) )}