u
This commit is contained in:
440
src/components/article/boxes/ArticleViewPrice.jsx
Normal file
440
src/components/article/boxes/ArticleViewPrice.jsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { EditablePanelTile } from '../../panels';
|
||||
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
|
||||
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
|
||||
import { article } from '../../../api';
|
||||
import { requireDefined, requireObject } from '../../../utils/apiShape';
|
||||
import { loadUnits, selectUnits, selectUnitsStatus } from '../../../store/unitSlice';
|
||||
import { selectCurrentCompany } from '../../../store/companySlice';
|
||||
import { patchArticleInLists } from '../../../store/categorySlice';
|
||||
import {
|
||||
buildPosPricesPutBody,
|
||||
companyTaxRateOptions,
|
||||
priceBrutto,
|
||||
primaryPrice,
|
||||
tierDraftFromArticle,
|
||||
} from '../../../utils/articleDetail';
|
||||
import {
|
||||
formatPriceAmount,
|
||||
netFromGross,
|
||||
parsePriceInput,
|
||||
} from '../../../utils/price';
|
||||
import {
|
||||
findUnitBySi,
|
||||
firstPackUnitSi,
|
||||
isNoPackUnit,
|
||||
} from '../../../utils/units';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
import { PANEL_TILE_KEYS } from '../../../theme';
|
||||
import { PriceTaxRow } from './PriceTaxRow';
|
||||
import TierPricePills from './TierPricePills';
|
||||
import { PriceUnitRow, PriceUnitCorner } from './PriceUnitRow';
|
||||
|
||||
const SECOND_ROW_MIN_HEIGHT = 220;
|
||||
|
||||
const articlePriceTextSx = {
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: '-0.02em',
|
||||
};
|
||||
|
||||
const articlePriceFieldSx = {
|
||||
flexShrink: 0,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
alignItems: 'baseline',
|
||||
flexShrink: 0,
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
fontSize: '3.5rem',
|
||||
py: 0.5,
|
||||
height: 'auto',
|
||||
...articlePriceTextSx,
|
||||
},
|
||||
'& .MuiInputAdornment-root': {
|
||||
marginRight: 0.75,
|
||||
height: 'auto',
|
||||
maxHeight: 'none',
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
'& .MuiInputAdornment-root .MuiTypography-root': {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.1,
|
||||
},
|
||||
'& .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,
|
||||
};
|
||||
|
||||
/* ── helper functions ──────────────────────────────────────────────── */
|
||||
|
||||
function articleHasPackUnit(art) {
|
||||
const si = art.unit?.si;
|
||||
const amount = art.unitamount;
|
||||
if (si == null || amount == null) return false;
|
||||
return !isNoPackUnit(si, amount);
|
||||
}
|
||||
|
||||
function tiersEqual(a, b) {
|
||||
return JSON.stringify(a ?? []) === JSON.stringify(b ?? []);
|
||||
}
|
||||
|
||||
function priceDraftValue(art, company) {
|
||||
const basePrice = primaryPrice(art);
|
||||
const gross = priceBrutto(basePrice);
|
||||
const hasUnit = articleHasPackUnit(art);
|
||||
return {
|
||||
gross: gross != null ? formatPriceAmount(gross) : '',
|
||||
taxRate:
|
||||
basePrice?.taxRate != null
|
||||
? basePrice.taxRate
|
||||
: company?.taxrate != null
|
||||
? company.taxrate
|
||||
: '',
|
||||
tiers: tierDraftFromArticle(art),
|
||||
hasUnit,
|
||||
unitamount:
|
||||
hasUnit && art.unitamount != null
|
||||
? formatPriceAmount(art.unitamount)
|
||||
: '',
|
||||
unitSi: hasUnit ? (art.unit?.si ?? '') : '',
|
||||
};
|
||||
}
|
||||
|
||||
function priceSectionDirty(draft, saved) {
|
||||
return (
|
||||
parsePriceInput(draft.gross) !== parsePriceInput(saved.gross) ||
|
||||
Number(draft.taxRate) !== Number(saved.taxRate) ||
|
||||
!tiersEqual(draft.tiers, saved.tiers)
|
||||
);
|
||||
}
|
||||
|
||||
function unitSectionDirty(draft, saved) {
|
||||
if (Boolean(draft.hasUnit) !== Boolean(saved.hasUnit)) return true;
|
||||
if (!draft.hasUnit) return false;
|
||||
return (
|
||||
parsePriceInput(draft.unitamount) !== parsePriceInput(saved.unitamount) ||
|
||||
draft.unitSi !== saved.unitSi
|
||||
);
|
||||
}
|
||||
|
||||
function priceDraftDirty(draft, saved) {
|
||||
return priceSectionDirty(draft, saved) || unitSectionDirty(draft, saved);
|
||||
}
|
||||
|
||||
function priceDraftValid(draft) {
|
||||
if (draft.hasUnit) {
|
||||
const unitamount = parsePriceInput(draft.unitamount);
|
||||
if (unitamount == null || unitamount <= 0) return false;
|
||||
if (!draft.unitSi) return false;
|
||||
if (isNoPackUnit(draft.unitSi, unitamount)) return false;
|
||||
}
|
||||
|
||||
const hasGross = draft.gross !== '';
|
||||
if (!hasGross) {
|
||||
return !(draft.tiers?.length > 0);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── sub-component: amount field ──────────────────────────────────── */
|
||||
|
||||
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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── main component ────────────────────────────────────────────────── */
|
||||
|
||||
export default function ArticleViewPrice({ article: articleData, onArticleUpdated }) {
|
||||
const dispatch = useDispatch();
|
||||
const [tiersDialogOpen, setTiersDialogOpen] = useState(false);
|
||||
const [unitEditorOpen, setUnitEditorOpen] = useState(false);
|
||||
const units = useSelector(selectUnits);
|
||||
const unitsStatus = useSelector(selectUnitsStatus);
|
||||
const company = useSelector(selectCurrentCompany);
|
||||
const basePrice = primaryPrice(articleData);
|
||||
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
|
||||
const priceValue = priceDraftValue(articleData, company);
|
||||
const unitOptions =
|
||||
articleData.unit?.si && !findUnitBySi(units, articleData.unit.si)
|
||||
? [...units, articleData.unit]
|
||||
: units;
|
||||
|
||||
useEffect(() => {
|
||||
if (unitsStatus !== 'idle') return;
|
||||
void dispatch(loadUnits()).unwrap().catch((err) => notifyError(err));
|
||||
}, [dispatch, unitsStatus]);
|
||||
|
||||
const syncCategoryListPrice = (next) => {
|
||||
const price = primaryPrice(next);
|
||||
dispatch(
|
||||
patchArticleInLists({
|
||||
sku: next.sku,
|
||||
net: price?.net ?? null,
|
||||
taxRate: price?.taxRate ?? null,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async (draft) => {
|
||||
const saved = priceDraftValue(articleData, company);
|
||||
let nextArticle = articleData;
|
||||
|
||||
if (unitSectionDirty(draft, saved)) {
|
||||
if (draft.hasUnit) {
|
||||
const unitamount = requireDefined(
|
||||
parsePriceInput(draft.unitamount),
|
||||
'unitamount',
|
||||
);
|
||||
if (unitamount <= 0) {
|
||||
throw new Error('Ungültige Menge');
|
||||
}
|
||||
const unitSi = requireDefined(
|
||||
draft.unitSi?.trim() ? draft.unitSi.trim() : null,
|
||||
'unitSi',
|
||||
);
|
||||
if (isNoPackUnit(unitSi, unitamount)) {
|
||||
throw new Error('Einheit pc × 1 ist keine Packungseinheit');
|
||||
}
|
||||
const res = await article.update(articleData.sku, { unitamount, unitSi });
|
||||
requireObject(res, 'PATCH /api/article/:sku');
|
||||
requireObject(res.article, 'article');
|
||||
nextArticle = res.article;
|
||||
} else {
|
||||
const res = await article.update(articleData.sku, {
|
||||
unitamount: 1,
|
||||
unitSi: 'pc',
|
||||
});
|
||||
requireObject(res, 'PATCH /api/article/:sku');
|
||||
requireObject(res.article, 'article');
|
||||
nextArticle = res.article;
|
||||
}
|
||||
}
|
||||
|
||||
if (priceSectionDirty(draft, saved) && draft.gross !== '') {
|
||||
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');
|
||||
nextArticle = res.article;
|
||||
syncCategoryListPrice(res.article);
|
||||
}
|
||||
|
||||
onArticleUpdated?.(nextArticle);
|
||||
};
|
||||
|
||||
return (
|
||||
<EditablePanelTile
|
||||
wide={1}
|
||||
tileKey={PANEL_TILE_KEYS.price}
|
||||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||
contentAlignItems="flex-start"
|
||||
value={priceValue}
|
||||
onSave={handleSave}
|
||||
isDirty={priceDraftDirty}
|
||||
canSave={priceDraftValid}
|
||||
renderOverlay={({ editing, draft, onChange }) => {
|
||||
if (!editing || draft == null) return null;
|
||||
return (
|
||||
<PriceUnitCorner
|
||||
hasUnit={draft.hasUnit}
|
||||
onToggleHasUnit={(hasUnit) => {
|
||||
if (hasUnit) {
|
||||
const unitamount = draft.unitamount || '1';
|
||||
const amount = parsePriceInput(unitamount) ?? 1;
|
||||
let unitSi = firstPackUnitSi(
|
||||
unitOptions,
|
||||
draft.unitSi || articleData.unit?.si,
|
||||
);
|
||||
if (!unitSi && isNoPackUnit('pc', amount)) {
|
||||
onChange({
|
||||
...draft,
|
||||
hasUnit: true,
|
||||
unitSi: 'pc',
|
||||
unitamount: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!unitSi) unitSi = 'pc';
|
||||
onChange({
|
||||
...draft,
|
||||
hasUnit: true,
|
||||
unitSi,
|
||||
unitamount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...draft,
|
||||
hasUnit: false,
|
||||
unitamount: '',
|
||||
unitSi: '',
|
||||
});
|
||||
}}
|
||||
onOpenEditor={() => setUnitEditorOpen(true)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
renderDisplay={(saved) => (
|
||||
<Box sx={priceBodySx}>
|
||||
<PriceAmountField value={saved.gross} readOnly />
|
||||
<PriceTaxRow
|
||||
rates={taxRates}
|
||||
selected={saved.taxRate}
|
||||
readOnly
|
||||
/>
|
||||
<TierPricePills tiers={saved.tiers} />
|
||||
<PriceUnitRow
|
||||
hasUnit={saved.hasUnit}
|
||||
unitamount={saved.unitamount}
|
||||
unitSi={saved.unitSi}
|
||||
units={unitOptions}
|
||||
displaySymbol={articleData.unit?.symbol}
|
||||
readOnly
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
renderEdit={(draft, onChange, inputRef) => {
|
||||
const draftGross = parsePriceInput(draft.gross);
|
||||
const draftAmount = parsePriceInput(draft.unitamount);
|
||||
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} />
|
||||
<PriceUnitRow
|
||||
hasUnit={draft.hasUnit}
|
||||
unitamount={draft.unitamount}
|
||||
unitSi={draft.unitSi}
|
||||
units={unitOptions}
|
||||
displaySymbol={articleData.unit?.symbol}
|
||||
onChangeAmount={(unitamount) => onChange({ ...draft, unitamount })}
|
||||
onChangeSi={(unitSi) => onChange({ ...draft, unitSi })}
|
||||
amountError={
|
||||
draft.hasUnit &&
|
||||
draft.unitamount !== '' &&
|
||||
(draftAmount == null || draftAmount <= 0)
|
||||
}
|
||||
/>
|
||||
<ArticlePriceTiersDialog
|
||||
open={tiersDialogOpen}
|
||||
tiers={draft.tiers ?? []}
|
||||
onClose={() => setTiersDialogOpen(false)}
|
||||
onApply={(tiers) => onChange({ ...draft, tiers })}
|
||||
/>
|
||||
<ArticleUnitEditorDialog
|
||||
open={unitEditorOpen}
|
||||
onClose={() => setUnitEditorOpen(false)}
|
||||
onUnitUpserted={(row) => {
|
||||
if (!draft.unitSi) {
|
||||
onChange({ ...draft, unitSi: row.si });
|
||||
}
|
||||
}}
|
||||
onUnitDeleted={(si) => {
|
||||
if (draft.unitSi === si) {
|
||||
onChange({ ...draft, unitSi: '' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user