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 ( { 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 ( ); } } class PriceOverlay extends Component { render() { const { editing, draft, onChange, unitOptions, articleUnitSi, onOpenEditor, onOpenTiers, } = this.props; if (!editing || draft == null) return null; return ( { 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 ( onChange({ ...draft, gross })} error={draft.gross !== '' && draftGross == null} /> onChange({ ...draft, unitamount })} onChangeSi={(unitSi) => onChange({ ...draft, unitSi })} amountError={ draft.hasUnit && draft.unitamount !== '' && (draftAmount == null || draftAmount <= 0) } /> onChange({ ...draft, tiers })} /> ); } } 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 ( € } contentAlignItems="flex-start" > {priceText} ); } 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 ( ( € onChange({ ...draft, taxRate }) : undefined } readOnly={!editing} /> )} contentAlignItems="flex-start" value={priceValue} onSave={this.handleSave} onError={notifyError} isDirty={priceDraftDirty} canSave={priceDraftValid} overlay={({ editing, draft, onChange }) => ( )} display={ } edit={({ draft, onChange, inputRef }) => ( )} /> ); } }