Add editable article prices with gross input and staffel tiers.

Wire PUT /prices, de-DE formatting, tax/staffel pills, and keep the category list cache in sync after photo/price updates.
This commit is contained in:
seb
2026-08-01 05:44:34 +02:00
parent 09fb1da335
commit 335f808421
9 changed files with 709 additions and 22 deletions

View File

@@ -0,0 +1,185 @@
import { Component } from 'react';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { formatPriceAmount, parsePriceInput } from '../../utils/price';
function emptyTier() {
return { minmultiplier: '', gross: '' };
}
function parseMultiplier(value) {
if (value === '' || value == null) return null;
const n = Number(String(value).trim());
return Number.isInteger(n) && n >= 2 ? n : null;
}
function tiersValid(tiers) {
const seen = new Set();
for (const tier of tiers) {
const mult = parseMultiplier(tier.minmultiplier);
const gross = parsePriceInput(tier.gross);
if (mult == null || gross == null) return false;
if (seen.has(mult)) return false;
seen.add(mult);
}
return true;
}
class ArticlePriceTiersDialog extends Component {
constructor(props) {
super(props);
this.state = { tiers: [] };
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
const tiers = this.props.tiers?.length
? this.props.tiers.map((row) => ({
minmultiplier: String(row.minmultiplier),
gross: row.gross,
}))
: [];
this.setState({ tiers });
}
}
handleChange = (index, field, value) => {
this.setState((state) => {
const tiers = state.tiers.map((row, i) =>
i === index ? { ...row, [field]: value } : row,
);
return { tiers };
});
};
handleAdd = () => {
this.setState((state) => ({ tiers: [...state.tiers, emptyTier()] }));
};
handleRemove = (index) => {
this.setState((state) => ({
tiers: state.tiers.filter((_, i) => i !== index),
}));
};
handleApply = () => {
const { tiers } = this.state;
if (!tiersValid(tiers)) return;
this.props.onApply(
tiers.map((row) => ({
minmultiplier: parseMultiplier(row.minmultiplier),
gross: formatPriceAmount(parsePriceInput(row.gross)),
})),
);
this.props.onClose();
};
render() {
const { open, onClose } = this.props;
const { tiers } = this.state;
const canApply = tiersValid(tiers);
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Staffelpreise</DialogTitle>
<DialogContent dividers>
{tiers.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 1 }}>
Keine Staffeln. Basispreis bleibt der Einzelpreis (×1).
</Typography>
) : null}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{tiers.map((tier, index) => {
const mult = parseMultiplier(tier.minmultiplier);
const gross = parsePriceInput(tier.gross);
return (
<Box
key={index}
sx={{
display: 'flex',
gap: 1,
alignItems: 'flex-start',
}}
>
<TextField
label="Ab Menge"
value={tier.minmultiplier}
onChange={(event) =>
this.handleChange(index, 'minmultiplier', event.target.value)
}
size="small"
error={tier.minmultiplier !== '' && mult == null}
slotProps={{
inputLabel: { shrink: true },
htmlInput: { inputMode: 'numeric' },
}}
sx={{ width: 120 }}
/>
<TextField
label="Brutto"
value={tier.gross}
onChange={(event) =>
this.handleChange(index, 'gross', event.target.value)
}
size="small"
fullWidth
error={tier.gross !== '' && gross == null}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">€</InputAdornment>
),
},
htmlInput: { inputMode: 'decimal' },
}}
/>
<IconButton
type="button"
aria-label="Staffel entfernen"
onClick={() => this.handleRemove(index)}
sx={{ mt: 0.5 }}
>
<DeleteOutlineOutlinedIcon />
</IconButton>
</Box>
);
})}
</Box>
<Button
type="button"
startIcon={<AddIcon />}
onClick={this.handleAdd}
sx={{ mt: 2 }}
>
Staffel hinzufügen
</Button>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button type="button" onClick={onClose}>
Abbrechen
</Button>
<Button
type="button"
variant="contained"
onClick={this.handleApply}
disabled={!canApply}
>
Übernehmen
</Button>
</DialogActions>
</Dialog>
);
}
}
export default ArticlePriceTiersDialog;

View File

@@ -201,7 +201,6 @@ class ArticleTileTab extends Component {
value={tab}
onChange={this.handleTabChange}
variant="standard"
TabIndicatorProps={{ sx: { display: 'none', height: 0 } }}
sx={{
position: 'relative',
zIndex: 1,

View File

@@ -1,10 +1,17 @@
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { useState } from 'react';
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import Link from '@mui/material/Link';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import SingleLineTextEdit from '../SingleLineTextEdit';
import ArticlePhotoTile from './ArticlePhotoTile';
import ArticlePriceTiersDialog from './ArticlePriceTiersDialog';
import ArticleTileTab from './ArticleTileTab';
import { article } from '../../api';
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
@@ -12,10 +19,19 @@ import { requireDefined, requireObject } from '../../utils/apiShape';
import {
articleEans,
brandName,
buildPosPricesPutBody,
companyTaxRateOptions,
priceBrutto,
primaryPhoto,
primaryPrice,
tierDraftFromArticle,
} from '../../utils/articleDetail';
import { formatPrice } from '../../utils/price';
import {
formatPrice,
formatPriceAmount,
netFromGross,
parsePriceInput,
} from '../../utils/price';
import { PANEL_TILE_KEYS } from '../../theme';
import { formatStock } from '../../pages/artikel/searchFormat';
import {
@@ -23,9 +39,11 @@ import {
defaultCategoryPath,
} from '../../pages/artikel/categoryRoute';
import {
patchArticleInLists,
removeArticleBySku,
selectCategoryRootId,
} from '../../store/categorySlice';
import { selectCurrentCompany } from '../../store/companySlice';
function PanelValue({ label, children }) {
if (children == null || children === '') return null;
@@ -47,6 +65,9 @@ const headerTileSx = {
minHeight: 0,
};
/** Second article row (Bestand / Marke / SKU / Preis). */
const SECOND_ROW_MIN_HEIGHT = 176;
function CountLine({ label, count }) {
if (count == null) return null;
return (
@@ -79,6 +100,44 @@ function nameDraftValid(draft) {
return draft.name.trim().length > 0;
}
function priceDraftValue(article, company) {
const basePrice = primaryPrice(article);
const gross = priceBrutto(basePrice);
return {
gross: gross != null ? formatPriceAmount(gross) : '',
taxRate:
basePrice?.taxRate != null
? basePrice.taxRate
: company?.taxrate != null
? company.taxrate
: '',
tiers: tierDraftFromArticle(article),
};
}
function tiersEqual(a, b) {
return JSON.stringify(a ?? []) === JSON.stringify(b ?? []);
}
function priceDraftDirty(draft, saved) {
return (
parsePriceInput(draft.gross) !== parsePriceInput(saved.gross) ||
Number(draft.taxRate) !== Number(saved.taxRate) ||
!tiersEqual(draft.tiers, saved.tiers)
);
}
function priceDraftValid(draft) {
if (parsePriceInput(draft.gross) == null) return false;
if (draft.taxRate === '' || draft.taxRate == null) return false;
for (const tier of draft.tiers ?? []) {
if (parsePriceInput(tier.gross) == null) return false;
const mult = Number(tier.minmultiplier);
if (!Number.isFinite(mult) || mult <= 1) return false;
}
return true;
}
const articleNameTextSx = {
fontWeight: 700,
lineHeight: 1.2,
@@ -108,14 +167,237 @@ const articleNameFieldHiddenSx = {
},
};
const articlePriceTextSx = {
fontWeight: 700,
lineHeight: 1.1,
letterSpacing: '-0.02em',
};
const articlePriceFieldSx = {
'& .MuiInputBase-input': {
fontSize: '2rem',
...articlePriceTextSx,
},
'& .MuiInputAdornment-root .MuiTypography-root': {
fontSize: '1.25rem',
fontWeight: 700,
},
// Keep 1px outline in all states so focus/edit doesn't nudge the value.
'& .MuiOutlinedInput-notchedOutline': {
borderWidth: '1px !important',
},
};
const articlePriceFieldHiddenSx = {
...articlePriceFieldSx,
pointerEvents: 'none',
'& .MuiOutlinedInput-notchedOutline': {
borderWidth: '1px !important',
borderColor: 'transparent',
},
'& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': {
borderWidth: '1px !important',
borderColor: 'transparent',
},
'& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderWidth: '1px !important',
borderColor: 'transparent',
},
};
const priceBodySx = {
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: 0.75,
};
const taxPillSx = {
height: 28,
fontSize: 13,
fontWeight: 600,
'& .MuiChip-label': { px: 1 },
};
const tierPillSx = {
height: 22,
fontSize: 11,
fontWeight: 600,
};
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,
// Keep inactive pills in layout so the active one doesn't jump.
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>
);
}
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}
sx={{
p: 0.5,
visibility: readOnly ? 'hidden' : 'visible',
pointerEvents: readOnly ? 'none' : 'auto',
}}
>
<LayersOutlinedIcon sx={{ fontSize: 28 }} />
</IconButton>
</Box>
);
}
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>
);
}
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,
},
}}
/>
);
}
export default function ArticleView({ article: articleData, onArticleUpdated }) {
const navigate = useNavigate();
const dispatch = useDispatch();
const [tiersDialogOpen, setTiersDialogOpen] = useState(false);
const categories = useSelector((state) => state.category.categories);
const rootCategoryId = useSelector(selectCategoryRootId);
const company = useSelector(selectCurrentCompany);
const basePrice = primaryPrice(articleData);
const brutto = priceBrutto(basePrice);
const eans = articleEans(articleData);
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
const priceValue = priceDraftValue(articleData, company);
const syncCategoryListPhoto = (next) => {
const photo = primaryPhoto(next);
dispatch(
patchArticleInLists({
sku: next.sku,
fullHash: photo?.fullHash ?? null,
thumbW: photo?.thumbW ?? null,
thumbH: photo?.thumbH ?? null,
thumbHash: photo?.thumbHash ?? null,
}),
);
};
const syncCategoryListPrice = (next) => {
const price = primaryPrice(next);
dispatch(
patchArticleInLists({
sku: next.sku,
net: price?.net ?? null,
taxRate: price?.taxRate ?? null,
}),
);
};
const handleArticleUpdated = (next) => {
onArticleUpdated?.(next);
syncCategoryListPhoto(next);
};
const handleSaveName = async (draft) => {
const name = draft.name.trim();
@@ -125,6 +407,28 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
onArticleUpdated?.(res.article);
};
const handleSavePrice = async (draft) => {
const gross = requireDefined(parsePriceInput(draft.gross), 'price.gross');
const taxRate = requireDefined(
draft.taxRate === '' ? null : Number(draft.taxRate),
'price.tax',
);
if (!Number.isFinite(taxRate)) {
throw new Error('Ungültiger Steuersatz');
}
const net = requireDefined(netFromGross(gross, taxRate), 'price.net');
const prices = buildPosPricesPutBody({
net,
taxRate,
tiers: draft.tiers ?? [],
});
const res = await article.setPrices(articleData.sku, prices);
requireObject(res, 'PUT /api/article/:sku/prices');
requireObject(res.article, 'article');
onArticleUpdated?.(res.article);
syncCategoryListPrice(res.article);
};
const handleArticleDeleted = () => {
const categoryId = requireDefined(
articleData.categoryIds?.[0],
@@ -174,20 +478,35 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
tileKey={PANEL_TILE_KEYS.photo}
sx={headerTileSx}
article={articleData}
onArticleUpdated={onArticleUpdated}
onArticleUpdated={handleArticleUpdated}
/>
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.stock} title="Bestand">
<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>
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.brand} title="Marke">
<PanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.brand}
title="Marke"
minHeight={SECOND_ROW_MIN_HEIGHT}
>
<PanelValue>{brandName(articleData)}</PanelValue>
</PanelTile>
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.identifiers} title="SKU / EAN">
<PanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.identifiers}
title="SKU / EAN"
minHeight={SECOND_ROW_MIN_HEIGHT}
>
<PanelValue label="SKU">{articleData.sku}</PanelValue>
{eans.map((ean) => (
<PanelValue key={ean} label="EAN">
@@ -196,17 +515,53 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
))}
</PanelTile>
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.price} title="Preis">
<PanelValue label="Netto">
{basePrice?.net != null ? formatPrice(basePrice.net) : null}
</PanelValue>
<PanelValue label="MwSt.">
{basePrice?.taxRate != null ? `${basePrice.taxRate} %` : null}
</PanelValue>
<PanelValue label="Brutto">
{brutto != null ? formatPrice(brutto) : null}
</PanelValue>
</PanelTile>
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.price}
minHeight={SECOND_ROW_MIN_HEIGHT}
contentAlignItems="flex-start"
value={priceValue}
onSave={handleSavePrice}
isDirty={priceDraftDirty}
canSave={priceDraftValid}
renderDisplay={(saved) => (
<Box sx={priceBodySx}>
<PriceAmountField value={saved.gross} readOnly />
<PriceTaxRow
rates={taxRates}
selected={saved.taxRate}
readOnly
/>
<TierPricePills tiers={saved.tiers} />
</Box>
)}
renderEdit={(draft, onChange, inputRef) => {
const draftGross = parsePriceInput(draft.gross);
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} />
<ArticlePriceTiersDialog
open={tiersDialogOpen}
tiers={draft.tiers ?? []}
onClose={() => setTiersDialogOpen(false)}
onApply={(tiers) => onChange({ ...draft, tiers })}
/>
</Box>
);
}}
/>
<ArticleTileTab
wide={3}

View File

@@ -194,8 +194,17 @@ class EditablePanelTileBase extends Component {
}
render() {
const { onSave, theme, wide = 1, sx, minHeight = 120, tileKey, renderDisplay, renderEdit } =
this.props;
const {
onSave,
theme,
wide = 1,
sx,
minHeight = 120,
tileKey,
renderDisplay,
renderEdit,
contentAlignItems = 'center',
} = this.props;
const { editing, draft, error } = this.state;
const savedValue = this.getSavedValue();
const pending = this.isDirty();
@@ -222,7 +231,7 @@ class EditablePanelTileBase extends Component {
minWidth: 0,
pr: actionPadding,
display: 'flex',
alignItems: 'center',
alignItems: contentAlignItems,
}}
onKeyDown={editing ? this.handleEditKeyDown : undefined}
>