This commit is contained in:
seb
2026-08-05 17:01:42 +02:00
parent 7cecb3dd1f
commit 198e800fd3
14 changed files with 1250 additions and 929 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { Component } from 'react';
import Box from '@mui/material/Box';
import InputAdornment from '@mui/material/InputAdornment';
import TextField from '@mui/material/TextField';
@@ -6,7 +6,7 @@ import Typography from '@mui/material/Typography';
import { EditablePanelTile } from '../../panels';
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
import { useStorageManager } from '../../../api/storageManagerContext';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../../utils/apiShape';
import {
buildPosPricesPutBody,
@@ -91,8 +91,6 @@ const priceBodySx = {
gap: 0.75,
};
/* ── helper functions ──────────────────────────────────────────────── */
function articleHasPackUnit(art) {
const si = art.unit?.si;
const amount = art.unitamount;
@@ -170,93 +168,256 @@ function priceDraftValid(draft) {
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,
},
}}
/>
);
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;
}
/* ── main component ────────────────────────────────────────────────── */
export default function ArticleViewPrice({ article: articleData, onArticleUpdated }) {
const store = useStorageManager();
const [tiersDialogOpen, setTiersDialogOpen] = useState(false);
const [unitEditorOpen, setUnitEditorOpen] = useState(false);
const [units, setUnits] = useState([]);
const [company, setCompany] = useState(null);
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(() => {
let cancelled = false;
async function load() {
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 (!cancelled) {
setUnits(unitList);
setCompany(current);
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);
}
}
} catch (err) {
if (!cancelled) notifyError(err);
}
}
void load();
return () => {
cancelled = true;
};
}, [store]);
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,
},
}}
/>
);
}
}
const handleSave = async (draft) => {
class PriceDisplay extends Component {
render() {
const { saved, taxRates, unitOptions, displaySymbol } = this.props;
return (
<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={displaySymbol}
readOnly
/>
</Box>
);
}
}
class PriceOverlay extends Component {
render() {
const {
editing,
draft,
onChange,
unitOptions,
articleUnitSi,
onOpenEditor,
} = this.props;
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 || 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 (
<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={onOpenTiers}
/>
<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 Component {
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;
@@ -315,134 +476,73 @@ export default function ArticleViewPrice({ article: articleData, onArticleUpdate
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)}
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 (
<EditablePanelTile
wide={1}
tileKey={PANEL_TILE_KEYS.price}
minHeight={SECOND_ROW_MIN_HEIGHT}
contentAlignItems="flex-start"
value={priceValue}
onSave={this.handleSave}
isDirty={priceDraftDirty}
canSave={priceDraftValid}
overlay={
<PriceOverlay
unitOptions={unitOptions}
articleUnitSi={articleData.unit?.si}
onOpenEditor={this.openUnitEditor}
/>
);
}}
renderDisplay={(saved) => (
<Box sx={priceBodySx}>
<PriceAmountField value={saved.gross} readOnly />
<PriceTaxRow
rates={taxRates}
selected={saved.taxRate}
readOnly
}
display={
<PriceDisplay
saved={priceValue}
taxRates={taxRates}
unitOptions={unitOptions}
displaySymbol={displaySymbol}
/>
<TierPricePills tiers={saved.tiers} />
<PriceUnitRow
hasUnit={saved.hasUnit}
unitamount={saved.unitamount}
unitSi={saved.unitSi}
units={unitOptions}
displaySymbol={articleData.unit?.symbol}
readOnly
}
edit={
<PriceEdit
taxRates={taxRates}
unitOptions={unitOptions}
displaySymbol={displaySymbol}
tiersDialogOpen={tiersDialogOpen}
unitEditorOpen={unitEditorOpen}
onOpenTiers={this.openTiersDialog}
onCloseTiers={this.closeTiersDialog}
onCloseUnitEditor={this.closeUnitEditor}
onUnitUpserted={this.refreshUnits}
onUnitDeleted={this.refreshUnits}
/>
</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={async (row) => {
setUnits(await store.unitPromise);
if (!draft.unitSi) {
onChange({ ...draft, unitSi: row.si });
}
}}
onUnitDeleted={async (si) => {
setUnits(await store.unitPromise);
if (draft.unitSi === si) {
onChange({ ...draft, unitSi: '' });
}
}}
/>
</Box>
);
}}
/>
);
}
/>
);
}
}