Add article pack units with Redux-cached company unit list.
Edit amount/unit on the price tile (pc×1 = no pack unit), manage units in a dialog, and keep the default pc unit non-deletable.
This commit is contained in:
@@ -167,8 +167,9 @@ export const article = {
|
||||
|
||||
/**
|
||||
* PATCH /api/article/:sku
|
||||
* Body: partial fields, e.g. { name }
|
||||
* → 200 { article }
|
||||
* Body: at least one of { name, unitamount, unitSi }
|
||||
* unitSi must exist for selected company → 404 Unit not found
|
||||
* → 200 { article } (same detail shape as GET)
|
||||
*/
|
||||
update(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}`, {
|
||||
@@ -250,6 +251,35 @@ export const thumb = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Company-scoped units (selectedCompanyId from session).
|
||||
* Shape: { si, basesi, basesifactor, symbol, name }
|
||||
*/
|
||||
export const unit = {
|
||||
/** GET /api/unit → { units: [...] } · 400 No company selected */
|
||||
list() {
|
||||
return http('/api/unit');
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/unit
|
||||
* Body: { si, basesi, basesifactor, symbol, name }
|
||||
* → 200 { unit }
|
||||
*/
|
||||
upsert(body) {
|
||||
return http('/api/unit', { method: 'PUT', body });
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/unit?si=… → 204
|
||||
* 409 Unit in use
|
||||
*/
|
||||
delete(si) {
|
||||
const params = new URLSearchParams({ si });
|
||||
return http(`/api/unit?${params}`, { method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
|
||||
export const category = {
|
||||
/** GET /api/category/tree → { languageId, tree, articles } (company from session) */
|
||||
getTree() {
|
||||
@@ -372,4 +402,4 @@ export const auth = {
|
||||
},
|
||||
};
|
||||
|
||||
export default { auth, article, category, company, thumb };
|
||||
export default { auth, article, category, company, thumb, unit };
|
||||
|
||||
317
src/components/article/ArticleUnitEditorDialog.jsx
Normal file
317
src/components/article/ArticleUnitEditorDialog.jsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
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 List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { parsePriceInput } from '../../utils/price';
|
||||
import { COMMON_UNITS, DEFAULT_UNIT_SI, unitLabel } from '../../utils/units';
|
||||
import {
|
||||
deleteUnit,
|
||||
loadUnits,
|
||||
selectUnits,
|
||||
selectUnitsStatus,
|
||||
upsertUnit,
|
||||
} from '../../store/unitSlice';
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
si: '',
|
||||
basesi: '',
|
||||
basesifactor: '1',
|
||||
symbol: '',
|
||||
name: '',
|
||||
};
|
||||
}
|
||||
|
||||
function formFromUnit(row) {
|
||||
// API may return null symbol/name (e.g. default pc).
|
||||
return {
|
||||
si: row.si ?? '',
|
||||
basesi: row.basesi ?? '',
|
||||
basesifactor: String(row.basesifactor ?? '1'),
|
||||
symbol: row.symbol ?? '',
|
||||
name: row.name ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function parseFactor(value) {
|
||||
return parsePriceInput(value);
|
||||
}
|
||||
|
||||
function formValid(form) {
|
||||
const si = String(form.si ?? '').trim();
|
||||
const basesi = String(form.basesi ?? '').trim();
|
||||
const symbol = String(form.symbol ?? '').trim();
|
||||
const name = String(form.name ?? '').trim();
|
||||
const factor = parseFactor(form.basesifactor);
|
||||
return (
|
||||
si.length > 0 &&
|
||||
basesi.length > 0 &&
|
||||
symbol.length > 0 &&
|
||||
name.length > 0 &&
|
||||
factor != null &&
|
||||
factor > 0
|
||||
);
|
||||
}
|
||||
|
||||
class ArticleUnitEditorDialog extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
busy: false,
|
||||
form: emptyForm(),
|
||||
selectedSi: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.open && !prevProps.open) {
|
||||
this.setState({ form: emptyForm(), selectedSi: null });
|
||||
if (this.props.status !== 'succeeded') {
|
||||
void this.props.loadUnits().catch((err) => notifyError(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectUnit = (row) => {
|
||||
this.setState({ form: formFromUnit(row), selectedSi: row.si });
|
||||
};
|
||||
|
||||
handlePreset = (preset) => {
|
||||
this.setState({ form: formFromUnit(preset), selectedSi: preset.si });
|
||||
};
|
||||
|
||||
handleField = (field, value) => {
|
||||
this.setState((state) => {
|
||||
const form = { ...state.form, [field]: value };
|
||||
if (field === 'si' && (!state.form.basesi || state.form.basesi === state.form.si)) {
|
||||
form.basesi = value;
|
||||
}
|
||||
return { form };
|
||||
});
|
||||
};
|
||||
|
||||
handleSave = async () => {
|
||||
const { form } = this.state;
|
||||
if (!formValid(form)) return;
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const unit = await this.props.upsertUnit({
|
||||
si: String(form.si ?? '').trim(),
|
||||
basesi: String(form.basesi ?? '').trim(),
|
||||
basesifactor: parseFactor(form.basesifactor),
|
||||
symbol: String(form.symbol ?? '').trim(),
|
||||
name: String(form.name ?? '').trim(),
|
||||
}).unwrap();
|
||||
this.setState({
|
||||
busy: false,
|
||||
form: formFromUnit(unit),
|
||||
selectedSi: unit.si,
|
||||
});
|
||||
this.props.onUnitUpserted?.(unit);
|
||||
} catch (err) {
|
||||
this.setState({ busy: false });
|
||||
notifyError(err);
|
||||
}
|
||||
};
|
||||
|
||||
handleDelete = async () => {
|
||||
const { selectedSi } = this.state;
|
||||
if (!selectedSi || selectedSi === DEFAULT_UNIT_SI) return;
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
await this.props.deleteUnit(selectedSi).unwrap();
|
||||
this.setState({ busy: false, form: emptyForm(), selectedSi: null });
|
||||
this.props.onUnitDeleted?.(selectedSi);
|
||||
} catch (err) {
|
||||
this.setState({ busy: false });
|
||||
notifyError(err);
|
||||
}
|
||||
};
|
||||
|
||||
handleNew = () => {
|
||||
this.setState({ form: emptyForm(), selectedSi: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { open, onClose, units, status } = this.props;
|
||||
const { busy, form, selectedSi } = this.state;
|
||||
const canSave = formValid(form) && !busy;
|
||||
const loading = status === 'loading' && units.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={busy ? undefined : onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Einheiten</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
Typische Einheiten
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 2 }}>
|
||||
{COMMON_UNITS.map((preset) => (
|
||||
<Chip
|
||||
key={preset.si}
|
||||
size="small"
|
||||
label={`${preset.symbol} (${preset.si})`}
|
||||
onClick={() => this.handlePreset(preset)}
|
||||
disabled={busy}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1.2fr' },
|
||||
gap: 2,
|
||||
minHeight: 280,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Firma
|
||||
</Typography>
|
||||
{loading ? (
|
||||
<CircularProgress size={28} sx={{ mt: 2 }} />
|
||||
) : (
|
||||
<List dense disablePadding sx={{ maxHeight: 320, overflow: 'auto' }}>
|
||||
{units.map((row) => (
|
||||
<ListItem
|
||||
key={row.si}
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
selectedSi === row.si && row.si !== DEFAULT_UNIT_SI ? (
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="Löschen"
|
||||
disabled={busy}
|
||||
onClick={() => void this.handleDelete()}
|
||||
>
|
||||
<DeleteOutlineOutlinedIcon />
|
||||
</IconButton>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ListItemButton
|
||||
selected={selectedSi === row.si}
|
||||
onClick={() => this.handleSelectUnit(row)}
|
||||
>
|
||||
<ListItemText
|
||||
primary={unitLabel(row)}
|
||||
secondary={`si ${row.si} · ${row.basesifactor} × ${row.basesi}`}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
{units.length === 0 ? (
|
||||
<Typography color="text.secondary" sx={{ py: 2 }}>
|
||||
Noch keine Einheiten.
|
||||
</Typography>
|
||||
) : null}
|
||||
</List>
|
||||
)}
|
||||
<Button type="button" onClick={this.handleNew} disabled={busy} sx={{ mt: 1 }}>
|
||||
Neu
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={form.name}
|
||||
onChange={(e) => this.handleField('name', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Symbol"
|
||||
value={form.symbol}
|
||||
onChange={(e) => this.handleField('symbol', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="si"
|
||||
value={form.si}
|
||||
onChange={(e) => this.handleField('si', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="basesi"
|
||||
value={form.basesi}
|
||||
onChange={(e) => this.handleField('basesi', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="basesifactor"
|
||||
value={form.basesifactor}
|
||||
onChange={(e) => this.handleField('basesifactor', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
error={
|
||||
form.basesifactor !== '' &&
|
||||
(parseFactor(form.basesifactor) == null ||
|
||||
parseFactor(form.basesifactor) <= 0)
|
||||
}
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { inputMode: 'decimal' },
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="contained"
|
||||
onClick={() => void this.handleSave()}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{busy ? <CircularProgress size={22} color="inherit" /> : 'Speichern'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button type="button" onClick={onClose} disabled={busy}>
|
||||
Schließen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
units: selectUnits(state),
|
||||
status: selectUnitsStatus(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
loadUnits: () => dispatch(loadUnits()),
|
||||
upsertUnit: (body) => dispatch(upsertUnit(body)),
|
||||
deleteUnit: (si) => dispatch(deleteUnit(si)),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ArticleUnitEditorDialog);
|
||||
@@ -1,21 +1,29 @@
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined';
|
||||
import StraightenOutlinedIcon from '@mui/icons-material/StraightenOutlined';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Link from '@mui/material/Link';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SingleLineTextEdit from '../SingleLineTextEdit';
|
||||
import ArticlePhotoTile from './ArticlePhotoTile';
|
||||
import ArticlePriceTiersDialog from './ArticlePriceTiersDialog';
|
||||
import ArticleTileTab from './ArticleTileTab';
|
||||
import ArticleUnitEditorDialog from './ArticleUnitEditorDialog';
|
||||
import { article } from '../../api';
|
||||
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
|
||||
import { requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import { loadUnits, selectUnits, selectUnitsStatus } from '../../store/unitSlice';
|
||||
import {
|
||||
articleEans,
|
||||
brandName,
|
||||
@@ -32,6 +40,13 @@ import {
|
||||
netFromGross,
|
||||
parsePriceInput,
|
||||
} from '../../utils/price';
|
||||
import {
|
||||
findUnitBySi,
|
||||
firstPackUnitSi,
|
||||
isNoPackUnit,
|
||||
unitLabel,
|
||||
} from '../../utils/units';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { PANEL_TILE_KEYS } from '../../theme';
|
||||
import { formatStock } from '../../pages/artikel/searchFormat';
|
||||
import {
|
||||
@@ -66,7 +81,7 @@ const headerTileSx = {
|
||||
};
|
||||
|
||||
/** Second article row (Bestand / Marke / SKU / Preis). */
|
||||
const SECOND_ROW_MIN_HEIGHT = 176;
|
||||
const SECOND_ROW_MIN_HEIGHT = 220;
|
||||
|
||||
function CountLine({ label, count }) {
|
||||
if (count == null) return null;
|
||||
@@ -100,9 +115,17 @@ function nameDraftValid(draft) {
|
||||
return draft.name.trim().length > 0;
|
||||
}
|
||||
|
||||
function articleHasPackUnit(article) {
|
||||
const si = article.unit?.si;
|
||||
const amount = article.unitamount;
|
||||
if (si == null || amount == null) return false;
|
||||
return !isNoPackUnit(si, amount);
|
||||
}
|
||||
|
||||
function priceDraftValue(article, company) {
|
||||
const basePrice = primaryPrice(article);
|
||||
const gross = priceBrutto(basePrice);
|
||||
const hasUnit = articleHasPackUnit(article);
|
||||
return {
|
||||
gross: gross != null ? formatPriceAmount(gross) : '',
|
||||
taxRate:
|
||||
@@ -112,6 +135,12 @@ function priceDraftValue(article, company) {
|
||||
? company.taxrate
|
||||
: '',
|
||||
tiers: tierDraftFromArticle(article),
|
||||
hasUnit,
|
||||
unitamount:
|
||||
hasUnit && article.unitamount != null
|
||||
? formatPriceAmount(article.unitamount)
|
||||
: '',
|
||||
unitSi: hasUnit ? (article.unit?.si ?? '') : '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,7 +148,7 @@ function tiersEqual(a, b) {
|
||||
return JSON.stringify(a ?? []) === JSON.stringify(b ?? []);
|
||||
}
|
||||
|
||||
function priceDraftDirty(draft, saved) {
|
||||
function priceSectionDirty(draft, saved) {
|
||||
return (
|
||||
parsePriceInput(draft.gross) !== parsePriceInput(saved.gross) ||
|
||||
Number(draft.taxRate) !== Number(saved.taxRate) ||
|
||||
@@ -127,7 +156,33 @@ function priceDraftDirty(draft, saved) {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
// pc × 1 is the DB “no unit” sentinel — not a real pack unit.
|
||||
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 ?? []) {
|
||||
@@ -174,8 +229,10 @@ const articlePriceTextSx = {
|
||||
};
|
||||
|
||||
const articlePriceFieldSx = {
|
||||
flexShrink: 0,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
alignItems: 'baseline',
|
||||
flexShrink: 0,
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
fontSize: '3.5rem',
|
||||
@@ -224,6 +281,17 @@ const priceBodySx = {
|
||||
gap: 0.75,
|
||||
};
|
||||
|
||||
const priceUnitCornerSx = {
|
||||
position: 'absolute',
|
||||
top: 72,
|
||||
right: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.25,
|
||||
zIndex: 2,
|
||||
};
|
||||
|
||||
const taxPillSx = {
|
||||
height: 28,
|
||||
fontSize: 13,
|
||||
@@ -370,17 +438,142 @@ function PriceAmountField({
|
||||
);
|
||||
}
|
||||
|
||||
function PriceUnitRow({
|
||||
hasUnit,
|
||||
unitamount,
|
||||
unitSi,
|
||||
units,
|
||||
displaySymbol,
|
||||
onChangeAmount,
|
||||
onChangeSi,
|
||||
readOnly,
|
||||
amountError,
|
||||
}) {
|
||||
const selected = findUnitBySi(units, unitSi);
|
||||
const symbol = selected?.symbol ?? displaySymbol ?? unitSi ?? '';
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minHeight: 36 }}>
|
||||
{hasUnit && unitamount ? (
|
||||
<Chip
|
||||
size="medium"
|
||||
label={`${unitamount} ${symbol}`}
|
||||
sx={{
|
||||
height: 28,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasUnit) {
|
||||
return <Box sx={{ minHeight: 40 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
flexWrap: 'nowrap',
|
||||
minHeight: 40,
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Menge"
|
||||
value={unitamount}
|
||||
onChange={(event) => onChangeAmount(event.target.value)}
|
||||
size="small"
|
||||
error={amountError}
|
||||
sx={{ width: 88, flexShrink: 0 }}
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { inputMode: 'decimal' },
|
||||
}}
|
||||
/>
|
||||
<FormControl size="small" sx={{ flex: 1, minWidth: 0, maxWidth: 160 }}>
|
||||
<InputLabel id="article-unit-si-label" shrink>
|
||||
Einheit
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="article-unit-si-label"
|
||||
label="Einheit"
|
||||
value={unitSi}
|
||||
displayEmpty
|
||||
notched
|
||||
onChange={(event) => onChangeSi(event.target.value)}
|
||||
sx={{
|
||||
'& .MuiSelect-select': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{units.map((row) => (
|
||||
<MenuItem key={row.si} value={row.si}>
|
||||
{unitLabel(row)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceUnitCorner({ hasUnit, onToggleHasUnit, onOpenEditor }) {
|
||||
return (
|
||||
<Box sx={priceUnitCornerSx}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={Boolean(hasUnit)}
|
||||
onChange={(event) => onToggleHasUnit(event.target.checked)}
|
||||
inputProps={{ 'aria-label': 'Einheit' }}
|
||||
/>
|
||||
<IconButton
|
||||
type="button"
|
||||
size="small"
|
||||
aria-label="Einheiten bearbeiten"
|
||||
onClick={onOpenEditor}
|
||||
sx={{ p: 0.5 }}
|
||||
>
|
||||
<StraightenOutlinedIcon sx={{ fontSize: 28 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArticleView({ article: articleData, onArticleUpdated }) {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const [tiersDialogOpen, setTiersDialogOpen] = useState(false);
|
||||
const [unitEditorOpen, setUnitEditorOpen] = useState(false);
|
||||
const categories = useSelector((state) => state.category.categories);
|
||||
const rootCategoryId = useSelector(selectCategoryRootId);
|
||||
const company = useSelector(selectCurrentCompany);
|
||||
const units = useSelector(selectUnits);
|
||||
const unitsStatus = useSelector(selectUnitsStatus);
|
||||
const basePrice = primaryPrice(articleData);
|
||||
const eans = articleEans(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(() => {
|
||||
if (unitsStatus !== 'idle') return;
|
||||
void dispatch(loadUnits()).unwrap().catch((err) => notifyError(err));
|
||||
}, [dispatch, unitsStatus]);
|
||||
|
||||
const syncCategoryListPhoto = (next) => {
|
||||
const photo = primaryPhoto(next);
|
||||
@@ -420,25 +613,63 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
||||
};
|
||||
|
||||
const handleSavePrice = async (draft) => {
|
||||
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 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 article.update(articleData.sku, { unitamount, unitSi });
|
||||
requireObject(res, 'PATCH /api/article/:sku');
|
||||
requireObject(res.article, 'article');
|
||||
nextArticle = res.article;
|
||||
} else {
|
||||
const res = await article.update(articleData.sku, {
|
||||
unitamount: 1,
|
||||
unitSi: 'pc',
|
||||
});
|
||||
requireObject(res, 'PATCH /api/article/:sku');
|
||||
requireObject(res.article, 'article');
|
||||
nextArticle = res.article;
|
||||
}
|
||||
}
|
||||
const net = requireDefined(netFromGross(gross, taxRate), 'price.net');
|
||||
const prices = buildPosPricesPutBody({
|
||||
net,
|
||||
taxRate,
|
||||
tiers: draft.tiers ?? [],
|
||||
});
|
||||
const res = await article.setPrices(articleData.sku, prices);
|
||||
requireObject(res, 'PUT /api/article/:sku/prices');
|
||||
requireObject(res.article, 'article');
|
||||
onArticleUpdated?.(res.article);
|
||||
syncCategoryListPrice(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 article.setPrices(articleData.sku, prices);
|
||||
requireObject(res, 'PUT /api/article/:sku/prices');
|
||||
requireObject(res.article, 'article');
|
||||
nextArticle = res.article;
|
||||
syncCategoryListPrice(res.article);
|
||||
}
|
||||
|
||||
onArticleUpdated?.(nextArticle);
|
||||
};
|
||||
|
||||
const handleArticleDeleted = () => {
|
||||
@@ -536,6 +767,49 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
||||
onSave={handleSavePrice}
|
||||
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,
|
||||
);
|
||||
// Only pc available: keep pc but clear amount so user must set ≠ 1.
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
renderDisplay={(saved) => (
|
||||
<Box sx={priceBodySx}>
|
||||
<PriceAmountField value={saved.gross} readOnly />
|
||||
@@ -545,10 +819,19 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
||||
readOnly
|
||||
/>
|
||||
<TierPricePills tiers={saved.tiers} />
|
||||
<PriceUnitRow
|
||||
hasUnit={saved.hasUnit}
|
||||
unitamount={saved.unitamount}
|
||||
unitSi={saved.unitSi}
|
||||
units={unitOptions}
|
||||
displaySymbol={articleData.unit?.symbol}
|
||||
readOnly
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
renderEdit={(draft, onChange, inputRef) => {
|
||||
const draftGross = parsePriceInput(draft.gross);
|
||||
const draftAmount = parsePriceInput(draft.unitamount);
|
||||
return (
|
||||
<Box sx={priceBodySx}>
|
||||
<PriceAmountField
|
||||
@@ -564,12 +847,40 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
||||
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={(row) => {
|
||||
if (!draft.unitSi) {
|
||||
onChange({ ...draft, unitSi: row.si });
|
||||
}
|
||||
}}
|
||||
onUnitDeleted={(si) => {
|
||||
if (draft.unitSi === si) {
|
||||
onChange({ ...draft, unitSi: '' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -203,6 +203,7 @@ class EditablePanelTileBase extends Component {
|
||||
tileKey,
|
||||
renderDisplay,
|
||||
renderEdit,
|
||||
renderOverlay,
|
||||
contentAlignItems = 'center',
|
||||
} = this.props;
|
||||
const { editing, draft, error } = this.state;
|
||||
@@ -224,6 +225,13 @@ class EditablePanelTileBase extends Component {
|
||||
})}
|
||||
>
|
||||
{this.renderActions()}
|
||||
{typeof renderOverlay === 'function'
|
||||
? renderOverlay({
|
||||
editing,
|
||||
draft,
|
||||
onChange: this.handleDraftChange,
|
||||
})
|
||||
: null}
|
||||
{this.renderTitle()}
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -2,9 +2,11 @@ import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import authReducer from './authSlice';
|
||||
import categoryReducer from './categorySlice';
|
||||
import companyReducer, { companyLoadListener } from './companySlice';
|
||||
import unitReducer, { unitLoadListener } from './unitSlice';
|
||||
|
||||
const listenerMiddleware = createListenerMiddleware();
|
||||
companyLoadListener(listenerMiddleware);
|
||||
unitLoadListener(listenerMiddleware);
|
||||
|
||||
function reduxDevLogger() {
|
||||
return (storeApi) => (next) => (action) => {
|
||||
@@ -12,7 +14,8 @@ function reduxDevLogger() {
|
||||
!import.meta.env.DEV ||
|
||||
(!action.type?.startsWith('auth/') &&
|
||||
!action.type?.startsWith('company/') &&
|
||||
!action.type?.startsWith('category/'))
|
||||
!action.type?.startsWith('category/') &&
|
||||
!action.type?.startsWith('unit/'))
|
||||
) {
|
||||
return next(action);
|
||||
}
|
||||
@@ -27,6 +30,7 @@ function reduxDevLogger() {
|
||||
after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null,
|
||||
},
|
||||
company: after.company.current,
|
||||
units: after.unit.units.length,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
@@ -37,6 +41,7 @@ export const store = configureStore({
|
||||
auth: authReducer,
|
||||
company: companyReducer,
|
||||
category: categoryReducer,
|
||||
unit: unitReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware()
|
||||
|
||||
98
src/store/unitSlice.js
Normal file
98
src/store/unitSlice.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import { unit as unitApi } from '../api';
|
||||
import { requireArray, requireObject } from '../utils/apiShape';
|
||||
import {
|
||||
bootstrapSession,
|
||||
createCompany,
|
||||
loginWithGoogle,
|
||||
logout,
|
||||
selectCompany,
|
||||
} from './authSlice';
|
||||
|
||||
function sortUnits(units) {
|
||||
return [...units].sort((a, b) => {
|
||||
const left = a.symbol ?? a.si ?? '';
|
||||
const right = b.symbol ?? b.si ?? '';
|
||||
return String(left).localeCompare(String(right), 'de');
|
||||
});
|
||||
}
|
||||
|
||||
export const loadUnits = createAsyncThunk('unit/load', async () => {
|
||||
const res = await unitApi.list();
|
||||
requireObject(res, 'GET /api/unit');
|
||||
return requireArray(res.units, 'units');
|
||||
});
|
||||
|
||||
export const upsertUnit = createAsyncThunk('unit/upsert', async (body) => {
|
||||
const res = await unitApi.upsert(body);
|
||||
requireObject(res, 'PUT /api/unit');
|
||||
requireObject(res.unit, 'unit');
|
||||
return res.unit;
|
||||
});
|
||||
|
||||
export const deleteUnit = createAsyncThunk('unit/delete', async (si) => {
|
||||
await unitApi.delete(si);
|
||||
return si;
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
units: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
const unitSlice = createSlice({
|
||||
name: 'unit',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(loadUnits.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loadUnits.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded';
|
||||
state.units = sortUnits(action.payload);
|
||||
})
|
||||
.addCase(loadUnits.rejected, (state, action) => {
|
||||
state.status = 'failed';
|
||||
state.error = action.error.message;
|
||||
})
|
||||
.addCase(upsertUnit.fulfilled, (state, action) => {
|
||||
const row = action.payload;
|
||||
state.units = sortUnits([
|
||||
...state.units.filter((u) => u.si !== row.si),
|
||||
row,
|
||||
]);
|
||||
})
|
||||
.addCase(deleteUnit.fulfilled, (state, action) => {
|
||||
state.units = state.units.filter((u) => u.si !== action.payload);
|
||||
})
|
||||
.addCase(selectCompany.fulfilled, () => initialState)
|
||||
.addCase(createCompany.fulfilled, () => initialState)
|
||||
.addCase(logout.fulfilled, () => initialState);
|
||||
},
|
||||
});
|
||||
|
||||
export const selectUnits = (state) => state.unit.units;
|
||||
export const selectUnitsStatus = (state) => state.unit.status;
|
||||
export const selectUnitsError = (state) => state.unit.error;
|
||||
|
||||
export function unitLoadListener(listenerMiddleware) {
|
||||
listenerMiddleware.startListening({
|
||||
matcher: (action) =>
|
||||
bootstrapSession.fulfilled.match(action) ||
|
||||
loginWithGoogle.fulfilled.match(action) ||
|
||||
selectCompany.fulfilled.match(action) ||
|
||||
createCompany.fulfilled.match(action),
|
||||
effect: async (_action, api) => {
|
||||
const companyId = api.getState().auth.user?.selectedCompanyId;
|
||||
if (companyId) {
|
||||
api.dispatch(loadUnits());
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default unitSlice.reducer;
|
||||
47
src/utils/units.js
Normal file
47
src/utils/units.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/** Company default unit created with every article (`ensureDefaultUnit`). */
|
||||
export const DEFAULT_UNIT_SI = 'pc';
|
||||
|
||||
/**
|
||||
* Common unit presets for the editor UI (not an API).
|
||||
* Selecting a preset upserts via PUT /api/unit when missing.
|
||||
*/
|
||||
export const COMMON_UNITS = [
|
||||
{ si: 'pc', basesi: 'pc', basesifactor: 1, symbol: 'Stk', name: 'Stück' },
|
||||
{ si: 'g', basesi: 'g', basesifactor: 1, symbol: 'g', name: 'Gramm' },
|
||||
{ si: 'kg', basesi: 'kg', basesifactor: 1, symbol: 'kg', name: 'Kilogramm' },
|
||||
{ si: 'ml', basesi: 'l', basesifactor: 0.001, symbol: 'ml', name: 'Milliliter' },
|
||||
{ si: 'l', basesi: 'l', basesifactor: 1, symbol: 'L', name: 'Liter' },
|
||||
{ si: 'm', basesi: 'm', basesifactor: 1, symbol: 'm', name: 'Meter' },
|
||||
{ si: 'cm', basesi: 'm', basesifactor: 0.01, symbol: 'cm', name: 'Zentimeter' },
|
||||
];
|
||||
|
||||
export function unitLabel(unit) {
|
||||
if (!unit) return '';
|
||||
if (unit.symbol && unit.name) return `${unit.symbol} · ${unit.name}`;
|
||||
return unit.symbol || unit.name || unit.si;
|
||||
}
|
||||
|
||||
export function findUnitBySi(units, si) {
|
||||
if (!Array.isArray(units) || si == null) return null;
|
||||
return units.find((row) => row.si === si) ?? null;
|
||||
}
|
||||
|
||||
/** DB default on create: pc × 1 means “no pack unit” in the UI. */
|
||||
export function isNoPackUnit(si, amount) {
|
||||
return si === DEFAULT_UNIT_SI && Number(amount) === 1;
|
||||
}
|
||||
|
||||
export function firstPackUnitSi(units, preferredSi) {
|
||||
const list = Array.isArray(units) ? units : [];
|
||||
if (
|
||||
preferredSi &&
|
||||
preferredSi !== DEFAULT_UNIT_SI &&
|
||||
findUnitBySi(list, preferredSi)
|
||||
) {
|
||||
return preferredSi;
|
||||
}
|
||||
const nonPc = list.find((row) => row.si !== DEFAULT_UNIT_SI);
|
||||
if (nonPc) return nonPc.si;
|
||||
if (preferredSi && preferredSi !== DEFAULT_UNIT_SI) return preferredSi;
|
||||
return '';
|
||||
}
|
||||
Reference in New Issue
Block a user