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:
@@ -221,6 +221,19 @@ export const article = {
|
|||||||
body: { fullHashes },
|
body: { fullHashes },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/article/:sku/prices
|
||||||
|
* Body: { prices: [{ price (net), tax (rate), minmultiplier? }] }
|
||||||
|
* Replace-all POS customer group prices. Empty prices clears all POS prices.
|
||||||
|
* → 200 { article } (same shape as GET by SKU)
|
||||||
|
*/
|
||||||
|
setPrices(sku, prices) {
|
||||||
|
return http(`/api/article/${encodeURIComponent(sku)}/prices`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { prices },
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
185
src/components/article/ArticlePriceTiersDialog.jsx
Normal file
185
src/components/article/ArticlePriceTiersDialog.jsx
Normal 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;
|
||||||
@@ -201,7 +201,6 @@ class ArticleTileTab extends Component {
|
|||||||
value={tab}
|
value={tab}
|
||||||
onChange={this.handleTabChange}
|
onChange={this.handleTabChange}
|
||||||
variant="standard"
|
variant="standard"
|
||||||
TabIndicatorProps={{ sx: { display: 'none', height: 0 } }}
|
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined';
|
||||||
import Box from '@mui/material/Box';
|
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 Link from '@mui/material/Link';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import SingleLineTextEdit from '../SingleLineTextEdit';
|
import SingleLineTextEdit from '../SingleLineTextEdit';
|
||||||
import ArticlePhotoTile from './ArticlePhotoTile';
|
import ArticlePhotoTile from './ArticlePhotoTile';
|
||||||
|
import ArticlePriceTiersDialog from './ArticlePriceTiersDialog';
|
||||||
import ArticleTileTab from './ArticleTileTab';
|
import ArticleTileTab from './ArticleTileTab';
|
||||||
import { article } from '../../api';
|
import { article } from '../../api';
|
||||||
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
|
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
|
||||||
@@ -12,10 +19,19 @@ import { requireDefined, requireObject } from '../../utils/apiShape';
|
|||||||
import {
|
import {
|
||||||
articleEans,
|
articleEans,
|
||||||
brandName,
|
brandName,
|
||||||
|
buildPosPricesPutBody,
|
||||||
|
companyTaxRateOptions,
|
||||||
priceBrutto,
|
priceBrutto,
|
||||||
|
primaryPhoto,
|
||||||
primaryPrice,
|
primaryPrice,
|
||||||
|
tierDraftFromArticle,
|
||||||
} from '../../utils/articleDetail';
|
} from '../../utils/articleDetail';
|
||||||
import { formatPrice } from '../../utils/price';
|
import {
|
||||||
|
formatPrice,
|
||||||
|
formatPriceAmount,
|
||||||
|
netFromGross,
|
||||||
|
parsePriceInput,
|
||||||
|
} from '../../utils/price';
|
||||||
import { PANEL_TILE_KEYS } from '../../theme';
|
import { PANEL_TILE_KEYS } from '../../theme';
|
||||||
import { formatStock } from '../../pages/artikel/searchFormat';
|
import { formatStock } from '../../pages/artikel/searchFormat';
|
||||||
import {
|
import {
|
||||||
@@ -23,9 +39,11 @@ import {
|
|||||||
defaultCategoryPath,
|
defaultCategoryPath,
|
||||||
} from '../../pages/artikel/categoryRoute';
|
} from '../../pages/artikel/categoryRoute';
|
||||||
import {
|
import {
|
||||||
|
patchArticleInLists,
|
||||||
removeArticleBySku,
|
removeArticleBySku,
|
||||||
selectCategoryRootId,
|
selectCategoryRootId,
|
||||||
} from '../../store/categorySlice';
|
} from '../../store/categorySlice';
|
||||||
|
import { selectCurrentCompany } from '../../store/companySlice';
|
||||||
|
|
||||||
function PanelValue({ label, children }) {
|
function PanelValue({ label, children }) {
|
||||||
if (children == null || children === '') return null;
|
if (children == null || children === '') return null;
|
||||||
@@ -47,6 +65,9 @@ const headerTileSx = {
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Second article row (Bestand / Marke / SKU / Preis). */
|
||||||
|
const SECOND_ROW_MIN_HEIGHT = 176;
|
||||||
|
|
||||||
function CountLine({ label, count }) {
|
function CountLine({ label, count }) {
|
||||||
if (count == null) return null;
|
if (count == null) return null;
|
||||||
return (
|
return (
|
||||||
@@ -79,6 +100,44 @@ function nameDraftValid(draft) {
|
|||||||
return draft.name.trim().length > 0;
|
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 = {
|
const articleNameTextSx = {
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
lineHeight: 1.2,
|
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 }) {
|
export default function ArticleView({ article: articleData, onArticleUpdated }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const [tiersDialogOpen, setTiersDialogOpen] = useState(false);
|
||||||
const categories = useSelector((state) => state.category.categories);
|
const categories = useSelector((state) => state.category.categories);
|
||||||
const rootCategoryId = useSelector(selectCategoryRootId);
|
const rootCategoryId = useSelector(selectCategoryRootId);
|
||||||
|
const company = useSelector(selectCurrentCompany);
|
||||||
const basePrice = primaryPrice(articleData);
|
const basePrice = primaryPrice(articleData);
|
||||||
const brutto = priceBrutto(basePrice);
|
|
||||||
const eans = articleEans(articleData);
|
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 handleSaveName = async (draft) => {
|
||||||
const name = draft.name.trim();
|
const name = draft.name.trim();
|
||||||
@@ -125,6 +407,28 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
|||||||
onArticleUpdated?.(res.article);
|
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 handleArticleDeleted = () => {
|
||||||
const categoryId = requireDefined(
|
const categoryId = requireDefined(
|
||||||
articleData.categoryIds?.[0],
|
articleData.categoryIds?.[0],
|
||||||
@@ -174,20 +478,35 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
|||||||
tileKey={PANEL_TILE_KEYS.photo}
|
tileKey={PANEL_TILE_KEYS.photo}
|
||||||
sx={headerTileSx}
|
sx={headerTileSx}
|
||||||
article={articleData}
|
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>
|
<PanelValue>
|
||||||
{articleData.stock != null ? formatStock(articleData.stock) : null}
|
{articleData.stock != null ? formatStock(articleData.stock) : null}
|
||||||
</PanelValue>
|
</PanelValue>
|
||||||
</PanelTile>
|
</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>
|
<PanelValue>{brandName(articleData)}</PanelValue>
|
||||||
</PanelTile>
|
</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>
|
<PanelValue label="SKU">{articleData.sku}</PanelValue>
|
||||||
{eans.map((ean) => (
|
{eans.map((ean) => (
|
||||||
<PanelValue key={ean} label="EAN">
|
<PanelValue key={ean} label="EAN">
|
||||||
@@ -196,17 +515,53 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
|||||||
))}
|
))}
|
||||||
</PanelTile>
|
</PanelTile>
|
||||||
|
|
||||||
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.price} title="Preis">
|
<EditablePanelTile
|
||||||
<PanelValue label="Netto">
|
wide={1}
|
||||||
{basePrice?.net != null ? formatPrice(basePrice.net) : null}
|
tileKey={PANEL_TILE_KEYS.price}
|
||||||
</PanelValue>
|
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||||
<PanelValue label="MwSt.">
|
contentAlignItems="flex-start"
|
||||||
{basePrice?.taxRate != null ? `${basePrice.taxRate} %` : null}
|
value={priceValue}
|
||||||
</PanelValue>
|
onSave={handleSavePrice}
|
||||||
<PanelValue label="Brutto">
|
isDirty={priceDraftDirty}
|
||||||
{brutto != null ? formatPrice(brutto) : null}
|
canSave={priceDraftValid}
|
||||||
</PanelValue>
|
renderDisplay={(saved) => (
|
||||||
</PanelTile>
|
<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
|
<ArticleTileTab
|
||||||
wide={3}
|
wide={3}
|
||||||
|
|||||||
@@ -194,8 +194,17 @@ class EditablePanelTileBase extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { onSave, theme, wide = 1, sx, minHeight = 120, tileKey, renderDisplay, renderEdit } =
|
const {
|
||||||
this.props;
|
onSave,
|
||||||
|
theme,
|
||||||
|
wide = 1,
|
||||||
|
sx,
|
||||||
|
minHeight = 120,
|
||||||
|
tileKey,
|
||||||
|
renderDisplay,
|
||||||
|
renderEdit,
|
||||||
|
contentAlignItems = 'center',
|
||||||
|
} = this.props;
|
||||||
const { editing, draft, error } = this.state;
|
const { editing, draft, error } = this.state;
|
||||||
const savedValue = this.getSavedValue();
|
const savedValue = this.getSavedValue();
|
||||||
const pending = this.isDirty();
|
const pending = this.isDirty();
|
||||||
@@ -222,7 +231,7 @@ class EditablePanelTileBase extends Component {
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
pr: actionPadding,
|
pr: actionPadding,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: contentAlignItems,
|
||||||
}}
|
}}
|
||||||
onKeyDown={editing ? this.handleEditKeyDown : undefined}
|
onKeyDown={editing ? this.handleEditKeyDown : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -223,6 +223,15 @@ const categorySlice = createSlice({
|
|||||||
state.articlesTotal = state.articles.length;
|
state.articlesTotal = state.articles.length;
|
||||||
state.rootArticles = state.rootArticles.filter((row) => row.sku !== sku);
|
state.rootArticles = state.rootArticles.filter((row) => row.sku !== sku);
|
||||||
},
|
},
|
||||||
|
/** Keep category tile list in sync with article detail (e.g. after photo upload). */
|
||||||
|
patchArticleInLists(state, action) {
|
||||||
|
const { sku, ...fields } = action.payload;
|
||||||
|
if (sku == null) return;
|
||||||
|
const patchList = (list) =>
|
||||||
|
list.map((row) => (row.sku === sku ? { ...row, ...fields } : row));
|
||||||
|
state.articles = patchList(state.articles);
|
||||||
|
state.rootArticles = patchList(state.rootArticles);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder
|
builder
|
||||||
@@ -305,6 +314,7 @@ export const {
|
|||||||
navigateBack,
|
navigateBack,
|
||||||
resetCategoryState,
|
resetCategoryState,
|
||||||
removeArticleBySku,
|
removeArticleBySku,
|
||||||
|
patchArticleInLists,
|
||||||
} = categorySlice.actions;
|
} = categorySlice.actions;
|
||||||
|
|
||||||
export const selectCategoryMap = (state) => state.category.categories;
|
export const selectCategoryMap = (state) => state.category.categories;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
/** Helpers for GET/PATCH /api/article/:sku detail shape. */
|
/** Helpers for GET/PATCH /api/article/:sku detail shape. */
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatPriceAmount,
|
||||||
|
netFromGross,
|
||||||
|
parsePriceInput,
|
||||||
|
} from './price';
|
||||||
|
|
||||||
export function sortedPhotos(article) {
|
export function sortedPhotos(article) {
|
||||||
if (!Array.isArray(article?.photos)) return [];
|
if (!Array.isArray(article?.photos)) return [];
|
||||||
return [...article.photos].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
return [...article.photos].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
||||||
@@ -66,6 +72,72 @@ export function priceBrutto(price) {
|
|||||||
return price.net * (1 + price.taxRate / 100);
|
return price.net * (1 + price.taxRate / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tax rates offered in the price editor: company standard, reduced, free (0),
|
||||||
|
* plus the article's current rate if it is not already listed.
|
||||||
|
* (No GET /tax list endpoint — rates come from the company record.)
|
||||||
|
*/
|
||||||
|
export function companyTaxRateOptions(company, currentTaxRate) {
|
||||||
|
const rates = [];
|
||||||
|
if (company?.taxrate != null) rates.push(Number(company.taxrate));
|
||||||
|
if (company?.reducedtaxrate != null) rates.push(Number(company.reducedtaxrate));
|
||||||
|
if (company?.taxfreerate != null) rates.push(Number(company.taxfreerate));
|
||||||
|
if (currentTaxRate != null && !rates.includes(Number(currentTaxRate))) {
|
||||||
|
rates.push(Number(currentTaxRate));
|
||||||
|
}
|
||||||
|
return [...new Set(rates)].sort((a, b) => b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POS price rows for the same customer group as the primary (base) price.
|
||||||
|
*/
|
||||||
|
export function posPriceGroup(article) {
|
||||||
|
const base = primaryPrice(article);
|
||||||
|
if (!base) return [];
|
||||||
|
const groupId = base.customergroupId;
|
||||||
|
const rows =
|
||||||
|
groupId != null
|
||||||
|
? (article.prices ?? []).filter((row) => row.customergroupId === groupId)
|
||||||
|
: (article.prices ?? []);
|
||||||
|
return [...rows].sort(
|
||||||
|
(a, b) => (a.minmultiplier ?? 0) - (b.minmultiplier ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staffelpreise (minmultiplier ≠ 1) as editor draft rows (gross, de-DE). */
|
||||||
|
export function tierDraftFromArticle(article) {
|
||||||
|
return posPriceGroup(article)
|
||||||
|
.filter((row) => row.minmultiplier !== 1)
|
||||||
|
.map((row) => ({
|
||||||
|
minmultiplier: row.minmultiplier,
|
||||||
|
gross: formatPriceAmount(priceBrutto(row)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/article/:sku/prices body: base + optional tier rows.
|
||||||
|
* `tiers`: [{ minmultiplier, gross }] with gross as de-DE input string.
|
||||||
|
*/
|
||||||
|
export function buildPosPricesPutBody({ net, taxRate, tiers = [] }) {
|
||||||
|
const body = [{ price: net, tax: taxRate }];
|
||||||
|
const seen = new Set([1]);
|
||||||
|
|
||||||
|
for (const tier of tiers) {
|
||||||
|
const mult = Number(tier.minmultiplier);
|
||||||
|
const tierNet = netFromGross(parsePriceInput(tier.gross), taxRate);
|
||||||
|
if (!Number.isFinite(mult) || mult <= 1 || seen.has(mult)) {
|
||||||
|
throw new Error('Ungültiger Staffel-Multiplikator');
|
||||||
|
}
|
||||||
|
if (tierNet == null) {
|
||||||
|
throw new Error('Ungültiger Staffelpreis');
|
||||||
|
}
|
||||||
|
seen.add(mult);
|
||||||
|
body.push({ price: tierNet, tax: taxRate, minmultiplier: mult });
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
export function brandName(article) {
|
export function brandName(article) {
|
||||||
return article?.brand?.name ?? null;
|
return article?.brand?.name ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function normalizeCompany(raw, overrides = {}) {
|
|||||||
iso639: overrides.iso639 !== undefined ? overrides.iso639 : raw.iso639,
|
iso639: overrides.iso639 !== undefined ? overrides.iso639 : raw.iso639,
|
||||||
taxrate: overrides.taxrate !== undefined ? overrides.taxrate : raw.taxrate,
|
taxrate: overrides.taxrate !== undefined ? overrides.taxrate : raw.taxrate,
|
||||||
reducedtaxrate: overrides.reducedtaxrate !== undefined ? overrides.reducedtaxrate : raw.reducedtaxrate,
|
reducedtaxrate: overrides.reducedtaxrate !== undefined ? overrides.reducedtaxrate : raw.reducedtaxrate,
|
||||||
|
taxfreerate: overrides.taxfreerate !== undefined ? overrides.taxfreerate : raw.taxfreerate,
|
||||||
rootCategoryId: overrides.rootCategoryId !== undefined ? overrides.rootCategoryId : raw.rootCategoryId,
|
rootCategoryId: overrides.rootCategoryId !== undefined ? overrides.rootCategoryId : raw.rootCategoryId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,54 @@
|
|||||||
|
const PRICE_LOCALE = 'de-DE';
|
||||||
|
|
||||||
export function getBrutto(netto, kSteuerklasse) {
|
export function getBrutto(netto, kSteuerklasse) {
|
||||||
const rate = { 1: 19, 2: 7, 3: 0 }[kSteuerklasse] ?? 19;
|
const rate = { 1: 19, 2: 7, 3: 0 }[kSteuerklasse] ?? 19;
|
||||||
return netto * (1 + rate / 100);
|
return netto * (1 + rate / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Currency display, e.g. `6,60 €`. */
|
||||||
export function formatPrice(amount) {
|
export function formatPrice(amount) {
|
||||||
return new Intl.NumberFormat('de-DE', {
|
if (amount == null || !Number.isFinite(Number(amount))) return '';
|
||||||
|
return new Intl.NumberFormat(PRICE_LOCALE, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amount without currency symbol (for fields with a separate € adornment).
|
||||||
|
* e.g. `6,60` / `1.234,56`
|
||||||
|
*/
|
||||||
|
export function formatPriceAmount(amount, { minFractionDigits = 2, maxFractionDigits = 2 } = {}) {
|
||||||
|
if (amount == null || !Number.isFinite(Number(amount))) return '';
|
||||||
|
return new Intl.NumberFormat(PRICE_LOCALE, {
|
||||||
|
minimumFractionDigits: minFractionDigits,
|
||||||
|
maximumFractionDigits: maxFractionDigits,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse de-DE (or plain) price input → number.
|
||||||
|
* Accepts `6,60`, `1.234,56`, and plain `6.60`.
|
||||||
|
*/
|
||||||
|
export function parsePriceInput(value) {
|
||||||
|
if (value === '' || value == null) return null;
|
||||||
|
let normalized = String(value).trim().replace(/\s/g, '');
|
||||||
|
if (normalized === '') return null;
|
||||||
|
|
||||||
|
if (normalized.includes(',')) {
|
||||||
|
// de-DE: thousand `.`, decimal `,`
|
||||||
|
normalized = normalized.replace(/\./g, '').replace(',', '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert gross (brutto) → net for PUT /prices. Rounded to 4 decimal places. */
|
||||||
|
export function netFromGross(gross, taxRate) {
|
||||||
|
if (gross == null || taxRate == null) return null;
|
||||||
|
if (!Number.isFinite(gross) || !Number.isFinite(taxRate) || taxRate < 0) return null;
|
||||||
|
const factor = 1 + taxRate / 100;
|
||||||
|
if (factor <= 0) return null;
|
||||||
|
return Math.round((gross / factor) * 10000) / 10000;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user