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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user