Add editable article prices with gross input and staffel tiers.

Wire PUT /prices, de-DE formatting, tax/staffel pills, and keep the category list cache in sync after photo/price updates.
This commit is contained in:
seb
2026-08-01 05:44:34 +02:00
parent 09fb1da335
commit 335f808421
9 changed files with 709 additions and 22 deletions

View File

@@ -0,0 +1,185 @@
import { Component } from 'react';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { formatPriceAmount, parsePriceInput } from '../../utils/price';
function emptyTier() {
return { minmultiplier: '', gross: '' };
}
function parseMultiplier(value) {
if (value === '' || value == null) return null;
const n = Number(String(value).trim());
return Number.isInteger(n) && n >= 2 ? n : null;
}
function tiersValid(tiers) {
const seen = new Set();
for (const tier of tiers) {
const mult = parseMultiplier(tier.minmultiplier);
const gross = parsePriceInput(tier.gross);
if (mult == null || gross == null) return false;
if (seen.has(mult)) return false;
seen.add(mult);
}
return true;
}
class ArticlePriceTiersDialog extends Component {
constructor(props) {
super(props);
this.state = { tiers: [] };
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
const tiers = this.props.tiers?.length
? this.props.tiers.map((row) => ({
minmultiplier: String(row.minmultiplier),
gross: row.gross,
}))
: [];
this.setState({ tiers });
}
}
handleChange = (index, field, value) => {
this.setState((state) => {
const tiers = state.tiers.map((row, i) =>
i === index ? { ...row, [field]: value } : row,
);
return { tiers };
});
};
handleAdd = () => {
this.setState((state) => ({ tiers: [...state.tiers, emptyTier()] }));
};
handleRemove = (index) => {
this.setState((state) => ({
tiers: state.tiers.filter((_, i) => i !== index),
}));
};
handleApply = () => {
const { tiers } = this.state;
if (!tiersValid(tiers)) return;
this.props.onApply(
tiers.map((row) => ({
minmultiplier: parseMultiplier(row.minmultiplier),
gross: formatPriceAmount(parsePriceInput(row.gross)),
})),
);
this.props.onClose();
};
render() {
const { open, onClose } = this.props;
const { tiers } = this.state;
const canApply = tiersValid(tiers);
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Staffelpreise</DialogTitle>
<DialogContent dividers>
{tiers.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 1 }}>
Keine Staffeln. Basispreis bleibt der Einzelpreis (×1).
</Typography>
) : null}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{tiers.map((tier, index) => {
const mult = parseMultiplier(tier.minmultiplier);
const gross = parsePriceInput(tier.gross);
return (
<Box
key={index}
sx={{
display: 'flex',
gap: 1,
alignItems: 'flex-start',
}}
>
<TextField
label="Ab Menge"
value={tier.minmultiplier}
onChange={(event) =>
this.handleChange(index, 'minmultiplier', event.target.value)
}
size="small"
error={tier.minmultiplier !== '' && mult == null}
slotProps={{
inputLabel: { shrink: true },
htmlInput: { inputMode: 'numeric' },
}}
sx={{ width: 120 }}
/>
<TextField
label="Brutto"
value={tier.gross}
onChange={(event) =>
this.handleChange(index, 'gross', event.target.value)
}
size="small"
fullWidth
error={tier.gross !== '' && gross == null}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">€</InputAdornment>
),
},
htmlInput: { inputMode: 'decimal' },
}}
/>
<IconButton
type="button"
aria-label="Staffel entfernen"
onClick={() => this.handleRemove(index)}
sx={{ mt: 0.5 }}
>
<DeleteOutlineOutlinedIcon />
</IconButton>
</Box>
);
})}
</Box>
<Button
type="button"
startIcon={<AddIcon />}
onClick={this.handleAdd}
sx={{ mt: 2 }}
>
Staffel hinzufügen
</Button>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button type="button" onClick={onClose}>
Abbrechen
</Button>
<Button
type="button"
variant="contained"
onClick={this.handleApply}
disabled={!canApply}
>
Übernehmen
</Button>
</DialogActions>
</Dialog>
);
}
}
export default ArticlePriceTiersDialog;