Files
dashfront/src/components/article/boxes/ArticleViewPrice.jsx
2026-10-09 11:57:43 +02:00

608 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}
/>
)}
/>
);
}
}