From 66f88e395bdcb46d37eb11cc1fcec7f5920e84c0 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 5 Aug 2026 08:40:12 +0200 Subject: [PATCH] u --- src/api/storageManager.jsx | 8 +- src/components/article/ArticlePhoto.jsx | 6 +- src/components/article/ArticlePhotoTile.jsx | 6 +- .../article/ArticlePhotosDialog.jsx | 10 ++- src/components/article/ArticleTileTab.jsx | 6 +- .../article/ArticleUnitEditorDialog.jsx | 69 ++++++++------- src/components/article/ArticleView.jsx | 86 ++++++++++--------- .../article/boxes/ArticleViewBrand.jsx | 38 +++++--- .../article/boxes/ArticleViewEANs.jsx | 6 +- .../article/boxes/ArticleViewName.jsx | 8 +- .../article/boxes/ArticleViewPrice.jsx | 66 +++++++------- src/pages/artikel/sku.jsx | 7 +- 12 files changed, 176 insertions(+), 140 deletions(-) diff --git a/src/api/storageManager.jsx b/src/api/storageManager.jsx index 0c9968f..02e4363 100644 --- a/src/api/storageManager.jsx +++ b/src/api/storageManager.jsx @@ -215,18 +215,22 @@ class StorageManager { Object.defineProperties(this, { authMePromise: { get: () => authMeCache.promise }, updateAuthMe: { value: authMeCache.update }, + articleIndexPromise: { get: () => articleIndexCache.promise }, updateArticleIndex: { value: articleIndexCache.update }, - /** @deprecated use updateArticleIndex */ - update: { value: articleIndexCache.update }, + brandPromise: { get: () => brandCache.promise }, updateBrand: { value: brandCache.update }, + unitPromise: { get: () => unitCache.promise }, updateUnit: { value: unitCache.update }, + companyPromise: { get: () => companyCache.promise }, updateCompany: { value: companyCache.update }, + categoryTreePromise: { get: () => categoryTreeCache.promise }, updateCategoryTree: { value: categoryTreeCache.update }, + posterminalPromise: { get: () => posterminalCache.promise }, updatePosterminal: { value: posterminalCache.update }, }); diff --git a/src/components/article/ArticlePhoto.jsx b/src/components/article/ArticlePhoto.jsx index ec356f6..c3225db 100644 --- a/src/components/article/ArticlePhoto.jsx +++ b/src/components/article/ArticlePhoto.jsx @@ -1,9 +1,11 @@ import { Component } from 'react'; import Box from '@mui/material/Box'; -import { thumb } from '../../api'; +import { StorageManagerContext } from '../../api/storageManager'; import ThumbHashImage from './ThumbHashImage'; class ArticlePhoto extends Component { + static contextType = StorageManagerContext; + constructor(props) { super(props); this.state = { thumbLoaded: false }; @@ -78,7 +80,7 @@ class ArticlePhoto extends Component { {fullHash ? ( notifyError(err)); - } + void this.loadUnits(); + } + } + + async loadUnits() { + this.setState({ unitsLoading: true }); + try { + const units = await this.context.unitPromise; + this.setState({ units, unitsLoading: false }); + } catch (err) { + this.setState({ unitsLoading: false }); + notifyError(err); } } @@ -110,19 +117,23 @@ class ArticleUnitEditorDialog extends Component { if (!formValid(form)) return; this.setState({ busy: true }); try { - const unit = await this.props.upsertUnit({ + const data = await this.context.unit.upsert({ si: String(form.si ?? '').trim(), basesi: String(form.basesi ?? '').trim(), basesifactor: parseFactor(form.basesifactor), symbol: String(form.symbol ?? '').trim(), name: String(form.name ?? '').trim(), - }).unwrap(); + }); + requireObject(data, 'PUT /api/unit'); + requireObject(data.unit, 'unit'); + const units = await this.context.updateUnit(); this.setState({ busy: false, - form: formFromUnit(unit), - selectedSi: unit.si, + units, + form: formFromUnit(data.unit), + selectedSi: data.unit.si, }); - this.props.onUnitUpserted?.(unit); + this.props.onUnitUpserted?.(data.unit); } catch (err) { this.setState({ busy: false }); notifyError(err); @@ -134,8 +145,9 @@ class ArticleUnitEditorDialog extends Component { if (!selectedSi || selectedSi === DEFAULT_UNIT_SI) return; this.setState({ busy: true }); try { - await this.props.deleteUnit(selectedSi).unwrap(); - this.setState({ busy: false, form: emptyForm(), selectedSi: null }); + await this.context.unit.delete(selectedSi); + const units = await this.context.updateUnit(); + this.setState({ busy: false, units, form: emptyForm(), selectedSi: null }); this.props.onUnitDeleted?.(selectedSi); } catch (err) { this.setState({ busy: false }); @@ -148,10 +160,10 @@ class ArticleUnitEditorDialog extends Component { }; render() { - const { open, onClose, units, status } = this.props; - const { busy, form, selectedSi } = this.state; + const { open, onClose } = this.props; + const { busy, form, selectedSi, units, unitsLoading } = this.state; const canSave = formValid(form) && !busy; - const loading = status === 'loading' && units.length === 0; + const loading = unitsLoading && units.length === 0; return ( @@ -302,16 +314,3 @@ class ArticleUnitEditorDialog extends Component { ); } } - -const mapStateToProps = (state) => ({ - units: selectUnits(state), - status: selectUnitsStatus(state), -}); - -const mapDispatchToProps = (dispatch) => ({ - loadUnits: () => dispatch(loadUnits()), - upsertUnit: (body) => dispatch(upsertUnit(body)), - deleteUnit: (si) => dispatch(deleteUnit(si)), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(ArticleUnitEditorDialog); diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx index 66455f5..f75a57a 100644 --- a/src/components/article/ArticleView.jsx +++ b/src/components/article/ArticleView.jsx @@ -1,20 +1,15 @@ +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useDispatch, useSelector } from 'react-redux'; import { PanelTileGrid } from '../panels'; import { requireDefined } from '../../utils/apiShape'; -import { primaryPhoto } from '../../utils/articleDetail'; +import { useStorageManager } from '../../api/storageManager'; +import { parseCategoryTreeResponse } from '../../store/categorySlice'; import { PANEL_TILE_KEYS } from '../../theme'; +import { notifyError } from '../../utils/notify'; import { buildCategoryPath, defaultCategoryPath, } from '../../pages/artikel/categoryRoute'; -import { - patchArticleInLists, - removeArticleBySku, - selectCategoryRootId, - loadCategoryArticles, - selectCurrentCategoryId, -} from '../../store/categorySlice'; import ArticleTileTab from './ArticleTileTab'; import ArticlePhotoTile from './ArticlePhotoTile'; @@ -32,48 +27,55 @@ export default function ArticleView({ onArticleUpdated, }) { const navigate = useNavigate(); - const dispatch = useDispatch(); - const categories = useSelector((state) => state.category.categories); - const rootCategoryId = useSelector(selectCategoryRootId); - const currentCategoryId = useSelector(selectCurrentCategoryId); + const store = useStorageManager(); + const [categories, setCategories] = useState(null); + const [rootCategoryId, setRootCategoryId] = useState(null); - const syncCategoryListPhoto = (next) => { - const photo = primaryPhoto(next); - dispatch( - patchArticleInLists({ - sku: next.sku, - fullHash: photo?.fullHash ?? null, - thumbW: photo?.thumbW ?? null, - thumbH: photo?.thumbH ?? null, - thumbHash: photo?.thumbHash ?? null, - }), - ); + 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(); }; const handleArticleUpdated = (next) => { onArticleUpdated?.(next); - syncCategoryListPhoto(next); - dispatch( - patchArticleInLists({ - sku: next.sku, - name: next.name, - categoryIds: next.categoryIds, - }), - ); - - // Reload articles to reflect the update - // The reducer handles removing from lists where article no longer belongs - if (currentCategoryId != null && String(currentCategoryId) !== String(rootCategoryId)) { - dispatch(loadCategoryArticles(currentCategoryId)); - } + refreshSearchCaches(); }; const handleArticleDeleted = () => { + refreshSearchCaches(); const categoryId = requireDefined( articleData.categoryIds?.[0], 'article.categoryIds[0]', ); - dispatch(removeArticleBySku(articleData.sku)); if (categories && rootCategoryId != null) { navigate(buildCategoryPath(categories, rootCategoryId, categoryId)); return; @@ -102,17 +104,17 @@ export default function ArticleView({ { - if (brandsStatus !== 'idle') return; - void dispatch(loadBrands()).unwrap().catch((err) => notifyError(err)); - }, [dispatch, brandsStatus]); + let cancelled = false; + store.brandPromise + .then((list) => { + if (!cancelled) setBrands(list); + }) + .catch((err) => { + if (!cancelled) notifyError(err); + }); + return () => { + cancelled = true; + }; + }, [store]); const handleSave = async (draft) => { const name = String(draft.name ?? '').trim(); @@ -65,12 +71,16 @@ export default function ArticleViewBrand({ article: articleData, onArticleUpdate if (existing) { brandId = existing.id; } else { - const created = await dispatch(upsertBrand({ name })).unwrap(); - requireDefined(created.id, 'brand.id'); - brandId = created.id; + const createdRes = await store.brand.upsert({ name }); + requireObject(createdRes, 'PUT /api/brand'); + requireObject(createdRes.brand, 'brand'); + requireDefined(createdRes.brand.id, 'brand.id'); + brandId = createdRes.brand.id; + const nextBrands = await store.updateBrand(); + setBrands(nextBrands); } - const res = await article.setBrandIdBySku(articleData.sku, brandId); + const res = await store.article.setBrand(articleData.sku, { brandId }); requireObject(res, 'PUT /api/article/:sku/brand'); requireObject(res.article, 'article'); onArticleUpdated?.(res.article); diff --git a/src/components/article/boxes/ArticleViewEANs.jsx b/src/components/article/boxes/ArticleViewEANs.jsx index 026f766..95e5a55 100644 --- a/src/components/article/boxes/ArticleViewEANs.jsx +++ b/src/components/article/boxes/ArticleViewEANs.jsx @@ -3,7 +3,7 @@ import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; import { EditablePanelTile } from '../../panels'; -import { article } from '../../../api'; +import { useStorageManager } from '../../../api/storageManager'; import { requireObject } from '../../../utils/apiShape'; import { articleEans } from '../../../utils/articleDetail'; import { PANEL_TILE_KEYS } from '../../../theme'; @@ -41,11 +41,13 @@ function eansDraftValid(draft) { const SECOND_ROW_MIN_HEIGHT = 220; export default function ArticleViewEANs({ article: articleData, onArticleUpdated }) { + const store = useStorageManager(); + const handleSave = async (draft) => { const eans = (draft.eans ?? []) .map((ean) => String(ean).trim()) .filter((ean) => ean.length > 0); - const res = await article.setEansBySku(articleData.sku, eans); + const res = await store.article.setEans(articleData.sku, { eans }); requireObject(res, 'PUT /api/article/:sku/eans'); requireObject(res.article, 'article'); onArticleUpdated?.(res.article); diff --git a/src/components/article/boxes/ArticleViewName.jsx b/src/components/article/boxes/ArticleViewName.jsx index 23452b0..2790855 100644 --- a/src/components/article/boxes/ArticleViewName.jsx +++ b/src/components/article/boxes/ArticleViewName.jsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { EditablePanelTile } from '../../panels'; import SingleLineTextEdit from '../../SingleLineTextEdit'; -import { article } from '../../../api'; +import { useStorageManager } from '../../../api/storageManager'; import { requireObject } from '../../../utils/apiShape'; import { PANEL_TILE_KEYS } from '../../../theme'; import { getCategoryBreadcrumbs } from '../../../store/categorySlice'; @@ -122,13 +122,15 @@ function CategoryPills({ } export default function ArticleViewName({ article: articleData, categories, rootCategoryId, onArticleUpdated }) { + const store = useStorageManager(); + const handleSave = async (draft) => { const name = draft.name.trim(); const categoryIds = draft.categoryIds ?? []; const [nameRes] = await Promise.all([ - article.update(articleData.sku, { name }), - article.setCategories(articleData.sku, categoryIds), + store.article.update(articleData.sku, { name }), + store.article.setCategories(articleData.sku, { categoryIds }), ]); requireObject(nameRes, 'PATCH /api/article/:sku'); diff --git a/src/components/article/boxes/ArticleViewPrice.jsx b/src/components/article/boxes/ArticleViewPrice.jsx index 4968429..aabc9c7 100644 --- a/src/components/article/boxes/ArticleViewPrice.jsx +++ b/src/components/article/boxes/ArticleViewPrice.jsx @@ -1,4 +1,3 @@ -import { useDispatch, useSelector } from 'react-redux'; import { useEffect, useState } from 'react'; import Box from '@mui/material/Box'; import InputAdornment from '@mui/material/InputAdornment'; @@ -7,11 +6,8 @@ import Typography from '@mui/material/Typography'; import { EditablePanelTile } from '../../panels'; import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog'; import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog'; -import { article } from '../../../api'; +import { useStorageManager } from '../../../api/storageManager'; import { requireDefined, requireObject } from '../../../utils/apiShape'; -import { loadUnits, selectUnits, selectUnitsStatus } from '../../../store/unitSlice'; -import { selectCurrentCompany } from '../../../store/companySlice'; -import { patchArticleInLists } from '../../../store/categorySlice'; import { buildPosPricesPutBody, companyTaxRateOptions, @@ -219,12 +215,11 @@ function PriceAmountField({ /* ── main component ────────────────────────────────────────────────── */ export default function ArticleViewPrice({ article: articleData, onArticleUpdated }) { - const dispatch = useDispatch(); + const store = useStorageManager(); const [tiersDialogOpen, setTiersDialogOpen] = useState(false); const [unitEditorOpen, setUnitEditorOpen] = useState(false); - const units = useSelector(selectUnits); - const unitsStatus = useSelector(selectUnitsStatus); - const company = useSelector(selectCurrentCompany); + const [units, setUnits] = useState([]); + const [company, setCompany] = useState(null); const basePrice = primaryPrice(articleData); const taxRates = companyTaxRateOptions(company, basePrice?.taxRate); const priceValue = priceDraftValue(articleData, company); @@ -234,20 +229,32 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate : units; useEffect(() => { - if (unitsStatus !== 'idle') return; - void dispatch(loadUnits()).unwrap().catch((err) => notifyError(err)); - }, [dispatch, unitsStatus]); - - const syncCategoryListPrice = (next) => { - const price = primaryPrice(next); - dispatch( - patchArticleInLists({ - sku: next.sku, - net: price?.net ?? null, - taxRate: price?.taxRate ?? null, - }), - ); - }; + 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); + } + } catch (err) { + if (!cancelled) notifyError(err); + } + } + void load(); + return () => { + cancelled = true; + }; + }, [store]); const handleSave = async (draft) => { const saved = priceDraftValue(articleData, company); @@ -269,12 +276,12 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate if (isNoPackUnit(unitSi, unitamount)) { throw new Error('Einheit pc × 1 ist keine Packungseinheit'); } - const res = await article.update(articleData.sku, { unitamount, unitSi }); + const res = await store.article.update(articleData.sku, { unitamount, unitSi }); requireObject(res, 'PATCH /api/article/:sku'); requireObject(res.article, 'article'); nextArticle = res.article; } else { - const res = await article.update(articleData.sku, { + const res = await store.article.update(articleData.sku, { unitamount: 1, unitSi: 'pc', }); @@ -299,11 +306,10 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate taxRate, tiers: draft.tiers ?? [], }); - const res = await article.setPrices(articleData.sku, prices); + const res = await store.article.setPrices(articleData.sku, { prices }); requireObject(res, 'PUT /api/article/:sku/prices'); requireObject(res.article, 'article'); nextArticle = res.article; - syncCategoryListPrice(res.article); } onArticleUpdated?.(nextArticle); @@ -421,12 +427,14 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate setUnitEditorOpen(false)} - onUnitUpserted={(row) => { + onUnitUpserted={async (row) => { + setUnits(await store.unitPromise); if (!draft.unitSi) { onChange({ ...draft, unitSi: row.si }); } }} - onUnitDeleted={(si) => { + onUnitDeleted={async (si) => { + setUnits(await store.unitPromise); if (draft.unitSi === si) { onChange({ ...draft, unitSi: '' }); } diff --git a/src/pages/artikel/sku.jsx b/src/pages/artikel/sku.jsx index 66241ab..0caa7ae 100644 --- a/src/pages/artikel/sku.jsx +++ b/src/pages/artikel/sku.jsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; -import { article } from '../../api'; +import { useStorageManager } from '../../api/storageManager'; import { ArticleView } from '../../components/article'; import BackendError from '../../components/BackendError'; import { requireObject } from '../../utils/apiShape'; @@ -16,6 +16,7 @@ import { 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); @@ -51,7 +52,7 @@ export default function ArtikelSkuPage() { setLoading(true); setError(null); try { - const res = await article.getBySku(sku); + const res = await store.article.get(sku); requireObject(res, 'GET /api/article/:sku'); requireObject(res.article, 'article'); if (!cancelled) { @@ -71,7 +72,7 @@ export default function ArtikelSkuPage() { return () => { cancelled = true; }; - }, [sku, reloadKey]); + }, [sku, reloadKey, store]); useEffect(() => { if (!data) return;