diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js
index ec8a681..ea4796b 100644
--- a/src/api/ApiClient.js
+++ b/src/api/ApiClient.js
@@ -71,6 +71,30 @@ export class ApiClient {
delete: (id) => this.#empty('DELETE', `/api/brand/${enc(id)}`),
};
+ this.supplier = {
+ /** GET /api/supplier → { suppliers: Supplier[] } */
+ list: () => this.#json('GET', '/api/supplier'),
+ /** POST /api/supplier → 201 { supplier } */
+ create: (body) => this.#json('POST', '/api/supplier', { body }),
+ /** PATCH /api/supplier/{id} → { supplier } */
+ update: (id, body) => this.#json('PATCH', `/api/supplier/${enc(id)}`, { body }),
+ /** DELETE /api/supplier/{id} → 204 */
+ delete: (id) => this.#empty('DELETE', `/api/supplier/${enc(id)}`),
+ };
+
+ this.delivery = {
+ /** GET /api/delivery → { deliveries: DeliverySummary[] } */
+ list: () => this.#json('GET', '/api/delivery'),
+ /** GET /api/delivery/{id} → { delivery: Delivery } */
+ get: (id) => this.#json('GET', `/api/delivery/${enc(id)}`),
+ /** POST /api/delivery → 201 { delivery: Delivery } */
+ create: (body) => this.#json('POST', '/api/delivery', { body }),
+ /** PATCH /api/delivery/{id} → { delivery: Delivery } */
+ update: (id, body) => this.#json('PATCH', `/api/delivery/${enc(id)}`, { body }),
+ /** DELETE /api/delivery/{id} → 204 */
+ delete: (id) => this.#empty('DELETE', `/api/delivery/${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 c70459a..694a3a7 100644
--- a/src/api/StorageManager.js
+++ b/src/api/StorageManager.js
@@ -291,6 +291,8 @@ export class StorageManager extends ApiClient {
const companyCache = createCache('GET /company', () => this.#loadCompanies());
const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree());
const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals());
+ const supplierCache = createCache('GET /supplier', () => this.#loadSuppliers());
+ const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries());
Object.defineProperties(this, {
authMePromise: { get: () => authMeCache.promise },
@@ -324,6 +326,16 @@ export class StorageManager extends ApiClient {
posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update },
+ supplierPromise: { get: () => supplierCache.promise },
+ updateSupplier: { value: supplierCache.update },
+ /** Settled GET /supplier list, or undefined. */
+ suppliers: { get: () => supplierCache.value },
+
+ deliveryPromise: { get: () => deliveryCache.promise },
+ updateDelivery: { value: deliveryCache.update },
+ /** Settled GET /delivery list, or undefined. */
+ deliveries: { get: () => deliveryCache.value },
+
/** Close and reopen GET /api/events (required after selected-company change). */
reconnectEvents: { value: () => this.#reconnectEvents() },
});
@@ -444,6 +456,18 @@ export class StorageManager extends ApiClient {
return requireArray(data.posterminals, 'posterminals');
}
+ async #loadSuppliers() {
+ const data = await this.supplier.list();
+ requireObject(data, 'GET /api/supplier');
+ return requireArray(data.suppliers, 'suppliers');
+ }
+
+ async #loadDeliveries() {
+ const data = await this.delivery.list();
+ requireObject(data, 'GET /api/delivery');
+ return requireArray(data.deliveries, 'deliveries');
+ }
+
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 6d12d20..7dcc370 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -521,6 +521,84 @@ export const brand = {
},
};
+/**
+ * Company-scoped suppliers / Lieferanten (selectedCompanyId from session).
+ * Shape: Supplier = { id, name, createdAt, updatedAt }
+ */
+export const supplier = {
+ /** GET /api/supplier → { suppliers: Supplier[] } */
+ list() {
+ return http('/api/supplier');
+ },
+
+ /**
+ * POST /api/supplier
+ * Body: { name } — empty name ⇒ 400 Name required.
+ * → 201 { supplier: Supplier }
+ */
+ create(body) {
+ return http('/api/supplier', { method: 'POST', body });
+ },
+
+ /**
+ * PATCH /api/supplier/:id
+ * Body: { name } — empty name ⇒ 400 Name required.
+ * → 200 { supplier: Supplier }
+ */
+ update(id, body) {
+ return http(`/api/supplier/${id}`, { method: 'PATCH', body });
+ },
+
+ /** DELETE /api/supplier/:id → 204 */
+ delete(id) {
+ return http(`/api/supplier/${id}`, { method: 'DELETE' });
+ },
+};
+
+/**
+ * Company-scoped goods receipts (manifest type = delivery).
+ * DeliverySummary = { id, number, type: 'delivery', supplierId, date }
+ * DeliveryLine = { id, articleId, sku, amount, net, transactedAmount, rebate, tax, comment }
+ * Delivery = DeliverySummary & { lines: DeliveryLine[] }
+ * DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? }
+ */
+export const delivery = {
+ /** GET /api/delivery → { deliveries: DeliverySummary[] } */
+ list() {
+ return http('/api/delivery');
+ },
+
+ /** GET /api/delivery/:id → { delivery: Delivery } */
+ get(id) {
+ return http(`/api/delivery/${id}`);
+ },
+
+ /**
+ * POST /api/delivery
+ * Body: { number, date, supplierId?, lines: DeliveryLineInput[] }
+ * Line requires sku, amount, net, tax. rebate→0, transactedAmount→amount.
+ * → 201 { delivery: Delivery }
+ */
+ create(body) {
+ return http('/api/delivery', { method: 'POST', body });
+ },
+
+ /**
+ * PATCH /api/delivery/:id
+ * Body: partial { number?, date?, supplierId?, lines? }.
+ * If lines present, replaces all lines; if omitted, lines unchanged.
+ * → 200 { delivery: Delivery }
+ */
+ update(id, body) {
+ return http(`/api/delivery/${id}`, { method: 'PATCH', body });
+ },
+
+ /** DELETE /api/delivery/:id → 204 */
+ delete(id) {
+ return http(`/api/delivery/${id}`, { method: 'DELETE' });
+ },
+};
+
export const company = {
/** GET /api/company → { companies: [...] } */
list() {
@@ -697,6 +775,8 @@ export default {
category,
company,
posterminal,
+ supplier,
+ delivery,
thumb,
unit,
};
diff --git a/src/appRouter.jsx b/src/appRouter.jsx
index fdb5fcf..7c2de69 100644
--- a/src/appRouter.jsx
+++ b/src/appRouter.jsx
@@ -8,7 +8,9 @@ const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien'));
const RedirectArtikelKategorien = lazy(() => import('./pages/artikel/RedirectArtikelKategorien'));
const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager'));
const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku'));
-const LieferungPage = lazy(() => import('./pages/lieferung/index'));
+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 KundeListePage = lazy(() => import('./pages/kunde/index'));
@@ -22,7 +24,15 @@ export const router = createBrowserRouter([
element: ,
children: [
{ index: true, element: },
- { path: 'lieferung', element: },
+ {
+ path: 'lieferung',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'neu', element: },
+ { path: 'lieferanten', element: },
+ ],
+ },
{ path: 'infos', element: },
{
path: 'kategorien/*',
diff --git a/src/nav.js b/src/nav.js
index 553e2b7..e348356 100644
--- a/src/nav.js
+++ b/src/nav.js
@@ -10,6 +10,8 @@ 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 { isKategorienPath } from './pages/artikel/categoryRoute';
export const NAV_ITEMS = [
@@ -26,6 +28,11 @@ export const SUB_NAV = {
{ segment: 'kategorien', label: 'Kategorien', icon: CategoryIcon },
{ segment: 'lager', label: 'Lager', icon: WarehouseIcon },
],
+ '/lieferung': [
+ { segment: '', label: 'Lieferungen', icon: ListAltIcon },
+ { segment: 'neu', label: 'Neue Lieferung', icon: AddBoxIcon },
+ { segment: 'lieferanten', label: 'Lieferanten', icon: StorefrontIcon },
+ ],
'/auftrag': [
{ segment: '', label: 'Aktive Aufträge', icon: ListAltIcon },
{ segment: 'archiv', label: 'Archiv', icon: ArchiveIcon },
diff --git a/src/pages/lieferung/index.jsx b/src/pages/lieferung/index.jsx
index 2bab0b5..47d5afb 100644
--- a/src/pages/lieferung/index.jsx
+++ b/src/pages/lieferung/index.jsx
@@ -1,10 +1,192 @@
+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 PageLayout from '../../components/PageLayout';
+import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
+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 LieferungPage() {
- return (
-
- Lieferung — Inhalt folgt.
-
+function sortDeliveries(deliveries) {
+ return [...deliveries].sort((a, b) =>
+ String(b.date).localeCompare(String(a.date)),
);
}
+
+function formatDate(iso) {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return iso;
+ return d.toLocaleDateString('de-DE');
+}
+
+class LieferungListePage extends PureComponent {
+ static contextType = StorageManagerContext;
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ status: 'loading',
+ error: null,
+ deliveries: [],
+ suppliersById: {},
+ 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 [deliveries, suppliers] = await Promise.all([
+ this.context.deliveries ?? this.context.deliveryPromise,
+ this.context.suppliers ?? this.context.supplierPromise,
+ ]);
+ if (this._cancelled) return;
+ const suppliersById = Object.fromEntries(
+ suppliers.map((row) => [row.id, row]),
+ );
+ this.setState({
+ status: 'ready',
+ deliveries: sortDeliveries(deliveries),
+ suppliersById,
+ });
+ } catch (err) {
+ if (this._cancelled) return;
+ this.setState({ status: 'failed', error: backendErrorFrom(err) });
+ }
+ }
+
+ handleRetry() {
+ void this.load();
+ }
+
+ handleCreate() {
+ void router.navigate('/lieferung/neu');
+ }
+
+ async handleDelete(delivery) {
+ if (this.state.busyId != null) return;
+ if (
+ !window.confirm(
+ `Lieferung „${delivery.number}“ wirklich löschen?`,
+ )
+ ) {
+ return;
+ }
+
+ this.setState({ busyId: delivery.id });
+ try {
+ await this.context.delivery.delete(delivery.id);
+ const list = await this.context.updateDelivery();
+ if (this._cancelled) return;
+ this.setState({
+ deliveries: sortDeliveries(list),
+ busyId: null,
+ });
+ enqueueSnackbar('Lieferung gelöscht.', { variant: 'success' });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ busyId: null });
+ }
+ }
+
+ supplierName(supplierId) {
+ if (supplierId == null) return null;
+ return this.state.suppliersById[supplierId]?.name;
+ }
+
+ render() {
+ const { status, error, deliveries, busyId } = this.state;
+
+ if (status === 'loading') {
+ return (
+
+
+
+ );
+ }
+
+ if (status === 'failed') {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ {deliveries.length === 0 ? (
+
+ Noch keine Lieferungen.
+
+ ) : (
+
+
+
+
+ Nummer
+ Datum
+ Lieferant
+
+
+
+
+ {deliveries.map((row) => (
+
+ {row.number}
+ {formatDate(row.date)}
+ {this.supplierName(row.supplierId)}
+
+ void this.handleDelete(row)}
+ >
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+ }
+}
+
+export default LieferungListePage;
diff --git a/src/pages/lieferung/lieferanten.jsx b/src/pages/lieferung/lieferanten.jsx
new file mode 100644
index 0000000..4665475
--- /dev/null
+++ b/src/pages/lieferung/lieferanten.jsx
@@ -0,0 +1,272 @@
+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 StorefrontIcon from '@mui/icons-material/Storefront';
+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';
+
+function sortSuppliers(suppliers) {
+ return [...suppliers].sort((a, b) =>
+ String(a.name).localeCompare(String(b.name), 'de'),
+ );
+}
+
+function draftsFrom(suppliers) {
+ return Object.fromEntries(suppliers.map((row) => [row.id, row.name]));
+}
+
+class LieferungLieferantenPage extends PureComponent {
+ static contextType = StorageManagerContext;
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ status: 'loading',
+ error: null,
+ suppliers: [],
+ drafts: {},
+ newName: '',
+ busyKey: null,
+ };
+ this._cancelled = false;
+ this.handleCreate = this.handleCreate.bind(this);
+ this.handleRetry = this.handleRetry.bind(this);
+ this.loadSuppliers = this.loadSuppliers.bind(this);
+ }
+
+ componentDidMount() {
+ this._cancelled = false;
+ void this.loadSuppliers();
+ }
+
+ componentWillUnmount() {
+ this._cancelled = true;
+ }
+
+ applySuppliers(suppliers) {
+ const sorted = sortSuppliers(suppliers);
+ this.setState({
+ status: 'ready',
+ error: null,
+ suppliers: sorted,
+ drafts: draftsFrom(sorted),
+ });
+ }
+
+ async loadSuppliers() {
+ this.setState({ status: 'loading', error: null });
+ try {
+ const list = this.context.suppliers ?? (await this.context.supplierPromise);
+ if (this._cancelled) return;
+ this.applySuppliers(list);
+ } catch (err) {
+ if (this._cancelled) return;
+ this.setState({ status: 'failed', error: backendErrorFrom(err) });
+ }
+ }
+
+ handleRetry() {
+ void this.loadSuppliers();
+ }
+
+ async refreshFromServer() {
+ const list = await this.context.updateSupplier();
+ if (this._cancelled) return;
+ this.applySuppliers(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.supplier.create({ name });
+ requireObject(data, 'POST /api/supplier');
+ requireObject(data.supplier, 'supplier');
+ requireDefined(data.supplier.id, 'supplier.id');
+ await this.refreshFromServer();
+ if (this._cancelled) return;
+ this.setState({ newName: '', busyKey: null });
+ enqueueSnackbar('Lieferant angelegt.', { variant: 'success' });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ busyKey: null });
+ }
+ }
+
+ async handleRename(supplier) {
+ const draft = String(this.state.drafts[supplier.id] ?? '').trim();
+ if (!draft) {
+ this.setState((prev) => ({
+ drafts: { ...prev.drafts, [supplier.id]: supplier.name },
+ }));
+ return;
+ }
+ if (draft === supplier.name || this.state.busyKey != null) return;
+
+ this.setState({ busyKey: supplier.id });
+ try {
+ const data = await this.context.supplier.update(supplier.id, { name: draft });
+ requireObject(data, 'PATCH /api/supplier/:id');
+ requireObject(data.supplier, 'supplier');
+ requireDefined(data.supplier.id, 'supplier.id');
+ await this.refreshFromServer();
+ if (this._cancelled) return;
+ this.setState({ busyKey: null });
+ enqueueSnackbar('Lieferant gespeichert.', { variant: 'success' });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState((prev) => ({
+ busyKey: null,
+ drafts: { ...prev.drafts, [supplier.id]: supplier.name },
+ }));
+ }
+ }
+
+ async handleDelete(supplier) {
+ if (this.state.busyKey != null) return;
+ if (!window.confirm(`Lieferant „${supplier.name}“ wirklich löschen?`)) {
+ return;
+ }
+
+ this.setState({ busyKey: supplier.id });
+ try {
+ await this.context.supplier.delete(supplier.id);
+ await this.refreshFromServer();
+ if (this._cancelled) return;
+ this.setState({ busyKey: null });
+ enqueueSnackbar('Lieferant gelöscht.', { variant: 'success' });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ busyKey: null });
+ }
+ }
+
+ render() {
+ const { status, error, suppliers, drafts, newName, busyKey } = this.state;
+ const busy = busyKey != null;
+
+ if (status === 'loading') {
+ return (
+
+
+
+ );
+ }
+
+ if (status === 'failed') {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ Lieferanten der ausgewählten Firma.
+
+
+
+ {suppliers.length === 0 ? (
+
+ Noch keine Lieferanten.
+
+ ) : (
+ suppliers.map((supplier) => (
+
+
+
+ this.setState((prev) => ({
+ drafts: { ...prev.drafts, [supplier.id]: next },
+ }))
+ }
+ onBlur={() => void this.handleRename(supplier)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ event.target.blur();
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ this.setState((prev) => ({
+ drafts: { ...prev.drafts, [supplier.id]: supplier.name },
+ }));
+ event.target.blur();
+ }
+ }}
+ sx={{ flex: 1 }}
+ />
+ void this.handleDelete(supplier)}
+ >
+
+
+
+ ))
+ )}
+
+
+
+ this.setState({ newName: next })}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ void this.handleCreate();
+ }
+ }}
+ sx={{ flex: 1 }}
+ />
+
+
+
+
+
+ );
+ }
+}
+
+export default LieferungLieferantenPage;
diff --git a/src/pages/lieferung/neu.jsx b/src/pages/lieferung/neu.jsx
new file mode 100644
index 0000000..f41e392
--- /dev/null
+++ b/src/pages/lieferung/neu.jsx
@@ -0,0 +1,407 @@
+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 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 Typography from '@mui/material/Typography';
+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 { router } from '../../appRouter';
+import { requireDefined, requireObject } from '../../utils/apiShape';
+import { backendErrorFrom } from '../../utils/backendError';
+import { notifyError } from '../../utils/notify';
+
+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 parseNum(value) {
+ if (value == null || value === '') return null;
+ const n = Number(String(value).trim().replace(',', '.'));
+ return Number.isFinite(n) ? n : null;
+}
+
+/** Build POST /api/delivery body from form state. Throws Error on client validation. */
+function buildCreateBody({ shippingNumber, estimatedDeliveryDate, supplierId, lines }) {
+ const number = shippingNumber.trim();
+ const date = estimatedDeliveryDate;
+ if (!number) throw new Error('Sendungsnummer 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);
+ }
+ if (outLines.length === 0) {
+ throw new Error('Mindestens eine Artikelzeile nötig.');
+ }
+
+ const body = { number, date, lines: outLines };
+ if (supplierId != null) body.supplierId = supplierId;
+ return body;
+}
+
+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 LieferungNeuPage extends PureComponent {
+ static contextType = StorageManagerContext;
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ suppliersStatus: 'loading',
+ suppliersError: null,
+ suppliers: [],
+ estimatedDeliveryDate: '',
+ supplierId: null,
+ shippingNumber: '',
+ lines: [emptyLine()],
+ saving: false,
+ };
+ this._cancelled = false;
+ this.handleRetrySuppliers = this.handleRetrySuppliers.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.loadSuppliers();
+ }
+
+ componentWillUnmount() {
+ this._cancelled = true;
+ }
+
+ async loadSuppliers() {
+ this.setState({ suppliersStatus: 'loading', suppliersError: null });
+ try {
+ const list = this.context.suppliers ?? (await this.context.supplierPromise);
+ if (this._cancelled) return;
+ this.setState({
+ suppliersStatus: 'ready',
+ suppliers: sortSuppliers(list),
+ });
+ } catch (err) {
+ if (this._cancelled) return;
+ this.setState({
+ suppliersStatus: 'failed',
+ suppliersError: backendErrorFrom(err),
+ });
+ notifyError(err);
+ }
+ }
+
+ handleRetrySuppliers() {
+ void this.loadSuppliers();
+ }
+
+ handleSupplierChange(_event, option) {
+ this.setState({
+ supplierId: option?.id ?? null,
+ lines: option ? this.state.lines : [emptyLine()],
+ });
+ }
+
+ 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() {
+ if (this.state.saving) return;
+ let body;
+ try {
+ body = buildCreateBody(this.state);
+ } catch (err) {
+ notifyError(err);
+ return;
+ }
+
+ this.setState({ saving: true });
+ try {
+ const data = await this.context.delivery.create(body);
+ requireObject(data, 'POST /api/delivery');
+ requireObject(data.delivery, 'delivery');
+ requireDefined(data.delivery.id, 'delivery.id');
+ await this.context.updateDelivery();
+ if (this._cancelled) return;
+ enqueueSnackbar('Lieferung angelegt.', { variant: 'success' });
+ void router.navigate('/lieferung');
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ saving: false });
+ }
+ }
+
+ renderHeader() {
+ const {
+ suppliersStatus,
+ 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) => (
+
+ )}
+ />
+ 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 { supplierId, lines, saving } = this.state;
+ if (supplierId == null) {
+ return (
+
+
+ Lieferant wählen, um Artikel zu erfassen.
+
+
+ );
+ }
+
+ 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 { suppliersStatus, suppliersError } = this.state;
+
+ if (suppliersStatus === 'failed') {
+ return (
+
+ );
+ }
+
+ return (
+
+ {this.renderHeader()}
+ {this.renderArticles()}
+
+ );
+ }
+}
+
+export default LieferungNeuPage;