608 lines
18 KiB
JavaScript
608 lines
18 KiB
JavaScript
import { Component, PureComponent } from 'react';
|
||
import Box from '@mui/material/Box';
|
||
import TextField from '@mui/material/TextField';
|
||
import Typography from '@mui/material/Typography';
|
||
import { EditablePanelTile, PanelTile } from 'dashgui/panels';
|
||
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
|
||
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
|
||
import unitAutomat from '../../../automats/unitAutomat';
|
||
import companyAutomat from '../../../automats/companyAutomat';
|
||
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
||
import { requireDefined, requireObject } from '../../../utils/apiShape';
|
||
import {
|
||
buildPosPricesPutBody,
|
||
companyTaxRateOptions,
|
||
priceBrutto,
|
||
primaryPrice,
|
||
tierDraftFromArticle,
|
||
} from '../../../utils/articleDetail';
|
||
import {
|
||
formatPriceAmount,
|
||
getArticleGross,
|
||
netFromGross,
|
||
parsePriceInput,
|
||
} from '../../../utils/price';
|
||
import {
|
||
findUnitBySi,
|
||
firstPackUnitSi,
|
||
isNoPackUnit,
|
||
} from '../../../utils/units';
|
||
import { notifyError } from '../../../utils/notify';
|
||
import { PriceTaxRow } from './PriceTaxRow';
|
||
import TierPricePills from './TierPricePills';
|
||
import { PriceUnitRow, PriceUnitCorner } from './PriceUnitRow';
|
||
import {
|
||
BLUE_PANEL,
|
||
} from '../../../colors';
|
||
|
||
const SECOND_ROW_MIN_HEIGHT = 220;
|
||
|
||
const articlePriceTextSx = {
|
||
fontWeight: 700,
|
||
lineHeight: 1.1,
|
||
letterSpacing: '-0.02em',
|
||
};
|
||
|
||
const articlePriceFieldSx = {
|
||
flexShrink: 1,
|
||
width: '100%',
|
||
'& .MuiOutlinedInput-root': {
|
||
alignItems: 'baseline',
|
||
width: '100%',
|
||
flexShrink: 1,
|
||
},
|
||
'& .MuiInputBase-input': {
|
||
fontSize: '3.5rem',
|
||
py: 0.5,
|
||
height: 'auto',
|
||
...articlePriceTextSx,
|
||
},
|
||
'& .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,
|
||
pt: 0,
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
function companyFromStore() {
|
||
return companyAutomat.state.current ?? null;
|
||
}
|
||
|
||
class PriceAmountField extends Component {
|
||
render() {
|
||
const { value, onChange, inputRef, readOnly, error } = this.props;
|
||
return (
|
||
<TextField
|
||
inputRef={inputRef}
|
||
value={value}
|
||
onChange={
|
||
readOnly
|
||
? undefined
|
||
: (event) => {
|
||
onChange?.(event.target.value);
|
||
}
|
||
}
|
||
error={error}
|
||
sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx}
|
||
slotProps={{
|
||
input: {
|
||
readOnly,
|
||
},
|
||
htmlInput: {
|
||
inputMode: 'decimal',
|
||
readOnly,
|
||
tabIndex: readOnly ? -1 : undefined,
|
||
},
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
}
|
||
|
||
class PriceDisplay extends Component {
|
||
render() {
|
||
const { saved, unitOptions, displaySymbol } = this.props;
|
||
return (
|
||
<Box sx={priceBodySx}>
|
||
<PriceAmountField value={saved.gross} readOnly />
|
||
<TierPricePills tiers={saved.tiers} />
|
||
<PriceUnitRow
|
||
hasUnit={saved.hasUnit}
|
||
unitamount={saved.unitamount}
|
||
unitSi={saved.unitSi}
|
||
units={unitOptions}
|
||
displaySymbol={displaySymbol}
|
||
readOnly
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|
||
}
|
||
|
||
class PriceOverlay extends Component {
|
||
render() {
|
||
const {
|
||
editing,
|
||
draft,
|
||
onChange,
|
||
unitOptions,
|
||
articleUnitSi,
|
||
onOpenEditor,
|
||
onOpenTiers,
|
||
} = this.props;
|
||
if (!editing || draft == null) return null;
|
||
return (
|
||
<PriceUnitCorner
|
||
hasUnit={draft.hasUnit}
|
||
onOpenTiers={onOpenTiers}
|
||
onToggleHasUnit={(hasUnit) => {
|
||
if (hasUnit) {
|
||
const unitamount = draft.unitamount || '1';
|
||
const amount = parsePriceInput(unitamount) ?? 1;
|
||
let unitSi = firstPackUnitSi(unitOptions, draft.unitSi || articleUnitSi);
|
||
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={onOpenEditor}
|
||
/>
|
||
);
|
||
}
|
||
}
|
||
|
||
class PriceEdit extends Component {
|
||
handleUnitUpserted = async (row) => {
|
||
const { draft, onChange, onUnitUpserted } = this.props;
|
||
await onUnitUpserted?.(row);
|
||
if (!draft.unitSi) {
|
||
onChange({ ...draft, unitSi: row.si });
|
||
}
|
||
};
|
||
|
||
handleUnitDeleted = async (si) => {
|
||
const { draft, onChange, onUnitDeleted } = this.props;
|
||
await onUnitDeleted?.(si);
|
||
if (draft.unitSi === si) {
|
||
onChange({ ...draft, unitSi: '' });
|
||
}
|
||
};
|
||
|
||
render() {
|
||
|
||
const {
|
||
draft, onChange, inputRef, unitOptions, displaySymbol,
|
||
tiersDialogOpen, unitEditorOpen, onCloseTiers, onCloseUnitEditor,
|
||
} = this.props;
|
||
|
||
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}
|
||
/>
|
||
<TierPricePills tiers={draft.tiers} />
|
||
<PriceUnitRow
|
||
hasUnit={draft.hasUnit} unitamount={draft.unitamount}
|
||
unitSi={draft.unitSi} units={unitOptions}
|
||
displaySymbol={displaySymbol}
|
||
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={onCloseTiers} onApply={(tiers) => onChange({ ...draft, tiers })}
|
||
/>
|
||
<ArticleUnitEditorDialog
|
||
open={unitEditorOpen} onClose={onCloseUnitEditor}
|
||
onUnitUpserted={this.handleUnitUpserted} onUnitDeleted={this.handleUnitDeleted}
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|
||
}
|
||
|
||
export default class ArticleViewPrice extends PureComponent {
|
||
constructor(props) {
|
||
super(props);
|
||
const company = companyFromStore();
|
||
const units = unitAutomat.state.units;
|
||
this.state = {
|
||
tiersDialogOpen: false,
|
||
unitEditorOpen: false,
|
||
units: units ?? [],
|
||
company,
|
||
};
|
||
this._cancelled = false;
|
||
}
|
||
|
||
componentDidMount() {
|
||
this._cancelled = false;
|
||
this.unsubUnits = unitAutomat.subscribe(this, (state) => ({
|
||
units: state.units ?? [],
|
||
}));
|
||
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
|
||
company: state.current ?? null,
|
||
}));
|
||
this.unsubArtikel = artikelAutomat.subscribe(this, () => ({}));
|
||
if (this.props.article?.isParent) {
|
||
this.ensureChildren(this.props.article.children);
|
||
}
|
||
}
|
||
|
||
componentDidUpdate(prevProps) {
|
||
if (
|
||
this.props.article?.isParent &&
|
||
(prevProps.article?.sku !== this.props.article?.sku ||
|
||
prevProps.article?.children !== this.props.article?.children)
|
||
) {
|
||
this.ensureChildren(this.props.article.children);
|
||
}
|
||
}
|
||
|
||
componentWillUnmount() {
|
||
this._cancelled = true;
|
||
this.unsubUnits?.();
|
||
this.unsubUnits = null;
|
||
this.unsubCompany?.();
|
||
this.unsubCompany = null;
|
||
this.unsubArtikel?.();
|
||
this.unsubArtikel = null;
|
||
}
|
||
|
||
ensureChildren(children) {
|
||
if (!Array.isArray(children)) return;
|
||
for (const child of children) {
|
||
if (child?.sku && !getArticle(child.sku)) {
|
||
void artikelAutomat.actions.loadArticle(child.sku).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
handleSave = async (draft) => {
|
||
const { article: articleData, onArticleUpdated } = this.props;
|
||
const { company } = this.state;
|
||
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 artikelAutomat.actions.update(articleData.sku, { unitamount, unitSi });
|
||
requireObject(res, 'PATCH /api/article/:sku');
|
||
requireObject(res.article, 'article');
|
||
nextArticle = res.article;
|
||
} else {
|
||
const res = await artikelAutomat.actions.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 artikelAutomat.actions.setPrices(articleData.sku, prices);
|
||
requireObject(res, 'PUT /api/article/:sku/prices');
|
||
requireObject(res.article, 'article');
|
||
nextArticle = res.article;
|
||
}
|
||
|
||
onArticleUpdated?.(nextArticle);
|
||
};
|
||
|
||
openTiersDialog = () => this.setState({ tiersDialogOpen: true });
|
||
closeTiersDialog = () => this.setState({ tiersDialogOpen: false });
|
||
openUnitEditor = () => this.setState({ unitEditorOpen: true });
|
||
closeUnitEditor = () => this.setState({ unitEditorOpen: false });
|
||
|
||
unitOptions() {
|
||
const { article: articleData } = this.props;
|
||
const { units } = this.state;
|
||
if (articleData.unit?.si && !findUnitBySi(units, articleData.unit.si)) {
|
||
return [...units, articleData.unit];
|
||
}
|
||
return units;
|
||
}
|
||
|
||
refreshUnits = async () => {
|
||
const units = await unitAutomat.actions.load();
|
||
this.setState({ units });
|
||
};
|
||
|
||
render() {
|
||
const { article: articleData } = this.props;
|
||
|
||
if (articleData?.isParent) {
|
||
const { company } = this.state;
|
||
const defaultTaxRate = company?.taxrate ?? 19;
|
||
const children = Array.isArray(articleData.children) ? articleData.children : [];
|
||
const prices = [];
|
||
for (const child of children) {
|
||
const fullChild = getArticle(child.sku) || child;
|
||
const gross = getArticleGross(fullChild, defaultTaxRate);
|
||
if (gross != null && Number.isFinite(gross)) {
|
||
prices.push(gross);
|
||
}
|
||
}
|
||
|
||
let priceText = 'Kein Preis';
|
||
if (prices.length > 0) {
|
||
const min = Math.min(...prices);
|
||
const max = Math.max(...prices);
|
||
priceText =
|
||
min === max
|
||
? formatPriceAmount(min)
|
||
: `${formatPriceAmount(min)} bis ${formatPriceAmount(max)}`;
|
||
}
|
||
|
||
return (
|
||
<PanelTile
|
||
wide={1}
|
||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||
color={BLUE_PANEL}
|
||
title={
|
||
<Box component="span" sx={{ fontWeight: 700 }}>
|
||
€
|
||
</Box>
|
||
}
|
||
contentAlignItems="flex-start"
|
||
>
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}>
|
||
<Box sx={{ width: '100%', mt: 0.5 }}>
|
||
<Typography
|
||
component="div"
|
||
sx={{
|
||
width: '100%',
|
||
fontSize: '1.5rem',
|
||
fontWeight: 700,
|
||
lineHeight: 1.2,
|
||
letterSpacing: '-0.02em',
|
||
color: 'text.primary',
|
||
}}
|
||
>
|
||
{priceText}
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
</PanelTile>
|
||
);
|
||
}
|
||
|
||
const { company, tiersDialogOpen, unitEditorOpen } = this.state;
|
||
const priceValue = priceDraftValue(articleData, company);
|
||
const basePrice = primaryPrice(articleData);
|
||
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
|
||
const unitOptions = this.unitOptions();
|
||
const displaySymbol = articleData.unit?.symbol;
|
||
|
||
return (
|
||
<EditablePanelTile
|
||
wide={1}
|
||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||
color={BLUE_PANEL}
|
||
titleContent={({ editing, draft, onChange }) => (
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
width: '100%',
|
||
gap: 1,
|
||
}}
|
||
>
|
||
<Box component="span" sx={{ fontWeight: 700 }}>
|
||
€
|
||
</Box>
|
||
<PriceTaxRow
|
||
rates={taxRates}
|
||
selected={editing ? draft?.taxRate : priceValue.taxRate}
|
||
onSelectTax={
|
||
editing
|
||
? (taxRate) => onChange({ ...draft, taxRate })
|
||
: undefined
|
||
}
|
||
readOnly={!editing}
|
||
/>
|
||
</Box>
|
||
)}
|
||
contentAlignItems="flex-start"
|
||
value={priceValue}
|
||
onSave={this.handleSave}
|
||
onError={notifyError}
|
||
isDirty={priceDraftDirty}
|
||
canSave={priceDraftValid}
|
||
overlay={({ editing, draft, onChange }) => (
|
||
<PriceOverlay
|
||
editing={editing}
|
||
draft={draft}
|
||
onChange={onChange}
|
||
unitOptions={unitOptions}
|
||
articleUnitSi={articleData.unit?.si}
|
||
onOpenEditor={this.openUnitEditor}
|
||
onOpenTiers={this.openTiersDialog}
|
||
/>
|
||
)}
|
||
display={
|
||
<PriceDisplay
|
||
saved={priceValue}
|
||
unitOptions={unitOptions}
|
||
displaySymbol={displaySymbol}
|
||
/>
|
||
}
|
||
edit={({ draft, onChange, inputRef }) => (
|
||
<PriceEdit
|
||
draft={draft}
|
||
onChange={onChange}
|
||
inputRef={inputRef}
|
||
unitOptions={unitOptions}
|
||
displaySymbol={displaySymbol}
|
||
tiersDialogOpen={tiersDialogOpen}
|
||
unitEditorOpen={unitEditorOpen}
|
||
onCloseTiers={this.closeTiersDialog}
|
||
onCloseUnitEditor={this.closeUnitEditor}
|
||
onUnitUpserted={this.refreshUnits}
|
||
onUnitDeleted={this.refreshUnits}
|
||
/>
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
}
|