diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js
index addbf35..2730fe8 100644
--- a/src/api/StorageManager.js
+++ b/src/api/StorageManager.js
@@ -161,21 +161,31 @@ export class StorageManager extends ApiClient {
Object.defineProperties(this, {
authMePromise: { get: () => authMeCache.promise },
updateAuthMe: { value: authMeCache.update },
+ /** Settled GET /auth/me body, or undefined. */
+ authMe: { get: () => authMeCache.value },
articleIndexPromise: { get: () => articleIndexCache.promise },
updateArticleIndex: { value: articleIndexCache.update },
brandPromise: { get: () => brandCache.promise },
updateBrand: { value: brandCache.update },
+ /** Settled GET /brand list, or undefined. */
+ brands: { get: () => brandCache.value },
unitPromise: { get: () => unitCache.promise },
updateUnit: { value: unitCache.update },
+ /** Settled GET /unit list, or undefined. */
+ units: { get: () => unitCache.value },
companyPromise: { get: () => companyCache.promise },
updateCompany: { value: companyCache.update },
+ /** Settled GET /company list, or undefined. */
+ companies: { get: () => companyCache.value },
categoryTreePromise: { get: () => categoryTreeCache.promise },
updateCategoryTree: { value: categoryTreeCache.update },
+ /** Settled GET /category/tree body, or undefined. */
+ categoryTree: { get: () => categoryTreeCache.value },
posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update },
diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx
index f62e33c..4345641 100644
--- a/src/components/article/ArticleView.jsx
+++ b/src/components/article/ArticleView.jsx
@@ -1,11 +1,8 @@
-import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { Component } from 'react';
import { PanelTileGrid } from '../panels';
-import { requireDefined } from '../../utils/apiShape';
-import { useStorageManager } from '../../api/storageManagerContext';
-import { parseCategoryTreeResponse } from '../../store/categorySlice';
+import { StorageManagerContext } from '../../api/storageManagerContext';
import { PANEL_TILE_KEYS } from '../../theme';
-import { notifyError } from '../../utils/notify';
+import withNavigate from '../withNavigate';
import {
buildCategoryPath,
defaultCategoryPath,
@@ -20,114 +17,92 @@ import ArticleViewEANs from './boxes/ArticleViewEANs';
import ArticleViewPrice from './boxes/ArticleViewPrice';
import ArticleViewLists from './boxes/ArticleViewLists';
-export default function ArticleView({
- article: articleData,
- tab,
- onTabChange,
- onArticleUpdated,
-}) {
- const navigate = useNavigate();
- const store = useStorageManager();
- const [categories, setCategories] = useState(null);
- const [rootCategoryId, setRootCategoryId] = useState(null);
+class ArticleView extends Component {
+ static contextType = StorageManagerContext;
- useEffect(() => {
- let cancelled = false;
- async function load() {
- try {
- const [treeData, companies, session] = await Promise.all([
- store.categoryTreePromise,
- store.companyPromise,
- store.authMePromise,
- ]);
- const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
- const company = requireDefined(
- companies.find((c) => c.id === companyId),
- 'company',
- );
- const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
- if (!cancelled) {
- setCategories(parsed.categories);
- setRootCategoryId(parsed.rootCategoryId);
- }
- } catch (err) {
- if (!cancelled) notifyError(err);
- }
- }
- void load();
- return () => {
- cancelled = true;
- };
- }, [store]);
-
- const refreshSearchCaches = () => {
- void store.updateArticleIndex();
- void store.updateCategoryTree();
+ refreshSearchCaches = () => {
+ void this.context.updateArticleIndex();
+ void this.context.updateCategoryTree();
};
- const handleArticleUpdated = (next) => {
- onArticleUpdated?.(next);
- refreshSearchCaches();
+ handleArticleUpdated = (next) => {
+ this.props.onArticleUpdated?.(next);
+ this.refreshSearchCaches();
};
- const handleArticleDeleted = () => {
- refreshSearchCaches();
- const categoryId = requireDefined(
- articleData.categoryIds?.[0],
- 'article.categoryIds[0]',
- );
- if (categories && rootCategoryId != null) {
+ handleArticleDeleted = () => {
+ const {
+ article: articleData,
+ navigate,
+ categories,
+ rootCategoryId,
+ } = this.props;
+ this.refreshSearchCaches();
+ const categoryId = articleData.categoryIds?.[0];
+ if (categories && rootCategoryId != null && categoryId != null) {
navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
return;
}
navigate(defaultCategoryPath());
};
- return (
-
-
+ render() {
+ const {
+ article: articleData,
+ tab,
+ onTabChange,
+ categories,
+ rootCategoryId,
+ } = this.props;
-
+ return (
+
+
-
+
-
+
-
+
-
+
-
+
-
-
- );
+
+
+
+
+ );
+ }
}
+
+export default withNavigate(ArticleView);
diff --git a/src/components/article/boxes/ArticleViewBrand.jsx b/src/components/article/boxes/ArticleViewBrand.jsx
index a30b2e4..946421f 100644
--- a/src/components/article/boxes/ArticleViewBrand.jsx
+++ b/src/components/article/boxes/ArticleViewBrand.jsx
@@ -1,28 +1,31 @@
-import { useEffect, useState } from 'react';
+import { Component } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import { EditablePanelTile } from '../../panels';
-import { useStorageManager } from '../../../api/storageManagerContext';
+import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../../utils/apiShape';
import { notifyError } from '../../../utils/notify';
import { PANEL_TILE_KEYS } from '../../../theme';
-function PanelValue({ label, children }) {
- if (children == null || children === '') return null;
- return (
-
- {label ? (
-
- {label}
+class PanelValue extends Component {
+ render() {
+ const { label, children } = this.props;
+ if (children == null || children === '') return null;
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ {children}
- ) : null}
-
- {children}
-
-
- );
+
+ );
+ }
}
function brandDraftValue(article) {
@@ -39,25 +42,70 @@ function brandDraftValid(draft) {
const SECOND_ROW_MIN_HEIGHT = 220;
-export default function ArticleViewBrand({ article: articleData, onArticleUpdated }) {
- const store = useStorageManager();
- const [brands, setBrands] = useState([]);
+class BrandEdit extends Component {
+ render() {
+ const { draft, onChange, inputRef, brands } = this.props;
+ return (
+
+ typeof option === 'string' ? option : String(option.name ?? '')
+ }
+ onInputChange={(_event, value) => onChange({ name: value })}
+ onChange={(_event, option) => {
+ onChange({ name: option ? String(option.name ?? '') : '' });
+ }}
+ renderInput={(params) => (
+
+ )}
+ />
+ );
+ }
+}
- useEffect(() => {
- let cancelled = false;
- store.brandPromise
+export default class ArticleViewBrand extends Component {
+ static contextType = StorageManagerContext;
+
+ constructor(props) {
+ super(props);
+ const brands = this.context?.brands;
+ this.state = {
+ brands: brands ?? [],
+ };
+ this._cancelled = false;
+ this._brandsReady = brands != null;
+ }
+
+ componentDidMount() {
+ this._cancelled = false;
+ if (this._brandsReady) return;
+ this.context.brandPromise
.then((list) => {
- if (!cancelled) setBrands(list);
+ if (!this._cancelled) this.setState({ brands: list });
})
.catch((err) => {
- if (!cancelled) notifyError(err);
+ if (!this._cancelled) notifyError(err);
});
- return () => {
- cancelled = true;
- };
- }, [store]);
+ }
- const handleSave = async (draft) => {
+ componentWillUnmount() {
+ this._cancelled = true;
+ }
+
+ handleSave = async (draft) => {
+ const { article: articleData, onArticleUpdated } = this.props;
+ const store = this.context;
+ const { brands } = this.state;
const name = String(draft.name ?? '').trim();
if (!name) {
throw new Error('Bitte eine Marke angeben.');
@@ -77,7 +125,7 @@ export default function ArticleViewBrand({ article: articleData, onArticleUpdate
requireDefined(createdRes.brand.id, 'brand.id');
brandId = createdRes.brand.id;
const nextBrands = await store.updateBrand();
- setBrands(nextBrands);
+ this.setState({ brands: nextBrands });
}
const res = await store.article.setBrand(articleData.sku, { brandId });
@@ -86,44 +134,24 @@ export default function ArticleViewBrand({ article: articleData, onArticleUpdate
onArticleUpdated?.(res.article);
};
- return (
- (
- {saved.name || null}
- )}
- renderEdit={(draft, onChange, inputRef) => (
-
- typeof option === 'string' ? option : String(option.name ?? '')
- }
- onInputChange={(_event, value) => onChange({ name: value })}
- onChange={(_event, option) => {
- onChange({ name: option ? String(option.name ?? '') : '' });
- }}
- renderInput={(params) => (
-
- )}
- />
- )}
- />
- );
+ render() {
+ const { article: articleData } = this.props;
+ const { brands } = this.state;
+ const value = brandDraftValue(articleData);
+
+ return (
+ {value.name || null}}
+ edit={}
+ />
+ );
+ }
}
diff --git a/src/components/article/boxes/ArticleViewEANs.jsx b/src/components/article/boxes/ArticleViewEANs.jsx
index 80fc51b..a5db4c2 100644
--- a/src/components/article/boxes/ArticleViewEANs.jsx
+++ b/src/components/article/boxes/ArticleViewEANs.jsx
@@ -1,27 +1,31 @@
+import { Component } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import { EditablePanelTile } from '../../panels';
-import { useStorageManager } from '../../../api/storageManagerContext';
+import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireObject } from '../../../utils/apiShape';
import { articleEans } from '../../../utils/articleDetail';
import { PANEL_TILE_KEYS } from '../../../theme';
-function PanelValue({ label, children }) {
- if (children == null || children === '') return null;
- return (
-
- {label ? (
-
- {label}
+class PanelValue extends Component {
+ render() {
+ const { label, children } = this.props;
+ if (children == null || children === '') return null;
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ {children}
- ) : null}
-
- {children}
-
-
- );
+
+ );
+ }
}
function eansDraftValue(article) {
@@ -40,59 +44,79 @@ function eansDraftValid(draft) {
const SECOND_ROW_MIN_HEIGHT = 220;
-export default function ArticleViewEANs({ article: articleData, onArticleUpdated }) {
- const store = useStorageManager();
+class EansDisplay extends Component {
+ render() {
+ const { sku, eans } = this.props;
+ return (
+ <>
+ {sku}
+ {(eans ?? []).map((ean) => (
+
+ {ean}
+
+ ))}
+ >
+ );
+ }
+}
- const handleSave = async (draft) => {
+class EansEdit extends Component {
+ render() {
+ const { draft, onChange, inputRef } = this.props;
+ return (
+ onChange({ eans })}
+ renderInput={(params) => (
+
+ )}
+ />
+ );
+ }
+}
+
+export default class ArticleViewEANs extends Component {
+ static contextType = StorageManagerContext;
+
+ handleSave = async (draft) => {
+ const { article: articleData, onArticleUpdated } = this.props;
const eans = (draft.eans ?? [])
.map((ean) => String(ean).trim())
.filter((ean) => ean.length > 0);
- const res = await store.article.setEans(articleData.sku, { eans });
+ const res = await this.context.article.setEans(articleData.sku, { eans });
requireObject(res, 'PUT /api/article/:sku/eans');
requireObject(res.article, 'article');
onArticleUpdated?.(res.article);
};
- return (
- (
- <>
- {articleData.sku}
- {(saved.eans ?? []).map((ean) => (
-
- {ean}
-
- ))}
- >
- )}
- renderEdit={(draft, onChange, inputRef) => (
- onChange({ eans })}
- renderInput={(params) => (
-
- )}
- />
- )}
- />
- );
+ render() {
+ const { article: articleData } = this.props;
+ const value = eansDraftValue(articleData);
+
+ return (
+ }
+ edit={}
+ />
+ );
+ }
}
diff --git a/src/components/article/boxes/ArticleViewLists.jsx b/src/components/article/boxes/ArticleViewLists.jsx
index cb39a07..ad971ed 100644
--- a/src/components/article/boxes/ArticleViewLists.jsx
+++ b/src/components/article/boxes/ArticleViewLists.jsx
@@ -1,3 +1,4 @@
+import { Component } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography';
@@ -5,55 +6,61 @@ import { PanelTile } from '../../panels';
import { PANEL_TILE_KEYS } from '../../../theme';
import { artikelDetailPath } from '../../../pages/artikel/articleRoute';
-function CountLine({ label, count }) {
- if (count == null) return null;
- return (
-
- {label}: {count}
-
- );
+class CountLine extends Component {
+ render() {
+ const { label, count } = this.props;
+ if (count == null) return null;
+ return (
+
+ {label}: {count}
+
+ );
+ }
}
-function RelationLine({ label, active, children }) {
- return (
-
- {label}:{' '}
- {active ? (
- children ?? 'ja'
- ) : (
-
- nein
-
- )}
-
- );
+class RelationLine extends Component {
+ render() {
+ const { label, active, children } = this.props;
+ return (
+
+ {label}:{' '}
+ {active ? (
+ children ?? 'ja'
+ ) : (
+
+ nein
+
+ )}
+
+ );
+ }
}
-export default function ArticleViewLists({ article: articleData }) {
- return (
-
- 0}
- >
- {articleData.childCount}
-
-
- {articleData.fatherSku ? (
-
- {articleData.fatherSku}
-
- ) : null}
-
-
-
-
-
- );
+export default class ArticleViewLists extends Component {
+ render() {
+ const { article: articleData } = this.props;
+ return (
+
+ 0}>
+ {articleData.childCount}
+
+
+ {articleData.fatherSku ? (
+
+ {articleData.fatherSku}
+
+ ) : null}
+
+
+
+
+
+ );
+ }
}
diff --git a/src/components/article/boxes/ArticleViewName.jsx b/src/components/article/boxes/ArticleViewName.jsx
index 3daf204..b3abce6 100644
--- a/src/components/article/boxes/ArticleViewName.jsx
+++ b/src/components/article/boxes/ArticleViewName.jsx
@@ -1,7 +1,7 @@
-import { useState } from 'react';
+import { Component } from 'react';
import { EditablePanelTile } from '../../panels';
import SingleLineTextEdit from '../../SingleLineTextEdit';
-import { useStorageManager } from '../../../api/storageManagerContext';
+import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireObject } from '../../../utils/apiShape';
import { PANEL_TILE_KEYS } from '../../../theme';
import { getCategoryBreadcrumbs } from '../../../store/categorySlice';
@@ -45,6 +45,13 @@ const pillsContainerSx = {
mt: 'auto',
};
+const columnSx = {
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ alignSelf: 'stretch',
+};
+
function draftDirty(draft, saved) {
const nameDiff = draft.name.trim() !== String(saved.name ?? '').trim();
const a = draft.categoryIds ?? [];
@@ -70,61 +77,156 @@ function buildCategoryLabel(categories, categoryId, rootCategoryId) {
return crumbs.map((c) => c.name).join(' | ');
}
-function CategoryPills({
- categoryLabels,
- categoryIds,
- categories,
- rootCategoryId,
- sku,
- editMode,
- onCategoryIdsChange,
-}) {
- const [pickerOpen, setPickerOpen] = useState(false);
+function categoryLabels(categoryIds, categories, rootCategoryId) {
+ return (categoryIds ?? [])
+ .map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
+ .filter((c) => c.label);
+}
- const handleRemoveCategory = (removeId) => {
+class CategoryPills extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { pickerOpen: false };
+ }
+
+ handleRemoveCategory = (removeId) => {
+ const { categoryIds, onCategoryIdsChange } = this.props;
const remaining = categoryIds.filter((id) => String(id) !== String(removeId));
onCategoryIdsChange?.(remaining);
};
- return (
- <>
-
- {categoryLabels.map(({ id, label }) => (
- 1 ? () => handleRemoveCategory(id) : undefined}
- />
- ))}
- {editMode && (
- setPickerOpen(true)}
- sx={{ ml: 0.5, width: 24, height: 24 }}
- >
-
-
- )}
-
- setPickerOpen(false)}
- categories={categories}
- rootCategoryId={rootCategoryId}
- currentCategoryIds={categoryIds}
- onSelect={onCategoryIdsChange}
- />
- >
- );
+ handleOpenPicker = () => {
+ this.setState({ pickerOpen: true });
+ };
+
+ handleClosePicker = () => {
+ this.setState({ pickerOpen: false });
+ };
+
+ render() {
+ const {
+ categoryLabels: labels,
+ categoryIds,
+ categories,
+ rootCategoryId,
+ sku,
+ editMode,
+ onCategoryIdsChange,
+ } = this.props;
+ const { pickerOpen } = this.state;
+
+ return (
+ <>
+
+ {labels.map(({ id, label }) => (
+ 1
+ ? () => this.handleRemoveCategory(id)
+ : undefined
+ }
+ />
+ ))}
+ {editMode ? (
+
+
+
+ ) : null}
+
+
+ >
+ );
+ }
}
-export default function ArticleViewName({ article: articleData, categories, rootCategoryId, onArticleUpdated }) {
- const store = useStorageManager();
+class NameDisplay extends Component {
+ render() {
+ const { name, categoryIds, categories, rootCategoryId, sku } = this.props;
+ const labels = categoryLabels(categoryIds, categories, rootCategoryId);
+ return (
+
+
+ {}}
+ sx={articleNameFieldHiddenSx}
+ />
+
+ {labels.length > 0 ? (
+
+ ) : null}
+
+ );
+ }
+}
- const handleSave = async (draft) => {
+class NameEdit extends Component {
+ render() {
+ const {
+ draft,
+ onChange,
+ inputRef,
+ categories,
+ rootCategoryId,
+ sku,
+ } = this.props;
+ const labels = categoryLabels(draft.categoryIds, categories, rootCategoryId);
+ return (
+
+
+ onChange({ ...draft, name })}
+ sx={articleNameFieldSx}
+ />
+
+ onChange({ ...draft, categoryIds })}
+ />
+
+ );
+ }
+}
+
+export default class ArticleViewName extends Component {
+ static contextType = StorageManagerContext;
+
+ handleSave = async (draft) => {
+ const { article: articleData, onArticleUpdated } = this.props;
+ const store = this.context;
const name = draft.name.trim();
const categoryIds = draft.categoryIds ?? [];
@@ -138,73 +240,38 @@ export default function ArticleViewName({ article: articleData, categories, root
onArticleUpdated?.(nameRes.article);
};
- const savedCategoryIds = articleData.categoryIds ?? [];
+ render() {
+ const { article: articleData, categories, rootCategoryId } = this.props;
+ const savedCategoryIds = articleData.categoryIds ?? [];
+ const value = { name: articleData.name ?? '', categoryIds: [...savedCategoryIds] };
- return (
- {
- const labels = (categoryIds ?? [])
- .map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
- .filter((c) => c.label);
- return (
-
-
- {}}
- sx={articleNameFieldHiddenSx}
- />
-
- {labels.length > 0 && (
-
- )}
-
- );
- }}
- renderEdit={(draft, onChange, inputRef) => {
- const labels = (draft.categoryIds ?? [])
- .map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
- .filter((c) => c.label);
- return (
-
-
- onChange({ ...draft, name })}
- sx={articleNameFieldSx}
- />
-
- onChange({ ...draft, categoryIds })}
- />
-
- );
- }}
- />
- );
+ return (
+
+ }
+ edit={
+
+ }
+ />
+ );
+ }
}
diff --git a/src/components/article/boxes/ArticleViewPrice.jsx b/src/components/article/boxes/ArticleViewPrice.jsx
index 206575f..a291510 100644
--- a/src/components/article/boxes/ArticleViewPrice.jsx
+++ b/src/components/article/boxes/ArticleViewPrice.jsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { Component } from 'react';
import Box from '@mui/material/Box';
import InputAdornment from '@mui/material/InputAdornment';
import TextField from '@mui/material/TextField';
@@ -6,7 +6,7 @@ import Typography from '@mui/material/Typography';
import { EditablePanelTile } from '../../panels';
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
-import { useStorageManager } from '../../../api/storageManagerContext';
+import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../../utils/apiShape';
import {
buildPosPricesPutBody,
@@ -91,8 +91,6 @@ const priceBodySx = {
gap: 0.75,
};
-/* ── helper functions ──────────────────────────────────────────────── */
-
function articleHasPackUnit(art) {
const si = art.unit?.si;
const amount = art.unitamount;
@@ -170,93 +168,256 @@ function priceDraftValid(draft) {
return true;
}
-/* ── sub-component: amount field ──────────────────────────────────── */
-
-function PriceAmountField({
- value,
- onChange,
- inputRef,
- readOnly,
- error,
-}) {
- return (
- {
- onChange?.(event.target.value);
- }
- }
- fullWidth
- error={error}
- sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx}
- slotProps={{
- input: {
- readOnly,
- startAdornment: (
-
- €
-
- ),
- },
- htmlInput: {
- inputMode: 'decimal',
- readOnly,
- tabIndex: readOnly ? -1 : undefined,
- },
- }}
- />
- );
+function companyFromStore(store) {
+ if (!store) return null;
+ const companies = store.companies;
+ const session = store.authMe;
+ if (companies == null || session == null) return null;
+ const companyId = session.user?.selectedCompanyId;
+ if (companyId == null) return null;
+ return companies.find((c) => c.id === companyId) ?? null;
}
-/* ── main component ────────────────────────────────────────────────── */
-
-export default function ArticleViewPrice({ article: articleData, onArticleUpdated }) {
- const store = useStorageManager();
- const [tiersDialogOpen, setTiersDialogOpen] = useState(false);
- const [unitEditorOpen, setUnitEditorOpen] = useState(false);
- const [units, setUnits] = useState([]);
- const [company, setCompany] = useState(null);
- const basePrice = primaryPrice(articleData);
- const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
- const priceValue = priceDraftValue(articleData, company);
- const unitOptions =
- articleData.unit?.si && !findUnitBySi(units, articleData.unit.si)
- ? [...units, articleData.unit]
- : units;
-
- useEffect(() => {
- let cancelled = false;
- async function load() {
- try {
- const [unitList, companies, session] = await Promise.all([
- store.unitPromise,
- store.companyPromise,
- store.authMePromise,
- ]);
- const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
- const current = requireDefined(
- companies.find((c) => c.id === companyId),
- 'company',
- );
- if (!cancelled) {
- setUnits(unitList);
- setCompany(current);
+class PriceAmountField extends Component {
+ render() {
+ const { value, onChange, inputRef, readOnly, error } = this.props;
+ return (
+ {
+ onChange?.(event.target.value);
+ }
}
- } catch (err) {
- if (!cancelled) notifyError(err);
- }
- }
- void load();
- return () => {
- cancelled = true;
- };
- }, [store]);
+ fullWidth
+ error={error}
+ sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx}
+ slotProps={{
+ input: {
+ readOnly,
+ startAdornment: (
+
+ €
+
+ ),
+ },
+ htmlInput: {
+ inputMode: 'decimal',
+ readOnly,
+ tabIndex: readOnly ? -1 : undefined,
+ },
+ }}
+ />
+ );
+ }
+}
- const handleSave = async (draft) => {
+class PriceDisplay extends Component {
+ render() {
+ const { saved, taxRates, unitOptions, displaySymbol } = this.props;
+ return (
+
+
+
+
+
+
+ );
+ }
+}
+
+class PriceOverlay extends Component {
+ render() {
+ const {
+ editing,
+ draft,
+ onChange,
+ unitOptions,
+ articleUnitSi,
+ onOpenEditor,
+ } = this.props;
+ if (!editing || draft == null) return null;
+ return (
+ {
+ if (hasUnit) {
+ const unitamount = draft.unitamount || '1';
+ const amount = parsePriceInput(unitamount) ?? 1;
+ let unitSi = firstPackUnitSi(unitOptions, draft.unitSi || articleUnitSi);
+ if (!unitSi && isNoPackUnit('pc', amount)) {
+ onChange({
+ ...draft,
+ hasUnit: true,
+ unitSi: 'pc',
+ unitamount: '',
+ });
+ return;
+ }
+ if (!unitSi) unitSi = 'pc';
+ onChange({
+ ...draft,
+ hasUnit: true,
+ unitSi,
+ unitamount,
+ });
+ return;
+ }
+ onChange({
+ ...draft,
+ hasUnit: false,
+ unitamount: '',
+ unitSi: '',
+ });
+ }}
+ onOpenEditor={onOpenEditor}
+ />
+ );
+ }
+}
+
+class PriceEdit extends Component {
+ handleUnitUpserted = async (row) => {
+ const { draft, onChange, onUnitUpserted } = this.props;
+ await onUnitUpserted?.(row);
+ if (!draft.unitSi) {
+ onChange({ ...draft, unitSi: row.si });
+ }
+ };
+
+ handleUnitDeleted = async (si) => {
+ const { draft, onChange, onUnitDeleted } = this.props;
+ await onUnitDeleted?.(si);
+ if (draft.unitSi === si) {
+ onChange({ ...draft, unitSi: '' });
+ }
+ };
+
+ render() {
+ const {
+ draft,
+ onChange,
+ inputRef,
+ taxRates,
+ unitOptions,
+ displaySymbol,
+ tiersDialogOpen,
+ unitEditorOpen,
+ onOpenTiers,
+ onCloseTiers,
+ onCloseUnitEditor,
+ } = this.props;
+ const draftGross = parsePriceInput(draft.gross);
+ const draftAmount = parsePriceInput(draft.unitamount);
+ return (
+
+ onChange({ ...draft, gross })}
+ error={draft.gross !== '' && draftGross == null}
+ />
+ onChange({ ...draft, taxRate })}
+ onOpenTiers={onOpenTiers}
+ />
+
+ onChange({ ...draft, unitamount })}
+ onChangeSi={(unitSi) => onChange({ ...draft, unitSi })}
+ amountError={
+ draft.hasUnit &&
+ draft.unitamount !== '' &&
+ (draftAmount == null || draftAmount <= 0)
+ }
+ />
+ onChange({ ...draft, tiers })}
+ />
+
+
+ );
+ }
+}
+
+export default class ArticleViewPrice extends Component {
+ static contextType = StorageManagerContext;
+
+ constructor(props) {
+ super(props);
+ const store = this.context;
+ const company = companyFromStore(store);
+ const units = store?.units;
+ this.state = {
+ tiersDialogOpen: false,
+ unitEditorOpen: false,
+ units: units ?? [],
+ company,
+ };
+ this._cancelled = false;
+ this._ready = company != null && units != null;
+ }
+
+ componentDidMount() {
+ this._cancelled = false;
+ if (this._ready) return;
+ void this.load();
+ }
+
+ componentWillUnmount() {
+ this._cancelled = true;
+ }
+
+ async load() {
+ const store = this.context;
+ try {
+ const [unitList, companies, session] = await Promise.all([
+ store.unitPromise,
+ store.companyPromise,
+ store.authMePromise,
+ ]);
+ const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
+ const current = requireDefined(
+ companies.find((c) => c.id === companyId),
+ 'company',
+ );
+ if (!this._cancelled) {
+ this.setState({ units: unitList, company: current });
+ }
+ } catch (err) {
+ if (!this._cancelled) notifyError(err);
+ }
+ }
+
+ handleSave = async (draft) => {
+ const { article: articleData, onArticleUpdated } = this.props;
+ const store = this.context;
+ const { company } = this.state;
const saved = priceDraftValue(articleData, company);
let nextArticle = articleData;
@@ -315,134 +476,73 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate
onArticleUpdated?.(nextArticle);
};
- return (
- {
- if (!editing || draft == null) return null;
- return (
- {
- if (hasUnit) {
- const unitamount = draft.unitamount || '1';
- const amount = parsePriceInput(unitamount) ?? 1;
- let unitSi = firstPackUnitSi(
- unitOptions,
- draft.unitSi || articleData.unit?.si,
- );
- if (!unitSi && isNoPackUnit('pc', amount)) {
- onChange({
- ...draft,
- hasUnit: true,
- unitSi: 'pc',
- unitamount: '',
- });
- return;
- }
- if (!unitSi) unitSi = 'pc';
- onChange({
- ...draft,
- hasUnit: true,
- unitSi,
- unitamount,
- });
- return;
- }
- onChange({
- ...draft,
- hasUnit: false,
- unitamount: '',
- unitSi: '',
- });
- }}
- onOpenEditor={() => setUnitEditorOpen(true)}
+ openTiersDialog = () => this.setState({ tiersDialogOpen: true });
+ closeTiersDialog = () => this.setState({ tiersDialogOpen: false });
+ openUnitEditor = () => this.setState({ unitEditorOpen: true });
+ closeUnitEditor = () => this.setState({ unitEditorOpen: false });
+
+ unitOptions() {
+ const { article: articleData } = this.props;
+ const { units } = this.state;
+ if (articleData.unit?.si && !findUnitBySi(units, articleData.unit.si)) {
+ return [...units, articleData.unit];
+ }
+ return units;
+ }
+
+ refreshUnits = async () => {
+ this.setState({ units: await this.context.unitPromise });
+ };
+
+ render() {
+ const { article: articleData } = this.props;
+ const { company, tiersDialogOpen, unitEditorOpen } = this.state;
+ const priceValue = priceDraftValue(articleData, company);
+ const basePrice = primaryPrice(articleData);
+ const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
+ const unitOptions = this.unitOptions();
+ const displaySymbol = articleData.unit?.symbol;
+
+ return (
+
- );
- }}
- renderDisplay={(saved) => (
-
-
-
-
-
-
- )}
- renderEdit={(draft, onChange, inputRef) => {
- const draftGross = parsePriceInput(draft.gross);
- const draftAmount = parsePriceInput(draft.unitamount);
- return (
-
- onChange({ ...draft, gross })}
- error={draft.gross !== '' && draftGross == null}
- />
- onChange({ ...draft, taxRate })}
- onOpenTiers={() => setTiersDialogOpen(true)}
- />
-
- onChange({ ...draft, unitamount })}
- onChangeSi={(unitSi) => onChange({ ...draft, unitSi })}
- amountError={
- draft.hasUnit &&
- draft.unitamount !== '' &&
- (draftAmount == null || draftAmount <= 0)
- }
- />
- setTiersDialogOpen(false)}
- onApply={(tiers) => onChange({ ...draft, tiers })}
- />
- setUnitEditorOpen(false)}
- onUnitUpserted={async (row) => {
- setUnits(await store.unitPromise);
- if (!draft.unitSi) {
- onChange({ ...draft, unitSi: row.si });
- }
- }}
- onUnitDeleted={async (si) => {
- setUnits(await store.unitPromise);
- if (draft.unitSi === si) {
- onChange({ ...draft, unitSi: '' });
- }
- }}
- />
-
- );
- }}
- />
- );
+ }
+ />
+ );
+ }
}
diff --git a/src/components/article/boxes/ArticleViewStock.jsx b/src/components/article/boxes/ArticleViewStock.jsx
index 404437e..bf8ade9 100644
--- a/src/components/article/boxes/ArticleViewStock.jsx
+++ b/src/components/article/boxes/ArticleViewStock.jsx
@@ -1,38 +1,45 @@
+import { Component } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { PanelTile } from '../../panels';
import { PANEL_TILE_KEYS } from '../../../theme';
import { formatStock } from '../../../pages/artikel/searchFormat';
-function PanelValue({ label, children }) {
- if (children == null || children === '') return null;
- return (
-
- {label ? (
-
- {label}
+class PanelValue extends Component {
+ render() {
+ const { label, children } = this.props;
+ if (children == null || children === '') return null;
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ {children}
- ) : null}
-
- {children}
-
-
- );
+
+ );
+ }
}
const SECOND_ROW_MIN_HEIGHT = 220;
-export default function ArticleViewStock({ article: articleData }) {
- return (
-
-
- {articleData.stock != null ? formatStock(articleData.stock) : null}
-
-
- );
+export default class ArticleViewStock extends Component {
+ render() {
+ const { article: articleData } = this.props;
+ return (
+
+
+ {articleData.stock != null ? formatStock(articleData.stock) : null}
+
+
+ );
+ }
}
diff --git a/src/components/article/boxes/PriceTaxRow.jsx b/src/components/article/boxes/PriceTaxRow.jsx
index 6a959aa..6c7b35b 100644
--- a/src/components/article/boxes/PriceTaxRow.jsx
+++ b/src/components/article/boxes/PriceTaxRow.jsx
@@ -1,3 +1,4 @@
+import { Component } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
@@ -10,72 +11,72 @@ const taxPillSx = {
'& .MuiChip-label': { px: 1 },
};
-export function TaxRatePills({ rates, selected, onSelect, readOnly }) {
- return (
-
- {rates.map((rate) => {
- const active = Number(selected) === Number(rate);
- return (
- onSelect(rate)}
- sx={{
- ...taxPillSx,
- visibility: active || !readOnly ? 'visible' : 'hidden',
- bgcolor: active ? 'rgba(0, 0, 0, 0.14)' : 'rgba(0, 0, 0, 0.06)',
- ...(readOnly
- ? {}
- : {
- cursor: 'pointer',
- '&:hover': { bgcolor: 'rgba(0, 0, 0, 0.12)' },
- }),
- }}
- />
- );
- })}
-
- );
+export class TaxRatePills extends Component {
+ render() {
+ const { rates, selected, onSelect, readOnly } = this.props;
+ return (
+
+ {rates.map((rate) => {
+ const active = Number(selected) === Number(rate);
+ return (
+ onSelect(rate)}
+ sx={{
+ ...taxPillSx,
+ visibility: active || !readOnly ? 'visible' : 'hidden',
+ bgcolor: active ? 'rgba(0, 0, 0, 0.14)' : 'rgba(0, 0, 0, 0.06)',
+ ...(readOnly
+ ? {}
+ : {
+ cursor: 'pointer',
+ '&:hover': { bgcolor: 'rgba(0, 0, 0, 0.12)' },
+ }),
+ }}
+ />
+ );
+ })}
+
+ );
+ }
}
-export function PriceTaxRow({
- rates,
- selected,
- onSelectTax,
- readOnly,
- onOpenTiers,
-}) {
- return (
-
-
-
-
-
-
- );
+
+
+
+
+
+ );
+ }
}
diff --git a/src/components/article/boxes/PriceUnitRow.jsx b/src/components/article/boxes/PriceUnitRow.jsx
index a61f46f..9eddfd1 100644
--- a/src/components/article/boxes/PriceUnitRow.jsx
+++ b/src/components/article/boxes/PriceUnitRow.jsx
@@ -1,3 +1,4 @@
+import { Component } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import FormControl from '@mui/material/FormControl';
@@ -21,115 +22,121 @@ const priceUnitCornerSx = {
zIndex: 2,
};
-export function PriceUnitRow({
- hasUnit,
- unitamount,
- unitSi,
- units,
- displaySymbol,
- onChangeAmount,
- onChangeSi,
- readOnly,
- amountError,
-}) {
- const selected = findUnitBySi(units, unitSi);
- const symbol = selected?.symbol ?? displaySymbol ?? unitSi ?? '';
+export class PriceUnitRow extends Component {
+ render() {
+ const {
+ hasUnit,
+ unitamount,
+ unitSi,
+ units,
+ displaySymbol,
+ onChangeAmount,
+ onChangeSi,
+ readOnly,
+ amountError,
+ } = this.props;
+ const selected = findUnitBySi(units, unitSi);
+ const symbol = selected?.symbol ?? displaySymbol ?? unitSi ?? '';
+
+ if (readOnly) {
+ return (
+
+ {hasUnit && unitamount ? (
+
+ ) : null}
+
+ );
+ }
+
+ if (!hasUnit) {
+ return ;
+ }
- if (readOnly) {
return (
-
- {hasUnit && unitamount ? (
-
+ onChangeAmount(event.target.value)}
+ size="small"
+ error={amountError}
+ sx={{ width: 88, flexShrink: 0 }}
+ slotProps={{
+ inputLabel: { shrink: true },
+ htmlInput: { inputMode: 'decimal' },
+ }}
+ />
+
+
+ Einheit
+
+
+
);
}
+}
- if (!hasUnit) {
- return ;
- }
-
- return (
-
- onChangeAmount(event.target.value)}
- size="small"
- error={amountError}
- sx={{ width: 88, flexShrink: 0 }}
- slotProps={{
- inputLabel: { shrink: true },
- htmlInput: { inputMode: 'decimal' },
- }}
- />
-
-
- Einheit
-
-
-
-
- );
-}
-
-export function PriceUnitCorner({ hasUnit, onToggleHasUnit, onOpenEditor }) {
- return (
-
- onToggleHasUnit(event.target.checked)}
- inputProps={{ 'aria-label': 'Einheit' }}
- />
-
-
-
-
- );
+
+
+
+ );
+ }
}
diff --git a/src/components/article/boxes/TierPricePills.jsx b/src/components/article/boxes/TierPricePills.jsx
index 661e1c5..340b2f9 100644
--- a/src/components/article/boxes/TierPricePills.jsx
+++ b/src/components/article/boxes/TierPricePills.jsx
@@ -1,3 +1,4 @@
+import { Component } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import { formatPrice, parsePriceInput } from '../../../utils/price';
@@ -8,24 +9,27 @@ const tierPillSx = {
fontWeight: 600,
};
-export default function TierPricePills({ tiers }) {
- return (
-
- {(tiers ?? []).map((tier) => {
- const gross = parsePriceInput(tier.gross);
- if (gross == null) return null;
- return (
-
- );
- })}
-
- );
+export default class TierPricePills extends Component {
+ render() {
+ const { tiers } = this.props;
+ return (
+
+ {(tiers ?? []).map((tier) => {
+ const gross = parsePriceInput(tier.gross);
+ if (gross == null) return null;
+ return (
+
+ );
+ })}
+
+ );
+ }
}
diff --git a/src/components/panels/EditablePanelTile.jsx b/src/components/panels/EditablePanelTile.jsx
index 73ff203..75bf21d 100644
--- a/src/components/panels/EditablePanelTile.jsx
+++ b/src/components/panels/EditablePanelTile.jsx
@@ -1,4 +1,4 @@
-import { Component, createRef } from 'react';
+import { Component, cloneElement, createRef, isValidElement } from 'react';
import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
@@ -9,7 +9,6 @@ import IconButton from '@mui/material/IconButton';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import { injectMuiTheme } from './injectMuiTheme';
-import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../../utils/notify';
import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout';
@@ -20,6 +19,11 @@ function valuesEqual(a, b) {
/**
* Panel tile with centralized inline-edit chrome.
* Pass `onSave` to enable edit UI; omit it for read-only tiles (no edit icon).
+ *
+ * Content is element props (not render callbacks):
+ * display — React node shown when not editing
+ * edit — element cloned with { draft, onChange, inputRef } while editing
+ * overlay — optional element cloned with { editing, draft, onChange }
*/
class EditablePanelTileBase extends Component {
constructor(props) {
@@ -191,6 +195,31 @@ class EditablePanelTileBase extends Component {
);
}
+ renderBody() {
+ const { display, edit } = this.props;
+ const { editing, draft } = this.state;
+
+ if (editing && draft != null && isValidElement(edit)) {
+ return cloneElement(edit, {
+ draft,
+ onChange: this.handleDraftChange,
+ inputRef: this.inputRef,
+ });
+ }
+ return display;
+ }
+
+ renderOverlayNode() {
+ const { overlay } = this.props;
+ const { editing, draft } = this.state;
+ if (!isValidElement(overlay)) return null;
+ return cloneElement(overlay, {
+ editing,
+ draft,
+ onChange: this.handleDraftChange,
+ });
+ }
+
render() {
const {
onSave,
@@ -199,13 +228,9 @@ class EditablePanelTileBase extends Component {
sx,
minHeight = 120,
tileKey,
- renderDisplay,
- renderEdit,
- renderOverlay,
contentAlignItems = 'center',
} = this.props;
- const { editing, draft, error } = this.state;
- const savedValue = this.getSavedValue();
+ const { editing, error } = this.state;
const pending = this.isDirty();
const actionPadding = onSave ? TILE_ACTION_PADDING : 0;
@@ -223,13 +248,7 @@ class EditablePanelTileBase extends Component {
})}
>
{this.renderActions()}
- {typeof renderOverlay === 'function'
- ? renderOverlay({
- editing,
- draft,
- onChange: this.handleDraftChange,
- })
- : null}
+ {this.renderOverlayNode()}
{this.renderTitle()}
- {editing && draft != null
- ? renderEdit(draft, this.handleDraftChange, this.inputRef)
- : renderDisplay(savedValue)}
+ {this.renderBody()}
{error ? (
diff --git a/src/pages/artikel/sku.jsx b/src/pages/artikel/sku.jsx
index 04a2f06..6e385c9 100644
--- a/src/pages/artikel/sku.jsx
+++ b/src/pages/artikel/sku.jsx
@@ -1,124 +1,185 @@
-import { useCallback, useEffect, useState } from 'react';
-import { useNavigate, useParams } from 'react-router-dom';
+import { Component } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
-import { useStorageManager } from '../../api/storageManagerContext';
+import { StorageManagerContext } from '../../api/storageManagerContext';
import { ArticleView } from '../../components/article';
import BackendError from '../../components/BackendError';
-import { requireObject } from '../../utils/apiShape';
+import { requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom, isBackendFailure } from '../../utils/backendError';
+import { parseCategoryTreeResponse } from '../../store/categorySlice';
import {
artikelDetailPath,
resolveArticleTab,
} from './articleRoute';
+import withSkuRoute from './withSkuRoute';
-export default function ArtikelSkuPage() {
- const { sku: skuParam, tab: tabParam } = useParams();
- const navigate = useNavigate();
- const store = useStorageManager();
- const sku = skuParam ? decodeURIComponent(skuParam) : '';
- const [data, setData] = useState(null);
- const [error, setError] = useState(null);
- const [loading, setLoading] = useState(true);
- const [reloadKey, setReloadKey] = useState(0);
+class ArtikelSkuPage extends Component {
+ static contextType = StorageManagerContext;
- const retry = useCallback(() => {
- setReloadKey((key) => key + 1);
- }, []);
-
- useEffect(() => {
- function onKeyDown(e) {
- if (e.key !== 'Escape') return;
- e.preventDefault();
- navigate(-1);
- }
-
- document.addEventListener('keydown', onKeyDown);
- return () => document.removeEventListener('keydown', onKeyDown);
- }, [navigate]);
-
- useEffect(() => {
- let cancelled = false;
-
- async function load() {
- if (!sku) {
- setError('Keine Artikelnr. angegeben');
- setData(null);
- setLoading(false);
- return;
- }
-
- setLoading(true);
- setError(null);
- try {
- const res = await store.article.get(sku);
- requireObject(res, 'GET /api/article/:sku');
- requireObject(res.article, 'article');
- if (!cancelled) {
- setData(res.article);
- }
- } catch (err) {
- if (!cancelled) {
- setData(null);
- setError(backendErrorFrom(err));
- }
- } finally {
- if (!cancelled) setLoading(false);
- }
- }
-
- load();
- return () => {
- cancelled = true;
+ constructor(props) {
+ super(props);
+ this.state = {
+ data: null,
+ categories: null,
+ rootCategoryId: null,
+ error: null,
+ loading: true,
+ reloadKey: 0,
};
- }, [sku, reloadKey, store]);
+ this._cancelled = false;
+ }
+
+ componentDidMount() {
+ this._cancelled = false;
+ this.installEscape();
+ void this.load();
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const { sku, tab: tabParam, navigate } = this.props;
+ const { data, reloadKey } = this.state;
+
+ if (prevProps.sku !== sku || prevState.reloadKey !== reloadKey) {
+ void this.load();
+ return;
+ }
- useEffect(() => {
if (!data) return;
const resolved = resolveArticleTab(tabParam, data);
if (tabParam !== resolved) {
navigate(artikelDetailPath(data, resolved), { replace: true });
}
- }, [data, tabParam, navigate]);
-
- if (loading) {
- return (
-
-
-
- );
}
- if (error) {
- if (isBackendFailure(error)) {
+ componentWillUnmount() {
+ this._cancelled = true;
+ this.removeEscape();
+ }
+
+ installEscape() {
+ this._onKeyDown = (e) => {
+ if (e.key !== 'Escape') return;
+ e.preventDefault();
+ this.props.navigate(-1);
+ };
+ document.addEventListener('keydown', this._onKeyDown);
+ }
+
+ removeEscape() {
+ if (this._onKeyDown) {
+ document.removeEventListener('keydown', this._onKeyDown);
+ this._onKeyDown = null;
+ }
+ }
+
+ retry = () => {
+ this.setState((s) => ({ reloadKey: s.reloadKey + 1 }));
+ };
+
+ async load() {
+ const { sku } = this.props;
+ const store = this.context;
+
+ if (!sku) {
+ this.setState({
+ error: 'Keine Artikelnr. angegeben',
+ data: null,
+ categories: null,
+ rootCategoryId: null,
+ loading: false,
+ });
+ return;
+ }
+
+ this._cancelled = false;
+ this.setState({ loading: true, error: null });
+
+ try {
+ const [res, treeData, companies, session] = await Promise.all([
+ store.article.get(sku),
+ store.categoryTreePromise,
+ store.companyPromise,
+ store.authMePromise,
+ ]);
+ requireObject(res, 'GET /api/article/:sku');
+ requireObject(res.article, 'article');
+ const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
+ const company = requireDefined(
+ companies.find((c) => c.id === companyId),
+ 'company',
+ );
+ const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
+ if (!this._cancelled) {
+ this.setState({
+ data: res.article,
+ categories: parsed.categories,
+ rootCategoryId: parsed.rootCategoryId,
+ error: null,
+ loading: false,
+ });
+ }
+ } catch (err) {
+ if (!this._cancelled) {
+ this.setState({
+ data: null,
+ categories: null,
+ rootCategoryId: null,
+ error: backendErrorFrom(err),
+ loading: false,
+ });
+ }
+ }
+ }
+
+ handleArticleUpdated = (next) => {
+ this.setState({ data: next });
+ };
+
+ handleTabChange = (nextTab) => {
+ const { data } = this.state;
+ this.props.navigate(artikelDetailPath(data, nextTab));
+ };
+
+ render() {
+ const { tab: tabParam } = this.props;
+ const { data, categories, rootCategoryId, error, loading } = this.state;
+
+ if (loading) {
return (
-
+
+
+
);
}
+
+ if (error) {
+ if (isBackendFailure(error)) {
+ return ;
+ }
+ return (
+
+ {typeof error === 'string' ? error : error.message}
+
+ );
+ }
+
+ if (!data || categories == null || rootCategoryId == null) return null;
+
+ const tab = resolveArticleTab(tabParam, data);
+ if (tabParam !== tab) return null;
+
return (
-
- {typeof error === 'string' ? error : error.message}
-
+
);
}
-
- if (!data) return null;
-
- const tab = resolveArticleTab(tabParam, data);
- if (tabParam !== tab) return null;
-
- return (
- {
- navigate(artikelDetailPath(data, nextTab));
- }}
- onArticleUpdated={setData}
- />
- );
}
+
+export default withSkuRoute(ArtikelSkuPage);
diff --git a/src/pages/artikel/withSkuRoute.jsx b/src/pages/artikel/withSkuRoute.jsx
new file mode 100644
index 0000000..2f9ab34
--- /dev/null
+++ b/src/pages/artikel/withSkuRoute.jsx
@@ -0,0 +1,13 @@
+import { useNavigate, useParams } from 'react-router-dom';
+
+/** Injects `sku`, `tab`, and `navigate` from the article detail route. */
+export default function withSkuRoute(Wrapped) {
+ function SkuRouteWrapper(props) {
+ const navigate = useNavigate();
+ const { sku: skuParam, tab } = useParams();
+ const sku = skuParam ? decodeURIComponent(skuParam) : '';
+
+ return ;
+ }
+ return SkuRouteWrapper;
+}