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)} + > + + + + + ))} + +
+
+ + + + + +
+
+ ); + } + + render() { + const { suppliersStatus, suppliersError } = this.state; + + if (suppliersStatus === 'failed') { + return ( + + ); + } + + return ( + + {this.renderHeader()} + {this.renderArticles()} + + ); + } +} + +export default LieferungNeuPage;