This commit is contained in:
seb
2026-08-05 17:01:42 +02:00
parent 7cecb3dd1f
commit 198e800fd3
14 changed files with 1250 additions and 929 deletions

View File

@@ -161,21 +161,31 @@ export class StorageManager extends ApiClient {
Object.defineProperties(this, { Object.defineProperties(this, {
authMePromise: { get: () => authMeCache.promise }, authMePromise: { get: () => authMeCache.promise },
updateAuthMe: { value: authMeCache.update }, updateAuthMe: { value: authMeCache.update },
/** Settled GET /auth/me body, or undefined. */
authMe: { get: () => authMeCache.value },
articleIndexPromise: { get: () => articleIndexCache.promise }, articleIndexPromise: { get: () => articleIndexCache.promise },
updateArticleIndex: { value: articleIndexCache.update }, updateArticleIndex: { value: articleIndexCache.update },
brandPromise: { get: () => brandCache.promise }, brandPromise: { get: () => brandCache.promise },
updateBrand: { value: brandCache.update }, updateBrand: { value: brandCache.update },
/** Settled GET /brand list, or undefined. */
brands: { get: () => brandCache.value },
unitPromise: { get: () => unitCache.promise }, unitPromise: { get: () => unitCache.promise },
updateUnit: { value: unitCache.update }, updateUnit: { value: unitCache.update },
/** Settled GET /unit list, or undefined. */
units: { get: () => unitCache.value },
companyPromise: { get: () => companyCache.promise }, companyPromise: { get: () => companyCache.promise },
updateCompany: { value: companyCache.update }, updateCompany: { value: companyCache.update },
/** Settled GET /company list, or undefined. */
companies: { get: () => companyCache.value },
categoryTreePromise: { get: () => categoryTreeCache.promise }, categoryTreePromise: { get: () => categoryTreeCache.promise },
updateCategoryTree: { value: categoryTreeCache.update }, updateCategoryTree: { value: categoryTreeCache.update },
/** Settled GET /category/tree body, or undefined. */
categoryTree: { get: () => categoryTreeCache.value },
posterminalPromise: { get: () => posterminalCache.promise }, posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update }, updatePosterminal: { value: posterminalCache.update },

View File

@@ -1,11 +1,8 @@
import { useEffect, useState } from 'react'; import { Component } from 'react';
import { useNavigate } from 'react-router-dom';
import { PanelTileGrid } from '../panels'; import { PanelTileGrid } from '../panels';
import { requireDefined } from '../../utils/apiShape'; import { StorageManagerContext } from '../../api/storageManagerContext';
import { useStorageManager } from '../../api/storageManagerContext';
import { parseCategoryTreeResponse } from '../../store/categorySlice';
import { PANEL_TILE_KEYS } from '../../theme'; import { PANEL_TILE_KEYS } from '../../theme';
import { notifyError } from '../../utils/notify'; import withNavigate from '../withNavigate';
import { import {
buildCategoryPath, buildCategoryPath,
defaultCategoryPath, defaultCategoryPath,
@@ -20,76 +17,51 @@ import ArticleViewEANs from './boxes/ArticleViewEANs';
import ArticleViewPrice from './boxes/ArticleViewPrice'; import ArticleViewPrice from './boxes/ArticleViewPrice';
import ArticleViewLists from './boxes/ArticleViewLists'; import ArticleViewLists from './boxes/ArticleViewLists';
export default function ArticleView({ class ArticleView extends Component {
static contextType = StorageManagerContext;
refreshSearchCaches = () => {
void this.context.updateArticleIndex();
void this.context.updateCategoryTree();
};
handleArticleUpdated = (next) => {
this.props.onArticleUpdated?.(next);
this.refreshSearchCaches();
};
handleArticleDeleted = () => {
const {
article: articleData, article: articleData,
tab, navigate,
onTabChange, categories,
onArticleUpdated, rootCategoryId,
}) { } = this.props;
const navigate = useNavigate(); this.refreshSearchCaches();
const store = useStorageManager(); const categoryId = articleData.categoryIds?.[0];
const [categories, setCategories] = useState(null); if (categories && rootCategoryId != null && categoryId != null) {
const [rootCategoryId, setRootCategoryId] = useState(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);
refreshSearchCaches();
};
const handleArticleDeleted = () => {
refreshSearchCaches();
const categoryId = requireDefined(
articleData.categoryIds?.[0],
'article.categoryIds[0]',
);
if (categories && rootCategoryId != null) {
navigate(buildCategoryPath(categories, rootCategoryId, categoryId)); navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
return; return;
} }
navigate(defaultCategoryPath()); navigate(defaultCategoryPath());
}; };
render() {
const {
article: articleData,
tab,
onTabChange,
categories,
rootCategoryId,
} = this.props;
return ( return (
<PanelTileGrid> <PanelTileGrid>
<ArticleViewName <ArticleViewName
article={articleData} article={articleData}
categories={categories} categories={categories}
rootCategoryId={rootCategoryId} rootCategoryId={rootCategoryId}
onArticleUpdated={handleArticleUpdated} onArticleUpdated={this.handleArticleUpdated}
/> />
<ArticlePhotoTile <ArticlePhotoTile
@@ -97,24 +69,24 @@ export default function ArticleView({
tileKey={PANEL_TILE_KEYS.photo} tileKey={PANEL_TILE_KEYS.photo}
sx={{ minHeight: 0 }} sx={{ minHeight: 0 }}
article={articleData} article={articleData}
onArticleUpdated={handleArticleUpdated} onArticleUpdated={this.handleArticleUpdated}
/> />
<ArticleViewStock article={articleData} /> <ArticleViewStock article={articleData} />
<ArticleViewBrand <ArticleViewBrand
article={articleData} article={articleData}
onArticleUpdated={handleArticleUpdated} onArticleUpdated={this.handleArticleUpdated}
/> />
<ArticleViewEANs <ArticleViewEANs
article={articleData} article={articleData}
onArticleUpdated={handleArticleUpdated} onArticleUpdated={this.handleArticleUpdated}
/> />
<ArticleViewPrice <ArticleViewPrice
article={articleData} article={articleData}
onArticleUpdated={handleArticleUpdated} onArticleUpdated={this.handleArticleUpdated}
/> />
<ArticleTileTab <ArticleTileTab
@@ -124,10 +96,13 @@ export default function ArticleView({
article={articleData} article={articleData}
tab={tab} tab={tab}
onTabChange={onTabChange} onTabChange={onTabChange}
onArticleDeleted={handleArticleDeleted} onArticleDeleted={this.handleArticleDeleted}
/> />
<ArticleViewLists article={articleData} /> <ArticleViewLists article={articleData} />
</PanelTileGrid> </PanelTileGrid>
); );
} }
}
export default withNavigate(ArticleView);

View File

@@ -1,15 +1,17 @@
import { useEffect, useState } from 'react'; import { Component } 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 { useStorageManager } from '../../../api/storageManagerContext'; import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../../utils/apiShape'; import { requireDefined, requireObject } from '../../../utils/apiShape';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
import { PANEL_TILE_KEYS } from '../../../theme'; import { PANEL_TILE_KEYS } from '../../../theme';
function PanelValue({ label, children }) { class PanelValue extends Component {
render() {
const { label, children } = this.props;
if (children == null || children === '') return null; if (children == null || children === '') return null;
return ( return (
<Box sx={{ mb: 1 }}> <Box sx={{ mb: 1 }}>
@@ -24,6 +26,7 @@ function PanelValue({ label, children }) {
</Box> </Box>
); );
} }
}
function brandDraftValue(article) { function brandDraftValue(article) {
return { name: article?.brand?.name ?? '' }; return { name: article?.brand?.name ?? '' };
@@ -39,67 +42,10 @@ function brandDraftValid(draft) {
const SECOND_ROW_MIN_HEIGHT = 220; const SECOND_ROW_MIN_HEIGHT = 220;
export default function ArticleViewBrand({ article: articleData, onArticleUpdated }) { class BrandEdit extends Component {
const store = useStorageManager(); render() {
const [brands, setBrands] = useState([]); const { draft, onChange, inputRef, brands } = this.props;
useEffect(() => {
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();
if (!name) {
throw new Error('Bitte eine Marke angeben.');
}
const existing = brands.find(
(row) => String(row.name ?? '').toLowerCase() === name.toLowerCase(),
);
let brandId;
if (existing) {
brandId = existing.id;
} else {
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 store.article.setBrand(articleData.sku, { brandId });
requireObject(res, 'PUT /api/article/:sku/brand');
requireObject(res.article, 'article');
onArticleUpdated?.(res.article);
};
return ( return (
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.brand}
title="Marke"
minHeight={SECOND_ROW_MIN_HEIGHT}
value={brandDraftValue(articleData)}
onSave={handleSave}
isDirty={brandDraftDirty}
canSave={brandDraftValid}
renderDisplay={(saved) => (
<PanelValue>{saved.name || null}</PanelValue>
)}
renderEdit={(draft, onChange, inputRef) => (
<Autocomplete <Autocomplete
fullWidth fullWidth
freeSolo freeSolo
@@ -123,7 +69,89 @@ export default function ArticleViewBrand({ article: articleData, onArticleUpdate
/> />
)} )}
/> />
)} );
}
}
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 (!this._cancelled) this.setState({ brands: list });
})
.catch((err) => {
if (!this._cancelled) notifyError(err);
});
}
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.');
}
const existing = brands.find(
(row) => String(row.name ?? '').toLowerCase() === name.toLowerCase(),
);
let brandId;
if (existing) {
brandId = existing.id;
} else {
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();
this.setState({ brands: nextBrands });
}
const res = await store.article.setBrand(articleData.sku, { brandId });
requireObject(res, 'PUT /api/article/:sku/brand');
requireObject(res.article, 'article');
onArticleUpdated?.(res.article);
};
render() {
const { article: articleData } = this.props;
const { brands } = this.state;
const value = brandDraftValue(articleData);
return (
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.brand}
title="Marke"
minHeight={SECOND_ROW_MIN_HEIGHT}
value={value}
onSave={this.handleSave}
isDirty={brandDraftDirty}
canSave={brandDraftValid}
display={<PanelValue>{value.name || null}</PanelValue>}
edit={<BrandEdit brands={brands} />}
/> />
); );
} }
}

View File

@@ -1,14 +1,17 @@
import { Component } 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 { useStorageManager } from '../../../api/storageManagerContext'; import { StorageManagerContext } from '../../../api/storageManagerContext';
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';
function PanelValue({ label, children }) { class PanelValue extends Component {
render() {
const { label, children } = this.props;
if (children == null || children === '') return null; if (children == null || children === '') return null;
return ( return (
<Box sx={{ mb: 1 }}> <Box sx={{ mb: 1 }}>
@@ -23,6 +26,7 @@ function PanelValue({ label, children }) {
</Box> </Box>
); );
} }
}
function eansDraftValue(article) { function eansDraftValue(article) {
return { eans: articleEans(article) }; return { eans: articleEans(article) };
@@ -40,41 +44,26 @@ function eansDraftValid(draft) {
const SECOND_ROW_MIN_HEIGHT = 220; const SECOND_ROW_MIN_HEIGHT = 220;
export default function ArticleViewEANs({ article: articleData, onArticleUpdated }) { class EansDisplay extends Component {
const store = useStorageManager(); render() {
const { sku, eans } = this.props;
const handleSave = async (draft) => {
const eans = (draft.eans ?? [])
.map((ean) => String(ean).trim())
.filter((ean) => ean.length > 0);
const res = await store.article.setEans(articleData.sku, { eans });
requireObject(res, 'PUT /api/article/:sku/eans');
requireObject(res.article, 'article');
onArticleUpdated?.(res.article);
};
return ( return (
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.identifiers}
title="SKU / EAN"
minHeight={SECOND_ROW_MIN_HEIGHT}
contentAlignItems="flex-start"
value={eansDraftValue(articleData)}
onSave={handleSave}
isDirty={eansDraftDirty}
canSave={eansDraftValid}
renderDisplay={(saved) => (
<> <>
<PanelValue label="SKU">{articleData.sku}</PanelValue> <PanelValue label="SKU">{sku}</PanelValue>
{(saved.eans ?? []).map((ean) => ( {(eans ?? []).map((ean) => (
<PanelValue key={ean} label="EAN"> <PanelValue key={ean} label="EAN">
{ean} {ean}
</PanelValue> </PanelValue>
))} ))}
</> </>
)} );
renderEdit={(draft, onChange, inputRef) => ( }
}
class EansEdit extends Component {
render() {
const { draft, onChange, inputRef } = this.props;
return (
<Autocomplete <Autocomplete
fullWidth fullWidth
multiple multiple
@@ -92,7 +81,42 @@ export default function ArticleViewEANs({ article: articleData, onArticleUpdated
/> />
)} )}
/> />
)} );
}
}
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 this.context.article.setEans(articleData.sku, { eans });
requireObject(res, 'PUT /api/article/:sku/eans');
requireObject(res.article, 'article');
onArticleUpdated?.(res.article);
};
render() {
const { article: articleData } = this.props;
const value = eansDraftValue(articleData);
return (
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.identifiers}
title="SKU / EAN"
minHeight={SECOND_ROW_MIN_HEIGHT}
contentAlignItems="flex-start"
value={value}
onSave={this.handleSave}
isDirty={eansDraftDirty}
canSave={eansDraftValid}
display={<EansDisplay sku={articleData.sku} eans={value.eans} />}
edit={<EansEdit />}
/> />
); );
} }
}

View File

@@ -1,3 +1,4 @@
import { Component } from 'react';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import Link from '@mui/material/Link'; import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
@@ -5,7 +6,9 @@ import { PanelTile } from '../../panels';
import { PANEL_TILE_KEYS } from '../../../theme'; import { PANEL_TILE_KEYS } from '../../../theme';
import { artikelDetailPath } from '../../../pages/artikel/articleRoute'; import { artikelDetailPath } from '../../../pages/artikel/articleRoute';
function CountLine({ label, count }) { class CountLine extends Component {
render() {
const { label, count } = this.props;
if (count == null) return null; if (count == null) return null;
return ( return (
<Typography variant="body2"> <Typography variant="body2">
@@ -13,8 +16,11 @@ function CountLine({ label, count }) {
</Typography> </Typography>
); );
} }
}
function RelationLine({ label, active, children }) { class RelationLine extends Component {
render() {
const { label, active, children } = this.props;
return ( return (
<Typography variant="body2" sx={{ mb: 0.5 }}> <Typography variant="body2" sx={{ mb: 0.5 }}>
{label}:{' '} {label}:{' '}
@@ -28,14 +34,14 @@ function RelationLine({ label, active, children }) {
</Typography> </Typography>
); );
} }
}
export default function ArticleViewLists({ article: articleData }) { export default class ArticleViewLists extends Component {
render() {
const { article: articleData } = this.props;
return ( return (
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.lists} title="Listen" minHeight={200}> <PanelTile wide={1} tileKey={PANEL_TILE_KEYS.lists} title="Listen" minHeight={200}>
<RelationLine <RelationLine label="Vaterartikel" active={articleData.childCount > 0}>
label="Vaterartikel"
active={articleData.childCount > 0}
>
{articleData.childCount} {articleData.childCount}
</RelationLine> </RelationLine>
<RelationLine <RelationLine
@@ -57,3 +63,4 @@ export default function ArticleViewLists({ article: articleData }) {
</PanelTile> </PanelTile>
); );
} }
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { Component } from 'react';
import { EditablePanelTile } from '../../panels'; import { EditablePanelTile } from '../../panels';
import SingleLineTextEdit from '../../SingleLineTextEdit'; import SingleLineTextEdit from '../../SingleLineTextEdit';
import { useStorageManager } from '../../../api/storageManagerContext'; import { StorageManagerContext } from '../../../api/storageManagerContext';
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';
@@ -45,6 +45,13 @@ const pillsContainerSx = {
mt: 'auto', mt: 'auto',
}; };
const columnSx = {
display: 'flex',
flexDirection: 'column',
flex: 1,
alignSelf: 'stretch',
};
function draftDirty(draft, saved) { function draftDirty(draft, saved) {
const nameDiff = draft.name.trim() !== String(saved.name ?? '').trim(); const nameDiff = draft.name.trim() !== String(saved.name ?? '').trim();
const a = draft.categoryIds ?? []; const a = draft.categoryIds ?? [];
@@ -70,48 +77,74 @@ function buildCategoryLabel(categories, categoryId, rootCategoryId) {
return crumbs.map((c) => c.name).join(' | '); return crumbs.map((c) => c.name).join(' | ');
} }
function CategoryPills({ function categoryLabels(categoryIds, categories, rootCategoryId) {
categoryLabels, return (categoryIds ?? [])
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
.filter((c) => c.label);
}
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);
};
handleOpenPicker = () => {
this.setState({ pickerOpen: true });
};
handleClosePicker = () => {
this.setState({ pickerOpen: false });
};
render() {
const {
categoryLabels: labels,
categoryIds, categoryIds,
categories, categories,
rootCategoryId, rootCategoryId,
sku, sku,
editMode, editMode,
onCategoryIdsChange, onCategoryIdsChange,
}) { } = this.props;
const [pickerOpen, setPickerOpen] = useState(false); const { pickerOpen } = this.state;
const handleRemoveCategory = (removeId) => {
const remaining = categoryIds.filter((id) => String(id) !== String(removeId));
onCategoryIdsChange?.(remaining);
};
return ( return (
<> <>
<Box sx={pillsContainerSx}> <Box sx={pillsContainerSx}>
{categoryLabels.map(({ id, label }) => ( {labels.map(({ id, label }) => (
<Chip <Chip
key={id} key={id}
label={label} label={label}
size="small" size="small"
variant="outlined" variant="outlined"
onDelete={editMode && categoryIds.length > 1 ? () => handleRemoveCategory(id) : undefined} onDelete={
editMode && categoryIds.length > 1
? () => this.handleRemoveCategory(id)
: undefined
}
/> />
))} ))}
{editMode && ( {editMode ? (
<IconButton <IconButton
size="small" size="small"
onClick={() => setPickerOpen(true)} onClick={this.handleOpenPicker}
sx={{ ml: 0.5, width: 24, height: 24 }} sx={{ ml: 0.5, width: 24, height: 24 }}
> >
<AddIcon fontSize="small" /> <AddIcon fontSize="small" />
</IconButton> </IconButton>
)} ) : null}
</Box> </Box>
<CategoryPickerDialog <CategoryPickerDialog
key={sku} key={sku}
open={pickerOpen} open={pickerOpen}
onClose={() => setPickerOpen(false)} onClose={this.handleClosePicker}
categories={categories} categories={categories}
rootCategoryId={rootCategoryId} rootCategoryId={rootCategoryId}
currentCategoryIds={categoryIds} currentCategoryIds={categoryIds}
@@ -120,42 +153,14 @@ function CategoryPills({
</> </>
); );
} }
}
export default function ArticleViewName({ article: articleData, categories, rootCategoryId, onArticleUpdated }) { class NameDisplay extends Component {
const store = useStorageManager(); render() {
const { name, categoryIds, categories, rootCategoryId, sku } = this.props;
const handleSave = async (draft) => { const labels = categoryLabels(categoryIds, categories, rootCategoryId);
const name = draft.name.trim();
const categoryIds = draft.categoryIds ?? [];
const [nameRes] = await Promise.all([
store.article.update(articleData.sku, { name }),
store.article.setCategories(articleData.sku, { categoryIds }),
]);
requireObject(nameRes, 'PATCH /api/article/:sku');
requireObject(nameRes.article, 'article');
onArticleUpdated?.(nameRes.article);
};
const savedCategoryIds = articleData.categoryIds ?? [];
return ( return (
<EditablePanelTile <Box sx={columnSx}>
wide={3}
tileKey={PANEL_TILE_KEYS.name}
minHeight={0}
sx={headerTileSx}
value={{ name: articleData.name ?? '', categoryIds: [...savedCategoryIds] }}
onSave={handleSave}
isDirty={draftDirty}
canSave={draftValid}
renderDisplay={({ name, categoryIds }) => {
const labels = (categoryIds ?? [])
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
.filter((c) => c.label);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, alignSelf: 'stretch' }}>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}> <Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<SingleLineTextEdit <SingleLineTextEdit
label="Name" label="Name"
@@ -165,25 +170,34 @@ export default function ArticleViewName({ article: articleData, categories, root
sx={articleNameFieldHiddenSx} sx={articleNameFieldHiddenSx}
/> />
</Box> </Box>
{labels.length > 0 && ( {labels.length > 0 ? (
<CategoryPills <CategoryPills
categoryLabels={labels} categoryLabels={labels}
categoryIds={categoryIds} categoryIds={categoryIds}
categories={categories} categories={categories}
rootCategoryId={rootCategoryId} rootCategoryId={rootCategoryId}
sku={articleData.sku} sku={sku}
editMode={false} editMode={false}
/> />
)} ) : null}
</Box> </Box>
); );
}} }
renderEdit={(draft, onChange, inputRef) => { }
const labels = (draft.categoryIds ?? [])
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) })) class NameEdit extends Component {
.filter((c) => c.label); render() {
const {
draft,
onChange,
inputRef,
categories,
rootCategoryId,
sku,
} = this.props;
const labels = categoryLabels(draft.categoryIds, categories, rootCategoryId);
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, alignSelf: 'stretch' }}> <Box sx={columnSx}>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}> <Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<SingleLineTextEdit <SingleLineTextEdit
inputRef={inputRef} inputRef={inputRef}
@@ -198,13 +212,66 @@ export default function ArticleViewName({ article: articleData, categories, root
categoryIds={draft.categoryIds} categoryIds={draft.categoryIds}
categories={categories} categories={categories}
rootCategoryId={rootCategoryId} rootCategoryId={rootCategoryId}
sku={articleData.sku} sku={sku}
editMode={true} editMode
onCategoryIdsChange={(categoryIds) => onChange({ ...draft, categoryIds })} onCategoryIdsChange={(categoryIds) => onChange({ ...draft, categoryIds })}
/> />
</Box> </Box>
); );
}} }
}
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 ?? [];
const [nameRes] = await Promise.all([
store.article.update(articleData.sku, { name }),
store.article.setCategories(articleData.sku, { categoryIds }),
]);
requireObject(nameRes, 'PATCH /api/article/:sku');
requireObject(nameRes.article, 'article');
onArticleUpdated?.(nameRes.article);
};
render() {
const { article: articleData, categories, rootCategoryId } = this.props;
const savedCategoryIds = articleData.categoryIds ?? [];
const value = { name: articleData.name ?? '', categoryIds: [...savedCategoryIds] };
return (
<EditablePanelTile
wide={3}
tileKey={PANEL_TILE_KEYS.name}
minHeight={0}
sx={headerTileSx}
value={value}
onSave={this.handleSave}
isDirty={draftDirty}
canSave={draftValid}
display={
<NameDisplay
name={value.name}
categoryIds={value.categoryIds}
categories={categories}
rootCategoryId={rootCategoryId}
sku={articleData.sku}
/>
}
edit={
<NameEdit
categories={categories}
rootCategoryId={rootCategoryId}
sku={articleData.sku}
/>
}
/> />
); );
} }
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { Component } 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';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
@@ -6,7 +6,7 @@ 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 { useStorageManager } from '../../../api/storageManagerContext'; import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../../utils/apiShape'; import { requireDefined, requireObject } from '../../../utils/apiShape';
import { import {
buildPosPricesPutBody, buildPosPricesPutBody,
@@ -91,8 +91,6 @@ const priceBodySx = {
gap: 0.75, gap: 0.75,
}; };
/* ── helper functions ──────────────────────────────────────────────── */
function articleHasPackUnit(art) { function articleHasPackUnit(art) {
const si = art.unit?.si; const si = art.unit?.si;
const amount = art.unitamount; const amount = art.unitamount;
@@ -170,15 +168,19 @@ function priceDraftValid(draft) {
return true; return true;
} }
/* ── sub-component: amount field ──────────────────────────────────── */ 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;
}
function PriceAmountField({ class PriceAmountField extends Component {
value, render() {
onChange, const { value, onChange, inputRef, readOnly, error } = this.props;
inputRef,
readOnly,
error,
}) {
return ( return (
<TextField <TextField
inputRef={inputRef} inputRef={inputRef}
@@ -211,26 +213,188 @@ function PriceAmountField({
/> />
); );
} }
}
/* ── main component ────────────────────────────────────────────────── */ class PriceDisplay extends Component {
render() {
const { saved, taxRates, unitOptions, displaySymbol } = this.props;
return (
<Box sx={priceBodySx}>
<PriceAmountField value={saved.gross} readOnly />
<PriceTaxRow rates={taxRates} selected={saved.taxRate} readOnly />
<TierPricePills tiers={saved.tiers} />
<PriceUnitRow
hasUnit={saved.hasUnit}
unitamount={saved.unitamount}
unitSi={saved.unitSi}
units={unitOptions}
displaySymbol={displaySymbol}
readOnly
/>
</Box>
);
}
}
export default function ArticleViewPrice({ article: articleData, onArticleUpdated }) { class PriceOverlay extends Component {
const store = useStorageManager(); render() {
const [tiersDialogOpen, setTiersDialogOpen] = useState(false); const {
const [unitEditorOpen, setUnitEditorOpen] = useState(false); editing,
const [units, setUnits] = useState([]); draft,
const [company, setCompany] = useState(null); onChange,
const basePrice = primaryPrice(articleData); unitOptions,
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate); articleUnitSi,
const priceValue = priceDraftValue(articleData, company); onOpenEditor,
const unitOptions = } = this.props;
articleData.unit?.si && !findUnitBySi(units, articleData.unit.si) if (!editing || draft == null) return null;
? [...units, articleData.unit] return (
: units; <PriceUnitCorner
hasUnit={draft.hasUnit}
onToggleHasUnit={(hasUnit) => {
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}
/>
);
}
}
useEffect(() => { class PriceEdit extends Component {
let cancelled = false; handleUnitUpserted = async (row) => {
async function load() { 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 (
<Box sx={priceBodySx}>
<PriceAmountField
inputRef={inputRef}
value={draft.gross}
onChange={(gross) => onChange({ ...draft, gross })}
error={draft.gross !== '' && draftGross == null}
/>
<PriceTaxRow
rates={taxRates}
selected={draft.taxRate}
onSelectTax={(taxRate) => onChange({ ...draft, taxRate })}
onOpenTiers={onOpenTiers}
/>
<TierPricePills tiers={draft.tiers} />
<PriceUnitRow
hasUnit={draft.hasUnit}
unitamount={draft.unitamount}
unitSi={draft.unitSi}
units={unitOptions}
displaySymbol={displaySymbol}
onChangeAmount={(unitamount) => onChange({ ...draft, unitamount })}
onChangeSi={(unitSi) => onChange({ ...draft, unitSi })}
amountError={
draft.hasUnit &&
draft.unitamount !== '' &&
(draftAmount == null || draftAmount <= 0)
}
/>
<ArticlePriceTiersDialog
open={tiersDialogOpen}
tiers={draft.tiers ?? []}
onClose={onCloseTiers}
onApply={(tiers) => onChange({ ...draft, tiers })}
/>
<ArticleUnitEditorDialog
open={unitEditorOpen}
onClose={onCloseUnitEditor}
onUnitUpserted={this.handleUnitUpserted}
onUnitDeleted={this.handleUnitDeleted}
/>
</Box>
);
}
}
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 { try {
const [unitList, companies, session] = await Promise.all([ const [unitList, companies, session] = await Promise.all([
store.unitPromise, store.unitPromise,
@@ -242,21 +406,18 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate
companies.find((c) => c.id === companyId), companies.find((c) => c.id === companyId),
'company', 'company',
); );
if (!cancelled) { if (!this._cancelled) {
setUnits(unitList); this.setState({ units: unitList, company: current });
setCompany(current);
} }
} catch (err) { } catch (err) {
if (!cancelled) notifyError(err); if (!this._cancelled) notifyError(err);
} }
} }
void load();
return () => {
cancelled = true;
};
}, [store]);
const handleSave = async (draft) => { handleSave = async (draft) => {
const { article: articleData, onArticleUpdated } = this.props;
const store = this.context;
const { company } = this.state;
const saved = priceDraftValue(articleData, company); const saved = priceDraftValue(articleData, company);
let nextArticle = articleData; let nextArticle = articleData;
@@ -315,6 +476,33 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate
onArticleUpdated?.(nextArticle); onArticleUpdated?.(nextArticle);
}; };
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 ( return (
<EditablePanelTile <EditablePanelTile
wide={1} wide={1}
@@ -322,127 +510,39 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate
minHeight={SECOND_ROW_MIN_HEIGHT} minHeight={SECOND_ROW_MIN_HEIGHT}
contentAlignItems="flex-start" contentAlignItems="flex-start"
value={priceValue} value={priceValue}
onSave={handleSave} onSave={this.handleSave}
isDirty={priceDraftDirty} isDirty={priceDraftDirty}
canSave={priceDraftValid} canSave={priceDraftValid}
renderOverlay={({ editing, draft, onChange }) => { overlay={
if (!editing || draft == null) return null; <PriceOverlay
return ( unitOptions={unitOptions}
<PriceUnitCorner articleUnitSi={articleData.unit?.si}
hasUnit={draft.hasUnit} onOpenEditor={this.openUnitEditor}
onToggleHasUnit={(hasUnit) => { />
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'; display={
onChange({ <PriceDisplay
...draft, saved={priceValue}
hasUnit: true, taxRates={taxRates}
unitSi, unitOptions={unitOptions}
unitamount, displaySymbol={displaySymbol}
}); />
return;
} }
onChange({ edit={
...draft, <PriceEdit
hasUnit: false, taxRates={taxRates}
unitamount: '', unitOptions={unitOptions}
unitSi: '', displaySymbol={displaySymbol}
}); tiersDialogOpen={tiersDialogOpen}
}} unitEditorOpen={unitEditorOpen}
onOpenEditor={() => setUnitEditorOpen(true)} onOpenTiers={this.openTiersDialog}
onCloseTiers={this.closeTiersDialog}
onCloseUnitEditor={this.closeUnitEditor}
onUnitUpserted={this.refreshUnits}
onUnitDeleted={this.refreshUnits}
/> />
);
}}
renderDisplay={(saved) => (
<Box sx={priceBodySx}>
<PriceAmountField value={saved.gross} readOnly />
<PriceTaxRow
rates={taxRates}
selected={saved.taxRate}
readOnly
/>
<TierPricePills tiers={saved.tiers} />
<PriceUnitRow
hasUnit={saved.hasUnit}
unitamount={saved.unitamount}
unitSi={saved.unitSi}
units={unitOptions}
displaySymbol={articleData.unit?.symbol}
readOnly
/>
</Box>
)}
renderEdit={(draft, onChange, inputRef) => {
const draftGross = parsePriceInput(draft.gross);
const draftAmount = parsePriceInput(draft.unitamount);
return (
<Box sx={priceBodySx}>
<PriceAmountField
inputRef={inputRef}
value={draft.gross}
onChange={(gross) => onChange({ ...draft, gross })}
error={draft.gross !== '' && draftGross == null}
/>
<PriceTaxRow
rates={taxRates}
selected={draft.taxRate}
onSelectTax={(taxRate) => onChange({ ...draft, taxRate })}
onOpenTiers={() => setTiersDialogOpen(true)}
/>
<TierPricePills tiers={draft.tiers} />
<PriceUnitRow
hasUnit={draft.hasUnit}
unitamount={draft.unitamount}
unitSi={draft.unitSi}
units={unitOptions}
displaySymbol={articleData.unit?.symbol}
onChangeAmount={(unitamount) => onChange({ ...draft, unitamount })}
onChangeSi={(unitSi) => onChange({ ...draft, unitSi })}
amountError={
draft.hasUnit &&
draft.unitamount !== '' &&
(draftAmount == null || draftAmount <= 0)
} }
/> />
<ArticlePriceTiersDialog
open={tiersDialogOpen}
tiers={draft.tiers ?? []}
onClose={() => setTiersDialogOpen(false)}
onApply={(tiers) => onChange({ ...draft, tiers })}
/>
<ArticleUnitEditorDialog
open={unitEditorOpen}
onClose={() => 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: '' });
}
}}
/>
</Box>
);
}}
/>
); );
} }
}

View File

@@ -1,10 +1,13 @@
import { Component } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { PanelTile } from '../../panels'; import { PanelTile } from '../../panels';
import { PANEL_TILE_KEYS } from '../../../theme'; import { PANEL_TILE_KEYS } from '../../../theme';
import { formatStock } from '../../../pages/artikel/searchFormat'; import { formatStock } from '../../../pages/artikel/searchFormat';
function PanelValue({ label, children }) { class PanelValue extends Component {
render() {
const { label, children } = this.props;
if (children == null || children === '') return null; if (children == null || children === '') return null;
return ( return (
<Box sx={{ mb: 1 }}> <Box sx={{ mb: 1 }}>
@@ -19,10 +22,13 @@ function PanelValue({ label, children }) {
</Box> </Box>
); );
} }
}
const SECOND_ROW_MIN_HEIGHT = 220; const SECOND_ROW_MIN_HEIGHT = 220;
export default function ArticleViewStock({ article: articleData }) { export default class ArticleViewStock extends Component {
render() {
const { article: articleData } = this.props;
return ( return (
<PanelTile <PanelTile
wide={1} wide={1}
@@ -36,3 +42,4 @@ export default function ArticleViewStock({ article: articleData }) {
</PanelTile> </PanelTile>
); );
} }
}

View File

@@ -1,3 +1,4 @@
import { Component } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
@@ -10,7 +11,9 @@ const taxPillSx = {
'& .MuiChip-label': { px: 1 }, '& .MuiChip-label': { px: 1 },
}; };
export function TaxRatePills({ rates, selected, onSelect, readOnly }) { export class TaxRatePills extends Component {
render() {
const { rates, selected, onSelect, readOnly } = this.props;
return ( return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 28 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 28 }}>
{rates.map((rate) => { {rates.map((rate) => {
@@ -38,14 +41,11 @@ export function TaxRatePills({ rates, selected, onSelect, readOnly }) {
</Box> </Box>
); );
} }
}
export function PriceTaxRow({ export class PriceTaxRow extends Component {
rates, render() {
selected, const { rates, selected, onSelectTax, readOnly, onOpenTiers } = this.props;
onSelectTax,
readOnly,
onOpenTiers,
}) {
return ( return (
<Box <Box
sx={{ sx={{
@@ -79,3 +79,4 @@ export function PriceTaxRow({
</Box> </Box>
); );
} }
}

View File

@@ -1,3 +1,4 @@
import { Component } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import FormControl from '@mui/material/FormControl'; import FormControl from '@mui/material/FormControl';
@@ -21,7 +22,9 @@ const priceUnitCornerSx = {
zIndex: 2, zIndex: 2,
}; };
export function PriceUnitRow({ export class PriceUnitRow extends Component {
render() {
const {
hasUnit, hasUnit,
unitamount, unitamount,
unitSi, unitSi,
@@ -31,7 +34,7 @@ export function PriceUnitRow({
onChangeSi, onChangeSi,
readOnly, readOnly,
amountError, amountError,
}) { } = this.props;
const selected = findUnitBySi(units, unitSi); const selected = findUnitBySi(units, unitSi);
const symbol = selected?.symbol ?? displaySymbol ?? unitSi ?? ''; const symbol = selected?.symbol ?? displaySymbol ?? unitSi ?? '';
@@ -111,8 +114,11 @@ export function PriceUnitRow({
</Box> </Box>
); );
} }
}
export function PriceUnitCorner({ hasUnit, onToggleHasUnit, onOpenEditor }) { export class PriceUnitCorner extends Component {
render() {
const { hasUnit, onToggleHasUnit, onOpenEditor } = this.props;
return ( return (
<Box sx={priceUnitCornerSx}> <Box sx={priceUnitCornerSx}>
<Switch <Switch
@@ -133,3 +139,4 @@ export function PriceUnitCorner({ hasUnit, onToggleHasUnit, onOpenEditor }) {
</Box> </Box>
); );
} }
}

View File

@@ -1,3 +1,4 @@
import { Component } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import { formatPrice, parsePriceInput } from '../../../utils/price'; import { formatPrice, parsePriceInput } from '../../../utils/price';
@@ -8,7 +9,9 @@ const tierPillSx = {
fontWeight: 600, fontWeight: 600,
}; };
export default function TierPricePills({ tiers }) { export default class TierPricePills extends Component {
render() {
const { tiers } = this.props;
return ( return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 22 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 22 }}>
{(tiers ?? []).map((tier) => { {(tiers ?? []).map((tier) => {
@@ -29,3 +32,4 @@ export default function TierPricePills({ tiers }) {
</Box> </Box>
); );
} }
}

View File

@@ -1,4 +1,4 @@
import { Component, createRef } from 'react'; import { Component, cloneElement, createRef, isValidElement } from 'react';
import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined'; import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined'; import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
@@ -9,7 +9,6 @@ import IconButton from '@mui/material/IconButton';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { injectMuiTheme } from './injectMuiTheme'; import { injectMuiTheme } from './injectMuiTheme';
import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout'; import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout';
@@ -20,6 +19,11 @@ function valuesEqual(a, b) {
/** /**
* Panel tile with centralized inline-edit chrome. * Panel tile with centralized inline-edit chrome.
* Pass `onSave` to enable edit UI; omit it for read-only tiles (no edit icon). * 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 { class EditablePanelTileBase extends Component {
constructor(props) { 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() { render() {
const { const {
onSave, onSave,
@@ -199,13 +228,9 @@ class EditablePanelTileBase extends Component {
sx, sx,
minHeight = 120, minHeight = 120,
tileKey, tileKey,
renderDisplay,
renderEdit,
renderOverlay,
contentAlignItems = 'center', contentAlignItems = 'center',
} = this.props; } = this.props;
const { editing, draft, error } = this.state; const { editing, error } = this.state;
const savedValue = this.getSavedValue();
const pending = this.isDirty(); const pending = this.isDirty();
const actionPadding = onSave ? TILE_ACTION_PADDING : 0; const actionPadding = onSave ? TILE_ACTION_PADDING : 0;
@@ -223,13 +248,7 @@ class EditablePanelTileBase extends Component {
})} })}
> >
{this.renderActions()} {this.renderActions()}
{typeof renderOverlay === 'function' {this.renderOverlayNode()}
? renderOverlay({
editing,
draft,
onChange: this.handleDraftChange,
})
: null}
{this.renderTitle()} {this.renderTitle()}
<Box <Box
sx={{ sx={{
@@ -241,9 +260,7 @@ class EditablePanelTileBase extends Component {
}} }}
onKeyDown={editing ? this.handleEditKeyDown : undefined} onKeyDown={editing ? this.handleEditKeyDown : undefined}
> >
{editing && draft != null {this.renderBody()}
? renderEdit(draft, this.handleDraftChange, this.inputRef)
: renderDisplay(savedValue)}
</Box> </Box>
{error ? ( {error ? (
<Alert severity="error" sx={{ mt: 1, py: 0 }}> <Alert severity="error" sx={{ mt: 1, py: 0 }}>

View File

@@ -1,86 +1,149 @@
import { useCallback, useEffect, useState } from 'react'; import { Component } from 'react';
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 { useStorageManager } from '../../api/storageManagerContext'; import { StorageManagerContext } from '../../api/storageManagerContext';
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 { requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom, isBackendFailure } from '../../utils/backendError'; import { backendErrorFrom, isBackendFailure } from '../../utils/backendError';
import { parseCategoryTreeResponse } from '../../store/categorySlice';
import { import {
artikelDetailPath, artikelDetailPath,
resolveArticleTab, resolveArticleTab,
} from './articleRoute'; } from './articleRoute';
import withSkuRoute from './withSkuRoute';
export default function ArtikelSkuPage() { class ArtikelSkuPage extends Component {
const { sku: skuParam, tab: tabParam } = useParams(); static contextType = StorageManagerContext;
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);
const retry = useCallback(() => { constructor(props) {
setReloadKey((key) => key + 1); super(props);
}, []); this.state = {
data: null,
useEffect(() => { categories: null,
function onKeyDown(e) { rootCategoryId: null,
if (e.key !== 'Escape') return; error: null,
e.preventDefault(); loading: true,
navigate(-1); reloadKey: 0,
};
this._cancelled = false;
} }
document.addEventListener('keydown', onKeyDown); componentDidMount() {
return () => document.removeEventListener('keydown', onKeyDown); this._cancelled = false;
}, [navigate]); this.installEscape();
void this.load();
}
useEffect(() => { componentDidUpdate(prevProps, prevState) {
let cancelled = false; const { sku, tab: tabParam, navigate } = this.props;
const { data, reloadKey } = this.state;
async function load() { if (prevProps.sku !== sku || prevState.reloadKey !== reloadKey) {
if (!sku) { void this.load();
setError('Keine Artikelnr. angegeben');
setData(null);
setLoading(false);
return; 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;
};
}, [sku, reloadKey, store]);
useEffect(() => {
if (!data) return; if (!data) return;
const resolved = resolveArticleTab(tabParam, data); const resolved = resolveArticleTab(tabParam, data);
if (tabParam !== resolved) { if (tabParam !== resolved) {
navigate(artikelDetailPath(data, resolved), { replace: true }); navigate(artikelDetailPath(data, resolved), { replace: true });
} }
}, [data, tabParam, navigate]); }
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) { if (loading) {
return ( return (
@@ -92,12 +155,7 @@ export default function ArtikelSkuPage() {
if (error) { if (error) {
if (isBackendFailure(error)) { if (isBackendFailure(error)) {
return ( return <BackendError error={error} onRetry={this.retry} />;
<BackendError
error={error}
onRetry={retry}
/>
);
} }
return ( return (
<Alert severity="error" sx={{ maxWidth: 640 }}> <Alert severity="error" sx={{ maxWidth: 640 }}>
@@ -106,7 +164,7 @@ export default function ArtikelSkuPage() {
); );
} }
if (!data) return null; if (!data || categories == null || rootCategoryId == null) return null;
const tab = resolveArticleTab(tabParam, data); const tab = resolveArticleTab(tabParam, data);
if (tabParam !== tab) return null; if (tabParam !== tab) return null;
@@ -114,11 +172,14 @@ export default function ArtikelSkuPage() {
return ( return (
<ArticleView <ArticleView
article={data} article={data}
categories={categories}
rootCategoryId={rootCategoryId}
tab={tab} tab={tab}
onTabChange={(nextTab) => { onTabChange={this.handleTabChange}
navigate(artikelDetailPath(data, nextTab)); onArticleUpdated={this.handleArticleUpdated}
}}
onArticleUpdated={setData}
/> />
); );
} }
}
export default withSkuRoute(ArtikelSkuPage);

View File

@@ -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 <Wrapped {...props} sku={sku} tab={tab} navigate={navigate} />;
}
return SkuRouteWrapper;
}