From e8b2ead83f08d28d3993e8576a6b473e12ae9641 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 11 Oct 2026 12:49:13 +0200 Subject: [PATCH] u --- .agents/skills/coding-style/SKILL.md | 9 +- src/automats/entityAutomat.js | 206 +++++++++++++-- src/components/kunde/Kunde.jsx | 223 ++++++++++++++++ src/components/kunde/index.js | 1 + src/routes/artikel/merkmale.jsx | 123 +++++---- src/routes/kunde.jsx | 363 ++++++++++++++++++--------- src/routes/kunde/neu.jsx | 30 ++- 7 files changed, 775 insertions(+), 180 deletions(-) create mode 100644 src/components/kunde/Kunde.jsx diff --git a/.agents/skills/coding-style/SKILL.md b/.agents/skills/coding-style/SKILL.md index 643181f..717b8a6 100644 --- a/.agents/skills/coding-style/SKILL.md +++ b/.agents/skills/coding-style/SKILL.md @@ -8,4 +8,11 @@ class componets disconnected from state, in automat. Reference: Component: src/routes/artikel/marken.jsx -it's Automat: src/automats/brandAutomat.js \ No newline at end of file +it's Automat: src/automats/brandAutomat.js + + + +use vs code region markers in code files +use: +// MARK: calls startApi from ./api/index.js +atop important functions \ No newline at end of file diff --git a/src/automats/entityAutomat.js b/src/automats/entityAutomat.js index bda4357..e3fbfac 100644 --- a/src/automats/entityAutomat.js +++ b/src/automats/entityAutomat.js @@ -1,42 +1,212 @@ /** * @file entityAutomat.js * @description Manages cached customer and contact entities (`GET /api/entity`) - * for the active company. Built via `createListAutomat`. + * for the active company. Follows the brand/warehouse/customergroup 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`, `filter`). */ +import { Automat } from 'react-automat'; import { entity as entityApi } from '../api'; -import { createListAutomat } from './createListAutomat'; +import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; +import authAutomat from './authAutomat'; +import customergroupAutomat from './customergroupAutomat'; /** * @typedef {Object} Entity * @property {string|number} id Entity id. * @property {string|number} customergroupId Owning customer group id. - * @property {string|null} firstName First name. + * @property {string} [number] Customer number. + * @property {string|null} [firstName] First name. * @property {string} name Last / company name. + * @property {string} [createdAt] Created timestamp. + * @property {string} [updatedAt] Updated timestamp. */ +/** + * @typedef {Object} EntityState + * @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 {EntityState} */ +const initialState = { + items: [], + filteredItems: [], + filter: '', + status: 'idle', + error: null, +}; + +const applyFilter = (entities, filter) => { + const query = String(filter ?? '').trim().toLowerCase(); + if (!query) return entities; + const groups = customergroupAutomat?.state?.items ?? []; + const groupsById = Object.fromEntries(groups.map((g) => [g.id, g])); + + return entities.filter((e) => { + const name = String(e.name ?? '').toLowerCase(); + const firstName = String(e.firstName ?? '').toLowerCase(); + const number = String(e.number ?? '').toLowerCase(); + const full1 = `${firstName} ${name}`.trim(); + const full2 = `${name} ${firstName}`.trim(); + const group = String(groupsById[e.customergroupId]?.name ?? '').toLowerCase(); + return ( + name.includes(query) || + firstName.includes(query) || + number.includes(query) || + full1.includes(query) || + full2.includes(query) || + group.includes(query) + ); + }); +}; + +const sortEntities = (items) => + [...items].sort((a, b) => { + const byName = String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'); + if (byName !== 0) return byName; + return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de'); + }); + +let entityAutomat; + +const updateStateWithEntities = (entities, extra = {}) => { + const list = sortEntities(Array.isArray(entities) ? entities : []); + const filteredItems = applyFilter(list, entityAutomat.state.filter); + entityAutomat.setState({ items: list, filteredItems, ...extra }); + return list; +}; + +async function fetchEntities() { + const data = await entityApi.list(); + requireObject(data, 'GET /api/entity'); + return requireArray(data.entities, 'entities'); +} + +let inflightLoad = null; + +function loadEntities() { + if (inflightLoad) return inflightLoad; + inflightLoad = (async () => { + entityAutomat.setState({ status: 'loading', error: null }); + try { + const entities = await fetchEntities(); + updateStateWithEntities(entities, { status: 'ready', error: null }); + return entities; + } catch (error) { + entityAutomat.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 ?? ''); + entityAutomat.setState({ + filter, + filteredItems: applyFilter(entityAutomat.state.items, filter), + }); + }, + + /** Loads the list, deduplicating concurrent callers. */ + load() { + return loadEntities(); + }, + + /** Silently refreshes the entities without flashing loading state. */ + async reload() { + try { + const entities = await fetchEntities(); + updateStateWithEntities(entities, { status: 'ready', error: null }); + return entities; + } catch (error) { + entityAutomat.setState({ error }); + throw error; + } + }, + + /** Replaces the cached list without an API call. */ + replace(items) { + return updateStateWithEntities(items, { status: 'ready', error: null }); + }, + + /** Returns cached items if ready, otherwise loads. */ + ensureLoaded() { + if (entityAutomat.state.status === 'ready') { + return Promise.resolve(entityAutomat.state.items); + } + return actions.load(); + }, + + /** Resets state to initial idle values. */ + reset() { + inflightLoad = null; + entityAutomat.setState(initialState); + }, + + /** Creates a customer entity and refreshes the cache. */ + async create(body) { + const data = await entityApi.create(body); + requireObject(data, 'POST /api/entity'); + requireObject(data.entity, 'entity'); + requireDefined(data.entity.id, 'entity.id'); + await entityAutomat.actions.reload(); + return data.entity; + }, + + /** Updates an entity name/fields and refreshes the cache. */ + async update(id, body) { + const data = await entityApi.update(id, body); + requireObject(data, 'PATCH /api/entity/:id'); + requireObject(data.entity, 'entity'); + requireDefined(data.entity.id, 'entity.id'); + await entityAutomat.actions.reload(); + return data.entity; + }, + + rename(id, body) { + return actions.update(id, body); + }, + + /** Deletes an entity and refreshes the cache. */ + async remove(id) { + await entityApi.delete(id); + await entityAutomat.actions.reload(); + }, +}; + /** * Company customers and contacts (`GET /api/entity`). * - * @type {import('react-automat').Automat} + * @type {Automat} */ -export const entityAutomat = createListAutomat({ +entityAutomat = new Automat(initialState, actions, { name: 'entity', - fetch: async () => { - const data = await entityApi.list(); - return data.entities ?? []; - }, - sort: (items) => - [...items].sort((a, b) => { - const byName = String(a.name).localeCompare(String(b.name), 'de'); - if (byName !== 0) return byName; - return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de'); - }), +}); + +export { entityAutomat }; + +// 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 entityAutomat; diff --git a/src/components/kunde/Kunde.jsx b/src/components/kunde/Kunde.jsx new file mode 100644 index 0000000..0ad991f --- /dev/null +++ b/src/components/kunde/Kunde.jsx @@ -0,0 +1,223 @@ +import { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import ListItem from '@mui/material/ListItem'; +import Tooltip from '@mui/material/Tooltip'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; +import PersonOutlinedIcon from '@mui/icons-material/PersonOutlined'; +import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; +import { enqueueSnackbar } from 'notistack'; +import { router } from '../../appRouter'; +import entityAutomat from '../../automats/entityAutomat'; +import { notifyError } from '../../utils/notify'; + +function getCustomerDisplayName(item) { + if (!item) return ''; + return [item.firstName, item.name].filter(Boolean).join(' ') || item.name || ''; +} + +class Kunde extends PureComponent { + constructor(props) { + super(props); + const item = props.customer ?? props.item; + this.state = { + draft: getCustomerDisplayName(item), + }; + } + + componentDidUpdate(prevProps) { + const prevItem = prevProps.customer ?? prevProps.item; + const item = this.props.customer ?? this.props.item; + const prevName = getCustomerDisplayName(prevItem); + const nextName = getCustomerDisplayName(item); + + if (prevName !== nextName) { + this.setState({ draft: nextName }); + } else if (prevProps.disabled && !this.props.disabled) { + if (this.state.draft !== nextName) { + this.setState({ draft: nextName }); + } + } + } + + handleChange = (next) => { + this.setState({ draft: next }); + const item = this.props.customer ?? this.props.item; + this.props.onChange?.(next, item); + }; + + handleBlur = () => { + const item = this.props.customer ?? this.props.item; + if (!item) return; + + const draft = this.state.draft.trim(); + const currentDisplay = getCustomerDisplayName(item); + if (!draft) { + this.setState({ draft: currentDisplay }); + return; + } + if (draft !== currentDisplay) { + void this.renameCustomer(item, draft); + } + }; + + async renameCustomer(item, rawText) { + const trimmed = rawText.trim(); + if (!trimmed) return; + let name = trimmed; + let firstName = null; + + if (trimmed.includes(',')) { + const parts = trimmed.split(',').map((p) => p.trim()); + name = parts[0]; + firstName = parts.slice(1).join(' ') || null; + } else if (trimmed.includes(' ') && !/gmbh|ag|kg|ug|inc|ltd|co\./i.test(trimmed)) { + const parts = trimmed.split(' '); + firstName = parts[0]; + name = parts.slice(1).join(' '); + } + + try { + await entityAutomat.actions.update(item.id, { name, firstName }); + enqueueSnackbar('Kunde gespeichert.', { variant: 'success' }); + } catch (err) { + this.setState({ draft: getCustomerDisplayName(item) }); + notifyError(err); + } + } + + handleKeyDown = (event) => { + const item = this.props.customer ?? this.props.item; + if (event.key === 'Enter') { + event.preventDefault(); + event.target.blur(); + } + if (event.key === 'Escape') { + event.preventDefault(); + this.setState({ draft: getCustomerDisplayName(item) }); + event.target.blur(); + } + }; + + handleDelete = () => { + const item = this.props.customer ?? this.props.item; + if (!item) return; + const displayName = getCustomerDisplayName(item) || String(item.number ?? item.id); + + if (!window.confirm(`Kunde „${displayName}“ wirklich löschen?`)) return; + void (async () => { + try { + await entityAutomat.actions.remove(item.id); + enqueueSnackbar('Kunde gelöscht.', { variant: 'success' }); + } catch (err) { + notifyError(err); + } + })(); + }; + + render() { + const { disabled, sx, groupName, onEdit } = this.props; + const item = this.props.customer ?? this.props.item; + const { draft } = this.state; + const displayName = getCustomerDisplayName(item) || String(item?.number ?? ''); + + const handleOpenEdit = () => { + if (!item) return; + if (onEdit) onEdit(item); + else void router.navigate(`/kunde/${item.id}`); + }; + + return ( + + + + + + + + + + {item?.number ? ( + + ) : null} + + {groupName ? ( + + ) : null} + + + + + + + + + + + + ); + } +} + +export { getCustomerDisplayName }; +export default Kunde; diff --git a/src/components/kunde/index.js b/src/components/kunde/index.js index 6a5a50d..8ba917b 100644 --- a/src/components/kunde/index.js +++ b/src/components/kunde/index.js @@ -1,2 +1,3 @@ +export { default as Kunde } from './Kunde'; export { default as KundeGruppe } from './KundeGruppe'; export { default as withEntityRoute } from './withEntityRoute'; diff --git a/src/routes/artikel/merkmale.jsx b/src/routes/artikel/merkmale.jsx index 563705f..ca8c52a 100644 --- a/src/routes/artikel/merkmale.jsx +++ b/src/routes/artikel/merkmale.jsx @@ -12,6 +12,7 @@ import Sortable from 'sortablejs'; import { enqueueSnackbar } from 'notistack'; import BackendError from '../../components/BackendError'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; +import FillSpace from 'dashgui/FillSpace'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import withNavigate from '../../components/withNavigate'; import { backendErrorFrom } from '../../utils/backendError'; @@ -45,14 +46,17 @@ class ArtikelMerkmalePage extends PureComponent { attributes: initialAttributes, attrFilter: '', attrSubmitting: false, + attrPage: 1, selectedAttributeId: initialSelectedAttrId, valFilter: '', valSubmitting: false, + valPage: 1, properties: initialProperties, propFilter: '', propSubmitting: false, + propPage: 1, }; this._cancelled = false; @@ -112,6 +116,7 @@ class ArtikelMerkmalePage extends PureComponent { if ( (prevState.properties !== this.state.properties || prevState.propFilter !== this.state.propFilter || + prevState.propPage !== this.state.propPage || prevState.propStatus !== this.state.propStatus) && this.propListRef.current ) { @@ -123,6 +128,7 @@ class ArtikelMerkmalePage extends PureComponent { if ( (prevState.attributes !== this.state.attributes || prevState.attrFilter !== this.state.attrFilter || + prevState.attrPage !== this.state.attrPage || prevState.attrStatus !== this.state.attrStatus) && this.attrListRef.current ) { @@ -135,6 +141,7 @@ class ArtikelMerkmalePage extends PureComponent { (prevState.selectedAttributeId !== this.state.selectedAttributeId || prevState.attributes !== this.state.attributes || prevState.valFilter !== this.state.valFilter || + prevState.valPage !== this.state.valPage || prevState.attrStatus !== this.state.attrStatus) && this.valListRef.current ) { @@ -170,11 +177,16 @@ class ArtikelMerkmalePage extends PureComponent { const { oldIndex, newIndex } = evt; if (oldIndex === newIndex) return; + const offset = ((this.state.propPage || 1) - 1) * 8; + const realOld = offset + oldIndex; + const realNew = offset + newIndex; + if (realOld === realNew) return; + this.setState( (prev) => { const next = [...prev.properties]; - const [moved] = next.splice(oldIndex, 1); - next.splice(newIndex, 0, moved); + const [moved] = next.splice(realOld, 1); + next.splice(realNew, 0, moved); return { properties: next }; }, () => { @@ -203,11 +215,16 @@ class ArtikelMerkmalePage extends PureComponent { const { oldIndex, newIndex } = evt; if (oldIndex === newIndex) return; + const offset = ((this.state.attrPage || 1) - 1) * 8; + const realOld = offset + oldIndex; + const realNew = offset + newIndex; + if (realOld === realNew) return; + this.setState( (prev) => { const next = [...prev.attributes]; - const [moved] = next.splice(oldIndex, 1); - next.splice(newIndex, 0, moved); + const [moved] = next.splice(realOld, 1); + next.splice(realNew, 0, moved); return { attributes: next }; }, () => { @@ -236,6 +253,11 @@ class ArtikelMerkmalePage extends PureComponent { const { oldIndex, newIndex } = evt; if (oldIndex === newIndex) return; + const offset = ((this.state.valPage || 1) - 1) * 8; + const realOld = offset + oldIndex; + const realNew = offset + newIndex; + if (realOld === realNew) return; + const currentAttr = this.state.attributes.find( (a) => a.id === this.state.selectedAttributeId ); @@ -243,8 +265,8 @@ class ArtikelMerkmalePage extends PureComponent { const values = currentAttr.values || []; const next = [...values]; - const [moved] = next.splice(oldIndex, 1); - next.splice(newIndex, 0, moved); + const [moved] = next.splice(realOld, 1); + next.splice(realNew, 0, moved); const updatedAttributes = this.state.attributes.map((a) => a.id === currentAttr.id ? { ...a, values: next } : a @@ -319,7 +341,7 @@ class ArtikelMerkmalePage extends PureComponent { } handleSelectAttribute(attrId) { - this.setState({ selectedAttributeId: attrId, valFilter: '' }); + this.setState({ selectedAttributeId: attrId, valFilter: '', valPage: 1 }); } async handleCreateAttribute() { @@ -468,24 +490,29 @@ class ArtikelMerkmalePage extends PureComponent { - - {filteredAttributes.length === 0 ? ( - - {attrFilterTrimmed - ? `Keine Treffer für „${attrFilterTrimmed}“.` - : 'Noch keine Auswahl-Merkmale angelegt.'} - - ) : ( - filteredAttributes.map((a) => ( + {filteredAttributes.length === 0 ? ( + + {attrFilterTrimmed + ? `Keine Treffer für „${attrFilterTrimmed}“.` + : 'Noch keine Auswahl-Merkmale angelegt.'} + + ) : ( + this.setState({ attrPage: page })} + renderItem={(a) => ( this.handleSelectAttribute(a.id)} /> - )) - )} - + )} + /> + )} @@ -531,22 +558,27 @@ class ArtikelMerkmalePage extends PureComponent { - - {filteredValues.length === 0 ? ( - - {valFilterTrimmed - ? `Keine Treffer für „${valFilterTrimmed}“.` - : 'Noch keine Werte angelegt.'} - - ) : ( - filteredValues.map((v) => ( + {filteredValues.length === 0 ? ( + + {valFilterTrimmed + ? `Keine Treffer für „${valFilterTrimmed}“.` + : 'Noch keine Werte angelegt.'} + + ) : ( + this.setState({ valPage: page })} + renderItem={(v) => ( - )) - )} - + )} + /> + )} )} @@ -588,22 +620,27 @@ class ArtikelMerkmalePage extends PureComponent { - - {filteredProperties.length === 0 ? ( - - {propFilterTrimmed - ? `Keine Treffer für „${propFilterTrimmed}“.` - : 'Noch keine Text-Merkmale angelegt.'} - - ) : ( - filteredProperties.map((p) => ( + {filteredProperties.length === 0 ? ( + + {propFilterTrimmed + ? `Keine Treffer für „${propFilterTrimmed}“.` + : 'Noch keine Text-Merkmale angelegt.'} + + ) : ( + this.setState({ propPage: page })} + renderItem={(p) => ( - )) - )} - + )} + /> + )} diff --git a/src/routes/kunde.jsx b/src/routes/kunde.jsx index b6de641..13e7bfd 100644 --- a/src/routes/kunde.jsx +++ b/src/routes/kunde.jsx @@ -1,156 +1,289 @@ -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 List from '@mui/material/List'; +import Typography from '@mui/material/Typography'; import { enqueueSnackbar } from 'notistack'; import BackendError from '../components/BackendError'; -import { RecordList } from 'dashgui/lists'; -import { entity as entityApi } from '../api'; -import entityAutomat from '../automats/entityAutomat'; -import customergroupAutomat from '../automats/customergroupAutomat'; -import { router } from '../appRouter'; +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 { - BLUE_PANEL, -} from '../colors'; +import entityAutomat from '../automats/entityAutomat'; +import customergroupAutomat from '../automats/customergroupAutomat'; +import { Kunde } from '../components/kunde'; +import { BLUE_PANEL } from '../colors'; -function sortEntities(entities) { - return [...entities].sort((a, b) => { - const byName = String(a.name).localeCompare(String(b.name), 'de'); - if (byName !== 0) return byName; - return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de'); - }); +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 formatDate(value) { - if (!value) return ''; - return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(value)); +class KundeSearchField extends PureComponent { + constructor(props) { + super(props); + const initial = entityAutomat.state.filter || props.prefill || ''; + this.state = { + input: initial, + submitting: false, + }; + if (props.prefill && !entityAutomat.state.filter) { + entityAutomat.actions.filter(props.prefill); + } + this.handleSubmit = this.handleSubmit.bind(this); + } + + componentDidMount() { + this.unsub = entityAutomat.subscribe(this, (state) => ({ + input: state.filter, + })); + } + + componentWillUnmount() { + this.unsub?.(); + } + + onInputChange(input) { + this.setState({ input }); + entityAutomat.actions.filter(input); + } + + async handleSubmit() { + const raw = this.state.input.trim(); + if (!raw || this.state.submitting) return; + + this.setState({ submitting: true }); + try { + await customergroupAutomat.actions.ensureLoaded(); + const groups = customergroupAutomat.state.items ?? []; + const defaultGroup = groups.find((g) => g.editable !== false) ?? groups[0]; + if (!defaultGroup) { + throw new Error('Keine Kundengruppe vorhanden.'); + } + + let name = raw; + let firstName = null; + + if (raw.includes(',')) { + const parts = raw.split(',').map((p) => p.trim()); + name = parts[0]; + firstName = parts.slice(1).join(' ') || null; + } else if (raw.includes(' ') && !/gmbh|ag|kg|ug|inc|ltd|co\./i.test(raw)) { + const parts = raw.split(' '); + firstName = parts[0]; + name = parts.slice(1).join(' '); + } + + const body = { + customergroupId: defaultGroup.id, + name, + }; + if (firstName) body.firstName = firstName; + + const row = await entityAutomat.actions.create(body); + const displayName = [row.firstName, row.name].filter(Boolean).join(' ') || row.name || raw; + enqueueSnackbar(`Kunde „${displayName}“ angelegt.`, { variant: 'success' }); + this.setState({ input: '', submitting: false }); + entityAutomat.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 + ? `Kundenname „${input}“ übernommen.` + : 'Neuen Kunden anlegen.'} + + ) : null} + + + this.onInputChange(next)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void this.handleSubmit(); + } + }} + /> + + + + + ); + } } class KundeListePage extends PureComponent { constructor(props) { super(props); + const initialEntities = + entityAutomat.state.filteredItems ?? entityAutomat.state.items ?? []; this.state = { - status: 'loading', - error: null, - entities: [], - groupsById: {}, - busyId: null, + status: entityAutomat.state.status === 'ready' ? 'ready' : 'loading', + error: entityAutomat.state.error ? backendErrorFrom(entityAutomat.state.error) : null, + entities: initialEntities, + groups: customergroupAutomat.state.items ?? [], + filter: entityAutomat.state.filter ?? '', }; - this._cancelled = false; + this.searchRef = createRef(); this.handleRetry = this.handleRetry.bind(this); - this.handleCreate = this.handleCreate.bind(this); + this.handleEdit = this.handleEdit.bind(this); } componentDidMount() { - this._cancelled = false; - void this.load(); + this.unsub = entityAutomat.subscribe(this, (state) => ({ + entities: state.filteredItems ?? state.items, + filter: state.filter ?? '', + status: state.status, + error: state.error ? backendErrorFrom(state.error) : null, + })); + + this.unsubGroups = customergroupAutomat.subscribe(this, (state) => ({ + groups: state.items, + })); + + if (entityAutomat.state.status !== 'ready') { + void entityAutomat.actions.load().catch((err) => notifyError(err)); + } + if (customergroupAutomat.state.status !== 'ready') { + void customergroupAutomat.actions.load().catch(() => {}); + } } componentWillUnmount() { - this._cancelled = true; - } - - async load() { - this.setState({ status: 'loading', error: null }); - try { - const [entities, groups] = await Promise.all([ - entityAutomat.actions.ensureLoaded(), - customergroupAutomat.actions.ensureLoaded(), - ]); - if (this._cancelled) return; - const groupsById = Object.fromEntries(groups.map((row) => [row.id, row])); - this.setState({ - status: 'ready', - entities: sortEntities(entities), - groupsById, - }); - } catch (err) { - if (this._cancelled) return; - this.setState({ status: 'failed', error: backendErrorFrom(err) }); - } + this.unsub?.(); + this.unsubGroups?.(); } handleRetry() { - void this.load(); - } - - handleCreate() { - void router.navigate('/kunde/neu'); + void entityAutomat.actions.load().catch((err) => notifyError(err)); } handleEdit(entity) { - void router.navigate(`/kunde/${entity.id}`); - } - - groupName(customergroupId) { - return this.state.groupsById[customergroupId]?.name; - } - - async handleDelete(entity) { - if (this.state.busyId != null) return; - const label = [entity.firstName, entity.name].filter(Boolean).join(' '); - if (!window.confirm(`Kunde „${label}“ wirklich löschen?`)) return; - - this.setState({ busyId: entity.id }); - try { - await entityApi.delete(entity.id); - const list = await entityAutomat.actions.reload(); - if (this._cancelled) return; - this.setState({ - entities: sortEntities(list), - busyId: null, - }); - enqueueSnackbar('Kunde gelöscht.', { variant: 'success' }); - } catch (err) { - if (this._cancelled) return; - notifyError(err); - this.setState({ busyId: null }); - } + if (!entity?.id) return; + this.props.navigate?.(`/kunde/${entity.id}`); } render() { - const { status, error, entities, busyId } = this.state; + const { status, error, entities, groups, filter } = this.state; + const { prefill, returnTo } = getInitialParams(this.props); + + if (status === 'loading' || status === 'idle') { + return ( + + + + ); + } if (status === 'failed') { return ; } - const columns = [ - { key: 'number', label: 'Kundennummer' }, - { key: 'name', label: 'Name' }, - { key: 'firstName', label: 'Vorname' }, - { - key: 'customergroupId', - label: 'Gruppe', - render: (row) => this.groupName(row.customergroupId), - }, - { - key: 'createdAt', - label: 'Erstellt am', - render: (row) => formatDate(row.createdAt), - }, - ]; - - const label = (row) => - [row.firstName, row.name].filter(Boolean).join(' ') || String(row.number); + const trimmedFilter = filter?.trim() ?? ''; + const groupsById = Object.fromEntries(groups.map((g) => [g.id, g])); return ( - } - rows={entities} - columns={columns} - createLabel="Neuer Kunde" - onCreate={this.handleCreate} - onEdit={this.handleEdit} - onDelete={this.handleDelete} - deleteDisabled={busyId != null} - emptyMessage="Noch keine Kunden." - color={BLUE_PANEL} - getEditLabel={(row) => `„${label(row)}“ bearbeiten`} - getDeleteLabel={(row) => `„${label(row)}“ löschen`} - /> + <> + + + + + {entities.length === 0 ? ( + + {trimmedFilter + ? `Keine Treffer für „${trimmedFilter}“.` + : 'Noch keine Kunden angelegt.'} + + ) : ( + entities.map((entity) => ( + + )) + )} + + + + ); } } -export default KundeListePage; +export { Kunde, KundeSearchField }; +export default withNavigate(KundeListePage); diff --git a/src/routes/kunde/neu.jsx b/src/routes/kunde/neu.jsx index 8d3dacc..9c34b88 100644 --- a/src/routes/kunde/neu.jsx +++ b/src/routes/kunde/neu.jsx @@ -17,16 +17,40 @@ import { notifyError } from '../../utils/notify'; import { BLUE_PANEL, } from '../../colors'; +import withNavigate from '../../components/withNavigate'; + +function getInitialPrefill(props) { + const searchParams = + typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null; + const raw = + props.location?.state?.name ?? + props.location?.state?.newName ?? + searchParams?.get('name') ?? + searchParams?.get('newName') ?? + ''; + const trimmed = raw.trim(); + if (!trimmed) return { name: '', firstName: '' }; + if (trimmed.includes(',')) { + const parts = trimmed.split(',').map((p) => p.trim()); + return { name: parts[0] || '', firstName: parts.slice(1).join(' ') || '' }; + } + if (trimmed.includes(' ') && !/gmbh|ag|kg|ug|inc|ltd|co\./i.test(trimmed)) { + const parts = trimmed.split(' '); + return { firstName: parts[0] || '', name: parts.slice(1).join(' ') || '' }; + } + return { name: trimmed, firstName: '' }; +} class KundeNeuPage extends PureComponent { constructor(props) { super(props); const groups = customergroupAutomat.state.items ?? []; + const prefill = getInitialPrefill(props); this.state = { customergroupId: groups.length > 0 ? groups[0].id : null, number: '', - name: '', - firstName: '', + name: prefill.name, + firstName: prefill.firstName, saving: false, }; this.handleGroupChange = this.handleGroupChange.bind(this); @@ -157,4 +181,4 @@ class KundeNeuPage extends PureComponent { } } -export default KundeNeuPage; +export default withNavigate(KundeNeuPage);