From 27fd5d3e942a964b7ea5b6148b42bbec7f367771 Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 6 Aug 2026 07:38:23 +0200 Subject: [PATCH] Add Kunde CRUD with addresses/contacts, delivery edit, and Auftrag sub-nav. Wire customergroup/entity APIs, Lieferungen detail editing, and Angebot/Bestellung/Ausliefern/Retoure placeholders. --- src/api/ApiClient.js | 53 ++ src/api/StorageManager.js | 26 + src/api/index.js | 130 ++++- src/appRouter.jsx | 20 +- src/components/SectionLayout.jsx | 7 +- src/nav.js | 24 +- src/pages/auftrag/archiv.jsx | 7 - src/pages/auftrag/ausliefern.jsx | 14 + src/pages/auftrag/bestellung.jsx | 14 + src/pages/auftrag/index.jsx | 15 +- src/pages/auftrag/retoure.jsx | 14 + src/pages/kunde/edit.jsx | 774 ++++++++++++++++++++++++++++ src/pages/kunde/gruppen.jsx | 277 +++++++++- src/pages/kunde/index.jsx | 198 ++++++- src/pages/kunde/kontakte.jsx | 7 - src/pages/kunde/neu.jsx | 203 ++++++++ src/pages/kunde/withEntityRoute.jsx | 16 + src/pages/lieferung/edit.jsx | 469 +++++++++++++++++ src/pages/lieferung/index.jsx | 21 +- src/pages/lieferung/neu.jsx | 32 +- 20 files changed, 2265 insertions(+), 56 deletions(-) delete mode 100644 src/pages/auftrag/archiv.jsx create mode 100644 src/pages/auftrag/ausliefern.jsx create mode 100644 src/pages/auftrag/bestellung.jsx create mode 100644 src/pages/auftrag/retoure.jsx create mode 100644 src/pages/kunde/edit.jsx delete mode 100644 src/pages/kunde/kontakte.jsx create mode 100644 src/pages/kunde/neu.jsx create mode 100644 src/pages/kunde/withEntityRoute.jsx create mode 100644 src/pages/lieferung/edit.jsx diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index ea4796b..e6fe86d 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -95,6 +95,59 @@ export class ApiClient { delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`), }; + this.customergroup = { + /** GET /api/customergroup → { customergroups } */ + list: () => this.#json('GET', '/api/customergroup'), + /** POST /api/customergroup → 201 { customergroup } */ + create: (body) => this.#json('POST', '/api/customergroup', { body }), + /** PATCH /api/customergroup/{id} → { customergroup } */ + update: (id, body) => + this.#json('PATCH', `/api/customergroup/${enc(id)}`, { body }), + /** DELETE /api/customergroup/{id} → 204 */ + delete: (id) => this.#empty('DELETE', `/api/customergroup/${enc(id)}`), + }; + + this.entity = { + /** GET /api/entity → { entities } · optional query customergroupId */ + list: (query) => this.#json('GET', '/api/entity', { query }), + /** GET /api/entity/{id} → { entity } */ + get: (id) => this.#json('GET', `/api/entity/${enc(id)}`), + /** POST /api/entity → 201 { entity } */ + create: (body) => this.#json('POST', '/api/entity', { body }), + /** PATCH /api/entity/{id} → { entity } */ + update: (id, body) => this.#json('PATCH', `/api/entity/${enc(id)}`, { body }), + /** DELETE /api/entity/{id} → 204 */ + delete: (id) => this.#empty('DELETE', `/api/entity/${enc(id)}`), + /** GET /api/entity/{entityId}/address → { addresses } */ + listAddresses: (entityId) => + this.#json('GET', `/api/entity/${enc(entityId)}/address`), + /** POST /api/entity/{entityId}/address → 201 { address } */ + createAddress: (entityId, body) => + this.#json('POST', `/api/entity/${enc(entityId)}/address`, { body }), + /** PATCH /api/entity/{entityId}/address/{id} → { address } */ + updateAddress: (entityId, id, body) => + this.#json('PATCH', `/api/entity/${enc(entityId)}/address/${enc(id)}`, { + body, + }), + /** DELETE /api/entity/{entityId}/address/{id} → 204 */ + deleteAddress: (entityId, id) => + this.#empty('DELETE', `/api/entity/${enc(entityId)}/address/${enc(id)}`), + /** GET /api/entity/{entityId}/contact → { contacts } */ + listContacts: (entityId) => + this.#json('GET', `/api/entity/${enc(entityId)}/contact`), + /** POST /api/entity/{entityId}/contact → 201 { contact } */ + createContact: (entityId, body) => + this.#json('POST', `/api/entity/${enc(entityId)}/contact`, { body }), + /** PATCH /api/entity/{entityId}/contact/{id} → { contact } */ + updateContact: (entityId, id, body) => + this.#json('PATCH', `/api/entity/${enc(entityId)}/contact/${enc(id)}`, { + body, + }), + /** DELETE /api/entity/{entityId}/contact/{id} → 204 */ + deleteContact: (entityId, id) => + this.#empty('DELETE', `/api/entity/${enc(entityId)}/contact/${enc(id)}`), + }; + this.category = { /** GET /api/category/tree */ tree: () => this.#json('GET', '/api/category/tree'), diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index 694a3a7..c4e36f7 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -293,6 +293,10 @@ export class StorageManager extends ApiClient { const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals()); const supplierCache = createCache('GET /supplier', () => this.#loadSuppliers()); const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries()); + const customergroupCache = createCache('GET /customergroup', () => + this.#loadCustomergroups(), + ); + const entityCache = createCache('GET /entity', () => this.#loadEntities()); Object.defineProperties(this, { authMePromise: { get: () => authMeCache.promise }, @@ -336,6 +340,16 @@ export class StorageManager extends ApiClient { /** Settled GET /delivery list, or undefined. */ deliveries: { get: () => deliveryCache.value }, + customergroupPromise: { get: () => customergroupCache.promise }, + updateCustomergroup: { value: customergroupCache.update }, + /** Settled GET /customergroup list, or undefined. */ + customergroups: { get: () => customergroupCache.value }, + + entityPromise: { get: () => entityCache.promise }, + updateEntity: { value: entityCache.update }, + /** Settled GET /entity list, or undefined. */ + entities: { get: () => entityCache.value }, + /** Close and reopen GET /api/events (required after selected-company change). */ reconnectEvents: { value: () => this.#reconnectEvents() }, }); @@ -468,6 +482,18 @@ export class StorageManager extends ApiClient { return requireArray(data.deliveries, 'deliveries'); } + async #loadCustomergroups() { + const data = await this.customergroup.list(); + requireObject(data, 'GET /api/customergroup'); + return requireArray(data.customergroups, 'customergroups'); + } + + async #loadEntities() { + const data = await this.entity.list(); + requireObject(data, 'GET /api/entity'); + return requireArray(data.entities, 'entities'); + } + async doSearch({ target, filter, offset, maxCount, onUpdate }) { const articles = await this.articleIndexPromise; const query = target.trim().toLowerCase(); diff --git a/src/api/index.js b/src/api/index.js index 7dcc370..5e4c83e 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -599,6 +599,132 @@ export const delivery = { }, }; +/** + * Company-scoped customer groups. + * Shape: CustomerGroup = { id, name } + */ +export const customergroup = { + /** GET /api/customergroup → { customergroups: CustomerGroup[] } */ + list() { + return http('/api/customergroup'); + }, + + /** + * POST /api/customergroup + * Body: { name } — empty name ⇒ 400 Name required. + * → 201 { customergroup: CustomerGroup } + */ + create(body) { + return http('/api/customergroup', { method: 'POST', body }); + }, + + /** + * PATCH /api/customergroup/:id + * Body: { name } + * → 200 { customergroup: CustomerGroup } + */ + update(id, body) { + return http(`/api/customergroup/${id}`, { method: 'PATCH', body }); + }, + + /** DELETE /api/customergroup/:id → 204 · 409 Customer group in use */ + delete(id) { + return http(`/api/customergroup/${id}`, { method: 'DELETE' }); + }, +}; + +/** + * Company-scoped entities (customers / contacts in a customergroup). + * Shape: Entity = { id, customergroupId, firstName, name, createdAt, updatedAt } + */ +export const entity = { + /** + * GET /api/entity → { entities: Entity[] } + * Optional query: customergroupId + */ + list({ customergroupId } = {}) { + const qs = + customergroupId != null + ? `?customergroupId=${encodeURIComponent(customergroupId)}` + : ''; + return http(`/api/entity${qs}`); + }, + + /** GET /api/entity/:id → { entity: Entity } */ + get(id) { + return http(`/api/entity/${id}`); + }, + + /** + * POST /api/entity + * Body: { customergroupId, name, firstName? } + * → 201 { entity: Entity } + */ + create(body) { + return http('/api/entity', { method: 'POST', body }); + }, + + /** + * PATCH /api/entity/:id + * Body: { name?, firstName? } + * → 200 { entity: Entity } + */ + update(id, body) { + return http(`/api/entity/${id}`, { method: 'PATCH', body }); + }, + + /** DELETE /api/entity/:id → 204 */ + delete(id) { + return http(`/api/entity/${id}`, { method: 'DELETE' }); + }, + + /** GET /api/entity/:entityId/address → { addresses: EntityAddress[] } */ + listAddresses(entityId) { + return http(`/api/entity/${entityId}/address`); + }, + + /** POST /api/entity/:entityId/address → 201 { address } */ + createAddress(entityId, body) { + return http(`/api/entity/${entityId}/address`, { method: 'POST', body }); + }, + + /** PATCH /api/entity/:entityId/address/:id → { address } */ + updateAddress(entityId, id, body) { + return http(`/api/entity/${entityId}/address/${id}`, { + method: 'PATCH', + body, + }); + }, + + /** DELETE /api/entity/:entityId/address/:id → 204 */ + deleteAddress(entityId, id) { + return http(`/api/entity/${entityId}/address/${id}`, { method: 'DELETE' }); + }, + + /** GET /api/entity/:entityId/contact → { contacts: EntityContact[] } */ + listContacts(entityId) { + return http(`/api/entity/${entityId}/contact`); + }, + + /** POST /api/entity/:entityId/contact → 201 { contact } */ + createContact(entityId, body) { + return http(`/api/entity/${entityId}/contact`, { method: 'POST', body }); + }, + + /** PATCH /api/entity/:entityId/contact/:id → { contact } */ + updateContact(entityId, id, body) { + return http(`/api/entity/${entityId}/contact/${id}`, { + method: 'PATCH', + body, + }); + }, + + /** DELETE /api/entity/:entityId/contact/:id → 204 */ + deleteContact(entityId, id) { + return http(`/api/entity/${entityId}/contact/${id}`, { method: 'DELETE' }); + }, +}; + export const company = { /** GET /api/company → { companies: [...] } */ list() { @@ -774,9 +900,11 @@ export default { brand, category, company, + customergroup, + delivery, + entity, posterminal, supplier, - delivery, thumb, unit, }; diff --git a/src/appRouter.jsx b/src/appRouter.jsx index 7c2de69..9000a7a 100644 --- a/src/appRouter.jsx +++ b/src/appRouter.jsx @@ -11,11 +11,15 @@ const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku')); const LieferungListePage = lazy(() => import('./pages/lieferung/index')); const LieferungNeuPage = lazy(() => import('./pages/lieferung/neu')); const LieferungLieferantenPage = lazy(() => import('./pages/lieferung/lieferanten')); -const AuftragAktivePage = lazy(() => import('./pages/auftrag/index')); -const AuftragArchivPage = lazy(() => import('./pages/auftrag/archiv')); +const LieferungEditPage = lazy(() => import('./pages/lieferung/edit')); +const AuftragAngebotPage = lazy(() => import('./pages/auftrag/index')); +const AuftragBestellungPage = lazy(() => import('./pages/auftrag/bestellung')); +const AuftragAusliefernPage = lazy(() => import('./pages/auftrag/ausliefern')); +const AuftragRetourePage = lazy(() => import('./pages/auftrag/retoure')); const KundeListePage = lazy(() => import('./pages/kunde/index')); +const KundeNeuPage = lazy(() => import('./pages/kunde/neu')); const KundeGruppenPage = lazy(() => import('./pages/kunde/gruppen')); -const KundeKontaktePage = lazy(() => import('./pages/kunde/kontakte')); +const KundeEditPage = lazy(() => import('./pages/kunde/edit')); const InfosPage = lazy(() => import('./pages/infos/index')); export const router = createBrowserRouter([ @@ -31,6 +35,7 @@ export const router = createBrowserRouter([ { index: true, element: }, { path: 'neu', element: }, { path: 'lieferanten', element: }, + { path: ':deliveryId', element: }, ], }, { path: 'infos', element: }, @@ -53,8 +58,10 @@ export const router = createBrowserRouter([ path: 'auftrag', element: , children: [ - { index: true, element: }, - { path: 'archiv', element: }, + { index: true, element: }, + { path: 'bestellung', element: }, + { path: 'ausliefern', element: }, + { path: 'retoure', element: }, ], }, { @@ -62,8 +69,9 @@ export const router = createBrowserRouter([ element: , children: [ { index: true, element: }, + { path: 'neu', element: }, { path: 'gruppen', element: }, - { path: 'kontakte', element: }, + { path: ':entityId', element: }, ], }, { path: '*', element: }, diff --git a/src/components/SectionLayout.jsx b/src/components/SectionLayout.jsx index e491071..4e44683 100644 --- a/src/components/SectionLayout.jsx +++ b/src/components/SectionLayout.jsx @@ -44,8 +44,13 @@ export default function SectionLayout({ sectionPath }) { !isKategorienPath(pathname) && rest && !knownSegments.has(rest); + const isKundeDetail = + sectionPath === '/kunde' && rest && !knownSegments.has(rest); + const isLieferungDetail = + sectionPath === '/lieferung' && rest && !knownSegments.has(rest); + const isDetail = isArticleDetail || isKundeDetail || isLieferungDetail; - const pageTitle = isArticleDetail + const pageTitle = isDetail ? title : (activeSub?.label ?? title); diff --git a/src/nav.js b/src/nav.js index e348356..4e5895b 100644 --- a/src/nav.js +++ b/src/nav.js @@ -7,11 +7,13 @@ import ViewListIcon from '@mui/icons-material/ViewList'; import CategoryIcon from '@mui/icons-material/Category'; import WarehouseIcon from '@mui/icons-material/Warehouse'; import ListAltIcon from '@mui/icons-material/ListAlt'; -import ArchiveIcon from '@mui/icons-material/Archive'; import GroupsIcon from '@mui/icons-material/Groups'; -import ContactPageIcon from '@mui/icons-material/ContactPage'; import AddBoxIcon from '@mui/icons-material/AddBox'; import StorefrontIcon from '@mui/icons-material/Storefront'; +import RequestQuoteIcon from '@mui/icons-material/RequestQuote'; +import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; +import OutboxIcon from '@mui/icons-material/Outbox'; +import AssignmentReturnIcon from '@mui/icons-material/AssignmentReturn'; import { isKategorienPath } from './pages/artikel/categoryRoute'; export const NAV_ITEMS = [ @@ -34,13 +36,15 @@ export const SUB_NAV = { { segment: 'lieferanten', label: 'Lieferanten', icon: StorefrontIcon }, ], '/auftrag': [ - { segment: '', label: 'Aktive Aufträge', icon: ListAltIcon }, - { segment: 'archiv', label: 'Archiv', icon: ArchiveIcon }, + { segment: '', label: 'Angebot', icon: RequestQuoteIcon }, + { segment: 'bestellung', label: 'Bestellung', icon: ShoppingCartIcon }, + { segment: 'ausliefern', label: 'Ausliefern', icon: OutboxIcon }, + { segment: 'retoure', label: 'Retoure', icon: AssignmentReturnIcon }, ], '/kunde': [ { segment: '', label: 'Kundenliste', icon: PeopleIcon }, - { segment: 'gruppen', label: 'Gruppen', icon: GroupsIcon }, - { segment: 'kontakte', label: 'Kontakte', icon: ContactPageIcon }, + { segment: 'neu', label: 'Neuer Kunde', icon: AddBoxIcon }, + { segment: 'gruppen', label: 'Kundengruppen', icon: GroupsIcon }, ], }; @@ -65,7 +69,7 @@ export function resolveActiveNav(pathname) { /** * Active sub-nav segment for a section. - * `''` = section index, `null` = none (e.g. article detail tabs). + * `''` = section index, `null` = none (e.g. article / kunde detail). */ export function resolveActiveSub(pathname, sectionPath) { const subNav = SUB_NAV[sectionPath]; @@ -78,7 +82,11 @@ export function resolveActiveSub(pathname, sectionPath) { !isKategorienPath(pathname) && rest && !knownSegments.has(rest); - if (isArticleDetail) return null; + const isKundeDetail = + sectionPath === '/kunde' && rest && !knownSegments.has(rest); + const isLieferungDetail = + sectionPath === '/lieferung' && rest && !knownSegments.has(rest); + if (isArticleDetail || isKundeDetail || isLieferungDetail) return null; const active = subNav.find((item) => { if (item.segment === 'kategorien') { diff --git a/src/pages/auftrag/archiv.jsx b/src/pages/auftrag/archiv.jsx deleted file mode 100644 index f7c381e..0000000 --- a/src/pages/auftrag/archiv.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import Typography from '@mui/material/Typography'; - -export default function AuftragArchivPage() { - return ( - Archiv — Inhalt folgt. - ); -} diff --git a/src/pages/auftrag/ausliefern.jsx b/src/pages/auftrag/ausliefern.jsx new file mode 100644 index 0000000..7beed5f --- /dev/null +++ b/src/pages/auftrag/ausliefern.jsx @@ -0,0 +1,14 @@ +import { PureComponent } from 'react'; +import Typography from '@mui/material/Typography'; + +class AuftragAusliefernPage extends PureComponent { + render() { + return ( + + Ausliefern — Inhalt folgt. + + ); + } +} + +export default AuftragAusliefernPage; diff --git a/src/pages/auftrag/bestellung.jsx b/src/pages/auftrag/bestellung.jsx new file mode 100644 index 0000000..e35f0b4 --- /dev/null +++ b/src/pages/auftrag/bestellung.jsx @@ -0,0 +1,14 @@ +import { PureComponent } from 'react'; +import Typography from '@mui/material/Typography'; + +class AuftragBestellungPage extends PureComponent { + render() { + return ( + + Bestellung — Inhalt folgt. + + ); + } +} + +export default AuftragBestellungPage; diff --git a/src/pages/auftrag/index.jsx b/src/pages/auftrag/index.jsx index 3d07381..020fdaa 100644 --- a/src/pages/auftrag/index.jsx +++ b/src/pages/auftrag/index.jsx @@ -1,7 +1,14 @@ +import { PureComponent } from 'react'; import Typography from '@mui/material/Typography'; -export default function AuftragAktivePage() { - return ( - Aktive Aufträge — Inhalt folgt. - ); +class AuftragAngebotPage extends PureComponent { + render() { + return ( + + Angebot — Inhalt folgt. + + ); + } } + +export default AuftragAngebotPage; diff --git a/src/pages/auftrag/retoure.jsx b/src/pages/auftrag/retoure.jsx new file mode 100644 index 0000000..ab3a2d3 --- /dev/null +++ b/src/pages/auftrag/retoure.jsx @@ -0,0 +1,14 @@ +import { PureComponent } from 'react'; +import Typography from '@mui/material/Typography'; + +class AuftragRetourePage extends PureComponent { + render() { + return ( + + Retoure — Inhalt folgt. + + ); + } +} + +export default AuftragRetourePage; diff --git a/src/pages/kunde/edit.jsx b/src/pages/kunde/edit.jsx new file mode 100644 index 0000000..e1212ce --- /dev/null +++ b/src/pages/kunde/edit.jsx @@ -0,0 +1,774 @@ +import { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +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 { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; +import { backendErrorFrom } from '../../utils/backendError'; +import { notifyError } from '../../utils/notify'; +import withEntityRoute from './withEntityRoute'; + +const ADDRESS_FIELDS = [ + 'addressType', + 'firstName', + 'name', + 'street', + 'streetNumber', + 'zip', + 'city', + 'county', +]; + +function emptyAddressDraft() { + return { + addressType: 'billing', + firstName: '', + name: '', + street: '', + streetNumber: '', + zip: '', + city: '', + county: '', + }; +} + +function addressDraftFrom(row) { + return { + addressType: row.addressType ?? '', + firstName: row.firstName ?? '', + name: row.name ?? '', + street: row.street ?? '', + streetNumber: row.streetNumber ?? '', + zip: row.zip ?? '', + city: row.city ?? '', + county: row.county ?? '', + }; +} + +function compactAddressBody(draft) { + const body = {}; + for (const key of ADDRESS_FIELDS) { + const trimmed = String(draft[key] ?? '').trim(); + if (key === 'addressType') { + body.addressType = trimmed; + continue; + } + body[key] = trimmed === '' ? null : trimmed; + } + return body; +} + +function emptyContactDraft() { + return { contactType: 'email', contact: '' }; +} + +class KundeEditPage extends PureComponent { + static contextType = StorageManagerContext; + + constructor(props) { + super(props); + this.state = { + status: 'loading', + error: null, + entity: null, + groupName: null, + name: '', + firstName: '', + savingEntity: false, + addresses: [], + addressDrafts: {}, + newAddress: emptyAddressDraft(), + busyAddressKey: null, + contacts: [], + contactDrafts: {}, + newContact: emptyContactDraft(), + busyContactKey: null, + }; + this._cancelled = false; + this.handleRetry = this.handleRetry.bind(this); + this.handleSaveEntity = this.handleSaveEntity.bind(this); + this.handleAddAddress = this.handleAddAddress.bind(this); + this.handleAddContact = this.handleAddContact.bind(this); + } + + componentDidMount() { + this._cancelled = false; + void this.load(); + } + + componentDidUpdate(prevProps) { + if (prevProps.entityId !== this.props.entityId) { + void this.load(); + } + } + + componentWillUnmount() { + this._cancelled = true; + } + + async load() { + const { entityId } = this.props; + if (entityId == null) { + this.setState({ + status: 'failed', + error: backendErrorFrom(new Error('Ungültige Kunden-ID.')), + }); + return; + } + + this.setState({ status: 'loading', error: null }); + try { + const [entityRes, addressRes, contactRes, groups] = await Promise.all([ + this.context.entity.get(entityId), + this.context.entity.listAddresses(entityId), + this.context.entity.listContacts(entityId), + this.context.customergroups ?? this.context.customergroupPromise, + ]); + if (this._cancelled) return; + + requireObject(entityRes, 'GET /api/entity/:id'); + requireObject(entityRes.entity, 'entity'); + requireObject(addressRes, 'GET /api/entity/:id/address'); + requireObject(contactRes, 'GET /api/entity/:id/contact'); + const addresses = requireArray(addressRes.addresses, 'addresses'); + const contacts = requireArray(contactRes.contacts, 'contacts'); + const entity = entityRes.entity; + const group = groups.find((g) => g.id === entity.customergroupId); + + this.setState({ + status: 'ready', + entity, + groupName: group?.name ?? null, + name: entity.name ?? '', + firstName: entity.firstName ?? '', + addresses, + addressDrafts: Object.fromEntries( + addresses.map((row) => [row.id, addressDraftFrom(row)]), + ), + contacts, + contactDrafts: Object.fromEntries( + contacts.map((row) => [ + row.id, + { contactType: row.contactType, contact: row.contact }, + ]), + ), + newAddress: emptyAddressDraft(), + newContact: emptyContactDraft(), + }); + } catch (err) { + if (this._cancelled) return; + this.setState({ status: 'failed', error: backendErrorFrom(err) }); + } + } + + handleRetry() { + void this.load(); + } + + async handleSaveEntity() { + const { entityId } = this.props; + const { name, firstName, savingEntity } = this.state; + if (savingEntity || entityId == null) return; + const trimmedName = name.trim(); + if (!trimmedName) { + notifyError(new Error('Name fehlt.')); + return; + } + + this.setState({ savingEntity: true }); + try { + const data = await this.context.entity.update(entityId, { + name: trimmedName, + firstName: firstName.trim() === '' ? null : firstName.trim(), + }); + requireObject(data, 'PATCH /api/entity/:id'); + requireObject(data.entity, 'entity'); + requireDefined(data.entity.id, 'entity.id'); + await this.context.updateEntity(); + if (this._cancelled) return; + this.setState({ + entity: data.entity, + name: data.entity.name ?? '', + firstName: data.entity.firstName ?? '', + savingEntity: false, + }); + enqueueSnackbar('Kunde gespeichert.', { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ savingEntity: false }); + } + } + + setAddressDraft(id, field, value) { + this.setState((prev) => ({ + addressDrafts: { + ...prev.addressDrafts, + [id]: { ...prev.addressDrafts[id], [field]: value }, + }, + })); + } + + async handleSaveAddress(address) { + const { entityId } = this.props; + const draft = this.state.addressDrafts[address.id]; + if (!draft || this.state.busyAddressKey != null || entityId == null) return; + const body = compactAddressBody(draft); + if (!body.addressType) { + notifyError(new Error('Adresstyp fehlt.')); + return; + } + + this.setState({ busyAddressKey: address.id }); + try { + const data = await this.context.entity.updateAddress( + entityId, + address.id, + body, + ); + requireObject(data, 'PATCH /api/entity/:id/address/:id'); + requireObject(data.address, 'address'); + const listRes = await this.context.entity.listAddresses(entityId); + requireObject(listRes, 'GET /api/entity/:id/address'); + const addresses = requireArray(listRes.addresses, 'addresses'); + if (this._cancelled) return; + this.setState({ + addresses, + addressDrafts: Object.fromEntries( + addresses.map((row) => [row.id, addressDraftFrom(row)]), + ), + busyAddressKey: null, + }); + enqueueSnackbar('Adresse gespeichert.', { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busyAddressKey: null }); + } + } + + async handleAddAddress() { + const { entityId } = this.props; + const { newAddress, busyAddressKey } = this.state; + if (busyAddressKey != null || entityId == null) return; + const body = compactAddressBody(newAddress); + if (!body.addressType) { + notifyError(new Error('Adresstyp fehlt.')); + return; + } + + this.setState({ busyAddressKey: 'new' }); + try { + const data = await this.context.entity.createAddress(entityId, body); + requireObject(data, 'POST /api/entity/:id/address'); + requireObject(data.address, 'address'); + requireDefined(data.address.id, 'address.id'); + const listRes = await this.context.entity.listAddresses(entityId); + requireObject(listRes, 'GET /api/entity/:id/address'); + const addresses = requireArray(listRes.addresses, 'addresses'); + if (this._cancelled) return; + this.setState({ + addresses, + addressDrafts: Object.fromEntries( + addresses.map((row) => [row.id, addressDraftFrom(row)]), + ), + newAddress: emptyAddressDraft(), + busyAddressKey: null, + }); + enqueueSnackbar('Adresse angelegt.', { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busyAddressKey: null }); + } + } + + async handleDeleteAddress(address) { + const { entityId } = this.props; + if (this.state.busyAddressKey != null || entityId == null) return; + if (!window.confirm('Adresse wirklich löschen?')) return; + + this.setState({ busyAddressKey: address.id }); + try { + await this.context.entity.deleteAddress(entityId, address.id); + const listRes = await this.context.entity.listAddresses(entityId); + requireObject(listRes, 'GET /api/entity/:id/address'); + const addresses = requireArray(listRes.addresses, 'addresses'); + if (this._cancelled) return; + this.setState({ + addresses, + addressDrafts: Object.fromEntries( + addresses.map((row) => [row.id, addressDraftFrom(row)]), + ), + busyAddressKey: null, + }); + enqueueSnackbar('Adresse gelöscht.', { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busyAddressKey: null }); + } + } + + setContactDraft(id, field, value) { + this.setState((prev) => ({ + contactDrafts: { + ...prev.contactDrafts, + [id]: { ...prev.contactDrafts[id], [field]: value }, + }, + })); + } + + async handleSaveContact(contact) { + const { entityId } = this.props; + const draft = this.state.contactDrafts[contact.id]; + if (!draft || this.state.busyContactKey != null || entityId == null) return; + const contactType = String(draft.contactType ?? '').trim(); + const value = String(draft.contact ?? '').trim(); + if (!contactType || !value) { + notifyError(new Error('Kontakttyp und Wert nötig.')); + return; + } + + this.setState({ busyContactKey: contact.id }); + try { + const data = await this.context.entity.updateContact(entityId, contact.id, { + contactType, + contact: value, + }); + requireObject(data, 'PATCH /api/entity/:id/contact/:id'); + requireObject(data.contact, 'contact'); + const listRes = await this.context.entity.listContacts(entityId); + requireObject(listRes, 'GET /api/entity/:id/contact'); + const contacts = requireArray(listRes.contacts, 'contacts'); + if (this._cancelled) return; + this.setState({ + contacts, + contactDrafts: Object.fromEntries( + contacts.map((row) => [ + row.id, + { contactType: row.contactType, contact: row.contact }, + ]), + ), + busyContactKey: null, + }); + enqueueSnackbar('Kontakt gespeichert.', { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busyContactKey: null }); + } + } + + async handleAddContact() { + const { entityId } = this.props; + const { newContact, busyContactKey } = this.state; + if (busyContactKey != null || entityId == null) return; + const contactType = String(newContact.contactType ?? '').trim(); + const value = String(newContact.contact ?? '').trim(); + if (!contactType || !value) { + notifyError(new Error('Kontakttyp und Wert nötig.')); + return; + } + + this.setState({ busyContactKey: 'new' }); + try { + const data = await this.context.entity.createContact(entityId, { + contactType, + contact: value, + }); + requireObject(data, 'POST /api/entity/:id/contact'); + requireObject(data.contact, 'contact'); + requireDefined(data.contact.id, 'contact.id'); + const listRes = await this.context.entity.listContacts(entityId); + requireObject(listRes, 'GET /api/entity/:id/contact'); + const contacts = requireArray(listRes.contacts, 'contacts'); + if (this._cancelled) return; + this.setState({ + contacts, + contactDrafts: Object.fromEntries( + contacts.map((row) => [ + row.id, + { contactType: row.contactType, contact: row.contact }, + ]), + ), + newContact: emptyContactDraft(), + busyContactKey: null, + }); + enqueueSnackbar('Kontakt angelegt.', { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busyContactKey: null }); + } + } + + async handleDeleteContact(contact) { + const { entityId } = this.props; + if (this.state.busyContactKey != null || entityId == null) return; + if (!window.confirm('Kontakt wirklich löschen?')) return; + + this.setState({ busyContactKey: contact.id }); + try { + await this.context.entity.deleteContact(entityId, contact.id); + const listRes = await this.context.entity.listContacts(entityId); + requireObject(listRes, 'GET /api/entity/:id/contact'); + const contacts = requireArray(listRes.contacts, 'contacts'); + if (this._cancelled) return; + this.setState({ + contacts, + contactDrafts: Object.fromEntries( + contacts.map((row) => [ + row.id, + { contactType: row.contactType, contact: row.contact }, + ]), + ), + busyContactKey: null, + }); + enqueueSnackbar('Kontakt gelöscht.', { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busyContactKey: null }); + } + } + + renderAddressFields(draft, onChange, disabled) { + return ( + + onChange('addressType', next)} + /> + + onChange('name', next)} + /> + onChange('firstName', next)} + /> + + + onChange('street', next)} + sx={{ flex: 2 }} + /> + onChange('streetNumber', next)} + sx={{ flex: 1 }} + /> + + + onChange('zip', next)} + sx={{ flex: 1 }} + /> + onChange('city', next)} + sx={{ flex: 2 }} + /> + + onChange('county', next)} + /> + + ); + } + + render() { + const { + status, + error, + groupName, + name, + firstName, + savingEntity, + addresses, + addressDrafts, + newAddress, + busyAddressKey, + contacts, + contactDrafts, + newContact, + busyContactKey, + } = this.state; + + if (status === 'loading') { + return ( + + + + ); + } + + if (status === 'failed') { + return ; + } + + const addressBusy = busyAddressKey != null; + const contactBusy = busyContactKey != null; + + return ( + + + + {groupName != null ? ( + + Gruppe: {groupName} + + ) : null} + this.setState({ name: next })} + /> + this.setState({ firstName: next })} + /> + + + + + + + + + {addresses.length === 0 ? ( + + Noch keine Adressen. + + ) : ( + addresses.map((address) => { + const draft = addressDrafts[address.id] ?? addressDraftFrom(address); + const busy = busyAddressKey === address.id; + return ( + + {this.renderAddressFields( + draft, + (field, value) => this.setAddressDraft(address.id, field, value), + addressBusy, + )} + + void this.handleDeleteAddress(address)} + > + + + + + + + ); + }) + )} + + Neue Adresse + {this.renderAddressFields( + newAddress, + (field, value) => + this.setState((prev) => ({ + newAddress: { ...prev.newAddress, [field]: value }, + })), + addressBusy, + )} + + + + + + + + + {contacts.length === 0 ? ( + + Noch keine Kontakte. + + ) : ( + contacts.map((contact) => { + const draft = + contactDrafts[contact.id] ?? { + contactType: contact.contactType, + contact: contact.contact, + }; + const busy = busyContactKey === contact.id; + return ( + + + this.setContactDraft(contact.id, 'contactType', next) + } + sx={{ width: 120, flex: '0 0 auto' }} + /> + + this.setContactDraft(contact.id, 'contact', next) + } + sx={{ flex: 1, minWidth: 0 }} + /> + void this.handleDeleteContact(contact)} + > + + + + + ); + }) + )} + + + Neuer Kontakt + + + this.setState((prev) => ({ + newContact: { ...prev.newContact, contactType: next }, + })) + } + sx={{ width: 120, flex: '0 0 auto' }} + /> + + this.setState((prev) => ({ + newContact: { ...prev.newContact, contact: next }, + })) + } + sx={{ flex: 1, minWidth: 0 }} + /> + + + + + + ); + } +} + +export default withEntityRoute(KundeEditPage); diff --git a/src/pages/kunde/gruppen.jsx b/src/pages/kunde/gruppen.jsx index 7dc5729..9ac29d6 100644 --- a/src/pages/kunde/gruppen.jsx +++ b/src/pages/kunde/gruppen.jsx @@ -1,7 +1,278 @@ +import { PureComponent } from 'react'; +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 { 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 { backendErrorFrom } from '../../utils/backendError'; +import { notifyError } from '../../utils/notify'; -export default function KundeGruppenPage() { - return ( - Gruppen — Inhalt folgt. +function sortGroups(groups) { + return [...groups].sort((a, b) => + String(a.name).localeCompare(String(b.name), 'de'), ); } + +function draftsFrom(groups) { + return Object.fromEntries(groups.map((row) => [row.id, row.name])); +} + +class KundeGruppenPage extends PureComponent { + static contextType = StorageManagerContext; + + constructor(props) { + super(props); + this.state = { + status: 'loading', + error: null, + groups: [], + drafts: {}, + newName: '', + busyKey: null, + }; + this._cancelled = false; + this.handleCreate = this.handleCreate.bind(this); + this.handleRetry = this.handleRetry.bind(this); + } + + componentDidMount() { + this._cancelled = false; + void this.load(); + } + + componentWillUnmount() { + this._cancelled = true; + } + + applyGroups(groups) { + const sorted = sortGroups(groups); + this.setState({ + status: 'ready', + error: null, + groups: sorted, + drafts: draftsFrom(sorted), + }); + } + + async load() { + this.setState({ status: 'loading', error: null }); + try { + const list = + this.context.customergroups ?? + (await this.context.customergroupPromise); + if (this._cancelled) return; + this.applyGroups(list); + } catch (err) { + if (this._cancelled) return; + this.setState({ status: 'failed', error: backendErrorFrom(err) }); + } + } + + handleRetry() { + void this.load(); + } + + async refreshFromServer() { + const list = await this.context.updateCustomergroup(); + 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 this.context.customergroup.create({ name }); + requireObject(data, 'POST /api/customergroup'); + requireObject(data.customergroup, 'customergroup'); + requireDefined(data.customergroup.id, 'customergroup.id'); + 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) { + 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 this.context.customergroup.update(group.id, { + name: draft, + }); + requireObject(data, 'PATCH /api/customergroup/:id'); + requireObject(data.customergroup, 'customergroup'); + requireDefined(data.customergroup.id, 'customergroup.id'); + 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 handleDelete(group) { + if (this.state.busyKey != null) return; + if (!window.confirm(`Kundengruppe „${group.name}“ wirklich löschen?`)) { + return; + } + + this.setState({ busyKey: group.id }); + try { + await this.context.customergroup.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 }); + } + } + + render() { + const { status, error, groups, drafts, newName, busyKey } = this.state; + const busy = busyKey != null; + + if (status === 'loading') { + return ( + + + + ); + } + + if (status === 'failed') { + return ; + } + + return ( + + + + + 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)} + > + + + + )) + )} + + + + this.setState({ newName: next })} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void this.handleCreate(); + } + }} + sx={{ flex: 1 }} + /> + + + + + + ); + } +} + +export default KundeGruppenPage; diff --git a/src/pages/kunde/index.jsx b/src/pages/kunde/index.jsx index 07f4ba8..f50d230 100644 --- a/src/pages/kunde/index.jsx +++ b/src/pages/kunde/index.jsx @@ -1,7 +1,197 @@ +import { PureComponent } from 'react'; +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 Stack from '@mui/material/Stack'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; import Typography from '@mui/material/Typography'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; +import { enqueueSnackbar } from 'notistack'; +import BackendError from '../../components/BackendError'; +import { PanelTile, PanelTileGrid } from '../../components/panels'; +import { StorageManagerContext } from '../../api/storageManagerContext'; +import { router } from '../../appRouter'; +import { backendErrorFrom } from '../../utils/backendError'; +import { notifyError } from '../../utils/notify'; -export default function KundeListePage() { - return ( - Kundenliste — Inhalt folgt. - ); +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'); + }); } + +class KundeListePage extends PureComponent { + static contextType = StorageManagerContext; + + constructor(props) { + super(props); + this.state = { + status: 'loading', + error: null, + entities: [], + groupsById: {}, + busyId: null, + }; + this._cancelled = false; + this.handleRetry = this.handleRetry.bind(this); + this.handleCreate = this.handleCreate.bind(this); + } + + componentDidMount() { + this._cancelled = false; + void this.load(); + } + + componentWillUnmount() { + this._cancelled = true; + } + + async load() { + this.setState({ status: 'loading', error: null }); + try { + const [entities, groups] = await Promise.all([ + this.context.entities ?? this.context.entityPromise, + this.context.customergroups ?? this.context.customergroupPromise, + ]); + 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) }); + } + } + + handleRetry() { + void this.load(); + } + + handleCreate() { + void router.navigate('/kunde/neu'); + } + + 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 this.context.entity.delete(entity.id); + const list = await this.context.updateEntity(); + 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 }); + } + } + + render() { + const { status, error, entities, busyId } = this.state; + + if (status === 'loading') { + return ( + + + + ); + } + + if (status === 'failed') { + return ; + } + + return ( + + + + + + + + {entities.length === 0 ? ( + + Noch keine Kunden. + + ) : ( + + + + + Name + Vorname + Gruppe + + + + + {entities.map((row) => ( + this.handleEdit(row)} + > + {row.name} + {row.firstName} + {this.groupName(row.customergroupId)} + e.stopPropagation()}> + this.handleEdit(row)} + > + + + void this.handleDelete(row)} + > + + + + + ))} + +
+
+ )} +
+
+
+ ); + } +} + +export default KundeListePage; diff --git a/src/pages/kunde/kontakte.jsx b/src/pages/kunde/kontakte.jsx deleted file mode 100644 index ea61351..0000000 --- a/src/pages/kunde/kontakte.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import Typography from '@mui/material/Typography'; - -export default function KundeKontaktePage() { - return ( - Kontakte — Inhalt folgt. - ); -} diff --git a/src/pages/kunde/neu.jsx b/src/pages/kunde/neu.jsx new file mode 100644 index 0000000..3ff3cf9 --- /dev/null +++ b/src/pages/kunde/neu.jsx @@ -0,0 +1,203 @@ +import { PureComponent } from 'react'; +import Autocomplete from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +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 { router } from '../../appRouter'; +import { 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'), + ); +} + +class KundeNeuPage extends PureComponent { + static contextType = StorageManagerContext; + + constructor(props) { + super(props); + this.state = { + status: 'loading', + error: null, + groups: [], + customergroupId: null, + name: '', + firstName: '', + saving: false, + }; + this._cancelled = false; + this.handleRetry = this.handleRetry.bind(this); + this.handleGroupChange = this.handleGroupChange.bind(this); + this.handleSave = this.handleSave.bind(this); + } + + componentDidMount() { + this._cancelled = false; + void this.loadGroups(); + } + + componentWillUnmount() { + this._cancelled = true; + } + + async loadGroups() { + this.setState({ status: 'loading', error: null }); + try { + const list = + this.context.customergroups ?? + (await this.context.customergroupPromise); + if (this._cancelled) return; + this.setState({ + status: 'ready', + groups: sortGroups(list), + }); + } catch (err) { + if (this._cancelled) return; + this.setState({ + status: 'failed', + error: backendErrorFrom(err), + }); + } + } + + handleRetry() { + void this.loadGroups(); + } + + handleGroupChange(_event, option) { + this.setState({ customergroupId: option?.id ?? null }); + } + + async handleSave() { + if (this.state.saving) return; + const { customergroupId, name, firstName } = this.state; + const trimmedName = name.trim(); + const trimmedFirst = firstName.trim(); + + if (customergroupId == null) { + notifyError(new Error('Kundengruppe fehlt.')); + return; + } + if (!trimmedName) { + notifyError(new Error('Name fehlt.')); + return; + } + + const body = { + customergroupId, + name: trimmedName, + }; + if (trimmedFirst) body.firstName = trimmedFirst; + + this.setState({ saving: true }); + try { + const data = await this.context.entity.create(body); + requireObject(data, 'POST /api/entity'); + requireObject(data.entity, 'entity'); + requireDefined(data.entity.id, 'entity.id'); + await this.context.updateEntity(); + if (this._cancelled) return; + enqueueSnackbar('Kunde angelegt.', { variant: 'success' }); + void router.navigate(`/kunde/${data.entity.id}`); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ saving: false }); + } + } + + render() { + const { + status, + error, + groups, + customergroupId, + name, + firstName, + saving, + } = this.state; + + if (status === 'failed') { + return ; + } + + const selected = + groups.find((row) => row.id === customergroupId) ?? null; + const canSave = + customergroupId != null && name.trim() !== '' && !saving; + + return ( + + + + String(option.name ?? '')} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={this.handleGroupChange} + renderInput={(params) => { + const { InputProps, inputProps, ...other } = params; + return ( + + ); + }} + /> + this.setState({ name: next })} + /> + this.setState({ firstName: next })} + /> + + + + + + + ); + } +} + +export default KundeNeuPage; diff --git a/src/pages/kunde/withEntityRoute.jsx b/src/pages/kunde/withEntityRoute.jsx new file mode 100644 index 0000000..b740066 --- /dev/null +++ b/src/pages/kunde/withEntityRoute.jsx @@ -0,0 +1,16 @@ +import { useParams } from 'react-router-dom'; + +/** Injects numeric `entityId` from `/kunde/:entityId`. */ +export default function withEntityRoute(Wrapped) { + function EntityRouteWrapper(props) { + const { entityId: raw } = useParams(); + const entityId = Number(raw); + return ( + + ); + } + return EntityRouteWrapper; +} diff --git a/src/pages/lieferung/edit.jsx b/src/pages/lieferung/edit.jsx new file mode 100644 index 0000000..7384079 --- /dev/null +++ b/src/pages/lieferung/edit.jsx @@ -0,0 +1,469 @@ +import { PureComponent } from 'react'; +import { useParams } from 'react-router-dom'; +import Autocomplete from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Stack from '@mui/material/Stack'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import TextField from '@mui/material/TextField'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import IconButton from '@mui/material/IconButton'; +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 { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; +import { backendErrorFrom } from '../../utils/backendError'; +import { notifyError } from '../../utils/notify'; + +function withDeliveryRoute(Wrapped) { + function DeliveryRouteWrapper(props) { + const { deliveryId: raw } = useParams(); + const deliveryId = Number(raw); + return ( + + ); + } + return DeliveryRouteWrapper; +} + +function sortSuppliers(suppliers) { + return [...suppliers].sort((a, b) => + String(a.name).localeCompare(String(b.name), 'de'), + ); +} + +function emptyLine() { + return { + key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + amount: '', + sku: '', + tax: '', + rebate: '', + net: '', + }; +} + +function lineFromApi(row) { + return { + key: `line-${row.id}`, + amount: row.amount == null ? '' : String(row.amount), + sku: row.sku ?? '', + tax: row.tax == null ? '' : String(row.tax), + rebate: row.rebate == null ? '' : String(row.rebate), + net: row.net == null ? '' : String(row.net), + }; +} + +function dateInputValue(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso).slice(0, 10); + return d.toISOString().slice(0, 10); +} + +function parseNum(value) { + if (value == null || value === '') return null; + const n = Number(String(value).trim().replace(',', '.')); + return Number.isFinite(n) ? n : null; +} + +/** Build PATCH /api/delivery/:id body. Throws Error on client validation. */ +function buildUpdateBody({ shippingNumber, estimatedDeliveryDate, supplierId, lines }) { + const number = shippingNumber.trim(); + const date = estimatedDeliveryDate; + if (!number) throw new Error('Nummer fehlt.'); + if (!date) throw new Error('Lieferdatum fehlt.'); + + const outLines = []; + for (const line of lines) { + const sku = String(line.sku ?? '').trim(); + const amount = parseNum(line.amount); + const net = parseNum(line.net); + const tax = parseNum(line.tax); + const rebate = parseNum(line.rebate); + const blank = + !sku && amount == null && net == null && tax == null && rebate == null; + if (blank) continue; + if (!sku || amount == null || net == null || tax == null) { + throw new Error('Jede Zeile braucht SKU, Amount, Net und Tax.'); + } + const row = { sku, amount, net, tax }; + if (rebate != null) row.rebate = rebate; + outLines.push(row); + } + + return { + number, + date, + supplierId: supplierId ?? null, + lines: outLines, + }; +} + +const COLUMNS = [ + { id: 'amount', label: 'Amount', width: 88, align: 'right', type: 'number' }, + { id: 'sku', label: 'SKU', width: 140, align: 'left', type: 'text' }, + { id: 'tax', label: 'Tax', width: 80, align: 'right', type: 'number' }, + { id: 'rebate', label: 'Rebate', width: 88, align: 'right', type: 'number' }, + { id: 'net', label: 'Net', width: 104, align: 'right', type: 'number' }, +]; + +class LieferungEditPage extends PureComponent { + static contextType = StorageManagerContext; + + constructor(props) { + super(props); + this.state = { + status: 'loading', + error: null, + suppliers: [], + estimatedDeliveryDate: '', + supplierId: null, + shippingNumber: '', + lines: [emptyLine()], + saving: false, + }; + this._cancelled = false; + this.handleRetry = this.handleRetry.bind(this); + this.handleAddLine = this.handleAddLine.bind(this); + this.handleSupplierChange = this.handleSupplierChange.bind(this); + this.handleSave = this.handleSave.bind(this); + } + + componentDidMount() { + this._cancelled = false; + void this.load(); + } + + componentDidUpdate(prevProps) { + if (prevProps.deliveryId !== this.props.deliveryId) { + void this.load(); + } + } + + componentWillUnmount() { + this._cancelled = true; + } + + async load() { + const { deliveryId } = this.props; + if (deliveryId == null) { + this.setState({ + status: 'failed', + error: backendErrorFrom(new Error('Ungültige Lieferungs-ID.')), + }); + return; + } + + this.setState({ status: 'loading', error: null }); + try { + const [deliveryRes, suppliers] = await Promise.all([ + this.context.delivery.get(deliveryId), + this.context.suppliers ?? this.context.supplierPromise, + ]); + if (this._cancelled) return; + + requireObject(deliveryRes, 'GET /api/delivery/:id'); + requireObject(deliveryRes.delivery, 'delivery'); + const delivery = deliveryRes.delivery; + const apiLines = requireArray(delivery.lines, 'delivery.lines'); + const lines = + apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()]; + + this.setState({ + status: 'ready', + suppliers: sortSuppliers(suppliers), + estimatedDeliveryDate: dateInputValue(delivery.date), + supplierId: delivery.supplierId, + shippingNumber: delivery.number ?? '', + lines, + }); + } catch (err) { + if (this._cancelled) return; + this.setState({ status: 'failed', error: backendErrorFrom(err) }); + } + } + + handleRetry() { + void this.load(); + } + + handleSupplierChange(_event, option) { + this.setState({ supplierId: option?.id ?? null }); + } + + handleLineChange(key, field, value) { + this.setState((prev) => ({ + lines: prev.lines.map((line) => + line.key === key ? { ...line, [field]: value } : line, + ), + })); + } + + handleAddLine() { + this.setState((prev) => ({ lines: [...prev.lines, emptyLine()] })); + } + + handleRemoveLine(key) { + this.setState((prev) => { + const next = prev.lines.filter((line) => line.key !== key); + return { lines: next.length > 0 ? next : [emptyLine()] }; + }); + } + + async handleSave() { + const { deliveryId } = this.props; + if (this.state.saving || deliveryId == null) return; + let body; + try { + body = buildUpdateBody(this.state); + } catch (err) { + notifyError(err); + return; + } + + this.setState({ saving: true }); + try { + const data = await this.context.delivery.update(deliveryId, body); + requireObject(data, 'PATCH /api/delivery/:id'); + requireObject(data.delivery, 'delivery'); + requireDefined(data.delivery.id, 'delivery.id'); + await this.context.updateDelivery(); + if (this._cancelled) return; + const apiLines = requireArray(data.delivery.lines, 'delivery.lines'); + this.setState({ + estimatedDeliveryDate: dateInputValue(data.delivery.date), + supplierId: data.delivery.supplierId, + shippingNumber: data.delivery.number ?? '', + lines: + apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()], + saving: false, + }); + enqueueSnackbar('Lieferung gespeichert.', { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ saving: false }); + } + } + + renderHeader() { + const { + suppliers, + estimatedDeliveryDate, + supplierId, + shippingNumber, + saving, + } = this.state; + const selected = + suppliers.find((row) => row.id === supplierId) ?? null; + + return ( + + + + this.setState({ estimatedDeliveryDate: e.target.value }) + } + autoComplete="off" + slotProps={{ + inputLabel: { shrink: true }, + htmlInput: { spellCheck: false }, + }} + sx={{ minWidth: 220 }} + /> + String(option.name ?? '')} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={this.handleSupplierChange} + sx={{ flex: 1, minWidth: 220 }} + renderInput={(params) => { + const { InputProps, inputProps, ...other } = params; + return ( + + ); + }} + /> + this.setState({ shippingNumber: next })} + sx={{ minWidth: 180, flex: 1 }} + /> + + + ); + } + + renderLineField(line, column) { + const { id, align, type } = column; + const { saving } = this.state; + return ( + this.handleLineChange(line.key, id, e.target.value)} + slotProps={{ + htmlInput: { + inputMode: type === 'number' ? 'decimal' : 'text', + style: { textAlign: align }, + spellCheck: false, + autoComplete: 'off', + step: type === 'number' ? 'any' : undefined, + }, + }} + sx={{ + '& input[type=number]': { + MozAppearance: 'textfield', + }, + '& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button': + { + WebkitAppearance: 'none', + margin: 0, + }, + }} + /> + ); + } + + renderArticles() { + const { lines, saving } = this.state; + + return ( + + + + + + + {COLUMNS.map((col) => ( + + {col.label} + + ))} + + + + + {lines.map((line) => ( + + {COLUMNS.map((col) => ( + + {this.renderLineField(line, col)} + + ))} + + this.handleRemoveLine(line.key)} + > + + + + + ))} + +
+
+ + + + + +
+
+ ); + } + + render() { + const { status, error } = this.state; + + if (status === 'loading') { + return ( + + + + ); + } + + if (status === 'failed') { + return ; + } + + return ( + + {this.renderHeader()} + {this.renderArticles()} + + ); + } +} + +export default withDeliveryRoute(LieferungEditPage); diff --git a/src/pages/lieferung/index.jsx b/src/pages/lieferung/index.jsx index 47d5afb..52aa43b 100644 --- a/src/pages/lieferung/index.jsx +++ b/src/pages/lieferung/index.jsx @@ -12,6 +12,7 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Typography from '@mui/material/Typography'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import { enqueueSnackbar } from 'notistack'; import BackendError from '../../components/BackendError'; import { PanelTile, PanelTileGrid } from '../../components/panels'; @@ -88,6 +89,10 @@ class LieferungListePage extends PureComponent { void router.navigate('/lieferung/neu'); } + handleEdit(delivery) { + void router.navigate(`/lieferung/${delivery.id}`); + } + async handleDelete(delivery) { if (this.state.busyId != null) return; if ( @@ -162,11 +167,23 @@ class LieferungListePage extends PureComponent { {deliveries.map((row) => ( - + this.handleEdit(row)} + > {row.number} {formatDate(row.date)} {this.supplierName(row.supplierId)} - + e.stopPropagation()}> + this.handleEdit(row)} + > + + option.id === value.id} onChange={this.handleSupplierChange} sx={{ flex: 1, minWidth: 220 }} - renderInput={(params) => ( - - )} + renderInput={(params) => { + const { InputProps, inputProps, ...other } = params; + return ( + + ); + }} />