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; +}