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,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);

View File

@@ -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 } }}
/>
)}
/>
)}
/>
);
} }

View File

@@ -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 } }}
/>
)}
/>
)}
/>
);
} }

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,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> );
); }
} }

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,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>
);
}}
/>
);
} }

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,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>
);
}}
/>
);
} }

View File

@@ -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>
);
}
} }

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,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>
);
}
} }

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,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>
);
} }

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,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>
);
}
} }

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,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);

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