diff --git a/src/api/index.js b/src/api/index.js
index 87f0a53..9af8bda 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -221,6 +221,19 @@ export const article = {
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 },
+ });
+ },
};
/**
diff --git a/src/components/article/ArticlePriceTiersDialog.jsx b/src/components/article/ArticlePriceTiersDialog.jsx
new file mode 100644
index 0000000..684b644
--- /dev/null
+++ b/src/components/article/ArticlePriceTiersDialog.jsx
@@ -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 (
+
+ );
+ }
+}
+
+export default ArticlePriceTiersDialog;
diff --git a/src/components/article/ArticleTileTab.jsx b/src/components/article/ArticleTileTab.jsx
index 48cce38..6c3ba00 100644
--- a/src/components/article/ArticleTileTab.jsx
+++ b/src/components/article/ArticleTileTab.jsx
@@ -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,
diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx
index 726c176..d158eb2 100644
--- a/src/components/article/ArticleView.jsx
+++ b/src/components/article/ArticleView.jsx
@@ -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 (
+
+ {rates.map((rate) => {
+ const active = Number(selected) === Number(rate);
+ return (
+ 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)' },
+ }),
+ }}
+ />
+ );
+ })}
+
+ );
+}
+
+function PriceTaxRow({
+ rates,
+ selected,
+ onSelectTax,
+ readOnly,
+ onOpenTiers,
+}) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function TierPricePills({ tiers }) {
+ return (
+
+ {(tiers ?? []).map((tier) => {
+ const gross = parsePriceInput(tier.gross);
+ if (gross == null) return null;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function PriceAmountField({
+ value,
+ onChange,
+ inputRef,
+ readOnly,
+ error,
+}) {
+ return (
+ {
+ onChange?.(event.target.value);
+ }
+ }
+ fullWidth
+ error={error}
+ sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx}
+ slotProps={{
+ input: {
+ readOnly,
+ startAdornment: (
+
+ €
+
+ ),
+ },
+ 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}
/>
-
+
{articleData.stock != null ? formatStock(articleData.stock) : null}
-
+
{brandName(articleData)}
-
+
{articleData.sku}
{eans.map((ean) => (
@@ -196,17 +515,53 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
))}
-
-
- {basePrice?.net != null ? formatPrice(basePrice.net) : null}
-
-
- {basePrice?.taxRate != null ? `${basePrice.taxRate} %` : null}
-
-
- {brutto != null ? formatPrice(brutto) : null}
-
-
+ (
+
+
+
+
+
+ )}
+ renderEdit={(draft, onChange, inputRef) => {
+ const draftGross = parsePriceInput(draft.gross);
+ return (
+
+ onChange({ ...draft, gross })}
+ error={draft.gross !== '' && draftGross == null}
+ />
+ onChange({ ...draft, taxRate })}
+ onOpenTiers={() => setTiersDialogOpen(true)}
+ />
+
+ setTiersDialogOpen(false)}
+ onApply={(tiers) => onChange({ ...draft, tiers })}
+ />
+
+ );
+ }}
+ />
diff --git a/src/store/categorySlice.js b/src/store/categorySlice.js
index 9fb4958..b11d5b9 100644
--- a/src/store/categorySlice.js
+++ b/src/store/categorySlice.js
@@ -223,6 +223,15 @@ const categorySlice = createSlice({
state.articlesTotal = state.articles.length;
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) => {
builder
@@ -305,6 +314,7 @@ export const {
navigateBack,
resetCategoryState,
removeArticleBySku,
+ patchArticleInLists,
} = categorySlice.actions;
export const selectCategoryMap = (state) => state.category.categories;
diff --git a/src/utils/articleDetail.js b/src/utils/articleDetail.js
index e93b255..e579a79 100644
--- a/src/utils/articleDetail.js
+++ b/src/utils/articleDetail.js
@@ -1,5 +1,11 @@
/** Helpers for GET/PATCH /api/article/:sku detail shape. */
+import {
+ formatPriceAmount,
+ netFromGross,
+ parsePriceInput,
+} from './price';
+
export function sortedPhotos(article) {
if (!Array.isArray(article?.photos)) return [];
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);
}
+/**
+ * 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) {
return article?.brand?.name ?? null;
}
diff --git a/src/utils/companyForm.js b/src/utils/companyForm.js
index cb07b68..0a75d12 100644
--- a/src/utils/companyForm.js
+++ b/src/utils/companyForm.js
@@ -19,6 +19,7 @@ export function normalizeCompany(raw, overrides = {}) {
iso639: overrides.iso639 !== undefined ? overrides.iso639 : raw.iso639,
taxrate: overrides.taxrate !== undefined ? overrides.taxrate : raw.taxrate,
reducedtaxrate: overrides.reducedtaxrate !== undefined ? overrides.reducedtaxrate : raw.reducedtaxrate,
+ taxfreerate: overrides.taxfreerate !== undefined ? overrides.taxfreerate : raw.taxfreerate,
rootCategoryId: overrides.rootCategoryId !== undefined ? overrides.rootCategoryId : raw.rootCategoryId,
};
}
diff --git a/src/utils/price.js b/src/utils/price.js
index d2db68c..9fdad2e 100644
--- a/src/utils/price.js
+++ b/src/utils/price.js
@@ -1,11 +1,54 @@
+const PRICE_LOCALE = 'de-DE';
+
export function getBrutto(netto, kSteuerklasse) {
const rate = { 1: 19, 2: 7, 3: 0 }[kSteuerklasse] ?? 19;
return netto * (1 + rate / 100);
}
+/** Currency display, e.g. `6,60 €`. */
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',
currency: 'EUR',
}).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;
+}