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 ( + + Staffelpreise + + {tiers.length === 0 ? ( + + Keine Staffeln. Basispreis bleibt der Einzelpreis (×1). + + ) : null} + + {tiers.map((tier, index) => { + const mult = parseMultiplier(tier.minmultiplier); + const gross = parsePriceInput(tier.gross); + return ( + + + this.handleChange(index, 'minmultiplier', event.target.value) + } + size="small" + error={tier.minmultiplier !== '' && mult == null} + slotProps={{ + inputLabel: { shrink: true }, + htmlInput: { inputMode: 'numeric' }, + }} + sx={{ width: 120 }} + /> + + this.handleChange(index, 'gross', event.target.value) + } + size="small" + fullWidth + error={tier.gross !== '' && gross == null} + slotProps={{ + input: { + startAdornment: ( + € + ), + }, + htmlInput: { inputMode: 'decimal' }, + }} + /> + this.handleRemove(index)} + sx={{ mt: 0.5 }} + > + + + + ); + })} + + + + + + + + + ); + } +} + +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; +}