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, {
authMePromise: { get: () => authMeCache.promise },
updateAuthMe: { value: authMeCache.update },
/** Settled GET /auth/me body, or undefined. */
authMe: { get: () => authMeCache.value },
articleIndexPromise: { get: () => articleIndexCache.promise },
updateArticleIndex: { value: articleIndexCache.update },
brandPromise: { get: () => brandCache.promise },
updateBrand: { value: brandCache.update },
/** Settled GET /brand list, or undefined. */
brands: { get: () => brandCache.value },
unitPromise: { get: () => unitCache.promise },
updateUnit: { value: unitCache.update },
/** Settled GET /unit list, or undefined. */
units: { get: () => unitCache.value },
companyPromise: { get: () => companyCache.promise },
updateCompany: { value: companyCache.update },
/** Settled GET /company list, or undefined. */
companies: { get: () => companyCache.value },
categoryTreePromise: { get: () => categoryTreeCache.promise },
updateCategoryTree: { value: categoryTreeCache.update },
/** Settled GET /category/tree body, or undefined. */
categoryTree: { get: () => categoryTreeCache.value },
posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update },

View File

@@ -1,11 +1,8 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Component } from 'react';
import { PanelTileGrid } from '../panels';
import { requireDefined } from '../../utils/apiShape';
import { useStorageManager } from '../../api/storageManagerContext';
import { parseCategoryTreeResponse } from '../../store/categorySlice';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { PANEL_TILE_KEYS } from '../../theme';
import { notifyError } from '../../utils/notify';
import withNavigate from '../withNavigate';
import {
buildCategoryPath,
defaultCategoryPath,
@@ -20,114 +17,92 @@ import ArticleViewEANs from './boxes/ArticleViewEANs';
import ArticleViewPrice from './boxes/ArticleViewPrice';
import ArticleViewLists from './boxes/ArticleViewLists';
export default function ArticleView({
article: articleData,
tab,
onTabChange,
onArticleUpdated,
}) {
const navigate = useNavigate();
const store = useStorageManager();
const [categories, setCategories] = useState(null);
const [rootCategoryId, setRootCategoryId] = useState(null);
class ArticleView extends Component {
static contextType = StorageManagerContext;
useEffect(() => {
let cancelled = false;
async function load() {
try {
const [treeData, companies, session] = await Promise.all([
store.categoryTreePromise,
store.companyPromise,
store.authMePromise,
]);
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
const company = requireDefined(
companies.find((c) => c.id === companyId),
'company',
);
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
if (!cancelled) {
setCategories(parsed.categories);
setRootCategoryId(parsed.rootCategoryId);
}
} catch (err) {
if (!cancelled) notifyError(err);
}
}
void load();
return () => {
cancelled = true;
};
}, [store]);
const refreshSearchCaches = () => {
void store.updateArticleIndex();
void store.updateCategoryTree();
refreshSearchCaches = () => {
void this.context.updateArticleIndex();
void this.context.updateCategoryTree();
};
const handleArticleUpdated = (next) => {
onArticleUpdated?.(next);
refreshSearchCaches();
handleArticleUpdated = (next) => {
this.props.onArticleUpdated?.(next);
this.refreshSearchCaches();
};
const handleArticleDeleted = () => {
refreshSearchCaches();
const categoryId = requireDefined(
articleData.categoryIds?.[0],
'article.categoryIds[0]',
);
if (categories && rootCategoryId != null) {
handleArticleDeleted = () => {
const {
article: articleData,
navigate,
categories,
rootCategoryId,
} = this.props;
this.refreshSearchCaches();
const categoryId = articleData.categoryIds?.[0];
if (categories && rootCategoryId != null && categoryId != null) {
navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
return;
}
navigate(defaultCategoryPath());
};
return (
<PanelTileGrid>
<ArticleViewName
article={articleData}
categories={categories}
rootCategoryId={rootCategoryId}
onArticleUpdated={handleArticleUpdated}
/>
render() {
const {
article: articleData,
tab,
onTabChange,
categories,
rootCategoryId,
} = this.props;
<ArticlePhotoTile
wide={1}
tileKey={PANEL_TILE_KEYS.photo}
sx={{ minHeight: 0 }}
article={articleData}
onArticleUpdated={handleArticleUpdated}
/>
return (
<PanelTileGrid>
<ArticleViewName
article={articleData}
categories={categories}
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
article={articleData}
onArticleUpdated={handleArticleUpdated}
/>
<ArticleViewStock article={articleData} />
<ArticleViewEANs
article={articleData}
onArticleUpdated={handleArticleUpdated}
/>
<ArticleViewBrand
article={articleData}
onArticleUpdated={this.handleArticleUpdated}
/>
<ArticleViewPrice
article={articleData}
onArticleUpdated={handleArticleUpdated}
/>
<ArticleViewEANs
article={articleData}
onArticleUpdated={this.handleArticleUpdated}
/>
<ArticleTileTab
wide={3}
tileKey={PANEL_TILE_KEYS.content}
minHeight={200}
article={articleData}
tab={tab}
onTabChange={onTabChange}
onArticleDeleted={handleArticleDeleted}
/>
<ArticleViewPrice
article={articleData}
onArticleUpdated={this.handleArticleUpdated}
/>
<ArticleViewLists article={articleData} />
</PanelTileGrid>
);
<ArticleTileTab
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 TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import { EditablePanelTile } from '../../panels';
import { useStorageManager } from '../../../api/storageManagerContext';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../../utils/apiShape';
import { notifyError } from '../../../utils/notify';
import { PANEL_TILE_KEYS } from '../../../theme';
function PanelValue({ label, children }) {
if (children == null || children === '') return null;
return (
<Box sx={{ mb: 1 }}>
{label ? (
<Typography variant="caption" color="text.secondary" display="block">
{label}
class PanelValue extends Component {
render() {
const { label, children } = this.props;
if (children == null || children === '') return null;
return (
<Box sx={{ mb: 1 }}>
{label ? (
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
) : null}
<Typography variant="body2" component="div">
{children}
</Typography>
) : null}
<Typography variant="body2" component="div">
{children}
</Typography>
</Box>
);
</Box>
);
}
}
function brandDraftValue(article) {
@@ -39,25 +42,70 @@ function brandDraftValid(draft) {
const SECOND_ROW_MIN_HEIGHT = 220;
export default function ArticleViewBrand({ article: articleData, onArticleUpdated }) {
const store = useStorageManager();
const [brands, setBrands] = useState([]);
class BrandEdit extends Component {
render() {
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(() => {
let cancelled = false;
store.brandPromise
export default class ArticleViewBrand extends Component {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
const brands = this.context?.brands;
this.state = {
brands: brands ?? [],
};
this._cancelled = false;
this._brandsReady = brands != null;
}
componentDidMount() {
this._cancelled = false;
if (this._brandsReady) return;
this.context.brandPromise
.then((list) => {
if (!cancelled) setBrands(list);
if (!this._cancelled) this.setState({ brands: list });
})
.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();
if (!name) {
throw new Error('Bitte eine Marke angeben.');
@@ -77,7 +125,7 @@ export default function ArticleViewBrand({ article: articleData, onArticleUpdate
requireDefined(createdRes.brand.id, 'brand.id');
brandId = createdRes.brand.id;
const nextBrands = await store.updateBrand();
setBrands(nextBrands);
this.setState({ brands: nextBrands });
}
const res = await store.article.setBrand(articleData.sku, { brandId });
@@ -86,44 +134,24 @@ export default function ArticleViewBrand({ article: articleData, onArticleUpdate
onArticleUpdated?.(res.article);
};
return (
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.brand}
title="Marke"
minHeight={SECOND_ROW_MIN_HEIGHT}
value={brandDraftValue(articleData)}
onSave={handleSave}
isDirty={brandDraftDirty}
canSave={brandDraftValid}
renderDisplay={(saved) => (
<PanelValue>{saved.name || null}</PanelValue>
)}
renderEdit={(draft, onChange, inputRef) => (
<Autocomplete
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 } }}
/>
)}
/>
)}
/>
);
render() {
const { article: articleData } = this.props;
const { brands } = this.state;
const value = brandDraftValue(articleData);
return (
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.brand}
title="Marke"
minHeight={SECOND_ROW_MIN_HEIGHT}
value={value}
onSave={this.handleSave}
isDirty={brandDraftDirty}
canSave={brandDraftValid}
display={<PanelValue>{value.name || null}</PanelValue>}
edit={<BrandEdit brands={brands} />}
/>
);
}
}

View File

@@ -1,27 +1,31 @@
import { Component } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import { EditablePanelTile } from '../../panels';
import { useStorageManager } from '../../../api/storageManagerContext';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireObject } from '../../../utils/apiShape';
import { articleEans } from '../../../utils/articleDetail';
import { PANEL_TILE_KEYS } from '../../../theme';
function PanelValue({ label, children }) {
if (children == null || children === '') return null;
return (
<Box sx={{ mb: 1 }}>
{label ? (
<Typography variant="caption" color="text.secondary" display="block">
{label}
class PanelValue extends Component {
render() {
const { label, children } = this.props;
if (children == null || children === '') return null;
return (
<Box sx={{ mb: 1 }}>
{label ? (
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
) : null}
<Typography variant="body2" component="div">
{children}
</Typography>
) : null}
<Typography variant="body2" component="div">
{children}
</Typography>
</Box>
);
</Box>
);
}
}
function eansDraftValue(article) {
@@ -40,59 +44,79 @@ function eansDraftValid(draft) {
const SECOND_ROW_MIN_HEIGHT = 220;
export default function ArticleViewEANs({ article: articleData, onArticleUpdated }) {
const store = useStorageManager();
class EansDisplay extends Component {
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 ?? [])
.map((ean) => String(ean).trim())
.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.article, 'article');
onArticleUpdated?.(res.article);
};
return (
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.identifiers}
title="SKU / EAN"
minHeight={SECOND_ROW_MIN_HEIGHT}
contentAlignItems="flex-start"
value={eansDraftValue(articleData)}
onSave={handleSave}
isDirty={eansDraftDirty}
canSave={eansDraftValid}
renderDisplay={(saved) => (
<>
<PanelValue label="SKU">{articleData.sku}</PanelValue>
{(saved.eans ?? []).map((ean) => (
<PanelValue key={ean} label="EAN">
{ean}
</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 } }}
/>
)}
/>
)}
/>
);
render() {
const { article: articleData } = this.props;
const value = eansDraftValue(articleData);
return (
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.identifiers}
title="SKU / EAN"
minHeight={SECOND_ROW_MIN_HEIGHT}
contentAlignItems="flex-start"
value={value}
onSave={this.handleSave}
isDirty={eansDraftDirty}
canSave={eansDraftValid}
display={<EansDisplay sku={articleData.sku} eans={value.eans} />}
edit={<EansEdit />}
/>
);
}
}

View File

@@ -1,3 +1,4 @@
import { Component } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography';
@@ -5,55 +6,61 @@ import { PanelTile } from '../../panels';
import { PANEL_TILE_KEYS } from '../../../theme';
import { artikelDetailPath } from '../../../pages/artikel/articleRoute';
function CountLine({ label, count }) {
if (count == null) return null;
return (
<Typography variant="body2">
{label}: {count}
</Typography>
);
class CountLine extends Component {
render() {
const { label, count } = this.props;
if (count == null) return null;
return (
<Typography variant="body2">
{label}: {count}
</Typography>
);
}
}
function RelationLine({ label, active, children }) {
return (
<Typography variant="body2" sx={{ mb: 0.5 }}>
{label}:{' '}
{active ? (
children ?? 'ja'
) : (
<Typography component="span" variant="body2" color="text.secondary">
nein
</Typography>
)}
</Typography>
);
class RelationLine extends Component {
render() {
const { label, active, children } = this.props;
return (
<Typography variant="body2" sx={{ mb: 0.5 }}>
{label}:{' '}
{active ? (
children ?? 'ja'
) : (
<Typography component="span" variant="body2" color="text.secondary">
nein
</Typography>
)}
</Typography>
);
}
}
export default function ArticleViewLists({ article: articleData }) {
return (
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.lists} title="Listen" minHeight={200}>
<RelationLine
label="Vaterartikel"
active={articleData.childCount > 0}
>
{articleData.childCount}
</RelationLine>
<RelationLine
label="Kindartikel"
active={articleData.fatherArticleId != null}
>
{articleData.fatherSku ? (
<Link
component={RouterLink}
to={artikelDetailPath(articleData.fatherSku)}
>
{articleData.fatherSku}
</Link>
) : null}
</RelationLine>
<CountLine label="Optionen" count={articleData.optionsCount} />
<CountLine label="Stücklisten" count={articleData.articleListCount} />
<CountLine label="Varianten" count={articleData.childCount} />
</PanelTile>
);
export default class ArticleViewLists extends Component {
render() {
const { article: articleData } = this.props;
return (
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.lists} title="Listen" minHeight={200}>
<RelationLine label="Vaterartikel" active={articleData.childCount > 0}>
{articleData.childCount}
</RelationLine>
<RelationLine
label="Kindartikel"
active={articleData.fatherArticleId != null}
>
{articleData.fatherSku ? (
<Link
component={RouterLink}
to={artikelDetailPath(articleData.fatherSku)}
>
{articleData.fatherSku}
</Link>
) : null}
</RelationLine>
<CountLine label="Optionen" count={articleData.optionsCount} />
<CountLine label="Stücklisten" count={articleData.articleListCount} />
<CountLine label="Varianten" count={articleData.childCount} />
</PanelTile>
);
}
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Component } from 'react';
import { EditablePanelTile } from '../../panels';
import SingleLineTextEdit from '../../SingleLineTextEdit';
import { useStorageManager } from '../../../api/storageManagerContext';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireObject } from '../../../utils/apiShape';
import { PANEL_TILE_KEYS } from '../../../theme';
import { getCategoryBreadcrumbs } from '../../../store/categorySlice';
@@ -45,6 +45,13 @@ const pillsContainerSx = {
mt: 'auto',
};
const columnSx = {
display: 'flex',
flexDirection: 'column',
flex: 1,
alignSelf: 'stretch',
};
function draftDirty(draft, saved) {
const nameDiff = draft.name.trim() !== String(saved.name ?? '').trim();
const a = draft.categoryIds ?? [];
@@ -70,61 +77,156 @@ function buildCategoryLabel(categories, categoryId, rootCategoryId) {
return crumbs.map((c) => c.name).join(' | ');
}
function CategoryPills({
categoryLabels,
categoryIds,
categories,
rootCategoryId,
sku,
editMode,
onCategoryIdsChange,
}) {
const [pickerOpen, setPickerOpen] = useState(false);
function categoryLabels(categoryIds, categories, rootCategoryId) {
return (categoryIds ?? [])
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
.filter((c) => c.label);
}
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));
onCategoryIdsChange?.(remaining);
};
return (
<>
<Box sx={pillsContainerSx}>
{categoryLabels.map(({ id, label }) => (
<Chip
key={id}
label={label}
size="small"
variant="outlined"
onDelete={editMode && categoryIds.length > 1 ? () => handleRemoveCategory(id) : undefined}
/>
))}
{editMode && (
<IconButton
size="small"
onClick={() => setPickerOpen(true)}
sx={{ ml: 0.5, width: 24, height: 24 }}
>
<AddIcon fontSize="small" />
</IconButton>
)}
</Box>
<CategoryPickerDialog
key={sku}
open={pickerOpen}
onClose={() => setPickerOpen(false)}
categories={categories}
rootCategoryId={rootCategoryId}
currentCategoryIds={categoryIds}
onSelect={onCategoryIdsChange}
/>
</>
);
handleOpenPicker = () => {
this.setState({ pickerOpen: true });
};
handleClosePicker = () => {
this.setState({ pickerOpen: false });
};
render() {
const {
categoryLabels: labels,
categoryIds,
categories,
rootCategoryId,
sku,
editMode,
onCategoryIdsChange,
} = this.props;
const { pickerOpen } = this.state;
return (
<>
<Box sx={pillsContainerSx}>
{labels.map(({ id, label }) => (
<Chip
key={id}
label={label}
size="small"
variant="outlined"
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 }) {
const store = useStorageManager();
class NameDisplay extends Component {
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 categoryIds = draft.categoryIds ?? [];
@@ -138,73 +240,38 @@ export default function ArticleViewName({ article: articleData, categories, root
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 (
<EditablePanelTile
wide={3}
tileKey={PANEL_TILE_KEYS.name}
minHeight={0}
sx={headerTileSx}
value={{ name: articleData.name ?? '', categoryIds: [...savedCategoryIds] }}
onSave={handleSave}
isDirty={draftDirty}
canSave={draftValid}
renderDisplay={({ name, categoryIds }) => {
const labels = (categoryIds ?? [])
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
.filter((c) => c.label);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, alignSelf: 'stretch' }}>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<SingleLineTextEdit
label="Name"
value={name}
readOnly
onChange={() => {}}
sx={articleNameFieldHiddenSx}
/>
</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>
);
}}
/>
);
return (
<EditablePanelTile
wide={3}
tileKey={PANEL_TILE_KEYS.name}
minHeight={0}
sx={headerTileSx}
value={value}
onSave={this.handleSave}
isDirty={draftDirty}
canSave={draftValid}
display={
<NameDisplay
name={value.name}
categoryIds={value.categoryIds}
categories={categories}
rootCategoryId={rootCategoryId}
sku={articleData.sku}
/>
}
edit={
<NameEdit
categories={categories}
rootCategoryId={rootCategoryId}
sku={articleData.sku}
/>
}
/>
);
}
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { Component } from 'react';
import Box from '@mui/material/Box';
import InputAdornment from '@mui/material/InputAdornment';
import TextField from '@mui/material/TextField';
@@ -6,7 +6,7 @@ import Typography from '@mui/material/Typography';
import { EditablePanelTile } from '../../panels';
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
import { useStorageManager } from '../../../api/storageManagerContext';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../../utils/apiShape';
import {
buildPosPricesPutBody,
@@ -91,8 +91,6 @@ const priceBodySx = {
gap: 0.75,
};
/* ── helper functions ──────────────────────────────────────────────── */
function articleHasPackUnit(art) {
const si = art.unit?.si;
const amount = art.unitamount;
@@ -170,93 +168,256 @@ function priceDraftValid(draft) {
return true;
}
/* ── sub-component: amount field ──────────────────────────────────── */
function PriceAmountField({
value,
onChange,
inputRef,
readOnly,
error,
}) {
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,
},
}}
/>
);
function companyFromStore(store) {
if (!store) return null;
const companies = store.companies;
const session = store.authMe;
if (companies == null || session == null) return null;
const companyId = session.user?.selectedCompanyId;
if (companyId == null) return null;
return companies.find((c) => c.id === companyId) ?? null;
}
/* ── main component ────────────────────────────────────────────────── */
export default function ArticleViewPrice({ article: articleData, onArticleUpdated }) {
const store = useStorageManager();
const [tiersDialogOpen, setTiersDialogOpen] = useState(false);
const [unitEditorOpen, setUnitEditorOpen] = useState(false);
const [units, setUnits] = useState([]);
const [company, setCompany] = useState(null);
const basePrice = primaryPrice(articleData);
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
const priceValue = priceDraftValue(articleData, company);
const unitOptions =
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);
class PriceAmountField extends Component {
render() {
const { value, onChange, inputRef, readOnly, error } = this.props;
return (
<TextField
inputRef={inputRef}
value={value}
onChange={
readOnly
? undefined
: (event) => {
onChange?.(event.target.value);
}
}
} catch (err) {
if (!cancelled) notifyError(err);
}
}
void load();
return () => {
cancelled = true;
};
}, [store]);
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,
},
}}
/>
);
}
}
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);
let nextArticle = articleData;
@@ -315,134 +476,73 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate
onArticleUpdated?.(nextArticle);
};
return (
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.price}
minHeight={SECOND_ROW_MIN_HEIGHT}
contentAlignItems="flex-start"
value={priceValue}
onSave={handleSave}
isDirty={priceDraftDirty}
canSave={priceDraftValid}
renderOverlay={({ editing, draft, onChange }) => {
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 || articleData.unit?.si,
);
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={() => setUnitEditorOpen(true)}
openTiersDialog = () => this.setState({ tiersDialogOpen: true });
closeTiersDialog = () => this.setState({ tiersDialogOpen: false });
openUnitEditor = () => this.setState({ unitEditorOpen: true });
closeUnitEditor = () => this.setState({ unitEditorOpen: false });
unitOptions() {
const { article: articleData } = this.props;
const { units } = this.state;
if (articleData.unit?.si && !findUnitBySi(units, articleData.unit.si)) {
return [...units, articleData.unit];
}
return units;
}
refreshUnits = async () => {
this.setState({ units: await this.context.unitPromise });
};
render() {
const { article: articleData } = this.props;
const { company, tiersDialogOpen, unitEditorOpen } = this.state;
const priceValue = priceDraftValue(articleData, company);
const basePrice = primaryPrice(articleData);
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
const unitOptions = this.unitOptions();
const displaySymbol = articleData.unit?.symbol;
return (
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.price}
minHeight={SECOND_ROW_MIN_HEIGHT}
contentAlignItems="flex-start"
value={priceValue}
onSave={this.handleSave}
isDirty={priceDraftDirty}
canSave={priceDraftValid}
overlay={
<PriceOverlay
unitOptions={unitOptions}
articleUnitSi={articleData.unit?.si}
onOpenEditor={this.openUnitEditor}
/>
);
}}
renderDisplay={(saved) => (
<Box sx={priceBodySx}>
<PriceAmountField value={saved.gross} readOnly />
<PriceTaxRow
rates={taxRates}
selected={saved.taxRate}
readOnly
}
display={
<PriceDisplay
saved={priceValue}
taxRates={taxRates}
unitOptions={unitOptions}
displaySymbol={displaySymbol}
/>
<TierPricePills tiers={saved.tiers} />
<PriceUnitRow
hasUnit={saved.hasUnit}
unitamount={saved.unitamount}
unitSi={saved.unitSi}
units={unitOptions}
displaySymbol={articleData.unit?.symbol}
readOnly
}
edit={
<PriceEdit
taxRates={taxRates}
unitOptions={unitOptions}
displaySymbol={displaySymbol}
tiersDialogOpen={tiersDialogOpen}
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 Typography from '@mui/material/Typography';
import { PanelTile } from '../../panels';
import { PANEL_TILE_KEYS } from '../../../theme';
import { formatStock } from '../../../pages/artikel/searchFormat';
function PanelValue({ label, children }) {
if (children == null || children === '') return null;
return (
<Box sx={{ mb: 1 }}>
{label ? (
<Typography variant="caption" color="text.secondary" display="block">
{label}
class PanelValue extends Component {
render() {
const { label, children } = this.props;
if (children == null || children === '') return null;
return (
<Box sx={{ mb: 1 }}>
{label ? (
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
) : null}
<Typography variant="body2" component="div">
{children}
</Typography>
) : null}
<Typography variant="body2" component="div">
{children}
</Typography>
</Box>
);
</Box>
);
}
}
const SECOND_ROW_MIN_HEIGHT = 220;
export default function ArticleViewStock({ article: articleData }) {
return (
<PanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.stock}
title="Bestand"
minHeight={SECOND_ROW_MIN_HEIGHT}
>
<PanelValue>
{articleData.stock != null ? formatStock(articleData.stock) : null}
</PanelValue>
</PanelTile>
);
export default class ArticleViewStock extends Component {
render() {
const { article: articleData } = this.props;
return (
<PanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.stock}
title="Bestand"
minHeight={SECOND_ROW_MIN_HEIGHT}
>
<PanelValue>
{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 Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
@@ -10,72 +11,72 @@ const taxPillSx = {
'& .MuiChip-label': { px: 1 },
};
export function TaxRatePills({ rates, selected, onSelect, readOnly }) {
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 28 }}>
{rates.map((rate) => {
const active = Number(selected) === Number(rate);
return (
<Chip
key={rate}
size="small"
label={`${rate} %`}
onClick={readOnly || !onSelect ? undefined : () => onSelect(rate)}
sx={{
...taxPillSx,
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)' },
}),
}}
/>
);
})}
</Box>
);
export class TaxRatePills extends Component {
render() {
const { rates, selected, onSelect, readOnly } = this.props;
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 28 }}>
{rates.map((rate) => {
const active = Number(selected) === Number(rate);
return (
<Chip
key={rate}
size="small"
label={`${rate} %`}
onClick={readOnly || !onSelect ? undefined : () => onSelect(rate)}
sx={{
...taxPillSx,
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)' },
}),
}}
/>
);
})}
</Box>
);
}
}
export function PriceTaxRow({
rates,
selected,
onSelectTax,
readOnly,
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}
export class PriceTaxRow extends Component {
render() {
const { rates, selected, onSelectTax, readOnly, onOpenTiers } = this.props;
return (
<Box
sx={{
p: 0.5,
visibility: readOnly ? 'hidden' : 'visible',
pointerEvents: readOnly ? 'none' : 'auto',
display: 'flex',
alignItems: 'center',
gap: 0.5,
flexWrap: 'wrap',
minHeight: 36,
}}
>
<LayersOutlinedIcon sx={{ fontSize: 28 }} />
</IconButton>
</Box>
);
<TaxRatePills
rates={rates}
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 Chip from '@mui/material/Chip';
import FormControl from '@mui/material/FormControl';
@@ -21,115 +22,121 @@ const priceUnitCornerSx = {
zIndex: 2,
};
export function PriceUnitRow({
hasUnit,
unitamount,
unitSi,
units,
displaySymbol,
onChangeAmount,
onChangeSi,
readOnly,
amountError,
}) {
const selected = findUnitBySi(units, unitSi);
const symbol = selected?.symbol ?? displaySymbol ?? unitSi ?? '';
export class PriceUnitRow extends Component {
render() {
const {
hasUnit,
unitamount,
unitSi,
units,
displaySymbol,
onChangeAmount,
onChangeSi,
readOnly,
amountError,
} = 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 (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minHeight: 36 }}>
{hasUnit && unitamount ? (
<Chip
size="medium"
label={`${unitamount} ${symbol}`}
<Box
sx={{
display: 'flex',
alignItems: 'center',
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={{
height: 28,
fontSize: 13,
fontWeight: 600,
bgcolor: 'rgba(0, 0, 0, 0.08)',
'& .MuiSelect-select': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
}}
/>
) : null}
>
{units.map((row) => (
<MenuItem key={row.si} value={row.si}>
{unitLabel(row)}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
}
}
if (!hasUnit) {
return <Box sx={{ minHeight: 40 }} />;
}
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
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={{
'& .MuiSelect-select': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
}}
export class PriceUnitCorner extends Component {
render() {
const { hasUnit, onToggleHasUnit, onOpenEditor } = this.props;
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 }}
>
{units.map((row) => (
<MenuItem key={row.si} value={row.si}>
{unitLabel(row)}
</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>
);
<StraightenOutlinedIcon sx={{ fontSize: 28 }} />
</IconButton>
</Box>
);
}
}

View File

@@ -1,3 +1,4 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import { formatPrice, parsePriceInput } from '../../../utils/price';
@@ -8,24 +9,27 @@ const tierPillSx = {
fontWeight: 600,
};
export default function TierPricePills({ tiers }) {
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 22 }}>
{(tiers ?? []).map((tier) => {
const gross = parsePriceInput(tier.gross);
if (gross == null) return null;
return (
<Chip
key={tier.minmultiplier}
size="small"
label={`ab ${tier.minmultiplier} · ${formatPrice(gross)}`}
sx={{
...tierPillSx,
bgcolor: 'rgba(0, 0, 0, 0.08)',
}}
/>
);
})}
</Box>
);
export default class TierPricePills extends Component {
render() {
const { tiers } = this.props;
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, minHeight: 22 }}>
{(tiers ?? []).map((tier) => {
const gross = parsePriceInput(tier.gross);
if (gross == null) return null;
return (
<Chip
key={tier.minmultiplier}
size="small"
label={`ab ${tier.minmultiplier} · ${formatPrice(gross)}`}
sx={{
...tierPillSx,
bgcolor: 'rgba(0, 0, 0, 0.08)',
}}
/>
);
})}
</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 EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
@@ -9,7 +9,6 @@ import IconButton from '@mui/material/IconButton';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import { injectMuiTheme } from './injectMuiTheme';
import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../../utils/notify';
import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout';
@@ -20,6 +19,11 @@ function valuesEqual(a, b) {
/**
* Panel tile with centralized inline-edit chrome.
* 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 {
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() {
const {
onSave,
@@ -199,13 +228,9 @@ class EditablePanelTileBase extends Component {
sx,
minHeight = 120,
tileKey,
renderDisplay,
renderEdit,
renderOverlay,
contentAlignItems = 'center',
} = this.props;
const { editing, draft, error } = this.state;
const savedValue = this.getSavedValue();
const { editing, error } = this.state;
const pending = this.isDirty();
const actionPadding = onSave ? TILE_ACTION_PADDING : 0;
@@ -223,13 +248,7 @@ class EditablePanelTileBase extends Component {
})}
>
{this.renderActions()}
{typeof renderOverlay === 'function'
? renderOverlay({
editing,
draft,
onChange: this.handleDraftChange,
})
: null}
{this.renderOverlayNode()}
{this.renderTitle()}
<Box
sx={{
@@ -241,9 +260,7 @@ class EditablePanelTileBase extends Component {
}}
onKeyDown={editing ? this.handleEditKeyDown : undefined}
>
{editing && draft != null
? renderEdit(draft, this.handleDraftChange, this.inputRef)
: renderDisplay(savedValue)}
{this.renderBody()}
</Box>
{error ? (
<Alert severity="error" sx={{ mt: 1, py: 0 }}>

View File

@@ -1,124 +1,185 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Component } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { useStorageManager } from '../../api/storageManagerContext';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { ArticleView } from '../../components/article';
import BackendError from '../../components/BackendError';
import { requireObject } from '../../utils/apiShape';
import { requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom, isBackendFailure } from '../../utils/backendError';
import { parseCategoryTreeResponse } from '../../store/categorySlice';
import {
artikelDetailPath,
resolveArticleTab,
} from './articleRoute';
import withSkuRoute from './withSkuRoute';
export default function ArtikelSkuPage() {
const { sku: skuParam, tab: tabParam } = useParams();
const navigate = useNavigate();
const store = useStorageManager();
const sku = skuParam ? decodeURIComponent(skuParam) : '';
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
class ArtikelSkuPage extends Component {
static contextType = StorageManagerContext;
const retry = useCallback(() => {
setReloadKey((key) => key + 1);
}, []);
useEffect(() => {
function onKeyDown(e) {
if (e.key !== 'Escape') return;
e.preventDefault();
navigate(-1);
}
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;
constructor(props) {
super(props);
this.state = {
data: null,
categories: null,
rootCategoryId: null,
error: null,
loading: true,
reloadKey: 0,
};
}, [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;
const resolved = resolveArticleTab(tabParam, data);
if (tabParam !== resolved) {
navigate(artikelDetailPath(data, resolved), { replace: true });
}
}, [data, tabParam, navigate]);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (error) {
if (isBackendFailure(error)) {
componentWillUnmount() {
this._cancelled = true;
this.removeEscape();
}
installEscape() {
this._onKeyDown = (e) => {
if (e.key !== 'Escape') return;
e.preventDefault();
this.props.navigate(-1);
};
document.addEventListener('keydown', this._onKeyDown);
}
removeEscape() {
if (this._onKeyDown) {
document.removeEventListener('keydown', this._onKeyDown);
this._onKeyDown = null;
}
}
retry = () => {
this.setState((s) => ({ reloadKey: s.reloadKey + 1 }));
};
async load() {
const { sku } = this.props;
const store = this.context;
if (!sku) {
this.setState({
error: 'Keine Artikelnr. angegeben',
data: null,
categories: null,
rootCategoryId: null,
loading: false,
});
return;
}
this._cancelled = false;
this.setState({ loading: true, error: null });
try {
const [res, treeData, companies, session] = await Promise.all([
store.article.get(sku),
store.categoryTreePromise,
store.companyPromise,
store.authMePromise,
]);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
const company = requireDefined(
companies.find((c) => c.id === companyId),
'company',
);
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
if (!this._cancelled) {
this.setState({
data: res.article,
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
error: null,
loading: false,
});
}
} catch (err) {
if (!this._cancelled) {
this.setState({
data: null,
categories: null,
rootCategoryId: null,
error: backendErrorFrom(err),
loading: false,
});
}
}
}
handleArticleUpdated = (next) => {
this.setState({ data: next });
};
handleTabChange = (nextTab) => {
const { data } = this.state;
this.props.navigate(artikelDetailPath(data, nextTab));
};
render() {
const { tab: tabParam } = this.props;
const { data, categories, rootCategoryId, error, loading } = this.state;
if (loading) {
return (
<BackendError
error={error}
onRetry={retry}
/>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</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 (
<Alert severity="error" sx={{ maxWidth: 640 }}>
{typeof error === 'string' ? error : error.message}
</Alert>
<ArticleView
article={data}
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;
}