From 4896b1dc362dc443b7d6fe4be596788d22a12109 Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 6 Aug 2026 20:08:56 +0200 Subject: [PATCH] Add shared Manifest UI for delivery, quote, and order. Wire quote/order API clients and routes, then unify list/create/edit behind ManifestList/Form tiles so Lieferungen and Auftrag share one stack. --- src/api/ApiClient.js | 26 + src/api/StorageManager.js | 24 + src/api/index.js | 83 ++++ src/appRouter.jsx | 15 +- src/components/SectionLayout.jsx | 12 +- .../article/boxes/ArticleViewBrand.jsx | 8 +- .../article/boxes/ArticleViewEANs.jsx | 8 +- src/components/manifest/ManifestForm.jsx | 321 ++++++++++++ src/components/manifest/ManifestFormLine.jsx | 83 ++++ src/components/manifest/ManifestHeader.jsx | 93 ++++ src/components/manifest/ManifestLines.jsx | 91 ++++ src/components/manifest/ManifestList.jsx | 206 ++++++++ src/components/manifest/index.js | 13 + src/components/manifest/manifestConfig.js | 264 ++++++++++ src/components/manifest/withManifestId.jsx | 20 + src/nav.js | 13 +- src/pages/auftrag/bestellung.jsx | 8 +- src/pages/auftrag/bestellungEdit.jsx | 16 + src/pages/auftrag/bestellungNeu.jsx | 10 + src/pages/auftrag/edit.jsx | 16 + src/pages/auftrag/index.jsx | 8 +- src/pages/auftrag/neu.jsx | 10 + src/pages/kunde/neu.jsx | 34 +- src/pages/lieferung/edit.jsx | 467 +----------------- src/pages/lieferung/index.jsx | 203 +------- src/pages/lieferung/neu.jsx | 407 +-------------- 26 files changed, 1356 insertions(+), 1103 deletions(-) create mode 100644 src/components/manifest/ManifestForm.jsx create mode 100644 src/components/manifest/ManifestFormLine.jsx create mode 100644 src/components/manifest/ManifestHeader.jsx create mode 100644 src/components/manifest/ManifestLines.jsx create mode 100644 src/components/manifest/ManifestList.jsx create mode 100644 src/components/manifest/index.js create mode 100644 src/components/manifest/manifestConfig.js create mode 100644 src/components/manifest/withManifestId.jsx create mode 100644 src/pages/auftrag/bestellungEdit.jsx create mode 100644 src/pages/auftrag/bestellungNeu.jsx create mode 100644 src/pages/auftrag/edit.jsx create mode 100644 src/pages/auftrag/neu.jsx diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index e6fe86d..a79742c 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -95,6 +95,32 @@ export class ApiClient { delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`), }; + this.quote = { + /** GET /api/quote → { quotes } */ + list: () => this.#json('GET', '/api/quote'), + /** GET /api/quote/{id} → { quote } */ + get: (id) => this.#json('GET', `/api/quote/${enc(id)}`), + /** POST /api/quote → 201 { quote } */ + create: (body) => this.#json('POST', '/api/quote', { body }), + /** PATCH /api/quote/{id} → { quote } */ + update: (id, body) => this.#json('PATCH', `/api/quote/${enc(id)}`, { body }), + /** DELETE /api/quote/{id} → 204 */ + delete: (id) => this.#empty('DELETE', `/api/quote/${enc(id)}`), + }; + + this.order = { + /** GET /api/order → { orders } */ + list: () => this.#json('GET', '/api/order'), + /** GET /api/order/{id} → { order } */ + get: (id) => this.#json('GET', `/api/order/${enc(id)}`), + /** POST /api/order → 201 { order } */ + create: (body) => this.#json('POST', '/api/order', { body }), + /** PATCH /api/order/{id} → { order } */ + update: (id, body) => this.#json('PATCH', `/api/order/${enc(id)}`, { body }), + /** DELETE /api/order/{id} → 204 */ + delete: (id) => this.#empty('DELETE', `/api/order/${enc(id)}`), + }; + this.customergroup = { /** GET /api/customergroup → { customergroups } */ list: () => this.#json('GET', '/api/customergroup'), diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index c4e36f7..7ab71e8 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -297,6 +297,8 @@ export class StorageManager extends ApiClient { this.#loadCustomergroups(), ); const entityCache = createCache('GET /entity', () => this.#loadEntities()); + const quoteCache = createCache('GET /quote', () => this.#loadQuotes()); + const orderCache = createCache('GET /order', () => this.#loadOrders()); Object.defineProperties(this, { authMePromise: { get: () => authMeCache.promise }, @@ -350,6 +352,16 @@ export class StorageManager extends ApiClient { /** Settled GET /entity list, or undefined. */ entities: { get: () => entityCache.value }, + quotePromise: { get: () => quoteCache.promise }, + updateQuote: { value: quoteCache.update }, + /** Settled GET /quote list, or undefined. */ + quotes: { get: () => quoteCache.value }, + + orderPromise: { get: () => orderCache.promise }, + updateOrder: { value: orderCache.update }, + /** Settled GET /order list, or undefined. */ + orders: { get: () => orderCache.value }, + /** Close and reopen GET /api/events (required after selected-company change). */ reconnectEvents: { value: () => this.#reconnectEvents() }, }); @@ -494,6 +506,18 @@ export class StorageManager extends ApiClient { return requireArray(data.entities, 'entities'); } + async #loadQuotes() { + const data = await this.quote.list(); + requireObject(data, 'GET /api/quote'); + return requireArray(data.quotes, 'quotes'); + } + + async #loadOrders() { + const data = await this.order.list(); + requireObject(data, 'GET /api/order'); + return requireArray(data.orders, 'orders'); + } + 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 5e4c83e..19c8d63 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -599,6 +599,87 @@ export const delivery = { }, }; +/** + * Sales quotes (manifest type = quote). + * Summary = { id, number, type: 'quote', entityId, date } + * Line = { id, articleId, sku, amount, net, rebate, tax, comment } — no transactedAmount + * LineInput = { sku, amount, net, tax, rebate?, comment? } + */ +export const quote = { + /** GET /api/quote → { quotes: QuoteSummary[] } */ + list() { + return http('/api/quote'); + }, + + /** GET /api/quote/:id → { quote: Quote } */ + get(id) { + return http(`/api/quote/${id}`); + }, + + /** + * POST /api/quote + * Body: { number, date, entityId?, lines } + * → 201 { quote: Quote } + */ + create(body) { + return http('/api/quote', { method: 'POST', body }); + }, + + /** + * PATCH /api/quote/:id + * Body: partial { number?, date?, entityId?, lines? } + * → 200 { quote: Quote } + */ + update(id, body) { + return http(`/api/quote/${id}`, { method: 'PATCH', body }); + }, + + /** DELETE /api/quote/:id → 204 */ + delete(id) { + return http(`/api/quote/${id}`, { method: 'DELETE' }); + }, +}; + +/** + * Sales orders (manifest type = order). + * Summary = { id, number, type: 'order', entityId, date } + * Line shape same as quote (no transactedAmount). + */ +export const order = { + /** GET /api/order → { orders: OrderSummary[] } */ + list() { + return http('/api/order'); + }, + + /** GET /api/order/:id → { order: Order } */ + get(id) { + return http(`/api/order/${id}`); + }, + + /** + * POST /api/order + * Body: { number, date, entityId?, lines } + * → 201 { order: Order } + */ + create(body) { + return http('/api/order', { method: 'POST', body }); + }, + + /** + * PATCH /api/order/:id + * Body: partial { number?, date?, entityId?, lines? } + * → 200 { order: Order } + */ + update(id, body) { + return http(`/api/order/${id}`, { method: 'PATCH', body }); + }, + + /** DELETE /api/order/:id → 204 */ + delete(id) { + return http(`/api/order/${id}`, { method: 'DELETE' }); + }, +}; + /** * Company-scoped customer groups. * Shape: CustomerGroup = { id, name } @@ -903,7 +984,9 @@ export default { customergroup, delivery, entity, + order, posterminal, + quote, supplier, thumb, unit, diff --git a/src/appRouter.jsx b/src/appRouter.jsx index 9000a7a..c6294f9 100644 --- a/src/appRouter.jsx +++ b/src/appRouter.jsx @@ -13,7 +13,11 @@ const LieferungNeuPage = lazy(() => import('./pages/lieferung/neu')); const LieferungLieferantenPage = lazy(() => import('./pages/lieferung/lieferanten')); const LieferungEditPage = lazy(() => import('./pages/lieferung/edit')); const AuftragAngebotPage = lazy(() => import('./pages/auftrag/index')); +const AuftragAngebotNeuPage = lazy(() => import('./pages/auftrag/neu')); +const AuftragAngebotEditPage = lazy(() => import('./pages/auftrag/edit')); const AuftragBestellungPage = lazy(() => import('./pages/auftrag/bestellung')); +const AuftragBestellungNeuPage = lazy(() => import('./pages/auftrag/bestellungNeu')); +const AuftragBestellungEditPage = lazy(() => import('./pages/auftrag/bestellungEdit')); const AuftragAusliefernPage = lazy(() => import('./pages/auftrag/ausliefern')); const AuftragRetourePage = lazy(() => import('./pages/auftrag/retoure')); const KundeListePage = lazy(() => import('./pages/kunde/index')); @@ -59,9 +63,18 @@ export const router = createBrowserRouter([ element: , children: [ { index: true, element: }, - { path: 'bestellung', element: }, + { path: 'neu', element: }, + { + path: 'bestellung', + children: [ + { index: true, element: }, + { path: 'neu', element: }, + { path: ':orderId', element: }, + ], + }, { path: 'ausliefern', element: }, { path: 'retoure', element: }, + { path: ':quoteId', element: }, ], }, { diff --git a/src/components/SectionLayout.jsx b/src/components/SectionLayout.jsx index 4e44683..5696b49 100644 --- a/src/components/SectionLayout.jsx +++ b/src/components/SectionLayout.jsx @@ -48,11 +48,19 @@ export default function SectionLayout({ sectionPath }) { sectionPath === '/kunde' && rest && !knownSegments.has(rest); const isLieferungDetail = sectionPath === '/lieferung' && rest && !knownSegments.has(rest); - const isDetail = isArticleDetail || isKundeDetail || isLieferungDetail; + const isAngebotDoc = + sectionPath === '/auftrag' && rest && !knownSegments.has(rest); + const isDetail = + isArticleDetail || isKundeDetail || isLieferungDetail || isAngebotDoc; - const pageTitle = isDetail + const angebotLabel = + subNav?.find((item) => item.segment === '')?.label ?? 'Angebot'; + let pageTitle = isDetail ? title : (activeSub?.label ?? title); + if (pathname === '/auftrag/neu') pageTitle = 'Neues Angebot'; + else if (isAngebotDoc) pageTitle = angebotLabel; + else if (pathname === '/auftrag/bestellung/neu') pageTitle = 'Neue Bestellung'; const showsRenameSwitch = activeSub?.segment === 'kategorien'; diff --git a/src/components/article/boxes/ArticleViewBrand.jsx b/src/components/article/boxes/ArticleViewBrand.jsx index ef0eebf..a462f5d 100644 --- a/src/components/article/boxes/ArticleViewBrand.jsx +++ b/src/components/article/boxes/ArticleViewBrand.jsx @@ -63,7 +63,13 @@ class BrandEdit extends Component { inputRef={inputRef} label="Marke" size="small" - slotProps={{ inputLabel: { shrink: true } }} + slotProps={{ + ...params.slotProps, + inputLabel: { + ...params.slotProps?.inputLabel, + shrink: true, + }, + }} /> )} /> diff --git a/src/components/article/boxes/ArticleViewEANs.jsx b/src/components/article/boxes/ArticleViewEANs.jsx index 56aae70..cc8febf 100644 --- a/src/components/article/boxes/ArticleViewEANs.jsx +++ b/src/components/article/boxes/ArticleViewEANs.jsx @@ -75,7 +75,13 @@ class EansEdit extends Component { inputRef={inputRef} label="EANs (Enter zum Hinzufügen)" size="small" - slotProps={{ inputLabel: { shrink: true } }} + slotProps={{ + ...params.slotProps, + inputLabel: { + ...params.slotProps?.inputLabel, + shrink: true, + }, + }} /> )} /> diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx new file mode 100644 index 0000000..11d6785 --- /dev/null +++ b/src/components/manifest/ManifestForm.jsx @@ -0,0 +1,321 @@ +import { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import Typography from '@mui/material/Typography'; +import { enqueueSnackbar } from 'notistack'; +import BackendError from '../BackendError'; +import { PanelTile, PanelTileGrid } from '../panels'; +import { StorageManagerContext } from '../../api/storageManagerContext'; +import { router } from '../../appRouter'; +import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; +import { backendErrorFrom } from '../../utils/backendError'; +import { notifyError } from '../../utils/notify'; +import ManifestHeader from './ManifestHeader'; +import ManifestLines from './ManifestLines'; +import { + buildManifestBody, + dateInputValue, + emptyLine, + lineFromApi, + loadParties, + manifestConfig, +} from './manifestConfig'; + +class ManifestForm extends PureComponent { + static contextType = StorageManagerContext; + + constructor(props) { + super(props); + this.cfg = manifestConfig(props.kind); + this.state = { + status: props.mode === 'edit' ? 'loading' : 'loadingParties', + error: null, + parties: [], + partiesLoading: true, + date: '', + partyId: null, + number: '', + lines: [emptyLine()], + saving: false, + }; + this._cancelled = false; + this.handleRetry = this.handleRetry.bind(this); + this.handlePartyChange = this.handlePartyChange.bind(this); + this.handleDateChange = this.handleDateChange.bind(this); + this.handleNumberChange = this.handleNumberChange.bind(this); + this.handleLineChange = this.handleLineChange.bind(this); + this.handleAddLine = this.handleAddLine.bind(this); + this.handleRemoveLine = this.handleRemoveLine.bind(this); + this.handleSave = this.handleSave.bind(this); + } + + componentDidMount() { + this._cancelled = false; + if (this.props.mode === 'edit') void this.loadEdit(); + else void this.loadPartiesOnly(); + } + + componentDidUpdate(prevProps) { + if ( + this.props.mode === 'edit' && + prevProps.docId !== this.props.docId + ) { + void this.loadEdit(); + } + } + + componentWillUnmount() { + this._cancelled = true; + } + + async loadPartiesOnly() { + this.setState({ status: 'loadingParties', error: null, partiesLoading: true }); + try { + const parties = await loadParties(this.props.kind, this.context); + if (this._cancelled) return; + this.setState({ + status: 'ready', + parties, + partiesLoading: false, + }); + } catch (err) { + if (this._cancelled) return; + this.setState({ + status: 'failed', + error: backendErrorFrom(err), + partiesLoading: false, + }); + notifyError(err); + } + } + + async loadEdit() { + const { docId, kind } = this.props; + const cfg = this.cfg; + if (docId == null) { + this.setState({ + status: 'failed', + error: backendErrorFrom(new Error(cfg.invalidId)), + }); + return; + } + + this.setState({ status: 'loading', error: null, partiesLoading: true }); + try { + const [docRes, parties] = await Promise.all([ + this.context[cfg.api].get(docId), + loadParties(kind, this.context), + ]); + if (this._cancelled) return; + + requireObject(docRes, cfg.getPathLabel); + requireObject(docRes[cfg.docField], cfg.docField); + const doc = docRes[cfg.docField]; + const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`); + const lines = + apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()]; + + this.setState({ + status: 'ready', + parties, + partiesLoading: false, + date: dateInputValue(doc.date), + partyId: doc[cfg.partyField], + number: doc.number ?? '', + lines, + }); + } catch (err) { + if (this._cancelled) return; + this.setState({ + status: 'failed', + error: backendErrorFrom(err), + partiesLoading: false, + }); + } + } + + handleRetry() { + if (this.props.mode === 'edit') void this.loadEdit(); + else void this.loadPartiesOnly(); + } + + handlePartyChange(partyId) { + const { mode } = this.props; + const { requirePartyBeforeLines } = this.cfg; + if (mode === 'create' && requirePartyBeforeLines && partyId == null) { + this.setState({ partyId: null, lines: [emptyLine()] }); + return; + } + this.setState({ partyId }); + } + + handleDateChange(date) { + this.setState({ date }); + } + + handleNumberChange(number) { + this.setState({ number }); + } + + 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 { mode, docId } = this.props; + const cfg = this.cfg; + if (this.state.saving) return; + if (mode === 'edit' && docId == null) return; + + const requireAtLeastOne = mode === 'create'; + let body; + try { + body = buildManifestBody( + { + number: this.state.number, + date: this.state.date, + partyId: this.state.partyId, + lines: this.state.lines, + }, + { partyField: cfg.partyField, requireAtLeastOne }, + ); + } catch (err) { + notifyError(err); + return; + } + + this.setState({ saving: true }); + try { + if (mode === 'create') { + const data = await this.context[cfg.api].create(body); + requireObject(data, cfg.postPathLabel); + requireObject(data[cfg.docField], cfg.docField); + requireDefined(data[cfg.docField].id, `${cfg.docField}.id`); + await this.context[cfg.updateCache](); + if (this._cancelled) return; + enqueueSnackbar(cfg.createdMsg, { variant: 'success' }); + void router.navigate(cfg.editPath(data[cfg.docField].id)); + return; + } + + const data = await this.context[cfg.api].update(docId, body); + requireObject(data, cfg.patchPathLabel); + requireObject(data[cfg.docField], cfg.docField); + requireDefined(data[cfg.docField].id, `${cfg.docField}.id`); + await this.context[cfg.updateCache](); + if (this._cancelled) return; + const doc = data[cfg.docField]; + const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`); + this.setState({ + date: dateInputValue(doc.date), + partyId: doc[cfg.partyField], + number: doc.number ?? '', + lines: + apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()], + saving: false, + }); + enqueueSnackbar(cfg.savedMsg, { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ saving: false }); + } + } + + renderArticles() { + const { mode } = this.props; + const cfg = this.cfg; + const { partyId, lines, saving } = this.state; + + if ( + cfg.requirePartyBeforeLines && + mode === 'create' && + partyId == null + ) { + return ( + + + {cfg.partyGateMessage} + + + ); + } + + return ( + void this.handleSave()} + /> + ); + } + + render() { + const { kind, mode } = this.props; + const cfg = this.cfg; + const { + status, + error, + parties, + partiesLoading, + date, + partyId, + number, + saving, + } = this.state; + + if (status === 'loading') { + return ( + + + + ); + } + + if (status === 'failed') { + return ; + } + + return ( + + + {status === 'ready' ? this.renderArticles() : null} + + ); + } +} + +export default ManifestForm; diff --git a/src/components/manifest/ManifestFormLine.jsx b/src/components/manifest/ManifestFormLine.jsx new file mode 100644 index 0000000..5132235 --- /dev/null +++ b/src/components/manifest/ManifestFormLine.jsx @@ -0,0 +1,83 @@ +import { PureComponent } from 'react'; +import IconButton from '@mui/material/IconButton'; +import TableCell from '@mui/material/TableCell'; +import TableRow from '@mui/material/TableRow'; +import TextField from '@mui/material/TextField'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import { LINE_COLUMNS } from './manifestConfig'; + +class ManifestFormLine extends PureComponent { + constructor(props) { + super(props); + this.handleRemove = this.handleRemove.bind(this); + } + + handleRemove() { + this.props.onRemove(this.props.line.key); + } + + renderField(column) { + const { id, align, type } = column; + const { line, saving, onChange } = this.props; + return ( + onChange(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, + }, + }} + /> + ); + } + + render() { + const { columns = LINE_COLUMNS, saving } = this.props; + + return ( + + {columns.map((col) => ( + + {this.renderField(col)} + + ))} + + + + + + + ); + } +} + +export default ManifestFormLine; diff --git a/src/components/manifest/ManifestHeader.jsx b/src/components/manifest/ManifestHeader.jsx new file mode 100644 index 0000000..39e069b --- /dev/null +++ b/src/components/manifest/ManifestHeader.jsx @@ -0,0 +1,93 @@ +import { PureComponent } from 'react'; +import Autocomplete from '@mui/material/Autocomplete'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import SingleLineTextEdit from '../SingleLineTextEdit'; +import { PanelTile } from '../panels'; +import { partyDisplayName } from './manifestConfig'; + +class ManifestHeader extends PureComponent { + constructor(props) { + super(props); + this.handlePartyChange = this.handlePartyChange.bind(this); + } + + handlePartyChange(_event, option) { + this.props.onPartyChange(option?.id ?? null); + } + + render() { + const { + kind, + headerColor, + dateLabel, + partyLabel, + parties, + partiesLoading, + partyId, + date, + number, + saving, + onDateChange, + onNumberChange, + } = this.props; + + const selected = parties.find((row) => row.id === partyId) ?? null; + + return ( + + + onDateChange(e.target.value)} + autoComplete="off" + slotProps={{ + inputLabel: { shrink: true }, + htmlInput: { spellCheck: false }, + }} + sx={{ minWidth: 220 }} + /> + partyDisplayName(kind, option)} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={this.handlePartyChange} + sx={{ flex: 1, minWidth: 220 }} + renderInput={(params) => ( + + )} + /> + + + + ); + } +} + +export default ManifestHeader; diff --git a/src/components/manifest/ManifestLines.jsx b/src/components/manifest/ManifestLines.jsx new file mode 100644 index 0000000..6ac588e --- /dev/null +++ b/src/components/manifest/ManifestLines.jsx @@ -0,0 +1,91 @@ +import { PureComponent } from 'react'; +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 AddIcon from '@mui/icons-material/Add'; +import { PanelTile } from '../panels'; +import { LINE_COLUMNS } from './manifestConfig'; +import ManifestFormLine from './ManifestFormLine'; + +class ManifestLines extends PureComponent { + render() { + const { + lines, + saving, + linesColor, + onLineChange, + onAddLine, + onRemoveLine, + onSave, + columns = LINE_COLUMNS, + } = this.props; + + return ( + + + + + + + {columns.map((col) => ( + + {col.label} + + ))} + + + + + {lines.map((line) => ( + + ))} + +
+
+ + + + + +
+
+ ); + } +} + +export default ManifestLines; diff --git a/src/components/manifest/ManifestList.jsx b/src/components/manifest/ManifestList.jsx new file mode 100644 index 0000000..ef87e4b --- /dev/null +++ b/src/components/manifest/ManifestList.jsx @@ -0,0 +1,206 @@ +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 '../BackendError'; +import { PanelTile, PanelTileGrid } from '../panels'; +import { StorageManagerContext } from '../../api/storageManagerContext'; +import { router } from '../../appRouter'; +import { backendErrorFrom } from '../../utils/backendError'; +import { notifyError } from '../../utils/notify'; +import { + formatDate, + loadParties, + manifestConfig, + partyDisplayName, + sortDocs, +} from './manifestConfig'; + +class ManifestList extends PureComponent { + static contextType = StorageManagerContext; + + constructor(props) { + super(props); + this.cfg = manifestConfig(props.kind); + this.state = { + status: 'loading', + error: null, + docs: [], + partiesById: {}, + 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() { + const cfg = this.cfg; + this.setState({ status: 'loading', error: null }); + try { + const [docs, parties] = await Promise.all([ + cfg.listPromise(this.context), + loadParties(this.props.kind, this.context), + ]); + if (this._cancelled) return; + this.setState({ + status: 'ready', + docs: sortDocs(docs), + partiesById: cfg.partiesByIdFromList(parties), + }); + } catch (err) { + if (this._cancelled) return; + this.setState({ status: 'failed', error: backendErrorFrom(err) }); + } + } + + handleRetry() { + void this.load(); + } + + handleCreate() { + void router.navigate(this.cfg.createPath); + } + + handleEdit(doc) { + void router.navigate(this.cfg.editPath(doc.id)); + } + + async handleDelete(doc) { + if (this.state.busyId != null) return; + if (!window.confirm(this.cfg.confirmDelete(doc.number))) return; + + const cfg = this.cfg; + this.setState({ busyId: doc.id }); + try { + await this.context[cfg.api].delete(doc.id); + const list = await this.context[cfg.updateCache](); + if (this._cancelled) return; + this.setState({ + docs: sortDocs(list), + busyId: null, + }); + enqueueSnackbar(cfg.deletedMsg, { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busyId: null }); + } + } + + partyName(partyId) { + if (partyId == null) return null; + return partyDisplayName( + this.props.kind, + this.state.partiesById[partyId], + ); + } + + render() { + const { status, error, docs, busyId } = this.state; + const cfg = this.cfg; + const partyField = cfg.partyField; + + if (status === 'loading') { + return ( + + + + ); + } + + if (status === 'failed') { + return ; + } + + return ( + + + + + + + + {docs.length === 0 ? ( + + {cfg.emptyList} + + ) : ( + + + + + Nummer + {cfg.dateLabel} + {cfg.partyLabel} + + + + + {docs.map((row) => ( + this.handleEdit(row)} + > + {row.number} + {formatDate(row.date)} + {this.partyName(row[partyField])} + e.stopPropagation()} + > + this.handleEdit(row)} + > + + + void this.handleDelete(row)} + > + + + + + ))} + +
+
+ )} +
+
+
+ ); + } +} + +export default ManifestList; diff --git a/src/components/manifest/index.js b/src/components/manifest/index.js new file mode 100644 index 0000000..7cb78e8 --- /dev/null +++ b/src/components/manifest/index.js @@ -0,0 +1,13 @@ +export { default as ManifestList } from './ManifestList'; +export { default as ManifestForm } from './ManifestForm'; +export { default as ManifestHeader } from './ManifestHeader'; +export { default as ManifestLines } from './ManifestLines'; +export { default as ManifestFormLine } from './ManifestFormLine'; +export { withManifestId } from './withManifestId'; +export { + manifestConfig, + LINE_COLUMNS, + emptyLine, + lineFromApi, + buildManifestBody, +} from './manifestConfig'; diff --git a/src/components/manifest/manifestConfig.js b/src/components/manifest/manifestConfig.js new file mode 100644 index 0000000..115e4fd --- /dev/null +++ b/src/components/manifest/manifestConfig.js @@ -0,0 +1,264 @@ +/** Groups that are not sales customers (filter client-side). */ +export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']); + +export function entityDisplayName(entity) { + if (!entity) return ''; + return [entity.firstName, entity.name].filter(Boolean).join(' '); +} + +export 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'); + }); +} + +/** + * Customers = entities whose customergroup.name is not Lieferanten/POS. + */ +export function filterCustomerEntities(entities, groups) { + const excludedIds = new Set( + groups + .filter((g) => EXCLUDED_CUSTOMER_GROUP_NAMES.has(g.name)) + .map((g) => g.id), + ); + return sortEntities( + entities.filter((e) => !excludedIds.has(e.customergroupId)), + ); +} + +export function sortSuppliers(suppliers) { + return [...suppliers].sort((a, b) => + String(a.name).localeCompare(String(b.name), 'de'), + ); +} + +export function sortDocs(docs) { + return [...docs].sort((a, b) => String(b.date).localeCompare(String(a.date))); +} + +export function formatDate(iso) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString('de-DE'); +} + +export 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); +} + +export function emptyLine() { + return { + key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + amount: '', + sku: '', + tax: '', + rebate: '', + net: '', + }; +} + +export 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 parseNum(value) { + if (value == null || value === '') return null; + const n = Number(String(value).trim().replace(',', '.')); + return Number.isFinite(n) ? n : null; +} + +/** Build create/update line array. Throws Error on client validation. */ +export function buildLines(lines, { requireAtLeastOne }) { + 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 (requireAtLeastOne && outLines.length === 0) { + throw new Error('Mindestens eine Artikelzeile nötig.'); + } + return outLines; +} + +/** + * @param {{ number: string, date: string, partyId: number|null, lines: object[] }} form + * @param {{ partyField: 'supplierId'|'entityId', requireAtLeastOne: boolean }} opts + */ +export function buildManifestBody(form, { partyField, requireAtLeastOne }) { + const number = String(form.number ?? '').trim(); + const date = form.date; + if (!number) throw new Error('Nummer fehlt.'); + if (!date) throw new Error('Datum fehlt.'); + + const body = { + number, + date, + lines: buildLines(form.lines, { requireAtLeastOne }), + }; + if (form.partyId != null) body[partyField] = form.partyId; + else if (!requireAtLeastOne) body[partyField] = null; + return body; +} + +export const LINE_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' }, +]; + +/** @typedef {'delivery'|'quote'|'order'} ManifestKind */ + +/** + * Load party options for Autocomplete / list name lookup. + * @param {ManifestKind} kind + * @param {object} ctx StorageManager instance + */ +export async function loadParties(kind, ctx) { + if (kind === 'delivery') { + const list = ctx.suppliers ?? (await ctx.supplierPromise); + return sortSuppliers(list); + } + const [entities, groups] = await Promise.all([ + ctx.entities ?? ctx.entityPromise, + ctx.customergroups ?? ctx.customergroupPromise, + ]); + return filterCustomerEntities(entities, groups); +} + +export function partyDisplayName(kind, party) { + if (!party) return ''; + if (kind === 'delivery') return String(party.name ?? ''); + return entityDisplayName(party); +} + +/** + * @param {ManifestKind} kind + */ +export function manifestConfig(kind) { + if (kind === 'delivery') { + return { + kind: 'delivery', + api: 'delivery', + listField: 'deliveries', + docField: 'delivery', + partyField: 'supplierId', + listPath: '/lieferung', + createPath: '/lieferung/neu', + editPath: (id) => `/lieferung/${id}`, + dateLabel: 'Lieferdatum', + partyLabel: 'Lieferant', + emptyList: 'Noch keine Lieferungen.', + createButton: 'Neue Lieferung', + createdMsg: 'Lieferung angelegt.', + savedMsg: 'Lieferung gespeichert.', + deletedMsg: 'Lieferung gelöscht.', + confirmDelete: (number) => `Lieferung „${number}“ wirklich löschen?`, + invalidId: 'Ungültige Lieferungs-ID.', + partyGateMessage: 'Lieferant wählen, um Artikel zu erfassen.', + getPathLabel: 'GET /api/delivery/:id', + postPathLabel: 'POST /api/delivery', + patchPathLabel: 'PATCH /api/delivery/:id', + listColor: '#fed7aa', + headerColor: '#fed7aa', + linesColor: '#bbf7d0', + requirePartyBeforeLines: true, + updateCache: 'updateDelivery', + listPromise: (ctx) => ctx.deliveries ?? ctx.deliveryPromise, + partiesByIdFromList: (parties) => + Object.fromEntries(parties.map((row) => [row.id, row])), + }; + } + if (kind === 'quote') { + return { + kind: 'quote', + api: 'quote', + listField: 'quotes', + docField: 'quote', + partyField: 'entityId', + listPath: '/auftrag', + createPath: '/auftrag/neu', + editPath: (id) => `/auftrag/${id}`, + dateLabel: 'Gültig bis', + partyLabel: 'Kunde', + emptyList: 'Noch keine Angebote.', + createButton: 'Neues Angebot', + createdMsg: 'Angebot angelegt.', + savedMsg: 'Angebot gespeichert.', + deletedMsg: 'Angebot gelöscht.', + confirmDelete: (number) => `Angebot „${number}“ wirklich löschen?`, + invalidId: 'Ungültige Angebots-ID.', + partyGateMessage: null, + getPathLabel: 'GET /api/quote/:id', + postPathLabel: 'POST /api/quote', + patchPathLabel: 'PATCH /api/quote/:id', + listColor: '#fde68a', + headerColor: '#fde68a', + linesColor: '#bbf7d0', + requirePartyBeforeLines: false, + updateCache: 'updateQuote', + listPromise: (ctx) => ctx.quotes ?? ctx.quotePromise, + partiesByIdFromList: (parties) => + Object.fromEntries(parties.map((row) => [row.id, row])), + }; + } + if (kind === 'order') { + return { + kind: 'order', + api: 'order', + listField: 'orders', + docField: 'order', + partyField: 'entityId', + listPath: '/auftrag/bestellung', + createPath: '/auftrag/bestellung/neu', + editPath: (id) => `/auftrag/bestellung/${id}`, + dateLabel: 'Lieferdatum', + partyLabel: 'Kunde', + emptyList: 'Noch keine Bestellungen.', + createButton: 'Neue Bestellung', + createdMsg: 'Bestellung angelegt.', + savedMsg: 'Bestellung gespeichert.', + deletedMsg: 'Bestellung gelöscht.', + confirmDelete: (number) => `Bestellung „${number}“ wirklich löschen?`, + invalidId: 'Ungültige Bestellungs-ID.', + partyGateMessage: null, + getPathLabel: 'GET /api/order/:id', + postPathLabel: 'POST /api/order', + patchPathLabel: 'PATCH /api/order/:id', + listColor: '#c4b5fd', + headerColor: '#c4b5fd', + linesColor: '#bbf7d0', + requirePartyBeforeLines: false, + updateCache: 'updateOrder', + listPromise: (ctx) => ctx.orders ?? ctx.orderPromise, + partiesByIdFromList: (parties) => + Object.fromEntries(parties.map((row) => [row.id, row])), + }; + } + throw new Error(`Unknown manifest kind: ${kind}`); +} diff --git a/src/components/manifest/withManifestId.jsx b/src/components/manifest/withManifestId.jsx new file mode 100644 index 0000000..13b70ed --- /dev/null +++ b/src/components/manifest/withManifestId.jsx @@ -0,0 +1,20 @@ +import { useParams } from 'react-router-dom'; + +/** + * Router glue only: maps a path param to `docId` (integer or null). + * Function component is intentional — useParams has no class equivalent. + */ +export function withManifestId(paramName, Wrapped) { + function ManifestIdWrapper(props) { + const params = useParams(); + const raw = params[paramName]; + const docId = Number(raw); + return ( + + ); + } + return ManifestIdWrapper; +} diff --git a/src/nav.js b/src/nav.js index 4e5895b..be96fca 100644 --- a/src/nav.js +++ b/src/nav.js @@ -86,7 +86,18 @@ export function resolveActiveSub(pathname, sectionPath) { sectionPath === '/kunde' && rest && !knownSegments.has(rest); const isLieferungDetail = sectionPath === '/lieferung' && rest && !knownSegments.has(rest); - if (isArticleDetail || isKundeDetail || isLieferungDetail) return null; + // Angebot neu/edit: /auftrag/neu or /auftrag/:quoteId (not a known sub) + const isAngebotDoc = + sectionPath === '/auftrag' && rest && !knownSegments.has(rest); + if (isArticleDetail || isKundeDetail || isLieferungDetail || isAngebotDoc) { + return null; + } + + // Keep Bestellung/Ausliefern/Retoure active for nested paths + // (e.g. /auftrag/bestellung/neu). + if (sectionPath === '/auftrag' && rest && knownSegments.has(rest)) { + return rest; + } const active = subNav.find((item) => { if (item.segment === 'kategorien') { diff --git a/src/pages/auftrag/bestellung.jsx b/src/pages/auftrag/bestellung.jsx index e35f0b4..2a4cc5d 100644 --- a/src/pages/auftrag/bestellung.jsx +++ b/src/pages/auftrag/bestellung.jsx @@ -1,13 +1,9 @@ import { PureComponent } from 'react'; -import Typography from '@mui/material/Typography'; +import { ManifestList } from '../../components/manifest'; class AuftragBestellungPage extends PureComponent { render() { - return ( - - Bestellung — Inhalt folgt. - - ); + return ; } } diff --git a/src/pages/auftrag/bestellungEdit.jsx b/src/pages/auftrag/bestellungEdit.jsx new file mode 100644 index 0000000..94e70ce --- /dev/null +++ b/src/pages/auftrag/bestellungEdit.jsx @@ -0,0 +1,16 @@ +import { PureComponent } from 'react'; +import { ManifestForm, withManifestId } from '../../components/manifest'; + +class AuftragBestellungEditPage extends PureComponent { + render() { + return ( + + ); + } +} + +export default withManifestId('orderId', AuftragBestellungEditPage); diff --git a/src/pages/auftrag/bestellungNeu.jsx b/src/pages/auftrag/bestellungNeu.jsx new file mode 100644 index 0000000..32cb394 --- /dev/null +++ b/src/pages/auftrag/bestellungNeu.jsx @@ -0,0 +1,10 @@ +import { PureComponent } from 'react'; +import { ManifestForm } from '../../components/manifest'; + +class AuftragBestellungNeuPage extends PureComponent { + render() { + return ; + } +} + +export default AuftragBestellungNeuPage; diff --git a/src/pages/auftrag/edit.jsx b/src/pages/auftrag/edit.jsx new file mode 100644 index 0000000..8bce38e --- /dev/null +++ b/src/pages/auftrag/edit.jsx @@ -0,0 +1,16 @@ +import { PureComponent } from 'react'; +import { ManifestForm, withManifestId } from '../../components/manifest'; + +class AuftragAngebotEditPage extends PureComponent { + render() { + return ( + + ); + } +} + +export default withManifestId('quoteId', AuftragAngebotEditPage); diff --git a/src/pages/auftrag/index.jsx b/src/pages/auftrag/index.jsx index 020fdaa..4637bfa 100644 --- a/src/pages/auftrag/index.jsx +++ b/src/pages/auftrag/index.jsx @@ -1,13 +1,9 @@ import { PureComponent } from 'react'; -import Typography from '@mui/material/Typography'; +import { ManifestList } from '../../components/manifest'; class AuftragAngebotPage extends PureComponent { render() { - return ( - - Angebot — Inhalt folgt. - - ); + return ; } } diff --git a/src/pages/auftrag/neu.jsx b/src/pages/auftrag/neu.jsx new file mode 100644 index 0000000..75be5b9 --- /dev/null +++ b/src/pages/auftrag/neu.jsx @@ -0,0 +1,10 @@ +import { PureComponent } from 'react'; +import { ManifestForm } from '../../components/manifest'; + +class AuftragAngebotNeuPage extends PureComponent { + render() { + return ; + } +} + +export default AuftragAngebotNeuPage; diff --git a/src/pages/kunde/neu.jsx b/src/pages/kunde/neu.jsx index 3ff3cf9..08083f2 100644 --- a/src/pages/kunde/neu.jsx +++ b/src/pages/kunde/neu.jsx @@ -148,25 +148,21 @@ class KundeNeuPage extends PureComponent { getOptionLabel={(option) => String(option.name ?? '')} isOptionEqualToValue={(option, value) => option.id === value.id} onChange={this.handleGroupChange} - renderInput={(params) => { - const { InputProps, inputProps, ...other } = params; - return ( - - ); - }} + renderInput={(params) => ( + + )} /> - ); - } - 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' }, -]; +import { ManifestForm, withManifestId } from '../../components/manifest'; class LieferungEditPage extends PureComponent { - static contextType = StorageManagerContext; - - constructor(props) { - super(props); - this.state = { - status: 'loading', - error: null, - suppliers: [], - estimatedDeliveryDate: '', - supplierId: null, - shippingNumber: '', - lines: [emptyLine()], - saving: false, - }; - this._cancelled = false; - this.handleRetry = this.handleRetry.bind(this); - this.handleAddLine = this.handleAddLine.bind(this); - this.handleSupplierChange = this.handleSupplierChange.bind(this); - this.handleSave = this.handleSave.bind(this); - } - - componentDidMount() { - this._cancelled = false; - void this.load(); - } - - componentDidUpdate(prevProps) { - if (prevProps.deliveryId !== this.props.deliveryId) { - void this.load(); - } - } - - componentWillUnmount() { - this._cancelled = true; - } - - async load() { - const { deliveryId } = this.props; - if (deliveryId == null) { - this.setState({ - status: 'failed', - error: backendErrorFrom(new Error('Ungültige Lieferungs-ID.')), - }); - return; - } - - this.setState({ status: 'loading', error: null }); - try { - const [deliveryRes, suppliers] = await Promise.all([ - this.context.delivery.get(deliveryId), - this.context.suppliers ?? this.context.supplierPromise, - ]); - if (this._cancelled) return; - - requireObject(deliveryRes, 'GET /api/delivery/:id'); - requireObject(deliveryRes.delivery, 'delivery'); - const delivery = deliveryRes.delivery; - const apiLines = requireArray(delivery.lines, 'delivery.lines'); - const lines = - apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()]; - - this.setState({ - status: 'ready', - suppliers: sortSuppliers(suppliers), - estimatedDeliveryDate: dateInputValue(delivery.date), - supplierId: delivery.supplierId, - shippingNumber: delivery.number ?? '', - lines, - }); - } catch (err) { - if (this._cancelled) return; - this.setState({ status: 'failed', error: backendErrorFrom(err) }); - } - } - - handleRetry() { - void this.load(); - } - - handleSupplierChange(_event, option) { - this.setState({ supplierId: option?.id ?? null }); - } - - handleLineChange(key, field, value) { - this.setState((prev) => ({ - lines: prev.lines.map((line) => - line.key === key ? { ...line, [field]: value } : line, - ), - })); - } - - handleAddLine() { - this.setState((prev) => ({ lines: [...prev.lines, emptyLine()] })); - } - - handleRemoveLine(key) { - this.setState((prev) => { - const next = prev.lines.filter((line) => line.key !== key); - return { lines: next.length > 0 ? next : [emptyLine()] }; - }); - } - - async handleSave() { - const { deliveryId } = this.props; - if (this.state.saving || deliveryId == null) return; - let body; - try { - body = buildUpdateBody(this.state); - } catch (err) { - notifyError(err); - return; - } - - this.setState({ saving: true }); - try { - const data = await this.context.delivery.update(deliveryId, body); - requireObject(data, 'PATCH /api/delivery/:id'); - requireObject(data.delivery, 'delivery'); - requireDefined(data.delivery.id, 'delivery.id'); - await this.context.updateDelivery(); - if (this._cancelled) return; - const apiLines = requireArray(data.delivery.lines, 'delivery.lines'); - this.setState({ - estimatedDeliveryDate: dateInputValue(data.delivery.date), - supplierId: data.delivery.supplierId, - shippingNumber: data.delivery.number ?? '', - lines: - apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()], - saving: false, - }); - enqueueSnackbar('Lieferung gespeichert.', { variant: 'success' }); - } catch (err) { - if (this._cancelled) return; - notifyError(err); - this.setState({ saving: false }); - } - } - - renderHeader() { - const { - suppliers, - estimatedDeliveryDate, - supplierId, - shippingNumber, - saving, - } = this.state; - const selected = - suppliers.find((row) => row.id === supplierId) ?? null; - - return ( - - - - this.setState({ estimatedDeliveryDate: e.target.value }) - } - autoComplete="off" - slotProps={{ - inputLabel: { shrink: true }, - htmlInput: { spellCheck: false }, - }} - sx={{ minWidth: 220 }} - /> - String(option.name ?? '')} - isOptionEqualToValue={(option, value) => option.id === value.id} - onChange={this.handleSupplierChange} - sx={{ flex: 1, minWidth: 220 }} - renderInput={(params) => { - const { InputProps, inputProps, ...other } = params; - return ( - - ); - }} - /> - this.setState({ shippingNumber: next })} - sx={{ minWidth: 180, flex: 1 }} - /> - - - ); - } - - renderLineField(line, column) { - const { id, align, type } = column; - const { saving } = this.state; - return ( - this.handleLineChange(line.key, id, e.target.value)} - slotProps={{ - htmlInput: { - inputMode: type === 'number' ? 'decimal' : 'text', - style: { textAlign: align }, - spellCheck: false, - autoComplete: 'off', - step: type === 'number' ? 'any' : undefined, - }, - }} - sx={{ - '& input[type=number]': { - MozAppearance: 'textfield', - }, - '& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button': - { - WebkitAppearance: 'none', - margin: 0, - }, - }} - /> - ); - } - - renderArticles() { - const { lines, saving } = this.state; - - return ( - - - - - - - {COLUMNS.map((col) => ( - - {col.label} - - ))} - - - - - {lines.map((line) => ( - - {COLUMNS.map((col) => ( - - {this.renderLineField(line, col)} - - ))} - - this.handleRemoveLine(line.key)} - > - - - - - ))} - -
-
- - - - - -
-
- ); - } - render() { - const { status, error } = this.state; - - if (status === 'loading') { - return ( - - - - ); - } - - if (status === 'failed') { - return ; - } - return ( - - {this.renderHeader()} - {this.renderArticles()} - + ); } } -export default withDeliveryRoute(LieferungEditPage); +export default withManifestId('deliveryId', LieferungEditPage); diff --git a/src/pages/lieferung/index.jsx b/src/pages/lieferung/index.jsx index 52aa43b..a7200d9 100644 --- a/src/pages/lieferung/index.jsx +++ b/src/pages/lieferung/index.jsx @@ -1,208 +1,9 @@ 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'; - -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'); -} +import { ManifestList } from '../../components/manifest'; 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'); - } - - handleEdit(delivery) { - void router.navigate(`/lieferung/${delivery.id}`); - } - - 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) => ( - this.handleEdit(row)} - > - {row.number} - {formatDate(row.date)} - {this.supplierName(row.supplierId)} - e.stopPropagation()}> - this.handleEdit(row)} - > - - - void this.handleDelete(row)} - > - - - - - ))} - -
-
- )} -
-
-
- ); + return ; } } diff --git a/src/pages/lieferung/neu.jsx b/src/pages/lieferung/neu.jsx index bc632fa..e6f886d 100644 --- a/src/pages/lieferung/neu.jsx +++ b/src/pages/lieferung/neu.jsx @@ -1,412 +1,9 @@ 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' }, -]; +import { ManifestForm } from '../../components/manifest'; 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/${data.delivery.id}`); - } 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) => { - 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 { 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()} - - ); + return ; } }