import { Component, PureComponent } 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 'dashgui/panels'; import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog'; import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog'; import { StorageManagerContext } from '../../../api/storageManagerContext'; import { requireDefined, requireObject } from '../../../utils/apiShape'; 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 { 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, }; 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(store) { if (!store) return null; const companies = store.companies; const session = store.authMe; if (companies == null || session == null) return null; const companyId = session.user?.selectedCompanyId; if (companyId == null) return null; return companies.find((c) => c.id === companyId) ?? null; } class PriceAmountField extends Component { render() { const { value, onChange, inputRef, readOnly, error } = this.props; return ( { onChange?.(event.target.value); } } fullWidth error={error} sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx} slotProps={{ input: { readOnly, startAdornment: ( € ), }, htmlInput: { inputMode: 'decimal', readOnly, tabIndex: readOnly ? -1 : undefined, }, }} /> ); } } class PriceDisplay extends Component { render() { const { saved, taxRates, unitOptions, displaySymbol } = this.props; return ( ); } } class PriceOverlay extends Component { render() { const { editing, draft, onChange, unitOptions, articleUnitSi, onOpenEditor, } = 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, taxRates, unitOptions, displaySymbol, tiersDialogOpen, unitEditorOpen, onOpenTiers, 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, taxRate })} onOpenTiers={onOpenTiers} /> 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 { static contextType = StorageManagerContext; constructor(props) { super(props); const store = this.context; const company = companyFromStore(store); const units = store?.units; this.state = { tiersDialogOpen: false, unitEditorOpen: false, units: units ?? [], company, }; this._cancelled = false; this._ready = company != null && units != null; } componentDidMount() { this._cancelled = false; if (this._ready) return; void this.load(); } componentWillUnmount() { this._cancelled = true; } async load() { const store = this.context; try { const [unitList, companies, session] = await Promise.all([ store.unitPromise, store.companyPromise, store.authMePromise, ]); const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId'); const current = requireDefined( companies.find((c) => c.id === companyId), 'company', ); if (!this._cancelled) { this.setState({ units: unitList, company: current }); } } catch (err) { if (!this._cancelled) notifyError(err); } } handleSave = async (draft) => { const { article: articleData, onArticleUpdated } = this.props; const store = this.context; 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 store.article.update(articleData.sku, { unitamount, unitSi }); requireObject(res, 'PATCH /api/article/:sku'); requireObject(res.article, 'article'); nextArticle = res.article; } else { const res = await store.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 store.article.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 () => { this.setState({ units: await this.context.unitPromise }); }; render() { const { article: articleData } = this.props; 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 ( ( )} display={ } edit={({ draft, onChange, inputRef }) => ( )} /> ); } }