sotre
This commit is contained in:
@@ -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 },
|
||||||
|
|||||||
@@ -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,114 +17,92 @@ 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 {
|
||||||
article: articleData,
|
static contextType = StorageManagerContext;
|
||||||
tab,
|
|
||||||
onTabChange,
|
|
||||||
onArticleUpdated,
|
|
||||||
}) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const store = useStorageManager();
|
|
||||||
const [categories, setCategories] = useState(null);
|
|
||||||
const [rootCategoryId, setRootCategoryId] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
refreshSearchCaches = () => {
|
||||||
let cancelled = false;
|
void this.context.updateArticleIndex();
|
||||||
async function load() {
|
void this.context.updateCategoryTree();
|
||||||
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) => {
|
handleArticleUpdated = (next) => {
|
||||||
onArticleUpdated?.(next);
|
this.props.onArticleUpdated?.(next);
|
||||||
refreshSearchCaches();
|
this.refreshSearchCaches();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArticleDeleted = () => {
|
handleArticleDeleted = () => {
|
||||||
refreshSearchCaches();
|
const {
|
||||||
const categoryId = requireDefined(
|
article: articleData,
|
||||||
articleData.categoryIds?.[0],
|
navigate,
|
||||||
'article.categoryIds[0]',
|
categories,
|
||||||
);
|
rootCategoryId,
|
||||||
if (categories && rootCategoryId != null) {
|
} = this.props;
|
||||||
|
this.refreshSearchCaches();
|
||||||
|
const categoryId = articleData.categoryIds?.[0];
|
||||||
|
if (categories && rootCategoryId != null && categoryId != null) {
|
||||||
navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
|
navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate(defaultCategoryPath());
|
navigate(defaultCategoryPath());
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
render() {
|
||||||
<PanelTileGrid>
|
const {
|
||||||
<ArticleViewName
|
article: articleData,
|
||||||
article={articleData}
|
tab,
|
||||||
categories={categories}
|
onTabChange,
|
||||||
rootCategoryId={rootCategoryId}
|
categories,
|
||||||
onArticleUpdated={handleArticleUpdated}
|
rootCategoryId,
|
||||||
/>
|
} = this.props;
|
||||||
|
|
||||||
<ArticlePhotoTile
|
return (
|
||||||
wide={1}
|
<PanelTileGrid>
|
||||||
tileKey={PANEL_TILE_KEYS.photo}
|
<ArticleViewName
|
||||||
sx={{ minHeight: 0 }}
|
article={articleData}
|
||||||
article={articleData}
|
categories={categories}
|
||||||
onArticleUpdated={handleArticleUpdated}
|
rootCategoryId={rootCategoryId}
|
||||||
/>
|
onArticleUpdated={this.handleArticleUpdated}
|
||||||
|
/>
|
||||||
|
|
||||||
<ArticleViewStock article={articleData} />
|
<ArticlePhotoTile
|
||||||
|
wide={1}
|
||||||
|
tileKey={PANEL_TILE_KEYS.photo}
|
||||||
|
sx={{ minHeight: 0 }}
|
||||||
|
article={articleData}
|
||||||
|
onArticleUpdated={this.handleArticleUpdated}
|
||||||
|
/>
|
||||||
|
|
||||||
<ArticleViewBrand
|
<ArticleViewStock article={articleData} />
|
||||||
article={articleData}
|
|
||||||
onArticleUpdated={handleArticleUpdated}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ArticleViewEANs
|
<ArticleViewBrand
|
||||||
article={articleData}
|
article={articleData}
|
||||||
onArticleUpdated={handleArticleUpdated}
|
onArticleUpdated={this.handleArticleUpdated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ArticleViewPrice
|
<ArticleViewEANs
|
||||||
article={articleData}
|
article={articleData}
|
||||||
onArticleUpdated={handleArticleUpdated}
|
onArticleUpdated={this.handleArticleUpdated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ArticleTileTab
|
<ArticleViewPrice
|
||||||
wide={3}
|
article={articleData}
|
||||||
tileKey={PANEL_TILE_KEYS.content}
|
onArticleUpdated={this.handleArticleUpdated}
|
||||||
minHeight={200}
|
/>
|
||||||
article={articleData}
|
|
||||||
tab={tab}
|
|
||||||
onTabChange={onTabChange}
|
|
||||||
onArticleDeleted={handleArticleDeleted}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ArticleViewLists article={articleData} />
|
<ArticleTileTab
|
||||||
</PanelTileGrid>
|
wide={3}
|
||||||
);
|
tileKey={PANEL_TILE_KEYS.content}
|
||||||
|
minHeight={200}
|
||||||
|
article={articleData}
|
||||||
|
tab={tab}
|
||||||
|
onTabChange={onTabChange}
|
||||||
|
onArticleDeleted={this.handleArticleDeleted}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArticleViewLists article={articleData} />
|
||||||
|
</PanelTileGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withNavigate(ArticleView);
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
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 {
|
||||||
if (children == null || children === '') return null;
|
render() {
|
||||||
return (
|
const { label, children } = this.props;
|
||||||
<Box sx={{ mb: 1 }}>
|
if (children == null || children === '') return null;
|
||||||
{label ? (
|
return (
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
<Box sx={{ mb: 1 }}>
|
||||||
{label}
|
{label ? (
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
<Typography variant="body2" component="div">
|
||||||
|
{children}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
</Box>
|
||||||
<Typography variant="body2" component="div">
|
);
|
||||||
{children}
|
}
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function brandDraftValue(article) {
|
function brandDraftValue(article) {
|
||||||
@@ -39,25 +42,70 @@ 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;
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
fullWidth
|
||||||
|
freeSolo
|
||||||
|
options={brands}
|
||||||
|
value={draft.name ?? ''}
|
||||||
|
inputValue={draft.name ?? ''}
|
||||||
|
getOptionLabel={(option) =>
|
||||||
|
typeof option === 'string' ? option : String(option.name ?? '')
|
||||||
|
}
|
||||||
|
onInputChange={(_event, value) => onChange({ name: value })}
|
||||||
|
onChange={(_event, option) => {
|
||||||
|
onChange({ name: option ? String(option.name ?? '') : '' });
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
inputRef={inputRef}
|
||||||
|
label="Marke"
|
||||||
|
size="small"
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
export default class ArticleViewBrand extends Component {
|
||||||
let cancelled = false;
|
static contextType = StorageManagerContext;
|
||||||
store.brandPromise
|
|
||||||
|
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) => {
|
.then((list) => {
|
||||||
if (!cancelled) setBrands(list);
|
if (!this._cancelled) this.setState({ brands: list });
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled) notifyError(err);
|
if (!this._cancelled) notifyError(err);
|
||||||
});
|
});
|
||||||
return () => {
|
}
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [store]);
|
|
||||||
|
|
||||||
const handleSave = async (draft) => {
|
componentWillUnmount() {
|
||||||
|
this._cancelled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSave = async (draft) => {
|
||||||
|
const { article: articleData, onArticleUpdated } = this.props;
|
||||||
|
const store = this.context;
|
||||||
|
const { brands } = this.state;
|
||||||
const name = String(draft.name ?? '').trim();
|
const name = String(draft.name ?? '').trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw new Error('Bitte eine Marke angeben.');
|
throw new Error('Bitte eine Marke angeben.');
|
||||||
@@ -77,7 +125,7 @@ export default function ArticleViewBrand({ article: articleData, onArticleUpdate
|
|||||||
requireDefined(createdRes.brand.id, 'brand.id');
|
requireDefined(createdRes.brand.id, 'brand.id');
|
||||||
brandId = createdRes.brand.id;
|
brandId = createdRes.brand.id;
|
||||||
const nextBrands = await store.updateBrand();
|
const nextBrands = await store.updateBrand();
|
||||||
setBrands(nextBrands);
|
this.setState({ brands: nextBrands });
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await store.article.setBrand(articleData.sku, { brandId });
|
const res = await store.article.setBrand(articleData.sku, { brandId });
|
||||||
@@ -86,44 +134,24 @@ export default function ArticleViewBrand({ article: articleData, onArticleUpdate
|
|||||||
onArticleUpdated?.(res.article);
|
onArticleUpdated?.(res.article);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
render() {
|
||||||
<EditablePanelTile
|
const { article: articleData } = this.props;
|
||||||
wide={1}
|
const { brands } = this.state;
|
||||||
tileKey={PANEL_TILE_KEYS.brand}
|
const value = brandDraftValue(articleData);
|
||||||
title="Marke"
|
|
||||||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
return (
|
||||||
value={brandDraftValue(articleData)}
|
<EditablePanelTile
|
||||||
onSave={handleSave}
|
wide={1}
|
||||||
isDirty={brandDraftDirty}
|
tileKey={PANEL_TILE_KEYS.brand}
|
||||||
canSave={brandDraftValid}
|
title="Marke"
|
||||||
renderDisplay={(saved) => (
|
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||||
<PanelValue>{saved.name || null}</PanelValue>
|
value={value}
|
||||||
)}
|
onSave={this.handleSave}
|
||||||
renderEdit={(draft, onChange, inputRef) => (
|
isDirty={brandDraftDirty}
|
||||||
<Autocomplete
|
canSave={brandDraftValid}
|
||||||
fullWidth
|
display={<PanelValue>{value.name || null}</PanelValue>}
|
||||||
freeSolo
|
edit={<BrandEdit brands={brands} />}
|
||||||
options={brands}
|
/>
|
||||||
value={draft.name ?? ''}
|
);
|
||||||
inputValue={draft.name ?? ''}
|
}
|
||||||
getOptionLabel={(option) =>
|
|
||||||
typeof option === 'string' ? option : String(option.name ?? '')
|
|
||||||
}
|
|
||||||
onInputChange={(_event, value) => onChange({ name: value })}
|
|
||||||
onChange={(_event, option) => {
|
|
||||||
onChange({ name: option ? String(option.name ?? '') : '' });
|
|
||||||
}}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
inputRef={inputRef}
|
|
||||||
label="Marke"
|
|
||||||
size="small"
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
|
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 {
|
||||||
if (children == null || children === '') return null;
|
render() {
|
||||||
return (
|
const { label, children } = this.props;
|
||||||
<Box sx={{ mb: 1 }}>
|
if (children == null || children === '') return null;
|
||||||
{label ? (
|
return (
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
<Box sx={{ mb: 1 }}>
|
||||||
{label}
|
{label ? (
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
<Typography variant="body2" component="div">
|
||||||
|
{children}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
</Box>
|
||||||
<Typography variant="body2" component="div">
|
);
|
||||||
{children}
|
}
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function eansDraftValue(article) {
|
function eansDraftValue(article) {
|
||||||
@@ -40,59 +44,79 @@ 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;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PanelValue label="SKU">{sku}</PanelValue>
|
||||||
|
{(eans ?? []).map((ean) => (
|
||||||
|
<PanelValue key={ean} label="EAN">
|
||||||
|
{ean}
|
||||||
|
</PanelValue>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async (draft) => {
|
class EansEdit extends Component {
|
||||||
|
render() {
|
||||||
|
const { draft, onChange, inputRef } = this.props;
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
fullWidth
|
||||||
|
multiple
|
||||||
|
freeSolo
|
||||||
|
options={[]}
|
||||||
|
value={draft.eans ?? []}
|
||||||
|
onChange={(_event, eans) => onChange({ eans })}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
inputRef={inputRef}
|
||||||
|
label="EANs (Enter zum Hinzufügen)"
|
||||||
|
size="small"
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ArticleViewEANs extends Component {
|
||||||
|
static contextType = StorageManagerContext;
|
||||||
|
|
||||||
|
handleSave = async (draft) => {
|
||||||
|
const { article: articleData, onArticleUpdated } = this.props;
|
||||||
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 store.article.setEans(articleData.sku, { eans });
|
const res = await this.context.article.setEans(articleData.sku, { eans });
|
||||||
requireObject(res, 'PUT /api/article/:sku/eans');
|
requireObject(res, 'PUT /api/article/:sku/eans');
|
||||||
requireObject(res.article, 'article');
|
requireObject(res.article, 'article');
|
||||||
onArticleUpdated?.(res.article);
|
onArticleUpdated?.(res.article);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
render() {
|
||||||
<EditablePanelTile
|
const { article: articleData } = this.props;
|
||||||
wide={1}
|
const value = eansDraftValue(articleData);
|
||||||
tileKey={PANEL_TILE_KEYS.identifiers}
|
|
||||||
title="SKU / EAN"
|
return (
|
||||||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
<EditablePanelTile
|
||||||
contentAlignItems="flex-start"
|
wide={1}
|
||||||
value={eansDraftValue(articleData)}
|
tileKey={PANEL_TILE_KEYS.identifiers}
|
||||||
onSave={handleSave}
|
title="SKU / EAN"
|
||||||
isDirty={eansDraftDirty}
|
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||||
canSave={eansDraftValid}
|
contentAlignItems="flex-start"
|
||||||
renderDisplay={(saved) => (
|
value={value}
|
||||||
<>
|
onSave={this.handleSave}
|
||||||
<PanelValue label="SKU">{articleData.sku}</PanelValue>
|
isDirty={eansDraftDirty}
|
||||||
{(saved.eans ?? []).map((ean) => (
|
canSave={eansDraftValid}
|
||||||
<PanelValue key={ean} label="EAN">
|
display={<EansDisplay sku={articleData.sku} eans={value.eans} />}
|
||||||
{ean}
|
edit={<EansEdit />}
|
||||||
</PanelValue>
|
/>
|
||||||
))}
|
);
|
||||||
</>
|
}
|
||||||
)}
|
|
||||||
renderEdit={(draft, onChange, inputRef) => (
|
|
||||||
<Autocomplete
|
|
||||||
fullWidth
|
|
||||||
multiple
|
|
||||||
freeSolo
|
|
||||||
options={[]}
|
|
||||||
value={draft.eans ?? []}
|
|
||||||
onChange={(_event, eans) => onChange({ eans })}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
inputRef={inputRef}
|
|
||||||
label="EANs (Enter zum Hinzufügen)"
|
|
||||||
size="small"
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,55 +6,61 @@ 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 {
|
||||||
if (count == null) return null;
|
render() {
|
||||||
return (
|
const { label, count } = this.props;
|
||||||
<Typography variant="body2">
|
if (count == null) return null;
|
||||||
{label}: {count}
|
return (
|
||||||
</Typography>
|
<Typography variant="body2">
|
||||||
);
|
{label}: {count}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function RelationLine({ label, active, children }) {
|
class RelationLine extends Component {
|
||||||
return (
|
render() {
|
||||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
const { label, active, children } = this.props;
|
||||||
{label}:{' '}
|
return (
|
||||||
{active ? (
|
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||||
children ?? 'ja'
|
{label}:{' '}
|
||||||
) : (
|
{active ? (
|
||||||
<Typography component="span" variant="body2" color="text.secondary">
|
children ?? 'ja'
|
||||||
nein
|
) : (
|
||||||
</Typography>
|
<Typography component="span" variant="body2" color="text.secondary">
|
||||||
)}
|
nein
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
)}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArticleViewLists({ article: articleData }) {
|
export default class ArticleViewLists extends Component {
|
||||||
return (
|
render() {
|
||||||
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.lists} title="Listen" minHeight={200}>
|
const { article: articleData } = this.props;
|
||||||
<RelationLine
|
return (
|
||||||
label="Vaterartikel"
|
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.lists} title="Listen" minHeight={200}>
|
||||||
active={articleData.childCount > 0}
|
<RelationLine label="Vaterartikel" active={articleData.childCount > 0}>
|
||||||
>
|
{articleData.childCount}
|
||||||
{articleData.childCount}
|
</RelationLine>
|
||||||
</RelationLine>
|
<RelationLine
|
||||||
<RelationLine
|
label="Kindartikel"
|
||||||
label="Kindartikel"
|
active={articleData.fatherArticleId != null}
|
||||||
active={articleData.fatherArticleId != null}
|
>
|
||||||
>
|
{articleData.fatherSku ? (
|
||||||
{articleData.fatherSku ? (
|
<Link
|
||||||
<Link
|
component={RouterLink}
|
||||||
component={RouterLink}
|
to={artikelDetailPath(articleData.fatherSku)}
|
||||||
to={artikelDetailPath(articleData.fatherSku)}
|
>
|
||||||
>
|
{articleData.fatherSku}
|
||||||
{articleData.fatherSku}
|
</Link>
|
||||||
</Link>
|
) : null}
|
||||||
) : null}
|
</RelationLine>
|
||||||
</RelationLine>
|
<CountLine label="Optionen" count={articleData.optionsCount} />
|
||||||
<CountLine label="Optionen" count={articleData.optionsCount} />
|
<CountLine label="Stücklisten" count={articleData.articleListCount} />
|
||||||
<CountLine label="Stücklisten" count={articleData.articleListCount} />
|
<CountLine label="Varianten" count={articleData.childCount} />
|
||||||
<CountLine label="Varianten" count={articleData.childCount} />
|
</PanelTile>
|
||||||
</PanelTile>
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,61 +77,156 @@ 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 ?? [])
|
||||||
categoryIds,
|
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
|
||||||
categories,
|
.filter((c) => c.label);
|
||||||
rootCategoryId,
|
}
|
||||||
sku,
|
|
||||||
editMode,
|
|
||||||
onCategoryIdsChange,
|
|
||||||
}) {
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleRemoveCategory = (removeId) => {
|
class CategoryPills extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { pickerOpen: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRemoveCategory = (removeId) => {
|
||||||
|
const { categoryIds, onCategoryIdsChange } = this.props;
|
||||||
const remaining = categoryIds.filter((id) => String(id) !== String(removeId));
|
const remaining = categoryIds.filter((id) => String(id) !== String(removeId));
|
||||||
onCategoryIdsChange?.(remaining);
|
onCategoryIdsChange?.(remaining);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
handleOpenPicker = () => {
|
||||||
<>
|
this.setState({ pickerOpen: true });
|
||||||
<Box sx={pillsContainerSx}>
|
};
|
||||||
{categoryLabels.map(({ id, label }) => (
|
|
||||||
<Chip
|
handleClosePicker = () => {
|
||||||
key={id}
|
this.setState({ pickerOpen: false });
|
||||||
label={label}
|
};
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
render() {
|
||||||
onDelete={editMode && categoryIds.length > 1 ? () => handleRemoveCategory(id) : undefined}
|
const {
|
||||||
/>
|
categoryLabels: labels,
|
||||||
))}
|
categoryIds,
|
||||||
{editMode && (
|
categories,
|
||||||
<IconButton
|
rootCategoryId,
|
||||||
size="small"
|
sku,
|
||||||
onClick={() => setPickerOpen(true)}
|
editMode,
|
||||||
sx={{ ml: 0.5, width: 24, height: 24 }}
|
onCategoryIdsChange,
|
||||||
>
|
} = this.props;
|
||||||
<AddIcon fontSize="small" />
|
const { pickerOpen } = this.state;
|
||||||
</IconButton>
|
|
||||||
)}
|
return (
|
||||||
</Box>
|
<>
|
||||||
<CategoryPickerDialog
|
<Box sx={pillsContainerSx}>
|
||||||
key={sku}
|
{labels.map(({ id, label }) => (
|
||||||
open={pickerOpen}
|
<Chip
|
||||||
onClose={() => setPickerOpen(false)}
|
key={id}
|
||||||
categories={categories}
|
label={label}
|
||||||
rootCategoryId={rootCategoryId}
|
size="small"
|
||||||
currentCategoryIds={categoryIds}
|
variant="outlined"
|
||||||
onSelect={onCategoryIdsChange}
|
onDelete={
|
||||||
/>
|
editMode && categoryIds.length > 1
|
||||||
</>
|
? () => this.handleRemoveCategory(id)
|
||||||
);
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{editMode ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={this.handleOpenPicker}
|
||||||
|
sx={{ ml: 0.5, width: 24, height: 24 }}
|
||||||
|
>
|
||||||
|
<AddIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<CategoryPickerDialog
|
||||||
|
key={sku}
|
||||||
|
open={pickerOpen}
|
||||||
|
onClose={this.handleClosePicker}
|
||||||
|
categories={categories}
|
||||||
|
rootCategoryId={rootCategoryId}
|
||||||
|
currentCategoryIds={categoryIds}
|
||||||
|
onSelect={onCategoryIdsChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 labels = categoryLabels(categoryIds, categories, rootCategoryId);
|
||||||
|
return (
|
||||||
|
<Box sx={columnSx}>
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<SingleLineTextEdit
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
readOnly
|
||||||
|
onChange={() => {}}
|
||||||
|
sx={articleNameFieldHiddenSx}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{labels.length > 0 ? (
|
||||||
|
<CategoryPills
|
||||||
|
categoryLabels={labels}
|
||||||
|
categoryIds={categoryIds}
|
||||||
|
categories={categories}
|
||||||
|
rootCategoryId={rootCategoryId}
|
||||||
|
sku={sku}
|
||||||
|
editMode={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async (draft) => {
|
class NameEdit extends Component {
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
draft,
|
||||||
|
onChange,
|
||||||
|
inputRef,
|
||||||
|
categories,
|
||||||
|
rootCategoryId,
|
||||||
|
sku,
|
||||||
|
} = this.props;
|
||||||
|
const labels = categoryLabels(draft.categoryIds, categories, rootCategoryId);
|
||||||
|
return (
|
||||||
|
<Box sx={columnSx}>
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<SingleLineTextEdit
|
||||||
|
inputRef={inputRef}
|
||||||
|
label="Name"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(name) => onChange({ ...draft, name })}
|
||||||
|
sx={articleNameFieldSx}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<CategoryPills
|
||||||
|
categoryLabels={labels}
|
||||||
|
categoryIds={draft.categoryIds}
|
||||||
|
categories={categories}
|
||||||
|
rootCategoryId={rootCategoryId}
|
||||||
|
sku={sku}
|
||||||
|
editMode
|
||||||
|
onCategoryIdsChange={(categoryIds) => onChange({ ...draft, categoryIds })}
|
||||||
|
/>
|
||||||
|
</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 name = draft.name.trim();
|
||||||
const categoryIds = draft.categoryIds ?? [];
|
const categoryIds = draft.categoryIds ?? [];
|
||||||
|
|
||||||
@@ -138,73 +240,38 @@ export default function ArticleViewName({ article: articleData, categories, root
|
|||||||
onArticleUpdated?.(nameRes.article);
|
onArticleUpdated?.(nameRes.article);
|
||||||
};
|
};
|
||||||
|
|
||||||
const savedCategoryIds = articleData.categoryIds ?? [];
|
render() {
|
||||||
|
const { article: articleData, categories, rootCategoryId } = this.props;
|
||||||
|
const savedCategoryIds = articleData.categoryIds ?? [];
|
||||||
|
const value = { name: articleData.name ?? '', categoryIds: [...savedCategoryIds] };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditablePanelTile
|
<EditablePanelTile
|
||||||
wide={3}
|
wide={3}
|
||||||
tileKey={PANEL_TILE_KEYS.name}
|
tileKey={PANEL_TILE_KEYS.name}
|
||||||
minHeight={0}
|
minHeight={0}
|
||||||
sx={headerTileSx}
|
sx={headerTileSx}
|
||||||
value={{ name: articleData.name ?? '', categoryIds: [...savedCategoryIds] }}
|
value={value}
|
||||||
onSave={handleSave}
|
onSave={this.handleSave}
|
||||||
isDirty={draftDirty}
|
isDirty={draftDirty}
|
||||||
canSave={draftValid}
|
canSave={draftValid}
|
||||||
renderDisplay={({ name, categoryIds }) => {
|
display={
|
||||||
const labels = (categoryIds ?? [])
|
<NameDisplay
|
||||||
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
|
name={value.name}
|
||||||
.filter((c) => c.label);
|
categoryIds={value.categoryIds}
|
||||||
return (
|
categories={categories}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, alignSelf: 'stretch' }}>
|
rootCategoryId={rootCategoryId}
|
||||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
sku={articleData.sku}
|
||||||
<SingleLineTextEdit
|
/>
|
||||||
label="Name"
|
}
|
||||||
value={name}
|
edit={
|
||||||
readOnly
|
<NameEdit
|
||||||
onChange={() => {}}
|
categories={categories}
|
||||||
sx={articleNameFieldHiddenSx}
|
rootCategoryId={rootCategoryId}
|
||||||
/>
|
sku={articleData.sku}
|
||||||
</Box>
|
/>
|
||||||
{labels.length > 0 && (
|
}
|
||||||
<CategoryPills
|
/>
|
||||||
categoryLabels={labels}
|
);
|
||||||
categoryIds={categoryIds}
|
}
|
||||||
categories={categories}
|
|
||||||
rootCategoryId={rootCategoryId}
|
|
||||||
sku={articleData.sku}
|
|
||||||
editMode={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
renderEdit={(draft, onChange, inputRef) => {
|
|
||||||
const labels = (draft.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' }}>
|
|
||||||
<SingleLineTextEdit
|
|
||||||
inputRef={inputRef}
|
|
||||||
label="Name"
|
|
||||||
value={draft.name}
|
|
||||||
onChange={(name) => onChange({ ...draft, name })}
|
|
||||||
sx={articleNameFieldSx}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<CategoryPills
|
|
||||||
categoryLabels={labels}
|
|
||||||
categoryIds={draft.categoryIds}
|
|
||||||
categories={categories}
|
|
||||||
rootCategoryId={rootCategoryId}
|
|
||||||
sku={articleData.sku}
|
|
||||||
editMode={true}
|
|
||||||
onCategoryIdsChange={(categoryIds) => onChange({ ...draft, categoryIds })}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,93 +168,256 @@ function priceDraftValid(draft) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── sub-component: amount field ──────────────────────────────────── */
|
function companyFromStore(store) {
|
||||||
|
if (!store) return null;
|
||||||
function PriceAmountField({
|
const companies = store.companies;
|
||||||
value,
|
const session = store.authMe;
|
||||||
onChange,
|
if (companies == null || session == null) return null;
|
||||||
inputRef,
|
const companyId = session.user?.selectedCompanyId;
|
||||||
readOnly,
|
if (companyId == null) return null;
|
||||||
error,
|
return companies.find((c) => c.id === companyId) ?? null;
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
inputRef={inputRef}
|
|
||||||
value={value}
|
|
||||||
onChange={
|
|
||||||
readOnly
|
|
||||||
? undefined
|
|
||||||
: (event) => {
|
|
||||||
onChange?.(event.target.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fullWidth
|
|
||||||
error={error}
|
|
||||||
sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
readOnly,
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<Typography component="span">€</Typography>
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
htmlInput: {
|
|
||||||
inputMode: 'decimal',
|
|
||||||
readOnly,
|
|
||||||
tabIndex: readOnly ? -1 : undefined,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── main component ────────────────────────────────────────────────── */
|
class PriceAmountField extends Component {
|
||||||
|
render() {
|
||||||
export default function ArticleViewPrice({ article: articleData, onArticleUpdated }) {
|
const { value, onChange, inputRef, readOnly, error } = this.props;
|
||||||
const store = useStorageManager();
|
return (
|
||||||
const [tiersDialogOpen, setTiersDialogOpen] = useState(false);
|
<TextField
|
||||||
const [unitEditorOpen, setUnitEditorOpen] = useState(false);
|
inputRef={inputRef}
|
||||||
const [units, setUnits] = useState([]);
|
value={value}
|
||||||
const [company, setCompany] = useState(null);
|
onChange={
|
||||||
const basePrice = primaryPrice(articleData);
|
readOnly
|
||||||
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
|
? undefined
|
||||||
const priceValue = priceDraftValue(articleData, company);
|
: (event) => {
|
||||||
const unitOptions =
|
onChange?.(event.target.value);
|
||||||
articleData.unit?.si && !findUnitBySi(units, articleData.unit.si)
|
}
|
||||||
? [...units, articleData.unit]
|
|
||||||
: units;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const [unitList, companies, session] = await Promise.all([
|
|
||||||
store.unitPromise,
|
|
||||||
store.companyPromise,
|
|
||||||
store.authMePromise,
|
|
||||||
]);
|
|
||||||
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
|
|
||||||
const current = requireDefined(
|
|
||||||
companies.find((c) => c.id === companyId),
|
|
||||||
'company',
|
|
||||||
);
|
|
||||||
if (!cancelled) {
|
|
||||||
setUnits(unitList);
|
|
||||||
setCompany(current);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
fullWidth
|
||||||
if (!cancelled) notifyError(err);
|
error={error}
|
||||||
}
|
sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx}
|
||||||
}
|
slotProps={{
|
||||||
void load();
|
input: {
|
||||||
return () => {
|
readOnly,
|
||||||
cancelled = true;
|
startAdornment: (
|
||||||
};
|
<InputAdornment position="start">
|
||||||
}, [store]);
|
<Typography component="span">€</Typography>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
htmlInput: {
|
||||||
|
inputMode: 'decimal',
|
||||||
|
readOnly,
|
||||||
|
tabIndex: readOnly ? -1 : undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async (draft) => {
|
class PriceDisplay extends Component {
|
||||||
|
render() {
|
||||||
|
const { saved, taxRates, unitOptions, displaySymbol } = this.props;
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PriceOverlay extends Component {
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
editing,
|
||||||
|
draft,
|
||||||
|
onChange,
|
||||||
|
unitOptions,
|
||||||
|
articleUnitSi,
|
||||||
|
onOpenEditor,
|
||||||
|
} = this.props;
|
||||||
|
if (!editing || draft == null) return null;
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PriceEdit extends Component {
|
||||||
|
handleUnitUpserted = async (row) => {
|
||||||
|
const { draft, onChange, onUnitUpserted } = this.props;
|
||||||
|
await onUnitUpserted?.(row);
|
||||||
|
if (!draft.unitSi) {
|
||||||
|
onChange({ ...draft, unitSi: row.si });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleUnitDeleted = async (si) => {
|
||||||
|
const { draft, onChange, onUnitDeleted } = this.props;
|
||||||
|
await onUnitDeleted?.(si);
|
||||||
|
if (draft.unitSi === si) {
|
||||||
|
onChange({ ...draft, unitSi: '' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
draft,
|
||||||
|
onChange,
|
||||||
|
inputRef,
|
||||||
|
taxRates,
|
||||||
|
unitOptions,
|
||||||
|
displaySymbol,
|
||||||
|
tiersDialogOpen,
|
||||||
|
unitEditorOpen,
|
||||||
|
onOpenTiers,
|
||||||
|
onCloseTiers,
|
||||||
|
onCloseUnitEditor,
|
||||||
|
} = this.props;
|
||||||
|
const draftGross = parsePriceInput(draft.gross);
|
||||||
|
const draftAmount = parsePriceInput(draft.unitamount);
|
||||||
|
return (
|
||||||
|
<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 {
|
||||||
|
const [unitList, companies, session] = await Promise.all([
|
||||||
|
store.unitPromise,
|
||||||
|
store.companyPromise,
|
||||||
|
store.authMePromise,
|
||||||
|
]);
|
||||||
|
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
|
||||||
|
const current = requireDefined(
|
||||||
|
companies.find((c) => c.id === companyId),
|
||||||
|
'company',
|
||||||
|
);
|
||||||
|
if (!this._cancelled) {
|
||||||
|
this.setState({ units: unitList, company: current });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!this._cancelled) notifyError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSave = async (draft) => {
|
||||||
|
const { article: articleData, onArticleUpdated } = this.props;
|
||||||
|
const store = this.context;
|
||||||
|
const { company } = this.state;
|
||||||
const saved = priceDraftValue(articleData, company);
|
const saved = priceDraftValue(articleData, company);
|
||||||
let nextArticle = articleData;
|
let nextArticle = articleData;
|
||||||
|
|
||||||
@@ -315,134 +476,73 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate
|
|||||||
onArticleUpdated?.(nextArticle);
|
onArticleUpdated?.(nextArticle);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
openTiersDialog = () => this.setState({ tiersDialogOpen: true });
|
||||||
<EditablePanelTile
|
closeTiersDialog = () => this.setState({ tiersDialogOpen: false });
|
||||||
wide={1}
|
openUnitEditor = () => this.setState({ unitEditorOpen: true });
|
||||||
tileKey={PANEL_TILE_KEYS.price}
|
closeUnitEditor = () => this.setState({ unitEditorOpen: false });
|
||||||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
|
||||||
contentAlignItems="flex-start"
|
unitOptions() {
|
||||||
value={priceValue}
|
const { article: articleData } = this.props;
|
||||||
onSave={handleSave}
|
const { units } = this.state;
|
||||||
isDirty={priceDraftDirty}
|
if (articleData.unit?.si && !findUnitBySi(units, articleData.unit.si)) {
|
||||||
canSave={priceDraftValid}
|
return [...units, articleData.unit];
|
||||||
renderOverlay={({ editing, draft, onChange }) => {
|
}
|
||||||
if (!editing || draft == null) return null;
|
return units;
|
||||||
return (
|
}
|
||||||
<PriceUnitCorner
|
|
||||||
hasUnit={draft.hasUnit}
|
refreshUnits = async () => {
|
||||||
onToggleHasUnit={(hasUnit) => {
|
this.setState({ units: await this.context.unitPromise });
|
||||||
if (hasUnit) {
|
};
|
||||||
const unitamount = draft.unitamount || '1';
|
|
||||||
const amount = parsePriceInput(unitamount) ?? 1;
|
render() {
|
||||||
let unitSi = firstPackUnitSi(
|
const { article: articleData } = this.props;
|
||||||
unitOptions,
|
const { company, tiersDialogOpen, unitEditorOpen } = this.state;
|
||||||
draft.unitSi || articleData.unit?.si,
|
const priceValue = priceDraftValue(articleData, company);
|
||||||
);
|
const basePrice = primaryPrice(articleData);
|
||||||
if (!unitSi && isNoPackUnit('pc', amount)) {
|
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
|
||||||
onChange({
|
const unitOptions = this.unitOptions();
|
||||||
...draft,
|
const displaySymbol = articleData.unit?.symbol;
|
||||||
hasUnit: true,
|
|
||||||
unitSi: 'pc',
|
return (
|
||||||
unitamount: '',
|
<EditablePanelTile
|
||||||
});
|
wide={1}
|
||||||
return;
|
tileKey={PANEL_TILE_KEYS.price}
|
||||||
}
|
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||||
if (!unitSi) unitSi = 'pc';
|
contentAlignItems="flex-start"
|
||||||
onChange({
|
value={priceValue}
|
||||||
...draft,
|
onSave={this.handleSave}
|
||||||
hasUnit: true,
|
isDirty={priceDraftDirty}
|
||||||
unitSi,
|
canSave={priceDraftValid}
|
||||||
unitamount,
|
overlay={
|
||||||
});
|
<PriceOverlay
|
||||||
return;
|
unitOptions={unitOptions}
|
||||||
}
|
articleUnitSi={articleData.unit?.si}
|
||||||
onChange({
|
onOpenEditor={this.openUnitEditor}
|
||||||
...draft,
|
|
||||||
hasUnit: false,
|
|
||||||
unitamount: '',
|
|
||||||
unitSi: '',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onOpenEditor={() => setUnitEditorOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
);
|
}
|
||||||
}}
|
display={
|
||||||
renderDisplay={(saved) => (
|
<PriceDisplay
|
||||||
<Box sx={priceBodySx}>
|
saved={priceValue}
|
||||||
<PriceAmountField value={saved.gross} readOnly />
|
taxRates={taxRates}
|
||||||
<PriceTaxRow
|
unitOptions={unitOptions}
|
||||||
rates={taxRates}
|
displaySymbol={displaySymbol}
|
||||||
selected={saved.taxRate}
|
|
||||||
readOnly
|
|
||||||
/>
|
/>
|
||||||
<TierPricePills tiers={saved.tiers} />
|
}
|
||||||
<PriceUnitRow
|
edit={
|
||||||
hasUnit={saved.hasUnit}
|
<PriceEdit
|
||||||
unitamount={saved.unitamount}
|
taxRates={taxRates}
|
||||||
unitSi={saved.unitSi}
|
unitOptions={unitOptions}
|
||||||
units={unitOptions}
|
displaySymbol={displaySymbol}
|
||||||
displaySymbol={articleData.unit?.symbol}
|
tiersDialogOpen={tiersDialogOpen}
|
||||||
readOnly
|
unitEditorOpen={unitEditorOpen}
|
||||||
|
onOpenTiers={this.openTiersDialog}
|
||||||
|
onCloseTiers={this.closeTiersDialog}
|
||||||
|
onCloseUnitEditor={this.closeUnitEditor}
|
||||||
|
onUnitUpserted={this.refreshUnits}
|
||||||
|
onUnitDeleted={this.refreshUnits}
|
||||||
/>
|
/>
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,45 @@
|
|||||||
|
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 {
|
||||||
if (children == null || children === '') return null;
|
render() {
|
||||||
return (
|
const { label, children } = this.props;
|
||||||
<Box sx={{ mb: 1 }}>
|
if (children == null || children === '') return null;
|
||||||
{label ? (
|
return (
|
||||||
<Typography variant="caption" color="text.secondary" display="block">
|
<Box sx={{ mb: 1 }}>
|
||||||
{label}
|
{label ? (
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
<Typography variant="body2" component="div">
|
||||||
|
{children}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
</Box>
|
||||||
<Typography variant="body2" component="div">
|
);
|
||||||
{children}
|
}
|
||||||
</Typography>
|
|
||||||
</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 {
|
||||||
return (
|
render() {
|
||||||
<PanelTile
|
const { article: articleData } = this.props;
|
||||||
wide={1}
|
return (
|
||||||
tileKey={PANEL_TILE_KEYS.stock}
|
<PanelTile
|
||||||
title="Bestand"
|
wide={1}
|
||||||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
tileKey={PANEL_TILE_KEYS.stock}
|
||||||
>
|
title="Bestand"
|
||||||
<PanelValue>
|
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||||
{articleData.stock != null ? formatStock(articleData.stock) : null}
|
>
|
||||||
</PanelValue>
|
<PanelValue>
|
||||||
</PanelTile>
|
{articleData.stock != null ? formatStock(articleData.stock) : null}
|
||||||
);
|
</PanelValue>
|
||||||
|
</PanelTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,72 +11,72 @@ const taxPillSx = {
|
|||||||
'& .MuiChip-label': { px: 1 },
|
'& .MuiChip-label': { px: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TaxRatePills({ rates, selected, onSelect, readOnly }) {
|
export class TaxRatePills extends Component {
|
||||||
return (
|
render() {
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 28 }}>
|
const { rates, selected, onSelect, readOnly } = this.props;
|
||||||
{rates.map((rate) => {
|
return (
|
||||||
const active = Number(selected) === Number(rate);
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 28 }}>
|
||||||
return (
|
{rates.map((rate) => {
|
||||||
<Chip
|
const active = Number(selected) === Number(rate);
|
||||||
key={rate}
|
return (
|
||||||
size="small"
|
<Chip
|
||||||
label={`${rate} %`}
|
key={rate}
|
||||||
onClick={readOnly || !onSelect ? undefined : () => onSelect(rate)}
|
size="small"
|
||||||
sx={{
|
label={`${rate} %`}
|
||||||
...taxPillSx,
|
onClick={readOnly || !onSelect ? undefined : () => onSelect(rate)}
|
||||||
visibility: active || !readOnly ? 'visible' : 'hidden',
|
sx={{
|
||||||
bgcolor: active ? 'rgba(0, 0, 0, 0.14)' : 'rgba(0, 0, 0, 0.06)',
|
...taxPillSx,
|
||||||
...(readOnly
|
visibility: active || !readOnly ? 'visible' : 'hidden',
|
||||||
? {}
|
bgcolor: active ? 'rgba(0, 0, 0, 0.14)' : 'rgba(0, 0, 0, 0.06)',
|
||||||
: {
|
...(readOnly
|
||||||
cursor: 'pointer',
|
? {}
|
||||||
'&:hover': { bgcolor: 'rgba(0, 0, 0, 0.12)' },
|
: {
|
||||||
}),
|
cursor: 'pointer',
|
||||||
}}
|
'&:hover': { bgcolor: 'rgba(0, 0, 0, 0.12)' },
|
||||||
/>
|
}),
|
||||||
);
|
}}
|
||||||
})}
|
/>
|
||||||
</Box>
|
);
|
||||||
);
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PriceTaxRow({
|
export class PriceTaxRow extends Component {
|
||||||
rates,
|
render() {
|
||||||
selected,
|
const { rates, selected, onSelectTax, readOnly, onOpenTiers } = this.props;
|
||||||
onSelectTax,
|
return (
|
||||||
readOnly,
|
<Box
|
||||||
onOpenTiers,
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
minHeight: 36,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TaxRatePills
|
|
||||||
rates={rates}
|
|
||||||
selected={selected}
|
|
||||||
onSelect={onSelectTax}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
type="button"
|
|
||||||
size="small"
|
|
||||||
aria-label="Staffelpreise"
|
|
||||||
disabled={readOnly}
|
|
||||||
onClick={readOnly ? undefined : onOpenTiers}
|
|
||||||
sx={{
|
sx={{
|
||||||
p: 0.5,
|
display: 'flex',
|
||||||
visibility: readOnly ? 'hidden' : 'visible',
|
alignItems: 'center',
|
||||||
pointerEvents: readOnly ? 'none' : 'auto',
|
gap: 0.5,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
minHeight: 36,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LayersOutlinedIcon sx={{ fontSize: 28 }} />
|
<TaxRatePills
|
||||||
</IconButton>
|
rates={rates}
|
||||||
</Box>
|
selected={selected}
|
||||||
);
|
onSelect={onSelectTax}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
aria-label="Staffelpreise"
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={readOnly ? undefined : onOpenTiers}
|
||||||
|
sx={{
|
||||||
|
p: 0.5,
|
||||||
|
visibility: readOnly ? 'hidden' : 'visible',
|
||||||
|
pointerEvents: readOnly ? 'none' : 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LayersOutlinedIcon sx={{ fontSize: 28 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,115 +22,121 @@ const priceUnitCornerSx = {
|
|||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PriceUnitRow({
|
export class PriceUnitRow extends Component {
|
||||||
hasUnit,
|
render() {
|
||||||
unitamount,
|
const {
|
||||||
unitSi,
|
hasUnit,
|
||||||
units,
|
unitamount,
|
||||||
displaySymbol,
|
unitSi,
|
||||||
onChangeAmount,
|
units,
|
||||||
onChangeSi,
|
displaySymbol,
|
||||||
readOnly,
|
onChangeAmount,
|
||||||
amountError,
|
onChangeSi,
|
||||||
}) {
|
readOnly,
|
||||||
const selected = findUnitBySi(units, unitSi);
|
amountError,
|
||||||
const symbol = selected?.symbol ?? displaySymbol ?? unitSi ?? '';
|
} = this.props;
|
||||||
|
const selected = findUnitBySi(units, unitSi);
|
||||||
|
const symbol = selected?.symbol ?? displaySymbol ?? unitSi ?? '';
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minHeight: 36 }}>
|
||||||
|
{hasUnit && unitamount ? (
|
||||||
|
<Chip
|
||||||
|
size="medium"
|
||||||
|
label={`${unitamount} ${symbol}`}
|
||||||
|
sx={{
|
||||||
|
height: 28,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
bgcolor: 'rgba(0, 0, 0, 0.08)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUnit) {
|
||||||
|
return <Box sx={{ minHeight: 40 }} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (readOnly) {
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minHeight: 36 }}>
|
<Box
|
||||||
{hasUnit && unitamount ? (
|
sx={{
|
||||||
<Chip
|
display: 'flex',
|
||||||
size="medium"
|
alignItems: 'center',
|
||||||
label={`${unitamount} ${symbol}`}
|
gap: 0.75,
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
minHeight: 40,
|
||||||
|
minWidth: 0,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Menge"
|
||||||
|
value={unitamount}
|
||||||
|
onChange={(event) => onChangeAmount(event.target.value)}
|
||||||
|
size="small"
|
||||||
|
error={amountError}
|
||||||
|
sx={{ width: 88, flexShrink: 0 }}
|
||||||
|
slotProps={{
|
||||||
|
inputLabel: { shrink: true },
|
||||||
|
htmlInput: { inputMode: 'decimal' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormControl size="small" sx={{ flex: 1, minWidth: 0, maxWidth: 160 }}>
|
||||||
|
<InputLabel id="article-unit-si-label" shrink>
|
||||||
|
Einheit
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="article-unit-si-label"
|
||||||
|
label="Einheit"
|
||||||
|
value={unitSi}
|
||||||
|
displayEmpty
|
||||||
|
notched
|
||||||
|
onChange={(event) => onChangeSi(event.target.value)}
|
||||||
sx={{
|
sx={{
|
||||||
height: 28,
|
'& .MuiSelect-select': {
|
||||||
fontSize: 13,
|
overflow: 'hidden',
|
||||||
fontWeight: 600,
|
textOverflow: 'ellipsis',
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.08)',
|
whiteSpace: 'nowrap',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
) : null}
|
{units.map((row) => (
|
||||||
|
<MenuItem key={row.si} value={row.si}>
|
||||||
|
{unitLabel(row)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasUnit) {
|
export class PriceUnitCorner extends Component {
|
||||||
return <Box sx={{ minHeight: 40 }} />;
|
render() {
|
||||||
}
|
const { hasUnit, onToggleHasUnit, onOpenEditor } = this.props;
|
||||||
|
return (
|
||||||
return (
|
<Box sx={priceUnitCornerSx}>
|
||||||
<Box
|
<Switch
|
||||||
sx={{
|
size="small"
|
||||||
display: 'flex',
|
checked={Boolean(hasUnit)}
|
||||||
alignItems: 'center',
|
onChange={(event) => onToggleHasUnit(event.target.checked)}
|
||||||
gap: 0.75,
|
inputProps={{ 'aria-label': 'Einheit' }}
|
||||||
flexWrap: 'nowrap',
|
/>
|
||||||
minHeight: 40,
|
<IconButton
|
||||||
minWidth: 0,
|
type="button"
|
||||||
width: '100%',
|
size="small"
|
||||||
}}
|
aria-label="Einheiten bearbeiten"
|
||||||
>
|
onClick={onOpenEditor}
|
||||||
<TextField
|
sx={{ p: 0.5 }}
|
||||||
label="Menge"
|
|
||||||
value={unitamount}
|
|
||||||
onChange={(event) => onChangeAmount(event.target.value)}
|
|
||||||
size="small"
|
|
||||||
error={amountError}
|
|
||||||
sx={{ width: 88, flexShrink: 0 }}
|
|
||||||
slotProps={{
|
|
||||||
inputLabel: { shrink: true },
|
|
||||||
htmlInput: { inputMode: 'decimal' },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormControl size="small" sx={{ flex: 1, minWidth: 0, maxWidth: 160 }}>
|
|
||||||
<InputLabel id="article-unit-si-label" shrink>
|
|
||||||
Einheit
|
|
||||||
</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="article-unit-si-label"
|
|
||||||
label="Einheit"
|
|
||||||
value={unitSi}
|
|
||||||
displayEmpty
|
|
||||||
notched
|
|
||||||
onChange={(event) => onChangeSi(event.target.value)}
|
|
||||||
sx={{
|
|
||||||
'& .MuiSelect-select': {
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{units.map((row) => (
|
<StraightenOutlinedIcon sx={{ fontSize: 28 }} />
|
||||||
<MenuItem key={row.si} value={row.si}>
|
</IconButton>
|
||||||
{unitLabel(row)}
|
</Box>
|
||||||
</MenuItem>
|
);
|
||||||
))}
|
}
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PriceUnitCorner({ hasUnit, onToggleHasUnit, onOpenEditor }) {
|
|
||||||
return (
|
|
||||||
<Box sx={priceUnitCornerSx}>
|
|
||||||
<Switch
|
|
||||||
size="small"
|
|
||||||
checked={Boolean(hasUnit)}
|
|
||||||
onChange={(event) => onToggleHasUnit(event.target.checked)}
|
|
||||||
inputProps={{ 'aria-label': 'Einheit' }}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
type="button"
|
|
||||||
size="small"
|
|
||||||
aria-label="Einheiten bearbeiten"
|
|
||||||
onClick={onOpenEditor}
|
|
||||||
sx={{ p: 0.5 }}
|
|
||||||
>
|
|
||||||
<StraightenOutlinedIcon sx={{ fontSize: 28 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,24 +9,27 @@ const tierPillSx = {
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TierPricePills({ tiers }) {
|
export default class TierPricePills extends Component {
|
||||||
return (
|
render() {
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 22 }}>
|
const { tiers } = this.props;
|
||||||
{(tiers ?? []).map((tier) => {
|
return (
|
||||||
const gross = parsePriceInput(tier.gross);
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 22 }}>
|
||||||
if (gross == null) return null;
|
{(tiers ?? []).map((tier) => {
|
||||||
return (
|
const gross = parsePriceInput(tier.gross);
|
||||||
<Chip
|
if (gross == null) return null;
|
||||||
key={tier.minmultiplier}
|
return (
|
||||||
size="small"
|
<Chip
|
||||||
label={`ab ${tier.minmultiplier} · ${formatPrice(gross)}`}
|
key={tier.minmultiplier}
|
||||||
sx={{
|
size="small"
|
||||||
...tierPillSx,
|
label={`ab ${tier.minmultiplier} · ${formatPrice(gross)}`}
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.08)',
|
sx={{
|
||||||
}}
|
...tierPillSx,
|
||||||
/>
|
bgcolor: 'rgba(0, 0, 0, 0.08)',
|
||||||
);
|
}}
|
||||||
})}
|
/>
|
||||||
</Box>
|
);
|
||||||
);
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -1,124 +1,185 @@
|
|||||||
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,
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', onKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', onKeyDown);
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
if (!sku) {
|
|
||||||
setError('Keine Artikelnr. angegeben');
|
|
||||||
setData(null);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await store.article.get(sku);
|
|
||||||
requireObject(res, 'GET /api/article/:sku');
|
|
||||||
requireObject(res.article, 'article');
|
|
||||||
if (!cancelled) {
|
|
||||||
setData(res.article);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setData(null);
|
|
||||||
setError(backendErrorFrom(err));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
};
|
||||||
}, [sku, reloadKey, store]);
|
this._cancelled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this._cancelled = false;
|
||||||
|
this.installEscape();
|
||||||
|
void this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
const { sku, tab: tabParam, navigate } = this.props;
|
||||||
|
const { data, reloadKey } = this.state;
|
||||||
|
|
||||||
|
if (prevProps.sku !== sku || prevState.reloadKey !== reloadKey) {
|
||||||
|
void this.load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data) return;
|
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]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
componentWillUnmount() {
|
||||||
if (isBackendFailure(error)) {
|
this._cancelled = true;
|
||||||
|
this.removeEscape();
|
||||||
|
}
|
||||||
|
|
||||||
|
installEscape() {
|
||||||
|
this._onKeyDown = (e) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.props.navigate(-1);
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', this._onKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEscape() {
|
||||||
|
if (this._onKeyDown) {
|
||||||
|
document.removeEventListener('keydown', this._onKeyDown);
|
||||||
|
this._onKeyDown = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
retry = () => {
|
||||||
|
this.setState((s) => ({ reloadKey: s.reloadKey + 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const { sku } = this.props;
|
||||||
|
const store = this.context;
|
||||||
|
|
||||||
|
if (!sku) {
|
||||||
|
this.setState({
|
||||||
|
error: 'Keine Artikelnr. angegeben',
|
||||||
|
data: null,
|
||||||
|
categories: null,
|
||||||
|
rootCategoryId: null,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._cancelled = false;
|
||||||
|
this.setState({ loading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [res, treeData, companies, session] = await Promise.all([
|
||||||
|
store.article.get(sku),
|
||||||
|
store.categoryTreePromise,
|
||||||
|
store.companyPromise,
|
||||||
|
store.authMePromise,
|
||||||
|
]);
|
||||||
|
requireObject(res, 'GET /api/article/:sku');
|
||||||
|
requireObject(res.article, 'article');
|
||||||
|
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
|
||||||
|
const company = requireDefined(
|
||||||
|
companies.find((c) => c.id === companyId),
|
||||||
|
'company',
|
||||||
|
);
|
||||||
|
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
|
||||||
|
if (!this._cancelled) {
|
||||||
|
this.setState({
|
||||||
|
data: res.article,
|
||||||
|
categories: parsed.categories,
|
||||||
|
rootCategoryId: parsed.rootCategoryId,
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!this._cancelled) {
|
||||||
|
this.setState({
|
||||||
|
data: null,
|
||||||
|
categories: null,
|
||||||
|
rootCategoryId: null,
|
||||||
|
error: backendErrorFrom(err),
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleArticleUpdated = (next) => {
|
||||||
|
this.setState({ data: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleTabChange = (nextTab) => {
|
||||||
|
const { data } = this.state;
|
||||||
|
this.props.navigate(artikelDetailPath(data, nextTab));
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { tab: tabParam } = this.props;
|
||||||
|
const { data, categories, rootCategoryId, error, loading } = this.state;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<BackendError
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
error={error}
|
<CircularProgress />
|
||||||
onRetry={retry}
|
</Box>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (isBackendFailure(error)) {
|
||||||
|
return <BackendError error={error} onRetry={this.retry} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Alert severity="error" sx={{ maxWidth: 640 }}>
|
||||||
|
{typeof error === 'string' ? error : error.message}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || categories == null || rootCategoryId == null) return null;
|
||||||
|
|
||||||
|
const tab = resolveArticleTab(tabParam, data);
|
||||||
|
if (tabParam !== tab) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert severity="error" sx={{ maxWidth: 640 }}>
|
<ArticleView
|
||||||
{typeof error === 'string' ? error : error.message}
|
article={data}
|
||||||
</Alert>
|
categories={categories}
|
||||||
|
rootCategoryId={rootCategoryId}
|
||||||
|
tab={tab}
|
||||||
|
onTabChange={this.handleTabChange}
|
||||||
|
onArticleUpdated={this.handleArticleUpdated}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
const tab = resolveArticleTab(tabParam, data);
|
|
||||||
if (tabParam !== tab) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ArticleView
|
|
||||||
article={data}
|
|
||||||
tab={tab}
|
|
||||||
onTabChange={(nextTab) => {
|
|
||||||
navigate(artikelDetailPath(data, nextTab));
|
|
||||||
}}
|
|
||||||
onArticleUpdated={setData}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withSkuRoute(ArtikelSkuPage);
|
||||||
|
|||||||
13
src/pages/artikel/withSkuRoute.jsx
Normal file
13
src/pages/artikel/withSkuRoute.jsx
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user