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)}
+ >
+
+
+
+
+ ))}
+
+
+
+
+ }
+ disabled={saving}
+ onClick={this.handleAddLine}
+ >
+ Zeile hinzufügen
+
+
+
+
+
+
+ );
+ }
+
+ 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 (
+
+ );
+ }}
/>