sotre
This commit is contained in:
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user