This commit is contained in:
seb
2026-08-05 08:40:12 +02:00
parent a9e942df7a
commit 66f88e395b
12 changed files with 176 additions and 140 deletions

View File

@@ -215,18 +215,22 @@ class StorageManager {
Object.defineProperties(this, { Object.defineProperties(this, {
authMePromise: { get: () => authMeCache.promise }, authMePromise: { get: () => authMeCache.promise },
updateAuthMe: { value: authMeCache.update }, updateAuthMe: { value: authMeCache.update },
articleIndexPromise: { get: () => articleIndexCache.promise }, articleIndexPromise: { get: () => articleIndexCache.promise },
updateArticleIndex: { value: articleIndexCache.update }, updateArticleIndex: { value: articleIndexCache.update },
/** @deprecated use updateArticleIndex */
update: { value: articleIndexCache.update },
brandPromise: { get: () => brandCache.promise }, brandPromise: { get: () => brandCache.promise },
updateBrand: { value: brandCache.update }, updateBrand: { value: brandCache.update },
unitPromise: { get: () => unitCache.promise }, unitPromise: { get: () => unitCache.promise },
updateUnit: { value: unitCache.update }, updateUnit: { value: unitCache.update },
companyPromise: { get: () => companyCache.promise }, companyPromise: { get: () => companyCache.promise },
updateCompany: { value: companyCache.update }, updateCompany: { value: companyCache.update },
categoryTreePromise: { get: () => categoryTreeCache.promise }, categoryTreePromise: { get: () => categoryTreeCache.promise },
updateCategoryTree: { value: categoryTreeCache.update }, updateCategoryTree: { value: categoryTreeCache.update },
posterminalPromise: { get: () => posterminalCache.promise }, posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update }, updatePosterminal: { value: posterminalCache.update },
}); });

View File

@@ -1,9 +1,11 @@
import { Component } from 'react'; import { Component } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { thumb } from '../../api'; import { StorageManagerContext } from '../../api/storageManager';
import ThumbHashImage from './ThumbHashImage'; import ThumbHashImage from './ThumbHashImage';
class ArticlePhoto extends Component { class ArticlePhoto extends Component {
static contextType = StorageManagerContext;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { thumbLoaded: false }; this.state = { thumbLoaded: false };
@@ -78,7 +80,7 @@ class ArticlePhoto extends Component {
{fullHash ? ( {fullHash ? (
<Box <Box
component="img" component="img"
src={thumb.url(fullHash)} src={this.context.thumb.url(fullHash)}
alt="" alt=""
onLoad={this.handleThumbLoad} onLoad={this.handleThumbLoad}
onError={this.handleThumbError} onError={this.handleThumbError}

View File

@@ -1,7 +1,7 @@
import { Component, createRef } from 'react'; import { Component, createRef } from 'react';
import ContentPasteIcon from '@mui/icons-material/ContentPaste'; import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import { article } from '../../api'; import { StorageManagerContext } from '../../api/storageManager';
import { PanelTile } from '../panels'; import { PanelTile } from '../panels';
import { requireObject } from '../../utils/apiShape'; import { requireObject } from '../../utils/apiShape';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
@@ -12,6 +12,8 @@ import ArticlePhoto from './ArticlePhoto';
import ArticlePhotosDialog from './ArticlePhotosDialog'; import ArticlePhotosDialog from './ArticlePhotosDialog';
class ArticlePhotoTile extends Component { class ArticlePhotoTile extends Component {
static contextType = StorageManagerContext;
constructor(props) { constructor(props) {
super(props); super(props);
this.fileInputRef = createRef(); this.fileInputRef = createRef();
@@ -29,7 +31,7 @@ class ArticlePhotoTile extends Component {
try { try {
const file = await prepareArticlePhoto(rawBlob); const file = await prepareArticlePhoto(rawBlob);
const uploadStarted = performance.now(); const uploadStarted = performance.now();
const res = await article.uploadImage(articleData.sku, file); const res = await this.context.article.addImage(articleData.sku, file);
console.log('[article-photo] upload', { console.log('[article-photo] upload', {
uploadMs: Math.round(performance.now() - uploadStarted), uploadMs: Math.round(performance.now() - uploadStarted),
bytes: file.size, bytes: file.size,

View File

@@ -11,7 +11,7 @@ import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { article } from '../../api'; import { StorageManagerContext } from '../../api/storageManager';
import { requireArray, requireObject } from '../../utils/apiShape'; import { requireArray, requireObject } from '../../utils/apiShape';
import { import {
articleWithPhotoOrder, articleWithPhotoOrder,
@@ -41,6 +41,8 @@ const overlayIconSx = {
}; };
class ArticlePhotosDialog extends Component { class ArticlePhotosDialog extends Component {
static contextType = StorageManagerContext;
constructor(props) { constructor(props) {
super(props); super(props);
this.fileInputRef = createRef(); this.fileInputRef = createRef();
@@ -62,7 +64,7 @@ class ArticlePhotosDialog extends Component {
try { try {
const file = await prepareArticlePhoto(rawBlob); const file = await prepareArticlePhoto(rawBlob);
const uploadStarted = performance.now(); const uploadStarted = performance.now();
const res = await article.uploadImage(articleData.sku, file); const res = await this.context.article.addImage(articleData.sku, file);
console.log('[article-photo] upload', { console.log('[article-photo] upload', {
uploadMs: Math.round(performance.now() - uploadStarted), uploadMs: Math.round(performance.now() - uploadStarted),
bytes: file.size, bytes: file.size,
@@ -98,7 +100,7 @@ class ArticlePhotosDialog extends Component {
const { article: articleData, onArticleUpdated } = this.props; const { article: articleData, onArticleUpdated } = this.props;
this.setState({ busy: true }); this.setState({ busy: true });
try { try {
await article.deleteImage(articleData.sku, fullHash); await this.context.article.deleteImage(articleData.sku, fullHash);
onArticleUpdated?.(articleWithoutPhoto(articleData, fullHash)); onArticleUpdated?.(articleWithoutPhoto(articleData, fullHash));
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);
@@ -121,7 +123,7 @@ class ArticlePhotosDialog extends Component {
this.setState({ busy: true }); this.setState({ busy: true });
try { try {
const res = await article.orderImages(articleData.sku, fullHashes); const res = await this.context.article.orderImages(articleData.sku, { fullHashes });
requireObject(res, 'PUT /api/article/:sku/images/order'); requireObject(res, 'PUT /api/article/:sku/images/order');
requireArray(res.fullHashes, 'fullHashes'); requireArray(res.fullHashes, 'fullHashes');
onArticleUpdated?.(articleWithPhotoOrder(articleData, res.fullHashes)); onArticleUpdated?.(articleWithPhotoOrder(articleData, res.fullHashes));

View File

@@ -14,7 +14,7 @@ import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import ManageSearchIcon from '@mui/icons-material/ManageSearch'; import ManageSearchIcon from '@mui/icons-material/ManageSearch';
import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinIcon from '@mui/icons-material/PushPin';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import { article } from '../../api'; import { StorageManagerContext } from '../../api/storageManager';
import { import {
ARTICLE_TAB, ARTICLE_TAB,
DEFAULT_ARTICLE_TAB, DEFAULT_ARTICLE_TAB,
@@ -92,6 +92,8 @@ function Placeholder({ label }) {
} }
class ArticleTileTab extends Component { class ArticleTileTab extends Component {
static contextType = StorageManagerContext;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
@@ -123,7 +125,7 @@ class ArticleTileTab extends Component {
this.setState({ busy: true }); this.setState({ busy: true });
try { try {
await article.delete(articleData.sku); await this.context.article.delete(articleData.sku);
onArticleDeleted?.(articleData); onArticleDeleted?.(articleData);
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);

View File

@@ -1,5 +1,4 @@
import { Component } from 'react'; import { Component } from 'react';
import { connect } from 'react-redux';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
@@ -16,16 +15,11 @@ import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { StorageManagerContext } from '../../api/storageManager';
import { requireObject } from '../../utils/apiShape';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { parsePriceInput } from '../../utils/price'; import { parsePriceInput } from '../../utils/price';
import { COMMON_UNITS, DEFAULT_UNIT_SI, unitLabel } from '../../utils/units'; import { COMMON_UNITS, DEFAULT_UNIT_SI, unitLabel } from '../../utils/units';
import {
deleteUnit,
loadUnits,
selectUnits,
selectUnitsStatus,
upsertUnit,
} from '../../store/unitSlice';
function emptyForm() { function emptyForm() {
return { return {
@@ -68,11 +62,15 @@ function formValid(form) {
); );
} }
class ArticleUnitEditorDialog extends Component { export default class ArticleUnitEditorDialog extends Component {
static contextType = StorageManagerContext;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
busy: false, busy: false,
units: [],
unitsLoading: false,
form: emptyForm(), form: emptyForm(),
selectedSi: null, selectedSi: null,
}; };
@@ -81,10 +79,19 @@ class ArticleUnitEditorDialog extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) { if (this.props.open && !prevProps.open) {
this.setState({ form: emptyForm(), selectedSi: null }); this.setState({ form: emptyForm(), selectedSi: null });
if (this.props.status !== 'succeeded') { void this.loadUnits();
void this.props.loadUnits().catch((err) => notifyError(err));
} }
} }
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);
}
} }
handleSelectUnit = (row) => { handleSelectUnit = (row) => {
@@ -110,19 +117,23 @@ class ArticleUnitEditorDialog extends Component {
if (!formValid(form)) return; if (!formValid(form)) return;
this.setState({ busy: true }); this.setState({ busy: true });
try { try {
const unit = await this.props.upsertUnit({ const data = await this.context.unit.upsert({
si: String(form.si ?? '').trim(), si: String(form.si ?? '').trim(),
basesi: String(form.basesi ?? '').trim(), basesi: String(form.basesi ?? '').trim(),
basesifactor: parseFactor(form.basesifactor), basesifactor: parseFactor(form.basesifactor),
symbol: String(form.symbol ?? '').trim(), symbol: String(form.symbol ?? '').trim(),
name: String(form.name ?? '').trim(), name: String(form.name ?? '').trim(),
}).unwrap(); });
requireObject(data, 'PUT /api/unit');
requireObject(data.unit, 'unit');
const units = await this.context.updateUnit();
this.setState({ this.setState({
busy: false, busy: false,
form: formFromUnit(unit), units,
selectedSi: unit.si, form: formFromUnit(data.unit),
selectedSi: data.unit.si,
}); });
this.props.onUnitUpserted?.(unit); this.props.onUnitUpserted?.(data.unit);
} catch (err) { } catch (err) {
this.setState({ busy: false }); this.setState({ busy: false });
notifyError(err); notifyError(err);
@@ -134,8 +145,9 @@ class ArticleUnitEditorDialog extends Component {
if (!selectedSi || selectedSi === DEFAULT_UNIT_SI) return; if (!selectedSi || selectedSi === DEFAULT_UNIT_SI) return;
this.setState({ busy: true }); this.setState({ busy: true });
try { try {
await this.props.deleteUnit(selectedSi).unwrap(); await this.context.unit.delete(selectedSi);
this.setState({ busy: false, form: emptyForm(), selectedSi: null }); const units = await this.context.updateUnit();
this.setState({ busy: false, units, form: emptyForm(), selectedSi: null });
this.props.onUnitDeleted?.(selectedSi); this.props.onUnitDeleted?.(selectedSi);
} catch (err) { } catch (err) {
this.setState({ busy: false }); this.setState({ busy: false });
@@ -148,10 +160,10 @@ class ArticleUnitEditorDialog extends Component {
}; };
render() { render() {
const { open, onClose, units, status } = this.props; const { open, onClose } = this.props;
const { busy, form, selectedSi } = this.state; const { busy, form, selectedSi, units, unitsLoading } = this.state;
const canSave = formValid(form) && !busy; const canSave = formValid(form) && !busy;
const loading = status === 'loading' && units.length === 0; const loading = unitsLoading && units.length === 0;
return ( return (
<Dialog open={open} onClose={busy ? undefined : onClose} maxWidth="md" fullWidth> <Dialog open={open} onClose={busy ? undefined : onClose} maxWidth="md" fullWidth>
@@ -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);

View File

@@ -1,20 +1,15 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { PanelTileGrid } from '../panels'; import { PanelTileGrid } from '../panels';
import { requireDefined } from '../../utils/apiShape'; 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 { PANEL_TILE_KEYS } from '../../theme';
import { notifyError } from '../../utils/notify';
import { import {
buildCategoryPath, buildCategoryPath,
defaultCategoryPath, defaultCategoryPath,
} from '../../pages/artikel/categoryRoute'; } from '../../pages/artikel/categoryRoute';
import {
patchArticleInLists,
removeArticleBySku,
selectCategoryRootId,
loadCategoryArticles,
selectCurrentCategoryId,
} from '../../store/categorySlice';
import ArticleTileTab from './ArticleTileTab'; import ArticleTileTab from './ArticleTileTab';
import ArticlePhotoTile from './ArticlePhotoTile'; import ArticlePhotoTile from './ArticlePhotoTile';
@@ -32,48 +27,55 @@ export default function ArticleView({
onArticleUpdated, onArticleUpdated,
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const store = useStorageManager();
const categories = useSelector((state) => state.category.categories); const [categories, setCategories] = useState(null);
const rootCategoryId = useSelector(selectCategoryRootId); const [rootCategoryId, setRootCategoryId] = useState(null);
const currentCategoryId = useSelector(selectCurrentCategoryId);
const syncCategoryListPhoto = (next) => { useEffect(() => {
const photo = primaryPhoto(next); let cancelled = false;
dispatch( async function load() {
patchArticleInLists({ try {
sku: next.sku, const [treeData, companies, session] = await Promise.all([
fullHash: photo?.fullHash ?? null, store.categoryTreePromise,
thumbW: photo?.thumbW ?? null, store.companyPromise,
thumbH: photo?.thumbH ?? null, store.authMePromise,
thumbHash: photo?.thumbHash ?? null, ]);
}), 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) => { const handleArticleUpdated = (next) => {
onArticleUpdated?.(next); onArticleUpdated?.(next);
syncCategoryListPhoto(next); refreshSearchCaches();
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));
}
}; };
const handleArticleDeleted = () => { const handleArticleDeleted = () => {
refreshSearchCaches();
const categoryId = requireDefined( const categoryId = requireDefined(
articleData.categoryIds?.[0], articleData.categoryIds?.[0],
'article.categoryIds[0]', 'article.categoryIds[0]',
); );
dispatch(removeArticleBySku(articleData.sku));
if (categories && rootCategoryId != null) { if (categories && rootCategoryId != null) {
navigate(buildCategoryPath(categories, rootCategoryId, categoryId)); navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
return; return;
@@ -102,17 +104,17 @@ export default function ArticleView({
<ArticleViewBrand <ArticleViewBrand
article={articleData} article={articleData}
onArticleUpdated={onArticleUpdated} onArticleUpdated={handleArticleUpdated}
/> />
<ArticleViewEANs <ArticleViewEANs
article={articleData} article={articleData}
onArticleUpdated={onArticleUpdated} onArticleUpdated={handleArticleUpdated}
/> />
<ArticleViewPrice <ArticleViewPrice
article={articleData} article={articleData}
onArticleUpdated={onArticleUpdated} onArticleUpdated={handleArticleUpdated}
/> />
<ArticleTileTab <ArticleTileTab

View File

@@ -1,13 +1,11 @@
import { useDispatch, useSelector } from 'react-redux'; import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import Autocomplete from '@mui/material/Autocomplete'; import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { EditablePanelTile } from '../../panels'; import { EditablePanelTile } from '../../panels';
import { article } from '../../../api'; import { useStorageManager } from '../../../api/storageManager';
import { requireDefined, requireObject } from '../../../utils/apiShape'; import { requireDefined, requireObject } from '../../../utils/apiShape';
import { loadBrands, selectBrands, selectBrandsStatus, upsertBrand } from '../../../store/brandSlice';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
import { PANEL_TILE_KEYS } from '../../../theme'; import { PANEL_TILE_KEYS } from '../../../theme';
@@ -42,14 +40,22 @@ function brandDraftValid(draft) {
const SECOND_ROW_MIN_HEIGHT = 220; const SECOND_ROW_MIN_HEIGHT = 220;
export default function ArticleViewBrand({ article: articleData, onArticleUpdated }) { export default function ArticleViewBrand({ article: articleData, onArticleUpdated }) {
const dispatch = useDispatch(); const store = useStorageManager();
const brands = useSelector(selectBrands); const [brands, setBrands] = useState([]);
const brandsStatus = useSelector(selectBrandsStatus);
useEffect(() => { useEffect(() => {
if (brandsStatus !== 'idle') return; let cancelled = false;
void dispatch(loadBrands()).unwrap().catch((err) => notifyError(err)); store.brandPromise
}, [dispatch, brandsStatus]); .then((list) => {
if (!cancelled) setBrands(list);
})
.catch((err) => {
if (!cancelled) notifyError(err);
});
return () => {
cancelled = true;
};
}, [store]);
const handleSave = async (draft) => { const handleSave = async (draft) => {
const name = String(draft.name ?? '').trim(); const name = String(draft.name ?? '').trim();
@@ -65,12 +71,16 @@ export default function ArticleViewBrand({ article: articleData, onArticleUpdate
if (existing) { if (existing) {
brandId = existing.id; brandId = existing.id;
} else { } else {
const created = await dispatch(upsertBrand({ name })).unwrap(); const createdRes = await store.brand.upsert({ name });
requireDefined(created.id, 'brand.id'); requireObject(createdRes, 'PUT /api/brand');
brandId = created.id; 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, 'PUT /api/article/:sku/brand');
requireObject(res.article, 'article'); requireObject(res.article, 'article');
onArticleUpdated?.(res.article); onArticleUpdated?.(res.article);

View File

@@ -3,7 +3,7 @@ import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { EditablePanelTile } from '../../panels'; import { EditablePanelTile } from '../../panels';
import { article } from '../../../api'; import { useStorageManager } from '../../../api/storageManager';
import { requireObject } from '../../../utils/apiShape'; import { requireObject } from '../../../utils/apiShape';
import { articleEans } from '../../../utils/articleDetail'; import { articleEans } from '../../../utils/articleDetail';
import { PANEL_TILE_KEYS } from '../../../theme'; import { PANEL_TILE_KEYS } from '../../../theme';
@@ -41,11 +41,13 @@ function eansDraftValid(draft) {
const SECOND_ROW_MIN_HEIGHT = 220; const SECOND_ROW_MIN_HEIGHT = 220;
export default function ArticleViewEANs({ article: articleData, onArticleUpdated }) { export default function ArticleViewEANs({ article: articleData, onArticleUpdated }) {
const store = useStorageManager();
const handleSave = async (draft) => { const handleSave = async (draft) => {
const eans = (draft.eans ?? []) const eans = (draft.eans ?? [])
.map((ean) => String(ean).trim()) .map((ean) => String(ean).trim())
.filter((ean) => ean.length > 0); .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, 'PUT /api/article/:sku/eans');
requireObject(res.article, 'article'); requireObject(res.article, 'article');
onArticleUpdated?.(res.article); onArticleUpdated?.(res.article);

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { EditablePanelTile } from '../../panels'; import { EditablePanelTile } from '../../panels';
import SingleLineTextEdit from '../../SingleLineTextEdit'; import SingleLineTextEdit from '../../SingleLineTextEdit';
import { article } from '../../../api'; import { useStorageManager } from '../../../api/storageManager';
import { requireObject } from '../../../utils/apiShape'; import { requireObject } from '../../../utils/apiShape';
import { PANEL_TILE_KEYS } from '../../../theme'; import { PANEL_TILE_KEYS } from '../../../theme';
import { getCategoryBreadcrumbs } from '../../../store/categorySlice'; import { getCategoryBreadcrumbs } from '../../../store/categorySlice';
@@ -122,13 +122,15 @@ function CategoryPills({
} }
export default function ArticleViewName({ article: articleData, categories, rootCategoryId, onArticleUpdated }) { export default function ArticleViewName({ article: articleData, categories, rootCategoryId, onArticleUpdated }) {
const store = useStorageManager();
const handleSave = async (draft) => { const handleSave = async (draft) => {
const name = draft.name.trim(); const name = draft.name.trim();
const categoryIds = draft.categoryIds ?? []; const categoryIds = draft.categoryIds ?? [];
const [nameRes] = await Promise.all([ const [nameRes] = await Promise.all([
article.update(articleData.sku, { name }), store.article.update(articleData.sku, { name }),
article.setCategories(articleData.sku, categoryIds), store.article.setCategories(articleData.sku, { categoryIds }),
]); ]);
requireObject(nameRes, 'PATCH /api/article/:sku'); requireObject(nameRes, 'PATCH /api/article/:sku');

View File

@@ -1,4 +1,3 @@
import { useDispatch, useSelector } from 'react-redux';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import InputAdornment from '@mui/material/InputAdornment'; import InputAdornment from '@mui/material/InputAdornment';
@@ -7,11 +6,8 @@ import Typography from '@mui/material/Typography';
import { EditablePanelTile } from '../../panels'; import { EditablePanelTile } from '../../panels';
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog'; import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog'; import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
import { article } from '../../../api'; import { useStorageManager } from '../../../api/storageManager';
import { requireDefined, requireObject } from '../../../utils/apiShape'; import { requireDefined, requireObject } from '../../../utils/apiShape';
import { loadUnits, selectUnits, selectUnitsStatus } from '../../../store/unitSlice';
import { selectCurrentCompany } from '../../../store/companySlice';
import { patchArticleInLists } from '../../../store/categorySlice';
import { import {
buildPosPricesPutBody, buildPosPricesPutBody,
companyTaxRateOptions, companyTaxRateOptions,
@@ -219,12 +215,11 @@ function PriceAmountField({
/* ── main component ────────────────────────────────────────────────── */ /* ── main component ────────────────────────────────────────────────── */
export default function ArticleViewPrice({ article: articleData, onArticleUpdated }) { export default function ArticleViewPrice({ article: articleData, onArticleUpdated }) {
const dispatch = useDispatch(); const store = useStorageManager();
const [tiersDialogOpen, setTiersDialogOpen] = useState(false); const [tiersDialogOpen, setTiersDialogOpen] = useState(false);
const [unitEditorOpen, setUnitEditorOpen] = useState(false); const [unitEditorOpen, setUnitEditorOpen] = useState(false);
const units = useSelector(selectUnits); const [units, setUnits] = useState([]);
const unitsStatus = useSelector(selectUnitsStatus); const [company, setCompany] = useState(null);
const company = useSelector(selectCurrentCompany);
const basePrice = primaryPrice(articleData); const basePrice = primaryPrice(articleData);
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate); const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
const priceValue = priceDraftValue(articleData, company); const priceValue = priceDraftValue(articleData, company);
@@ -234,20 +229,32 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate
: units; : units;
useEffect(() => { useEffect(() => {
if (unitsStatus !== 'idle') return; let cancelled = false;
void dispatch(loadUnits()).unwrap().catch((err) => notifyError(err)); async function load() {
}, [dispatch, unitsStatus]); try {
const [unitList, companies, session] = await Promise.all([
const syncCategoryListPrice = (next) => { store.unitPromise,
const price = primaryPrice(next); store.companyPromise,
dispatch( store.authMePromise,
patchArticleInLists({ ]);
sku: next.sku, const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
net: price?.net ?? null, const current = requireDefined(
taxRate: price?.taxRate ?? null, 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 handleSave = async (draft) => {
const saved = priceDraftValue(articleData, company); const saved = priceDraftValue(articleData, company);
@@ -269,12 +276,12 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate
if (isNoPackUnit(unitSi, unitamount)) { if (isNoPackUnit(unitSi, unitamount)) {
throw new Error('Einheit pc × 1 ist keine Packungseinheit'); 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, 'PATCH /api/article/:sku');
requireObject(res.article, 'article'); requireObject(res.article, 'article');
nextArticle = res.article; nextArticle = res.article;
} else { } else {
const res = await article.update(articleData.sku, { const res = await store.article.update(articleData.sku, {
unitamount: 1, unitamount: 1,
unitSi: 'pc', unitSi: 'pc',
}); });
@@ -299,11 +306,10 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate
taxRate, taxRate,
tiers: draft.tiers ?? [], 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, 'PUT /api/article/:sku/prices');
requireObject(res.article, 'article'); requireObject(res.article, 'article');
nextArticle = res.article; nextArticle = res.article;
syncCategoryListPrice(res.article);
} }
onArticleUpdated?.(nextArticle); onArticleUpdated?.(nextArticle);
@@ -421,12 +427,14 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate
<ArticleUnitEditorDialog <ArticleUnitEditorDialog
open={unitEditorOpen} open={unitEditorOpen}
onClose={() => setUnitEditorOpen(false)} onClose={() => setUnitEditorOpen(false)}
onUnitUpserted={(row) => { onUnitUpserted={async (row) => {
setUnits(await store.unitPromise);
if (!draft.unitSi) { if (!draft.unitSi) {
onChange({ ...draft, unitSi: row.si }); onChange({ ...draft, unitSi: row.si });
} }
}} }}
onUnitDeleted={(si) => { onUnitDeleted={async (si) => {
setUnits(await store.unitPromise);
if (draft.unitSi === si) { if (draft.unitSi === si) {
onChange({ ...draft, unitSi: '' }); onChange({ ...draft, unitSi: '' });
} }

View File

@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { article } from '../../api'; import { useStorageManager } from '../../api/storageManager';
import { ArticleView } from '../../components/article'; import { ArticleView } from '../../components/article';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import { requireObject } from '../../utils/apiShape'; import { requireObject } from '../../utils/apiShape';
@@ -16,6 +16,7 @@ import {
export default function ArtikelSkuPage() { export default function ArtikelSkuPage() {
const { sku: skuParam, tab: tabParam } = useParams(); const { sku: skuParam, tab: tabParam } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const store = useStorageManager();
const sku = skuParam ? decodeURIComponent(skuParam) : ''; const sku = skuParam ? decodeURIComponent(skuParam) : '';
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -51,7 +52,7 @@ export default function ArtikelSkuPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const res = await article.getBySku(sku); const res = await store.article.get(sku);
requireObject(res, 'GET /api/article/:sku'); requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article'); requireObject(res.article, 'article');
if (!cancelled) { if (!cancelled) {
@@ -71,7 +72,7 @@ export default function ArtikelSkuPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [sku, reloadKey]); }, [sku, reloadKey, store]);
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;